From ba8a9693669c39555dcdf97a2997b493590292aa Mon Sep 17 00:00:00 2001 From: Khalid MOUTAOUAKIL Date: Sat, 4 Apr 2026 13:06:08 +0200 Subject: [PATCH] Campagnes: workflow complet, audit serveurs, assignation operateurs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Workflow: draft → pending_validation (COMEP) → planned → in_progress → completed - Prereqs auto: SSH, disque (1.2Go /, 800Mo /var), satellite - Assignation: operateurs prennent/liberent, coordinateur assigne/force - Limites par operateur par campagne (max_servers + raison) - Default intervenant permanent par serveur (auto-assigne) - Planning jours: lun+mar hors-prod, mer+jeu prod, jamais vendredi - Preferences serveur: pref_patch_jour, pref_patch_heure (permanents) - Audit serveurs: import Excel, 29 colonnes, KPIs, detail HTMX - Jours en francais (Lun, Mar, Mer, Jeu) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/main.py | 3 +- app/routers/audit.py | 84 ++++++ app/routers/campaigns.py | 149 ++++++++--- app/services/campaign_service.py | 197 ++++++++------ app/templates/audit.html | 89 +++++++ app/templates/base.html | 2 +- app/templates/campaign_detail.html | 318 +++++++++++------------ app/templates/partials/audit_detail.html | 128 +++++++++ 8 files changed, 694 insertions(+), 276 deletions(-) create mode 100644 app/routers/audit.py create mode 100644 app/templates/audit.html create mode 100644 app/templates/partials/audit_detail.html 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 %} +

Audit Serveurs ({{ stats.total }})

