patchcenter/app/routers/campaigns.py
Khalid MOUTAOUAKIL ba8a969366 Campagnes: workflow complet, audit serveurs, assignation operateurs
- 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) <noreply@anthropic.com>
2026-04-04 13:06:08 +02:00

286 lines
13 KiB
Python

"""Router campagnes — creation, prereqs, assignation, workflow"""
from datetime import datetime
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..services.campaign_service import (
list_campaigns, get_campaign, get_campaign_sessions, get_campaign_stats,
create_campaign_from_planning, get_servers_for_planning,
update_campaign_status, exclude_session, restore_session,
validate_prereq, get_prereq_stats, can_plan_campaign,
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()
templates = Jinja2Templates(directory="app/templates")
EXCLUSION_REASONS = [
("eol", "Fin de vie (EOL)"),
("creneau_inadequat", "Creneau non adequat"),
("intervention_non_secops", "Intervention non-SecOps prevue"),
("report_cycle", "Report au cycle suivant"),
("non_patchable", "Serveur non patchable"),
("autre", "Autre"),
]
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)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if not year:
year = datetime.now().year
campaigns = list_campaigns(db, year=year, status=status)
now = datetime.now()
current_week = now.isocalendar()[1]
planned_weeks = db.execute(text("""
SELECT DISTINCT pp.week_number, pp.week_code, pp.week_start, pp.week_end,
string_agg(DISTINCT d.name || ' (' || pp.env_scope || ')', ', ' ORDER BY d.name || ' (' || pp.env_scope || ')') as scope
FROM patch_planning pp
LEFT JOIN domains d ON pp.domain_code = d.code
WHERE pp.year = :y AND pp.status = 'open' AND pp.domain_code IS NOT NULL
AND pp.week_number >= :cw
GROUP BY pp.week_number, pp.week_code, pp.week_start, pp.week_end
ORDER BY pp.week_number
"""), {"y": year, "cw": current_week}).fetchall()
return templates.TemplateResponse("campaigns.html", {
"request": request, "user": user, "app_name": APP_NAME,
"campaigns": campaigns, "year": year, "status_filter": status,
"planned_weeks": planned_weeks,
})
@router.get("/campaigns/preview", response_class=HTMLResponse)
async def campaign_preview(request: Request, db=Depends(get_db),
year: int = Query(...), week: int = Query(...)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
servers, planning = get_servers_for_planning(db, year, week)
scope = ", ".join(set(f"{p.domain_name} ({p.env_scope})" for p in planning if p.domain_code))
return templates.TemplateResponse("partials/campaign_preview.html", {
"request": request, "servers": servers, "scope": scope,
"week": week, "year": year, "count": len(servers),
})
@router.post("/campaigns/create")
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}")
excluded = []
for key in form.keys():
if key.startswith("exclude_"):
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)
@router.get("/campaigns/{campaign_id}", response_class=HTMLResponse)
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"),
})
@router.post("/campaigns/{campaign_id}/status")
async def campaign_status_change(request: Request, campaign_id: int,
db=Depends(get_db), new_status: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
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)
return RedirectResponse(url=f"/campaigns/{campaign_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/prereq")
async def session_prereq(request: Request, session_id: int, db=Depends(get_db),
prereq_ssh: str = Form(...), prereq_satellite: str = Form(...),
rollback_method: str = Form(""), rollback_justif: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
validate_prereq(db, session_id, prereq_ssh, prereq_satellite,
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()
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)):
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)
@router.post("/campaigns/session/{session_id}/check-prereq")
async def session_check_prereq(request: Request, session_id: int, db=Depends(get_db)):
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()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/exclude")
async def session_exclude(request: Request, session_id: int, db=Depends(get_db),
reason: str = Form(...), detail: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
exclude_session(db, session_id, reason, detail, user.get("sub"))
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=excluded#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/restore")
async def session_restore(request: Request, session_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
restore_session(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=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)