diff --git a/app/main.py b/app/main.py index 68e4f1c..43ca935 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from .config import APP_NAME, APP_VERSION from .dependencies import get_current_user, get_user_perms from .database import SessionLocal, SessionLocalDemo -from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, qualys_tags, quickwin, referentiel, patching, applications, patch_history +from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, qualys_tags, quickwin, referentiel, patching, applications, patch_history, duty class PermissionsMiddleware(BaseHTTPMiddleware): @@ -71,6 +71,7 @@ app.include_router(quickwin.router) app.include_router(referentiel.router) app.include_router(patching.router) app.include_router(patch_history.router) +app.include_router(duty.router) app.include_router(applications.router) diff --git a/app/routers/duty.py b/app/routers/duty.py new file mode 100644 index 0000000..9f0796b --- /dev/null +++ b/app/routers/duty.py @@ -0,0 +1,57 @@ +"""Router Tour de garde SecOps""" +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("/duty", response_class=HTMLResponse) +async def duty_page(request: Request, db=Depends(get_db), + year: str = Query("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + from datetime import datetime + year = int(year) if year and year.isdigit() else datetime.now().year + current_week = datetime.now().isocalendar()[1] + current_year = datetime.now().year + + rows = db.execute(text(""" + SELECT * FROM secops_duty + WHERE year = :y + ORDER BY week_number + """), {"y": year}).fetchall() + + years = db.execute(text(""" + SELECT DISTINCT year FROM secops_duty ORDER BY year DESC + """)).fetchall() + + # Competences (hardcoded pour l'instant, pourra etre en DB plus tard) + competences = [ + {"nom": "Ayoub", "s1": True, "commvault": False, "m365": True, "symantec": True}, + {"nom": "Mouaad", "s1": True, "commvault": False, "m365": True, "symantec": True}, + {"nom": "Khalid", "s1": True, "commvault": False, "m365": True, "symantec": True}, + {"nom": "Thierno", "s1": True, "commvault": True, "m365": True, "symantec": True}, + {"nom": "Paul", "s1": True, "commvault": True, "m365": True, "symantec": True}, + ] + + # Stats : qui a le plus de gardes + stats = {} + for r in rows: + for field in ["tdg_s1", "tdg_symantec", "tdg_m365", "tdg_dmz"]: + name = getattr(r, field, None) + if name: + stats[name] = stats.get(name, 0) + 1 + + return templates.TemplateResponse("duty.html", { + "request": request, "user": user, "app_name": APP_NAME, + "rows": rows, "year": year, "years": [y.year for y in years], + "current_week": current_week, "current_year": current_year, + "competences": competences, "stats": stats, + }) diff --git a/app/templates/base.html b/app/templates/base.html index 74570c1..ac32699 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -90,6 +90,7 @@ {% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}Config exclusions{% endif %} {% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}Validations{% endif %} Historique +Tour de garde {# Quickwin sous-groupe #} {% if p.campaigns or p.quickwin %} diff --git a/app/templates/duty.html b/app/templates/duty.html new file mode 100644 index 0000000..b24e811 --- /dev/null +++ b/app/templates/duty.html @@ -0,0 +1,125 @@ +{% extends 'base.html' %} +{% block title %}Tour de garde SecOps{% endblock %} +{% block content %} +
+
+

Tour de garde SecOps

+

Planning hebdomadaire des astreintes et responsabilites.

+
+
+ {% for y in years %}{{ y }}{% endfor %} +
+
+ + +{% for r in rows %} +{% if r.week_number == current_week and year == current_year %} +
+
+

Cette semaine — {{ r.week_code }}

+ {{ r.week_start.strftime('%d/%m') if r.week_start else '' }} → {{ r.week_end.strftime('%d/%m/%Y') if r.week_end else '' }} +
+ {% if r.absences %}

Absences : {{ r.absences }}

{% endif %} +
+
+
{{ r.tdg_s1 or '-' }}
+
Sentinel One
+
+
+
{{ r.tdg_symantec or '-' }}
+
Symantec
+
+
+
{{ r.tdg_m365 or '-' }}
+
M365
+
+
+
{{ r.tdg_commvault or '-' }}
+
Commvault
+
+
+
{{ r.tdg_dmz or '-' }}
+
DMZ
+
+
+
{{ r.tdg_meteo or '-' }}
+
Meteo
+
+
+
{{ r.tdg_incident_majeur or '-' }}
+
Incident Maj.
+
+
+
+{% endif %} +{% endfor %} + + +
+

Matrice competences

+ + + + + + + + + + + {% for c in competences %} + + + + + + + + + {% endfor %} + +
NomS1CommvaultM365SymantecGardes {{ year }}
{{ c.nom }}{% if c.s1 %}{% else %}{% endif %}{% if c.commvault %}{% else %}{% endif %}{% if c.m365 %}{% else %}{% endif %}{% if c.symantec %}{% else %}{% endif %}{{ stats.get(c.nom, 0) }}
+
+ + +
+ + + + + + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + + + + + + + {% endfor %} + +
Sem.DatesAbsencesS1SymantecM365CommvaultMeteoDMZSafeNetQuarant.Inc. Maj.
{{ r.week_code }}{{ r.week_start.strftime('%d/%m') if r.week_start else '' }} → {{ r.week_end.strftime('%d/%m') if r.week_end else '' }}{{ r.absences or '' }}{{ r.tdg_s1 or '-' }}{{ r.tdg_symantec or '-' }}{{ r.tdg_m365 or '-' }}{{ r.tdg_commvault or '-' }}{{ r.tdg_meteo or '-' }}{{ r.tdg_dmz or '-' }}{{ r.tdg_safenet or '-' }}{{ r.tdg_quarantaine or '-' }}{{ r.tdg_incident_majeur or '-' }}
+
+ + +{% endblock %} diff --git a/deploy/Tour de garde secops_2026.xlsx b/deploy/Tour de garde secops_2026.xlsx new file mode 100644 index 0000000..6a2bb3b Binary files /dev/null and b/deploy/Tour de garde secops_2026.xlsx differ diff --git a/deploy/migrations/2026-04-18_secops_duty.sql b/deploy/migrations/2026-04-18_secops_duty.sql new file mode 100644 index 0000000..0dc5e7d --- /dev/null +++ b/deploy/migrations/2026-04-18_secops_duty.sql @@ -0,0 +1,32 @@ +-- Migration 2026-04-18 : table tour de garde SecOps +BEGIN; + +CREATE TABLE IF NOT EXISTS secops_duty ( + id serial PRIMARY KEY, + year smallint NOT NULL, + week_number smallint NOT NULL, + week_code varchar(5) NOT NULL, + week_start date, + week_end date, + absences text, + tdg_s1 varchar(50), + tdg_symantec varchar(50), + tdg_m365 varchar(50), + tdg_commvault varchar(50), + tdg_meteo varchar(50), + tdg_dmz varchar(50), + tdg_safenet varchar(50), + tdg_quarantaine varchar(50), + tdg_securisation varchar(50), + tdg_incident_majeur varchar(50), + tdg_incident_critique varchar(50), + emails_dest varchar(100), + created_at timestamptz DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS secops_duty_year_week_uniq ON secops_duty (year, week_number); +CREATE INDEX IF NOT EXISTS secops_duty_week_idx ON secops_duty (year, week_number); + +COMMENT ON TABLE secops_duty IS 'Tour de garde SecOps hebdomadaire (source: Tour de garde secops_2026.xlsx)'; + +COMMIT; diff --git a/tools/import_tour_de_garde_xlsx.py b/tools/import_tour_de_garde_xlsx.py new file mode 100644 index 0000000..2d04e33 --- /dev/null +++ b/tools/import_tour_de_garde_xlsx.py @@ -0,0 +1,159 @@ +"""Import tour de garde SecOps depuis Tour de garde secops_2026.xlsx. + +Lit la feuille 'Tour de garde', UPSERT dans secops_duty. + +Usage: + python tools/import_tour_de_garde_xlsx.py [xlsx] [--truncate] +""" +import os +import sys +import re +import glob +from pathlib import Path +from datetime import date, datetime, timedelta + +import openpyxl +from sqlalchemy import create_engine, text + +ROOT = Path(__file__).resolve().parent.parent +DATABASE_URL = (os.getenv("DATABASE_URL_DEMO") + or os.getenv("DATABASE_URL") + or "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_db") + + +def parse_dates(c_val): + if not c_val: + return None, None + s = str(c_val).strip() + m = re.search(r"(\d{2})/(\d{2})/(\d{4}).*?(\d{2})/(\d{2})/(\d{4})", s) + if m: + d1 = date(int(m.group(3)), int(m.group(2)), int(m.group(1))) + d2 = date(int(m.group(6)), int(m.group(5)), int(m.group(4))) + return d1, d2 + return None, None + + +def s(val): + if val is None: + return None + t = str(val).strip() + return t or None + + +def find_xlsx(): + for p in [ + ROOT / "deploy" / "Tour de garde secops_2026.xlsx", + ROOT / "deploy" / "Tour_de_garde_secops_2026.xlsx", + ]: + if p.exists(): + return str(p) + hits = glob.glob(str(ROOT / "deploy" / "*our*garde*.xlsx")) + return hits[0] if hits else None + + +def parse_tour_de_garde(xlsx_path): + wb = openpyxl.load_workbook(xlsx_path, data_only=True) + ws_name = next((n for n in wb.sheetnames if "garde" in n.lower()), None) + if not ws_name: + raise SystemExit(f"[ERR] Sheet 'Tour de garde' introuvable. Sheets: {wb.sheetnames}") + ws = wb[ws_name] + + rows = [] + for i, row in enumerate(ws.iter_rows(values_only=True)): + if i == 0: + continue + week_code = row[0] + if not week_code or not str(week_code).strip().startswith("S"): + continue + + wc = str(week_code).strip() + m = re.match(r"S(\d+)", wc) + if not m: + continue + week_num = int(m.group(1)) + + d1, d2 = parse_dates(row[2]) + year = d1.year if d1 and d1.month > 6 else (d2.year if d2 else 2026) + if d1 and d1.month == 12 and week_num <= 1: + year = d2.year if d2 else d1.year + 1 + + rows.append({ + "year": year, + "week_number": week_num, + "week_code": wc, + "week_start": d1, + "week_end": d2, + "absences": s(row[1]), + "tdg_s1": s(row[3]), + "tdg_symantec": s(row[4]), + "tdg_m365": s(row[5]), + "emails_dest": s(row[6]), + "tdg_commvault": s(row[7]), + "tdg_meteo": s(row[8]), + "tdg_dmz": s(row[11]) if len(row) > 11 else None, + "tdg_safenet": s(row[12]) if len(row) > 12 else None, + "tdg_quarantaine": s(row[13]) if len(row) > 13 else None, + "tdg_securisation": s(row[14]) if len(row) > 14 else None, + "tdg_incident_majeur": s(row[16]) if len(row) > 16 else None, + "tdg_incident_critique": s(row[17]) if len(row) > 17 else None, + }) + return rows + + +SQL_UPSERT = text(""" + INSERT INTO secops_duty + (year, week_number, week_code, week_start, week_end, absences, + tdg_s1, tdg_symantec, tdg_m365, tdg_commvault, tdg_meteo, + tdg_dmz, tdg_safenet, tdg_quarantaine, tdg_securisation, + tdg_incident_majeur, tdg_incident_critique, emails_dest) + VALUES + (:year, :week_number, :week_code, :week_start, :week_end, :absences, + :tdg_s1, :tdg_symantec, :tdg_m365, :tdg_commvault, :tdg_meteo, + :tdg_dmz, :tdg_safenet, :tdg_quarantaine, :tdg_securisation, + :tdg_incident_majeur, :tdg_incident_critique, :emails_dest) + ON CONFLICT (year, week_number) DO UPDATE SET + week_code = EXCLUDED.week_code, + week_start = EXCLUDED.week_start, + week_end = EXCLUDED.week_end, + absences = EXCLUDED.absences, + tdg_s1 = EXCLUDED.tdg_s1, + tdg_symantec = EXCLUDED.tdg_symantec, + tdg_m365 = EXCLUDED.tdg_m365, + tdg_commvault = EXCLUDED.tdg_commvault, + tdg_meteo = EXCLUDED.tdg_meteo, + tdg_dmz = EXCLUDED.tdg_dmz, + tdg_safenet = EXCLUDED.tdg_safenet, + tdg_quarantaine = EXCLUDED.tdg_quarantaine, + tdg_securisation = EXCLUDED.tdg_securisation, + tdg_incident_majeur = EXCLUDED.tdg_incident_majeur, + tdg_incident_critique = EXCLUDED.tdg_incident_critique, + emails_dest = EXCLUDED.emails_dest +""") + + +def main(): + xlsx = sys.argv[1] if len(sys.argv) > 1 else find_xlsx() + if not xlsx or not os.path.exists(xlsx): + print("[ERR] Fichier Tour de garde introuvable. Place-le dans deploy/") + sys.exit(1) + + print(f"[INFO] Fichier: {xlsx}") + rows = parse_tour_de_garde(xlsx) + print(f"[INFO] Semaines parsees: {len(rows)}") + + engine = create_engine(DATABASE_URL) + print(f"[INFO] DB: {DATABASE_URL.rsplit('@', 1)[-1]}") + + truncate = "--truncate" in sys.argv + with engine.begin() as conn: + if truncate: + conn.execute(text("TRUNCATE TABLE secops_duty RESTART IDENTITY")) + print("[INFO] TRUNCATE secops_duty") + for r in rows: + conn.execute(SQL_UPSERT, r) + + print(f"[OK] UPSERT: {len(rows)} semaines") + + +if __name__ == "__main__": + main()