diff --git a/app/main.py b/app/main.py index 07420c9..d7d917b 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from .config import APP_NAME, APP_VERSION -from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics +from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit app = FastAPI(title=APP_NAME, version=APP_VERSION) app.mount("/static", StaticFiles(directory="app/static"), name="static") @@ -16,6 +16,7 @@ app.include_router(users.router) app.include_router(campaigns.router) app.include_router(planning.router) app.include_router(specifics.router) +app.include_router(audit.router) @app.get("/") diff --git a/app/routers/audit.py b/app/routers/audit.py new file mode 100644 index 0000000..a4c9d84 --- /dev/null +++ b/app/routers/audit.py @@ -0,0 +1,84 @@ +"""Router audit serveurs — resultats des scans d'audit""" +from fastapi import APIRouter, Request, Depends, Query +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/audit", response_class=HTMLResponse) +async def audit_page(request: Request, db=Depends(get_db), + filter: str = Query(None), search: str = Query(None)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + where = ["1=1"] + params = {} + if filter == "failed": + where.append("sa.status = 'CONNECTION_FAILED'") + elif filter == "disk": + where.append("sa.disk_alert = true") + elif filter == "no_qualys": + where.append("sa.qualys_active = false AND sa.status = 'OK'") + elif filter == "no_s1": + where.append("sa.sentinelone_active = false AND sa.status = 'OK'") + elif filter == "no_autostart": + where.append("sa.running_not_enabled IS NOT NULL AND sa.running_not_enabled != '' AND sa.status = 'OK'") + elif filter == "failed_svc": + where.append("sa.failed_services IS NOT NULL AND sa.failed_services != '' AND sa.status = 'OK'") + if search: + where.append("sa.hostname ILIKE :q") + params["q"] = f"%{search}%" + + wc = " AND ".join(where) + + entries = db.execute(text(f""" + SELECT sa.*, d.name as domaine, e.name as environnement + FROM server_audit sa + LEFT JOIN servers s ON sa.server_id = s.id + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE {wc} + ORDER BY sa.disk_alert DESC, sa.status, sa.hostname + LIMIT 500 + """), params).fetchall() + + # Stats + stats = db.execute(text(""" + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'OK') as ok, + COUNT(*) FILTER (WHERE status = 'CONNECTION_FAILED') as failed, + COUNT(*) FILTER (WHERE disk_alert = true) as disk_alerts, + COUNT(*) FILTER (WHERE qualys_active = true) as qualys_ok, + COUNT(*) FILTER (WHERE sentinelone_active = true) as s1_ok, + COUNT(*) FILTER (WHERE running_not_enabled IS NOT NULL AND running_not_enabled != '') as no_autostart, + COUNT(*) FILTER (WHERE failed_services IS NOT NULL AND failed_services != '') as failed_svc + FROM server_audit + """)).fetchone() + + return templates.TemplateResponse("audit.html", { + "request": request, "user": user, "app_name": APP_NAME, + "entries": entries, "stats": stats, "filter": filter, + "search": search, + }) + + +@router.get("/audit/{audit_id}", response_class=HTMLResponse) +async def audit_detail(request: Request, audit_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return HTMLResponse("
Non autorise
") + entry = db.execute(text("SELECT * FROM server_audit WHERE id = :id"), + {"id": audit_id}).fetchone() + if not entry: + return HTMLResponse("Non trouve
") + return templates.TemplateResponse("partials/audit_detail.html", { + "request": request, "e": entry, + }) diff --git a/app/routers/campaigns.py b/app/routers/campaigns.py index f175108..463eb7d 100644 --- a/app/routers/campaigns.py +++ b/app/routers/campaigns.py @@ -1,4 +1,4 @@ -"""Router campagnes — creation depuis planning + gestion exclusions""" +"""Router campagnes — creation, prereqs, assignation, workflow""" from datetime import datetime from fastapi import APIRouter, Request, Depends, Query, Form from fastapi.responses import HTMLResponse, RedirectResponse @@ -10,9 +10,12 @@ from ..services.campaign_service import ( create_campaign_from_planning, get_servers_for_planning, update_campaign_status, exclude_session, restore_session, validate_prereq, get_prereq_stats, can_plan_campaign, - bulk_auto_exclude_failed_prereqs, + assign_operator, unassign_operator, get_operator_count, is_forced, + update_session_schedule, get_operator_limit, set_operator_limit, + get_campaign_operator_limits, ) from ..services.prereq_service import check_prereqs_campaign, check_single_prereq +from ..services.secrets_service import get_secret from ..config import APP_NAME router = APIRouter() @@ -28,6 +31,14 @@ EXCLUSION_REASONS = [ ] +def _get_max_servers(db): + v = get_secret(db, "max_servers_per_operator") + try: + return int(v) if v else 0 + except (ValueError, TypeError): + return 0 + + @router.get("/campaigns", response_class=HTMLResponse) async def campaigns_list(request: Request, db=Depends(get_db), year: int = Query(None), status: str = Query(None)): @@ -38,7 +49,6 @@ async def campaigns_list(request: Request, db=Depends(get_db), year = datetime.now().year campaigns = list_campaigns(db, year=year, status=status) - # Semaines planifiees pour cette annee (pour le formulaire de creation) now = datetime.now() current_week = now.isocalendar()[1] planned_weeks = db.execute(text(""" @@ -62,7 +72,6 @@ async def campaigns_list(request: Request, db=Depends(get_db), @router.get("/campaigns/preview", response_class=HTMLResponse) async def campaign_preview(request: Request, db=Depends(get_db), year: int = Query(...), week: int = Query(...)): - """HTMX: preview des serveurs pour une semaine du planning""" user = get_current_user(request) if not user: return HTMLResponse("Non autorise
") @@ -79,23 +88,17 @@ async def campaign_create(request: Request, db=Depends(get_db)): user = get_current_user(request) if not user: return RedirectResponse(url="/login") - form = await request.form() year = int(form.get("year", datetime.now().year)) week = int(form.get("week_number", 0)) label = form.get("label", f"Patch S{week:02d} {year}") - - # Serveurs exclus (checkboxes non cochees) excluded = [] for key in form.keys(): if key.startswith("exclude_"): - sid = int(key.replace("exclude_", "")) - excluded.append(sid) - + excluded.append(int(key.replace("exclude_", ""))) cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded) if not cid: return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303) - return RedirectResponse(url=f"/campaigns/{cid}", status_code=303) @@ -104,21 +107,29 @@ async def campaign_detail(request: Request, campaign_id: int, db=Depends(get_db) user = get_current_user(request) if not user: return RedirectResponse(url="/login") - campaign = get_campaign(db, campaign_id) if not campaign: return RedirectResponse(url="/campaigns") - sessions = get_campaign_sessions(db, campaign_id) stats = get_campaign_stats(db, campaign_id) prereq = get_prereq_stats(db, campaign_id) can_plan = can_plan_campaign(db, campaign_id) + role = user.get("role", "viewer") + is_coordinator = role in ("admin", "coordinator") + max_srv = _get_max_servers(db) + + intervenants = db.execute(text( + "SELECT id, display_name FROM users WHERE is_active = true ORDER BY display_name" + )).fetchall() + op_limits = get_campaign_operator_limits(db, campaign_id) return templates.TemplateResponse("campaign_detail.html", { "request": request, "user": user, "app_name": APP_NAME, "c": campaign, "sessions": sessions, "stats": stats, "prereq": prereq, "can_plan": can_plan, "exclusion_reasons": EXCLUSION_REASONS, + "is_coordinator": is_coordinator, "intervenants": intervenants, + "op_limits": op_limits, "msg": request.query_params.get("msg"), }) @@ -129,7 +140,6 @@ async def campaign_status_change(request: Request, campaign_id: int, user = get_current_user(request) if not user: return RedirectResponse(url="/login") - # Bloquer planned si prereqs non valides if new_status == "planned" and not can_plan_campaign(db, campaign_id): return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=prereq_needed", status_code=303) update_campaign_status(db, campaign_id, new_status) @@ -147,43 +157,27 @@ async def session_prereq(request: Request, session_id: int, db=Depends(get_db), rollback_method or None, rollback_justif, user.get("sub")) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - cid = row.campaign_id if row else 0 - return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_saved#row-{session_id}", status_code=303) - - -@router.post("/campaigns/{campaign_id}/auto-exclude-failed") -async def auto_exclude_failed(request: Request, campaign_id: int, db=Depends(get_db)): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") - count = bulk_auto_exclude_failed_prereqs(db, campaign_id, user.get("sub")) - return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=auto_excluded_{count}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved#row-{session_id}", status_code=303) @router.post("/campaigns/{campaign_id}/check-prereqs") async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends(get_db)): - """Lance la verification automatique des prereqs pour toute la campagne""" user = get_current_user(request) if not user: return RedirectResponse(url="/login") checked, auto_excluded = check_prereqs_campaign(db, campaign_id) - return RedirectResponse( - url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}", - status_code=303 - ) + return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}", status_code=303) @router.post("/campaigns/session/{session_id}/check-prereq") async def session_check_prereq(request: Request, session_id: int, db=Depends(get_db)): - """Lance la verification prereq pour un seul serveur""" user = get_current_user(request) if not user: return RedirectResponse(url="/login") check_single_prereq(db, session_id) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - cid = row.campaign_id if row else 0 - return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_checked#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked#row-{session_id}", status_code=303) @router.post("/campaigns/session/{session_id}/exclude") @@ -193,11 +187,9 @@ async def session_exclude(request: Request, session_id: int, db=Depends(get_db), if not user: return RedirectResponse(url="/login") exclude_session(db, session_id, reason, detail, user.get("sub")) - # Retrouver campaign_id row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - cid = row.campaign_id if row else 0 - return RedirectResponse(url=f"/campaigns/{cid}?msg=excluded#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded#row-{session_id}", status_code=303) @router.post("/campaigns/session/{session_id}/restore") @@ -208,5 +200,86 @@ async def session_restore(request: Request, session_id: int, db=Depends(get_db)) restore_session(db, session_id) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - cid = row.campaign_id if row else 0 - return RedirectResponse(url=f"/campaigns/{cid}?msg=restored#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored#row-{session_id}", status_code=303) + + +# --- Assignation operateurs --- + +@router.post("/campaigns/session/{session_id}/take") +async def session_take(request: Request, session_id: int, db=Depends(get_db)): + """Operateur prend un serveur""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + row = db.execute(text("SELECT campaign_id, intervenant_id, forced_assignment FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + if row.intervenant_id: + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=already_taken", status_code=303) + # Verifier limite par campagne pour cet operateur + limit = get_operator_limit(db, row.campaign_id, user.get("uid")) + if limit > 0: + current = get_operator_count(db, row.campaign_id, user.get("uid")) + if current >= limit: + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=limit_reached", status_code=303) + assign_operator(db, session_id, user.get("uid")) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=taken#row-{session_id}", status_code=303) + + +@router.post("/campaigns/session/{session_id}/release") +async def session_release(request: Request, session_id: int, db=Depends(get_db)): + """Operateur se desassigne (sauf si forced)""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + if is_forced(db, session_id): + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=forced_cant_release", status_code=303) + unassign_operator(db, session_id) + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=released#row-{session_id}", status_code=303) + + +@router.post("/campaigns/session/{session_id}/assign") +async def session_assign(request: Request, session_id: int, db=Depends(get_db), + operator_id: str = Form(""), forced: str = Form("")): + """Coordinateur assigne un operateur (peut forcer)""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + oid = int(operator_id) if operator_id else None + is_forced_flag = forced == "on" + if oid: + assign_operator(db, session_id, oid, forced=is_forced_flag) + else: + unassign_operator(db, session_id) + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=assigned#row-{session_id}", status_code=303) + + +# --- Limites operateurs par campagne --- + +@router.post("/campaigns/{campaign_id}/operator-limit") +async def set_op_limit(request: Request, campaign_id: int, db=Depends(get_db), + operator_id: int = Form(...), max_servers: int = Form(0), + note: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + set_operator_limit(db, campaign_id, operator_id, max_servers, note or None) + return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=limit_set", status_code=303) + + +@router.post("/campaigns/session/{session_id}/schedule") +async def session_schedule(request: Request, session_id: int, db=Depends(get_db), + date_prevue: str = Form(""), heure_prevue: str = Form("")): + """Coordinateur ajuste date/heure""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + update_session_schedule(db, session_id, date_prevue or None, heure_prevue or None) + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=scheduled#row-{session_id}", status_code=303) diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index f9209a7..3c0b915 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -1,5 +1,5 @@ """Service campagnes — logique metier patching""" -from datetime import datetime +from datetime import datetime, date, timedelta from sqlalchemy import text @@ -36,6 +36,7 @@ def get_campaign_sessions(db, campaign_id): return db.execute(text(""" SELECT ps.*, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier, s.etat, s.ssh_method, s.licence_support, s.machine_type, + s.pref_patch_jour, s.pref_patch_heure, d.name as domaine, e.name as environnement, u.display_name as intervenant_name FROM patch_sessions ps @@ -46,15 +47,10 @@ def get_campaign_sessions(db, campaign_id): LEFT JOIN users u ON ps.intervenant_id = u.id WHERE ps.campaign_id = :cid ORDER BY CASE ps.status - WHEN 'in_progress' THEN 1 - WHEN 'pending' THEN 2 - WHEN 'prereq_ok' THEN 3 - WHEN 'patched' THEN 4 - WHEN 'failed' THEN 5 - WHEN 'reported' THEN 6 - WHEN 'excluded' THEN 7 - WHEN 'cancelled' THEN 8 - ELSE 9 END, s.hostname + WHEN 'in_progress' THEN 1 WHEN 'pending' THEN 2 WHEN 'prereq_ok' THEN 3 + WHEN 'patched' THEN 4 WHEN 'failed' THEN 5 WHEN 'reported' THEN 6 + WHEN 'excluded' THEN 7 WHEN 'cancelled' THEN 8 ELSE 9 END, + ps.date_prevue, s.hostname """), {"cid": campaign_id}).fetchall() @@ -69,13 +65,14 @@ def get_campaign_stats(db, campaign_id): COUNT(*) FILTER (WHERE status = 'skipped') as skipped, COUNT(*) FILTER (WHERE status = 'excluded') as excluded, COUNT(*) FILTER (WHERE status = 'reported') as reported, - COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled + COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled, + COUNT(*) FILTER (WHERE intervenant_id IS NOT NULL AND status NOT IN ('excluded','cancelled')) as assigned, + COUNT(*) FILTER (WHERE intervenant_id IS NULL AND status NOT IN ('excluded','cancelled')) as unassigned FROM patch_sessions WHERE campaign_id = :cid """), {"cid": campaign_id}).fetchone() def get_planning_for_week(db, year, week_number): - """Retourne les entrees planning pour une semaine donnee""" return db.execute(text(""" SELECT pp.*, d.name as domain_name FROM patch_planning pp @@ -85,91 +82,105 @@ def get_planning_for_week(db, year, week_number): """), {"y": year, "wn": week_number}).fetchall() +def _week_dates(year, week_number): + """Retourne lun, mar, mer, jeu de la semaine ISO""" + jan4 = date(year, 1, 4) + start_of_w1 = jan4 - timedelta(days=jan4.isoweekday() - 1) + monday = start_of_w1 + timedelta(weeks=week_number - 1) + return monday, monday + timedelta(1), monday + timedelta(2), monday + timedelta(3) + + def get_servers_for_planning(db, year, week_number): - """Retourne les serveurs a proposer pour une semaine du planning. - Inclut les domaines planifies + DMZ (toujours inclus).""" planning = get_planning_for_week(db, year, week_number) if not planning: return [], [] - # Construire les filtres domaine/env depuis le planning domain_envs = [] for p in planning: if p.domain_code == 'DMZ': - continue # DMZ traite separement + continue if p.env_scope == 'prod': domain_envs.append(("d.code = :dc_{0} AND e.name = 'Production'".format(len(domain_envs)), p.domain_code)) elif p.env_scope == 'hprod': domain_envs.append(("d.code = :dc_{0} AND e.name != 'Production'".format(len(domain_envs)), p.domain_code)) elif p.env_scope == 'prod_pilot': domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code)) - else: # all + else: domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code)) if not domain_envs: return [], planning - # Construire la clause OR or_clauses = [] params = {} for i, (clause, dc) in enumerate(domain_envs): or_clauses.append(clause) params[f"dc_{i}"] = dc - - # Toujours inclure DMZ or_clauses.append("d.code = 'DMZ'") where = f""" - s.etat = 'en_production' - AND s.patch_os_owner = 'secops' - AND s.licence_support IN ('active', 'els') + s.etat = 'en_production' AND s.patch_os_owner = 'secops' + AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux' AND ({' OR '.join(or_clauses)}) """ servers = db.execute(text(f""" SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier, s.licence_support, s.ssh_method, s.machine_type, + s.pref_patch_jour, s.pref_patch_heure, s.default_intervenant_id, d.name as domaine, d.code as domain_code, e.name as environnement FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id LEFT JOIN domains d ON de.domain_id = d.id LEFT JOIN environments e ON de.environment_id = e.id WHERE {where} - ORDER BY d.name, e.name, s.hostname + ORDER BY e.name, d.name, s.hostname """), params).fetchall() return servers, planning def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None): - """Cree une campagne depuis le planning avec exclusions""" servers, planning = get_servers_for_planning(db, year, week_number) if not servers: return None wc = f"S{week_number:02d}" - # Dates de la semaine + lun, mar, mer, jeu = _week_dates(year, week_number) p = planning[0] if planning else None - ds = p.week_start if p else None - de = p.week_end if p else None row = db.execute(text(""" INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by) VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid) RETURNING id - """), {"wc": wc, "y": year, "label": label, "ds": ds, "de": de, "uid": user_id}).fetchone() + """), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone() cid = row.id excluded = set(excluded_ids or []) for s in servers: status = 'excluded' if s.id in excluded else 'pending' - db.execute(text(""" - INSERT INTO patch_sessions (campaign_id, server_id, status) - VALUES (:cid, :sid, :st) - ON CONFLICT (campaign_id, server_id) DO NOTHING - """), {"cid": cid, "sid": s.id, "st": status}) + # Date par defaut : hors-prod = lun/mar, prod = mer/jeu + is_prod = (s.environnement == 'Production') + if s.pref_patch_jour and s.pref_patch_jour != 'indifferent': + jour_map = {"lundi": lun, "mardi": mar, "mercredi": mer, "jeudi": jeu} + date_prevue = jour_map.get(s.pref_patch_jour, mer if is_prod else lun) + else: + date_prevue = mer if is_prod else lun + + heure = s.pref_patch_heure if s.pref_patch_heure and s.pref_patch_heure != 'indifferent' else None + + # Auto-assigner le default intervenant si defini + default_op = s.default_intervenant_id if hasattr(s, 'default_intervenant_id') else None + forced = True if default_op else False + + db.execute(text(""" + INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue, heure_prevue, + intervenant_id, forced_assignment, assigned_at) + VALUES (:cid, :sid, :st, :dp, :hp, :oid, :forced, CASE WHEN :oid IS NOT NULL THEN now() END) + ON CONFLICT (campaign_id, server_id) DO NOTHING + """), {"cid": cid, "sid": s.id, "st": status, "dp": date_prevue, "hp": heure, + "oid": default_op, "forced": forced}) - # Update total count = db.execute(text( "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'" ), {"cid": cid}).scalar() @@ -181,34 +192,28 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude def exclude_session(db, session_id, reason, detail, username): - """Exclut un serveur d'une campagne avec motif""" db.execute(text(""" UPDATE patch_sessions SET status = 'excluded', exclusion_reason = :reason, - exclusion_detail = :detail, excluded_by = :by, - excluded_at = now() + exclusion_detail = :detail, excluded_by = :by, excluded_at = now() WHERE id = :id """), {"id": session_id, "reason": reason, "detail": detail, "by": username}) - # Recalculer total - row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), - {"id": session_id}).fetchone() - if row: - count = db.execute(text( - "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')" - ), {"cid": row.campaign_id}).scalar() - db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"), - {"c": count, "cid": row.campaign_id}) + _recalc_total(db, session_id) db.commit() def restore_session(db, session_id): - """Restaure un serveur exclu""" db.execute(text(""" UPDATE patch_sessions SET status = 'pending', exclusion_reason = NULL, exclusion_detail = NULL, excluded_by = NULL, excluded_at = NULL WHERE id = :id """), {"id": session_id}) + _recalc_total(db, session_id) + db.commit() + + +def _recalc_total(db, session_id): row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() if row: @@ -217,11 +222,81 @@ def restore_session(db, session_id): ), {"cid": row.campaign_id}).scalar() db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"), {"c": count, "cid": row.campaign_id}) + + +def assign_operator(db, session_id, operator_id, forced=False): + """Assigne un operateur a un serveur""" + db.execute(text(""" + UPDATE patch_sessions SET intervenant_id = :oid, assigned_at = now(), + forced_assignment = :forced + WHERE id = :id + """), {"id": session_id, "oid": operator_id, "forced": forced}) + db.commit() + + +def unassign_operator(db, session_id): + """Desassigne un operateur""" + db.execute(text(""" + UPDATE patch_sessions SET intervenant_id = NULL, assigned_at = NULL, forced_assignment = false + WHERE id = :id + """), {"id": session_id}) + db.commit() + + +def is_forced(db, session_id): + """Verifie si l'assignation est forcee""" + row = db.execute(text("SELECT forced_assignment FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + return row.forced_assignment if row else False + + +def get_operator_count(db, campaign_id, operator_id): + """Nombre de serveurs pris par un operateur dans cette campagne""" + return db.execute(text(""" + SELECT COUNT(*) FROM patch_sessions + WHERE campaign_id = :cid AND intervenant_id = :oid AND status NOT IN ('excluded','cancelled') + """), {"cid": campaign_id, "oid": operator_id}).scalar() + + +def get_operator_limit(db, campaign_id, operator_id): + """Limite pour un operateur dans cette campagne (0=illimite)""" + row = db.execute(text(""" + SELECT max_servers FROM campaign_operator_limits + WHERE campaign_id = :cid AND user_id = :uid + """), {"cid": campaign_id, "uid": operator_id}).fetchone() + return row.max_servers if row else 0 + + +def set_operator_limit(db, campaign_id, operator_id, max_servers, note=None): + """Definit la limite pour un operateur dans cette campagne""" + db.execute(text(""" + INSERT INTO campaign_operator_limits (campaign_id, user_id, max_servers, note) + VALUES (:cid, :uid, :max, :note) + ON CONFLICT (campaign_id, user_id) DO UPDATE SET max_servers = EXCLUDED.max_servers, note = EXCLUDED.note + """), {"cid": campaign_id, "uid": operator_id, "max": max_servers, "note": note}) + db.commit() + + +def get_campaign_operator_limits(db, campaign_id): + """Retourne les limites de tous les operateurs pour une campagne""" + return db.execute(text(""" + SELECT col.*, u.display_name + FROM campaign_operator_limits col + JOIN users u ON col.user_id = u.id + WHERE col.campaign_id = :cid ORDER BY u.display_name + """), {"cid": campaign_id}).fetchall() + + +def update_session_schedule(db, session_id, date_prevue, heure_prevue): + """Coordinateur ajuste la date/heure d'un serveur""" + db.execute(text(""" + UPDATE patch_sessions SET date_prevue = :dp, heure_prevue = :hp + WHERE id = :id + """), {"id": session_id, "dp": date_prevue or None, "hp": heure_prevue or None}) db.commit() def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, username): - """Valide les prereqs d'un serveur dans une campagne""" db.execute(text(""" UPDATE patch_sessions SET prereq_ssh = :ssh, prereq_satellite = :sat, @@ -234,24 +309,7 @@ def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, u db.commit() -def bulk_auto_exclude_failed_prereqs(db, campaign_id, username): - """Exclut automatiquement les serveurs qui n'ont pas passe les prereqs""" - failed = db.execute(text(""" - SELECT id FROM patch_sessions - WHERE campaign_id = :cid AND status = 'pending' - AND prereq_validated = false - AND prereq_date IS NOT NULL - AND (prereq_ssh = 'ko' OR prereq_satellite = 'ko' OR rollback_method IS NULL) - """), {"cid": campaign_id}).fetchall() - count = 0 - for r in failed: - exclude_session(db, r.id, "creneau_inadequat", "Prereqs non valides — report auto", username) - count += 1 - return count - - def get_prereq_stats(db, campaign_id): - """Stats prereqs d'une campagne""" return db.execute(text(""" SELECT COUNT(*) FILTER (WHERE status = 'pending') as total_pending, @@ -267,7 +325,6 @@ def get_prereq_stats(db, campaign_id): def can_plan_campaign(db, campaign_id): - """Verifie si la campagne peut passer en 'planned' (tous les prereqs pending valides)""" pending_not_validated = db.execute(text(""" SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status = 'pending' AND prereq_validated = false @@ -279,9 +336,3 @@ def update_campaign_status(db, campaign_id, new_status): db.execute(text("UPDATE campaigns SET status = :s WHERE id = :id"), {"s": new_status, "id": campaign_id}) db.commit() - - -def get_reference_data(db): - domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall() - envs = db.execute(text("SELECT code, name FROM environments ORDER BY display_order")).fetchall() - return domains, envs diff --git a/app/templates/audit.html b/app/templates/audit.html new file mode 100644 index 0000000..f7c0879 --- /dev/null +++ b/app/templates/audit.html @@ -0,0 +1,89 @@ +{% extends 'base.html' %} +{% block title %}Audit Serveurs{% endblock %} +{% block content %} +| Hostname | +Statut | +Connexion | +Kernel | +Uptime | +Disque | +Qualys | +S1 | +Sans auto | +Svc KO | +Detail | +
|---|---|---|---|---|---|---|---|---|---|---|
| {{ e.hostname }} | +{{ e.status[:10] }} | +{% if e.resolved_fqdn %}{{ e.resolved_fqdn[:25] }}{% else %}-{% endif %} | +{{ (e.kernel or '-')[:20] }} | +{{ (e.uptime or '-')[:15] }} | ++ {% if e.disk_alert %}ALERTE + {% elif e.status == 'OK' %}OK + {% else %}-{% endif %} + | +{% if e.qualys_active %}OK{% else %}KO{% endif %} | +{% if e.sentinelone_active %}OK{% else %}KO{% endif %} | +{% if e.running_not_enabled %}{{ e.running_not_enabled.split('\n')|length }}{% else %}-{% endif %} | +{% if e.failed_services %}{{ e.failed_services[:20] }}{% else %}-{% endif %} | ++ + | +
| Hostname | Domaine | Env | -OS | -Licence | +Tier | +Jour prevu | +Heure | +Operateur | {% if c.status == 'draft' %}SSH | -Satellite | -Rollback | +Sat | Disque | -Prereq | {% endif %}Statut | Actions | @@ -130,115 +111,92 @@|||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ s.hostname }} | -{{ s.domaine or '-' }} | +{{ s.hostname }} | +{{ s.domaine or '-' }} | {{ (s.environnement or '-')[:6] }} | -{{ s.os_family or '-' }} | -{{ s.licence_support }} | +{{ s.tier }} | +{% if s.date_prevue %}{% set jours = {0:'Lun',1:'Mar',2:'Mer',3:'Jeu',4:'Ven',5:'Sam',6:'Dim'} %}{{ jours[s.date_prevue.weekday()] }} {{ s.date_prevue.strftime('%d/%m') }}{% else %}-{% endif %} | +{{ s.heure_prevue or s.pref_patch_heure or '-' }} | ++ {% if s.intervenant_name %} + {{ s.intervenant_name }} + {% if s.forced_assignment %}🔒{% endif %} + {% else %}—{% endif %} + | {% if c.status == 'draft' %} -- {% if s.prereq_ssh == 'ok' %}OK - {% elif s.prereq_ssh == 'ko' %}KO - {% else %}-{% endif %} - | -- {% if s.prereq_satellite == 'ok' %}OK - {% elif s.prereq_satellite == 'ko' %}KO - {% elif s.prereq_satellite == 'na' %}N/A - {% else %}-{% endif %} - | -- {% if s.rollback_method %}{{ s.rollback_method }} - {% else %}-{% endif %} - | -- {% if s.prereq_disk_ok is true %}OK - {% elif s.prereq_disk_ok is false %}KO - {% else %}-{% endif %} - | -- {% if s.prereq_validated %}OK - {% elif s.prereq_date %}KO - {% else %}-{% endif %} - | +{% if s.prereq_ssh == 'ok' %}OK{% elif s.prereq_ssh == 'ko' %}KO{% else %}-{% endif %} | +{% if s.prereq_satellite == 'ok' %}OK{% elif s.prereq_satellite == 'ko' %}KO{% else %}-{% endif %} | +{% if s.prereq_disk_ok is true %}OK{% elif s.prereq_disk_ok is false %}KO{% else %}-{% endif %} | {% endif %}
{{ s.status }}
{% if s.exclusion_reason %}
-
- {% if s.exclusion_reason == 'eol' %}EOL
- {% elif s.exclusion_reason == 'creneau_inadequat' %}Creneau/Prereq
- {% elif s.exclusion_reason == 'intervention_non_secops' %}Non-SecOps
- {% elif s.exclusion_reason == 'report_cycle' %}Reporte
- {% elif s.exclusion_reason == 'non_patchable' %}Non patchable
- {% else %}{{ s.exclusion_reason }}{% endif %}
+
+ {% if s.exclusion_reason == 'eol' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prereq KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %}
{% if s.excluded_by %}({{ s.excluded_by }}){% endif %}
- {% if s.exclusion_detail %}{{ s.exclusion_detail[:60] }} {% endif %}
{% endif %}
|
-
- {% if s.status == 'excluded' %}
-
- {% elif s.status == 'pending' and c.status == 'draft' %}
-
-
-
-
-
+ |
+ {% if s.status == 'excluded' and is_coordinator %}
+
+
+ {% elif s.status == 'pending' %}
+ {% if c.status == 'planned' %}
+ {# Operateur: prendre/liberer #}
+ {% if not s.intervenant_id %}
+
+ {% elif s.intervenant_id == user.uid and not s.forced_assignment %}
+
+ {% endif %}
+ {# Coordinateur: assigner + planifier #}
+ {% if is_coordinator %}
+
+
+ {% endif %}
+
+ {% elif c.status == 'draft' and is_coordinator %}
+
+
+
+
+ {% endif %}
{% endif %}
|
| - | |||||||||||||||||||||
{{ e.disk_detail }}{% endif %}
+ {{ e.apps_installed }}
+ {{ e.services_running }}
+ {{ e.running_not_enabled }}
+ {{ e.containers }}
+ {{ e.listening_ports }}
+ {{ e.applis_scripts }}
+ {{ e.crontab_root }}
+ {{ e.network_mounts }}
+ {{ e.failed_services }}
+ {{ e.db_detected }}
+ {{ e.cluster_detected }}
+