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
+
+
+ | Nom |
+ S1 |
+ Commvault |
+ M365 |
+ Symantec |
+ Gardes {{ year }} |
+
+
+ {% for c in competences %}
+
+ | {{ 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) }} |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+ | Sem. |
+ Dates |
+ Absences |
+ S1 |
+ Symantec |
+ M365 |
+ Commvault |
+ Meteo |
+ DMZ |
+ SafeNet |
+ Quarant. |
+ Inc. Maj. |
+
+
+ {% for r in rows %}
+
+ | {{ 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 '-' }} |
+
+ {% endfor %}
+
+
+
+
+
+{% 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()