+ + + + + +
+ + + + + + + + + + + + + + + + {% for e in entries %} + + + + + + + + + + + + + + {% endfor %} + +
HostnameStatutConnexionKernelUptimeDisqueQualysS1Sans autoSvc KODetail
{{ 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 %} + +
+
+ + + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 6113144..c02ee87 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -59,7 +59,7 @@ Campagnes Planning Tags Qualys - Audit + Audit Utilisateurs Settings diff --git a/app/templates/campaign_detail.html b/app/templates/campaign_detail.html index 909417e..0f61f6f 100644 --- a/app/templates/campaign_detail.html +++ b/app/templates/campaign_detail.html @@ -8,97 +8,78 @@ ← Campagnes

{{ c.label or c.week_code }}

- {{ c.status }} + {{ c.status }} {{ c.week_code }} {{ c.year }} {% if c.date_start %}{{ c.date_start.strftime('%d/%m/%Y') }}{% if c.date_end %} → {{ c.date_end.strftime('%d/%m/%Y') }}{% endif %}{% endif %} par {{ c.created_by_name or '-' }}
- {% if c.status == 'draft' %} - {% if can_plan %} + {% if is_coordinator %} + {% if c.status == 'draft' %} + {% if can_plan %} +
+
+ {% else %} + + {% endif %} + {% elif c.status == 'pending_validation' %}
-
- {% else %} - + +
+
+ {% elif c.status == 'planned' %} +
+
+ {% elif c.status == 'in_progress' %} +
+
+ {% endif %} + {% if c.status in ('draft', 'pending_validation', 'planned') %} +
+
{% endif %} - {% elif c.status == 'planned' %} -
-
- {% elif c.status == 'in_progress' %} -
-
- {% endif %} - {% if c.status in ('draft', 'planned') %} -
-
{% endif %}
{% if msg %} -
- {% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restaure.{% elif msg == 'prereq_saved' %}Prereqs sauvegardes.{% elif msg == 'prereq_checked' %}Prereq re-verifie.{% elif msg == 'prereq_needed' %}Impossible de planifier : tous les serveurs pending doivent avoir leurs prereqs valides.{% elif msg.startswith('checked_') %}Verification terminee : {{ msg.split('_')[1] }} serveur(s) verifies, {{ msg.split('_')[2] }} auto-exclus.{% elif msg.startswith('auto_excluded_') %}{{ msg.split('_')[-1] }} serveur(s) exclus (prereqs KO).{% endif %} +
+ {% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restaure.{% elif msg == 'prereq_saved' %}Prereqs sauvegardes.{% elif msg == 'prereq_checked' %}Prereq verifie.{% elif msg == 'prereq_needed' %}Prereqs requis avant soumission COMEP.{% elif msg == 'taken' %}Serveur pris.{% elif msg == 'released' %}Serveur libere.{% elif msg == 'assigned' %}Operateur assigne.{% elif msg == 'scheduled' %}Planning ajuste.{% elif msg == 'limit_set' %}Limite operateur definie.{% elif msg == 'already_taken' %}Ce serveur est deja pris.{% elif msg == 'limit_reached' %}Limite de serveurs atteinte pour cette campagne.{% elif msg == 'forced_cant_release' %}Assignation forcee — seul le coordinateur peut modifier.{% elif msg.startswith('checked_') %}Verification: {{ msg.split('_')[1] }} verifies, {{ msg.split('_')[2] }} auto-exclus.{% endif %}
{% endif %} -
-
-
{{ stats.total }}
-
Total
-
-
-
{{ stats.patched }}
-
Patches
-
-
-
{{ stats.failed }}
-
Echoues
-
-
-
{{ stats.pending }}
-
En attente
-
-
-
{{ stats.excluded }}
-
Exclus
-
-
-
{{ stats.reported }}
-
Reportes
-
-
+
+
{{ stats.total }}
Total
+
{{ stats.patched }}
Patches
+
{{ stats.failed }}
Echoues
+
{{ stats.pending }}
En attente
+
{{ stats.excluded }}
Exclus
+
{{ stats.assigned }}
Assignes
+
{{ stats.unassigned }}
Libres
+
{% set patchable = stats.total - stats.excluded - stats.cancelled %} - {% if patchable > 0 %} -
{{ (stats.patched / patchable * 100)|int }}%
- {% else %}
-
{% endif %} -
Progression
+
{% if patchable > 0 %}{{ (stats.patched / patchable * 100)|int }}%{% else %}-{% endif %}
+
Progression
- -{% if c.status == 'draft' and prereq %} + +{% if c.status == 'draft' and prereq and is_coordinator %}

Prerequis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)

-
-
- -
- {% if prereq.prereq_ko > 0 %} -
- -
- {% endif %} -
+
+ +
A verifier{{ prereq.prereq_todo }}
-
SSH OK{{ prereq.ssh_ok }}
-
Satellite OK{{ prereq.sat_ok }}
-
Rollback OK{{ prereq.rollback_ok }}
-
Disque OK{{ prereq.disk_ok }}
+
SSH{{ prereq.ssh_ok }}
+
Satellite{{ prereq.sat_ok }}
+
Rollback{{ prereq.rollback_ok }}
+
Disque{{ prereq.disk_ok }}
{% if prereq.total_pending > 0 %}
@@ -109,20 +90,20 @@ {% endif %} -
- +
+
- - + + + + {% if c.status == 'draft' %} - - + - {% endif %} @@ -130,115 +111,92 @@ {% for s in sessions %} - - + + - - + + + + {% if c.status == 'draft' %} - - - - - + + + {% endif %} - - - {% if s.status == 'pending' and c.status == 'draft' %} - - + - - - + + + + @@ -247,4 +205,38 @@
Hostname Domaine EnvOSLicenceTierJour prevuHeureOperateurSSHSatelliteRollbackSat DisquePrereqStatut Actions
{{ 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 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 %} {{ 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 %}
-
- SSH: - - Satellite: - - Rollback: - - - - + + {# Formulaires inline #} + {% if s.status == 'pending' %} +
+ + + + +
-
- Motif: -
+ + - - - + + + + +
+
+ + + +
+ + +{% if is_coordinator and c.status in ('planned', 'pending_validation') %} +
+

Limites operateurs pour cette campagne

+ {% if op_limits %} +
+ {% for ol in op_limits %} +
+ {{ ol.display_name }} + max {{ ol.max_servers }}{% if ol.note %} — {{ ol.note }}{% endif %} +
+ {% endfor %} +
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endif %} {% endblock %} diff --git a/app/templates/partials/audit_detail.html b/app/templates/partials/audit_detail.html new file mode 100644 index 0000000..fd3c00e --- /dev/null +++ b/app/templates/partials/audit_detail.html @@ -0,0 +1,128 @@ +
+
+

{{ e.hostname }}

+ +
+ +
+ +
+

Systeme

+
+
Connexion: {{ e.connection_method or '-' }}
+
FQDN resolu: {{ e.resolved_fqdn or '-' }}
+
OS: {{ e.os_release or '-' }}
+
Kernel: {{ e.kernel or '-' }}
+
Uptime: {{ e.uptime or '-' }}
+
SELinux: {{ e.selinux or '-' }}
+
Audit: {{ e.audit_date.strftime('%d/%m/%Y %H:%M') if e.audit_date else '-' }}
+
+
+ + +
+

Agents & Securite

+
+
Qualys: {{ 'Actif' if e.qualys_active else 'Inactif' }}
+
SentinelOne: {{ 'Actif' if e.sentinelone_active else 'Inactif' }}
+ {% if e.agents %}
{{ e.agents }}
{% endif %} +
+
+
+ + +
+

Espace disque {% if e.disk_alert %}ALERTE{% endif %}

+ {% if e.disk_detail %}
{{ e.disk_detail }}
{% endif %} +
+ + + {% if e.apps_installed %} +
+

Applications installees

+
{{ e.apps_installed }}
+
+ {% endif %} + +
+ + {% if e.services_running %} +
+

Services running

+
{{ e.services_running }}
+
+ {% endif %} + + + {% if e.running_not_enabled %} +
+

Running SANS auto-start

+
{{ e.running_not_enabled }}
+
+ {% endif %} +
+ +
+ + {% if e.containers %} +
+

Containers

+
{{ e.containers }}
+
+ {% endif %} + + + {% if e.listening_ports %} +
+

Ports en ecoute

+
{{ e.listening_ports }}
+
+ {% endif %} +
+ +
+ + {% if e.applis_scripts %} +
+

Scripts /applis

+
{{ e.applis_scripts }}
+
+ {% endif %} + + + {% if e.crontab_root and e.crontab_root != 'empty' %} +
+

Crontab root

+
{{ e.crontab_root }}
+
+ {% endif %} +
+ + {% if e.network_mounts and e.network_mounts != 'none' %} +
+

Montages reseau

+
{{ e.network_mounts }}
+
+ {% endif %} + + {% if e.failed_services %} +
+

Services en echec

+
{{ e.failed_services }}
+
+ {% endif %} + + {% if e.db_detected and e.db_detected != 'done' %} +
+

Base de donnees

+
{{ e.db_detected }}
+
+ {% endif %} + + {% if e.cluster_detected and e.cluster_detected != 'no_cluster' %} +
+

Cluster

+
{{ e.cluster_detected }}
+
+ {% endif %} +