Permissions DB, créneaux auto, assignations, audit Splunk, accents

- Permissions 100% depuis user_permissions (plus de hardcode)
- Middleware injecte perms dans chaque requête
- Créneaux auto: 09h-12h30 / 14h-16h45, pas 15min, hprod lun-mar, prod mer-jeu
- Assignations par défaut: par domaine, app_type, zone, serveur (table default_assignments)
- Auto-liaison app_group: même intervenant recette+prod
- Audit Splunk: /var/log/patchcenter_audit.json (JSON one-line par event)
- Login/logout/campagnes/prereqs loggés en base + fichier
- Page erreur maintenance (500/404) avec contact SecOps
- Accents français dans toute lUI
- Operator affiché comme Intervenant
- Session 1h, redirect / vers dashboard si connecté
- Demo mode prereqs (DEMO_MODE=True)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-04 15:25:43 +02:00
parent ba8a969366
commit 53c393b49b
26 changed files with 990 additions and 183 deletions

View File

@ -3,7 +3,7 @@ import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_db") DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_db")
SECRET_KEY = os.getenv("SECRET_KEY", "slpm-patchcenter-secret-key-2026-change-in-production") SECRET_KEY = os.getenv("SECRET_KEY", "slpm-patchcenter-secret-key-2026-change-in-production")
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 480 # 8 heures ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 8 heures
APP_NAME = "PatchCenter" APP_NAME = "PatchCenter"
APP_VERSION = "2.0" APP_VERSION = "2.0"

View File

@ -1,5 +1,6 @@
"""Dependances communes pour les routers""" """Dependances communes pour les routers"""
from fastapi import Request from fastapi import Request
from sqlalchemy import text
from .auth import decode_token from .auth import decode_token
from .database import SessionLocal from .database import SessionLocal
@ -18,3 +19,42 @@ def get_current_user(request: Request):
if not token: if not token:
return None return None
return decode_token(token) return decode_token(token)
def get_user_perms(db, user):
"""Charge les permissions depuis la base pour un user.
Retourne un dict {module: level} ex: {'servers': 'admin', 'campaigns': 'edit'}"""
if not user:
return {}
uid = user.get("uid")
if not uid:
return {}
rows = db.execute(text(
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
), {"uid": uid}).fetchall()
return {r.module: r.level for r in rows}
def can_view(perms, module):
"""L'utilisateur peut-il voir ce module ?"""
return module in perms
def can_edit(perms, module):
"""L'utilisateur peut-il editer ce module ?"""
return perms.get(module) in ("edit", "admin")
def can_admin(perms, module):
"""L'utilisateur a-t-il les droits admin sur ce module ?"""
return perms.get(module) == "admin"
def base_context(request, db, user):
"""Context de base pour tous les templates (user + perms)"""
perms = get_user_perms(db, user)
return {
"request": request,
"user": user,
"perms": perms,
}

View File

@ -1,11 +1,33 @@
"""PatchCenter v2 — Entry point FastAPI""" """PatchCenter v2 — Entry point FastAPI"""
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms
from .database import SessionLocal
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit
class PermissionsMiddleware(BaseHTTPMiddleware):
"""Injecte user + perms dans request.state pour tous les templates"""
async def dispatch(self, request: Request, call_next):
user = get_current_user(request)
perms = {}
if user:
db = SessionLocal()
try:
perms = get_user_perms(db, user)
finally:
db.close()
request.state.user = user
request.state.perms = perms
response = await call_next(request)
return response
app = FastAPI(title=APP_NAME, version=APP_VERSION) app = FastAPI(title=APP_NAME, version=APP_VERSION)
app.add_middleware(PermissionsMiddleware)
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router) app.include_router(auth.router)
@ -20,10 +42,44 @@ app.include_router(audit.router)
@app.get("/") @app.get("/")
async def root(): async def root(request: Request):
user = get_current_user(request)
if user:
return RedirectResponse(url="/dashboard")
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok", "app": APP_NAME, "version": APP_VERSION} return {"status": "ok", "app": APP_NAME, "version": APP_VERSION}
# --- Error handlers ---
from fastapi.templating import Jinja2Templates
_error_templates = Jinja2Templates(directory="app/templates")
@app.exception_handler(500)
async def internal_error(request: Request, exc):
return _error_templates.TemplateResponse("error.html", {
"request": request, "code": 500,
"title": "Application en maintenance",
"message": "Une erreur interne est survenue. L'équipe technique a été notifiée.",
}, status_code=500)
@app.exception_handler(404)
async def not_found(request: Request, exc):
return _error_templates.TemplateResponse("error.html", {
"request": request, "code": 404,
"title": "Page introuvable",
"message": "La page demandée n'existe pas.",
}, status_code=404)
@app.exception_handler(Exception)
async def generic_error(request: Request, exc):
import traceback
traceback.print_exc()
return _error_templates.TemplateResponse("error.html", {
"request": request, "code": 500,
"title": "Application en maintenance",
"message": "Une erreur interne est survenue. L'équipe technique a été notifiée.",
}, status_code=500)

View File

@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from ..dependencies import get_db, get_current_user from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..config import APP_NAME from ..config import APP_NAME
router = APIRouter() router = APIRouter()

View File

@ -4,6 +4,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from ..dependencies import get_db, get_current_user from ..dependencies import get_db, get_current_user
from ..auth import verify_password, create_access_token, hash_password from ..auth import verify_password, create_access_token, hash_password
from ..services.audit_service import log_login, log_logout, log_login_failed
from ..config import APP_NAME, APP_VERSION from ..config import APP_NAME, APP_VERSION
router = APIRouter() router = APIRouter()
@ -20,25 +21,35 @@ async def login(request: Request, username: str = Form(...), password: str = For
row = db.execute(text("SELECT id, username, password_hash, role FROM users WHERE LOWER(username) = LOWER(:u)"), row = db.execute(text("SELECT id, username, password_hash, role FROM users WHERE LOWER(username) = LOWER(:u)"),
{"u": username}).fetchone() {"u": username}).fetchone()
if not row: if not row:
log_login_failed(db, request, username)
db.commit()
return templates.TemplateResponse("login.html", { return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu" "request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
}) })
# Verifier mot de passe (bcrypt pour web, PBKDF2 legacy pour SLPM)
try: try:
ok = verify_password(password, row.password_hash) ok = verify_password(password, row.password_hash)
except Exception: except Exception:
ok = False ok = False
if not ok: if not ok:
log_login_failed(db, request, username)
db.commit()
return templates.TemplateResponse("login.html", { return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect" "request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect"
}) })
token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id}) token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id})
user = {"sub": row.username, "role": row.role, "uid": row.id}
log_login(db, request, user)
db.commit()
response = RedirectResponse(url="/dashboard", status_code=303) response = RedirectResponse(url="/dashboard", status_code=303)
response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax") response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600)
return response return response
@router.get("/logout") @router.get("/logout")
async def logout(): async def logout(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if user:
log_logout(db, request, user)
db.commit()
response = RedirectResponse(url="/login", status_code=302) response = RedirectResponse(url="/login", status_code=302)
response.delete_cookie("access_token") response.delete_cookie("access_token")
return response return response

View File

@ -4,7 +4,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from ..dependencies import get_db, get_current_user from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..services.campaign_service import ( from ..services.campaign_service import (
list_campaigns, get_campaign, get_campaign_sessions, get_campaign_stats, list_campaigns, get_campaign, get_campaign_sessions, get_campaign_stats,
create_campaign_from_planning, get_servers_for_planning, create_campaign_from_planning, get_servers_for_planning,
@ -16,6 +16,11 @@ from ..services.campaign_service import (
) )
from ..services.prereq_service import check_prereqs_campaign, check_single_prereq from ..services.prereq_service import check_prereqs_campaign, check_single_prereq
from ..services.secrets_service import get_secret from ..services.secrets_service import get_secret
from ..services.audit_service import (
log_campaign_create, log_campaign_status, log_campaign_delete,
log_session_exclude, log_session_assign, log_session_take, log_session_release,
log_prereq_check,
)
from ..config import APP_NAME from ..config import APP_NAME
router = APIRouter() router = APIRouter()
@ -47,6 +52,10 @@ async def campaigns_list(request: Request, db=Depends(get_db),
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
if not year: if not year:
year = datetime.now().year year = datetime.now().year
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/dashboard")
campaigns = list_campaigns(db, year=year, status=status) campaigns = list_campaigns(db, year=year, status=status)
now = datetime.now() now = datetime.now()
@ -62,11 +71,13 @@ async def campaigns_list(request: Request, db=Depends(get_db),
ORDER BY pp.week_number ORDER BY pp.week_number
"""), {"y": year, "cw": current_week}).fetchall() """), {"y": year, "cw": current_week}).fetchall()
return templates.TemplateResponse("campaigns.html", { ctx = base_context(request, db, user)
"request": request, "user": user, "app_name": APP_NAME, ctx.update({
"app_name": APP_NAME,
"campaigns": campaigns, "year": year, "status_filter": status, "campaigns": campaigns, "year": year, "status_filter": status,
"planned_weeks": planned_weeks, "planned_weeks": planned_weeks,
}) })
return templates.TemplateResponse("campaigns.html", ctx)
@router.get("/campaigns/preview", response_class=HTMLResponse) @router.get("/campaigns/preview", response_class=HTMLResponse)
@ -96,9 +107,19 @@ async def campaign_create(request: Request, db=Depends(get_db)):
for key in form.keys(): for key in form.keys():
if key.startswith("exclude_"): if key.startswith("exclude_"):
excluded.append(int(key.replace("exclude_", ""))) excluded.append(int(key.replace("exclude_", "")))
try:
cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded) cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded)
except Exception as e:
db.rollback()
err = str(e)
if "unique" in err.lower() or "duplicate" in err.lower():
return RedirectResponse(url=f"/campaigns?year={year}&msg=already_exists", status_code=303)
import traceback; traceback.print_exc()
return RedirectResponse(url=f"/campaigns?year={year}&msg=create_error", status_code=303)
if not cid: if not cid:
return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303) return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303)
log_campaign_create(db, request, user, cid, label)
db.commit()
return RedirectResponse(url=f"/campaigns/{cid}", status_code=303) return RedirectResponse(url=f"/campaigns/{cid}", status_code=303)
@ -113,25 +134,35 @@ async def campaign_detail(request: Request, campaign_id: int, db=Depends(get_db)
sessions = get_campaign_sessions(db, campaign_id) sessions = get_campaign_sessions(db, campaign_id)
stats = get_campaign_stats(db, campaign_id) stats = get_campaign_stats(db, campaign_id)
prereq = get_prereq_stats(db, campaign_id) prereq = get_prereq_stats(db, campaign_id)
can_plan = can_plan_campaign(db, campaign_id) can_plan_flag = can_plan_campaign(db, campaign_id)
role = user.get("role", "viewer") perms = get_user_perms(db, user)
is_coordinator = role in ("admin", "coordinator")
max_srv = _get_max_servers(db)
intervenants = db.execute(text( intervenants = db.execute(text(
"SELECT id, display_name FROM users WHERE is_active = true ORDER BY display_name" "SELECT id, display_name FROM users WHERE is_active = true AND role = 'operator' ORDER BY display_name"
)).fetchall() )).fetchall()
op_limits = get_campaign_operator_limits(db, campaign_id) op_limits = get_campaign_operator_limits(db, campaign_id)
return templates.TemplateResponse("campaign_detail.html", { # Compteur par operateur
"request": request, "user": user, "app_name": APP_NAME, op_counts = db.execute(text("""
SELECT u.display_name, COUNT(*) as count
FROM patch_sessions ps
JOIN users u ON ps.intervenant_id = u.id
WHERE ps.campaign_id = :cid AND ps.status NOT IN ('excluded','cancelled')
GROUP BY u.display_name ORDER BY count DESC
"""), {"cid": campaign_id}).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"c": campaign, "sessions": sessions, "stats": stats, "c": campaign, "sessions": sessions, "stats": stats,
"prereq": prereq, "can_plan": can_plan, "prereq": prereq, "can_plan": can_plan_flag,
"exclusion_reasons": EXCLUSION_REASONS, "exclusion_reasons": EXCLUSION_REASONS,
"is_coordinator": is_coordinator, "intervenants": intervenants, "can_edit_campaigns": can_edit(perms, "campaigns"),
"op_limits": op_limits, "can_admin_campaigns": can_admin(perms, "campaigns"),
"intervenants": intervenants, "op_limits": op_limits, "op_counts": op_counts,
"msg": request.query_params.get("msg"), "msg": request.query_params.get("msg"),
}) })
return templates.TemplateResponse("campaign_detail.html", ctx)
@router.post("/campaigns/{campaign_id}/status") @router.post("/campaigns/{campaign_id}/status")
@ -140,12 +171,40 @@ async def campaign_status_change(request: Request, campaign_id: int,
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=forbidden", status_code=303)
if new_status == "planned" and not can_plan_campaign(db, campaign_id): 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) return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=prereq_needed", status_code=303)
campaign = get_campaign(db, campaign_id)
old_status = campaign.status if campaign else "unknown"
update_campaign_status(db, campaign_id, new_status) update_campaign_status(db, campaign_id, new_status)
log_campaign_status(db, request, user, campaign_id, old_status, new_status)
db.commit()
return RedirectResponse(url=f"/campaigns/{campaign_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{campaign_id}", status_code=303)
@router.post("/campaigns/{campaign_id}/delete")
async def campaign_delete(request: Request, campaign_id: int, db=Depends(get_db)):
"""Supprime completement une campagne (admin/coordinateur)"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns", status_code=303)
campaign = get_campaign(db, campaign_id)
if not campaign:
return RedirectResponse(url="/campaigns", status_code=303)
year = campaign.year
# Supprimer sessions puis campagne
db.execute(text("DELETE FROM campaign_operator_limits WHERE campaign_id = :cid"), {"cid": campaign_id})
db.execute(text("DELETE FROM patch_sessions WHERE campaign_id = :cid"), {"cid": campaign_id})
db.execute(text("DELETE FROM campaigns WHERE id = :cid"), {"cid": campaign_id})
db.commit()
return RedirectResponse(url=f"/campaigns?year={year}&msg=deleted", status_code=303)
@router.post("/campaigns/session/{session_id}/prereq") @router.post("/campaigns/session/{session_id}/prereq")
async def session_prereq(request: Request, session_id: int, db=Depends(get_db), async def session_prereq(request: Request, session_id: int, db=Depends(get_db),
prereq_ssh: str = Form(...), prereq_satellite: str = Form(...), prereq_ssh: str = Form(...), prereq_satellite: str = Form(...),
@ -157,7 +216,7 @@ async def session_prereq(request: Request, session_id: int, db=Depends(get_db),
rollback_method or None, rollback_justif, user.get("sub")) rollback_method or None, rollback_justif, user.get("sub"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved#row-{session_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved", status_code=303)
@router.post("/campaigns/{campaign_id}/check-prereqs") @router.post("/campaigns/{campaign_id}/check-prereqs")
@ -166,6 +225,8 @@ async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends(
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
checked, auto_excluded = check_prereqs_campaign(db, campaign_id) checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
log_prereq_check(db, request, user, campaign_id, checked, auto_excluded)
db.commit()
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)
@ -177,7 +238,7 @@ async def session_check_prereq(request: Request, session_id: int, db=Depends(get
check_single_prereq(db, session_id) check_single_prereq(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked#row-{session_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked", status_code=303)
@router.post("/campaigns/session/{session_id}/exclude") @router.post("/campaigns/session/{session_id}/exclude")
@ -189,7 +250,7 @@ async def session_exclude(request: Request, session_id: int, db=Depends(get_db),
exclude_session(db, session_id, reason, detail, user.get("sub")) exclude_session(db, session_id, reason, detail, user.get("sub"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded#row-{session_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded", status_code=303)
@router.post("/campaigns/session/{session_id}/restore") @router.post("/campaigns/session/{session_id}/restore")
@ -200,7 +261,7 @@ async def session_restore(request: Request, session_id: int, db=Depends(get_db))
restore_session(db, session_id) restore_session(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored#row-{session_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored", status_code=303)
# --- Assignation operateurs --- # --- Assignation operateurs ---
@ -222,7 +283,7 @@ async def session_take(request: Request, session_id: int, db=Depends(get_db)):
if current >= limit: if current >= limit:
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=limit_reached", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=limit_reached", status_code=303)
assign_operator(db, session_id, user.get("uid")) assign_operator(db, session_id, user.get("uid"))
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=taken#row-{session_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=taken", status_code=303)
@router.post("/campaigns/session/{session_id}/release") @router.post("/campaigns/session/{session_id}/release")
@ -238,7 +299,7 @@ async def session_release(request: Request, session_id: int, db=Depends(get_db))
unassign_operator(db, session_id) unassign_operator(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=released#row-{session_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=released", status_code=303)
@router.post("/campaigns/session/{session_id}/assign") @router.post("/campaigns/session/{session_id}/assign")
@ -256,7 +317,7 @@ async def session_assign(request: Request, session_id: int, db=Depends(get_db),
unassign_operator(db, session_id) unassign_operator(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=assigned#row-{session_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=assigned", status_code=303)
# --- Limites operateurs par campagne --- # --- Limites operateurs par campagne ---
@ -272,6 +333,71 @@ async def set_op_limit(request: Request, campaign_id: int, db=Depends(get_db),
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=limit_set", status_code=303) return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=limit_set", status_code=303)
# --- Assignations par defaut ---
@router.get("/assignments", response_class=HTMLResponse)
async def assignments_page(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
rules = db.execute(text("""
SELECT da.*, u.display_name FROM default_assignments da
JOIN users u ON da.user_id = u.id ORDER BY da.priority, da.rule_type
""")).fetchall()
operators = db.execute(text(
"SELECT id, display_name FROM users WHERE is_active = true AND role = 'operator' ORDER BY display_name"
)).fetchall()
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
zones = db.execute(text("SELECT DISTINCT name FROM zones ORDER BY name")).fetchall()
app_types = db.execute(text(
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
)).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "rules": rules, "intervenants": operators,
"domains": domains, "zones": zones,
"app_types": [r.app_type for r in app_types],
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("assignments.html", ctx)
@router.post("/assignments/add")
async def assignment_add(request: Request, db=Depends(get_db),
rule_type: str = Form(...), rule_value: str = Form(...),
user_id: int = Form(...), priority: int = Form(10),
note: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
try:
db.execute(text("""
INSERT INTO default_assignments (rule_type, rule_value, user_id, priority, note)
VALUES (:rt, :rv, :uid, :p, :n)
"""), {"rt": rule_type, "rv": rule_value.strip(), "uid": user_id,
"p": priority, "n": note or None})
db.commit()
return RedirectResponse(url="/assignments?msg=added", status_code=303)
except Exception:
db.rollback()
return RedirectResponse(url="/assignments?msg=error", status_code=303)
@router.post("/assignments/{rule_id}/delete")
async def assignment_delete(request: Request, rule_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("DELETE FROM default_assignments WHERE id = :id"), {"id": rule_id})
db.commit()
return RedirectResponse(url="/assignments?msg=deleted", status_code=303)
@router.post("/campaigns/session/{session_id}/schedule") @router.post("/campaigns/session/{session_id}/schedule")
async def session_schedule(request: Request, session_id: int, db=Depends(get_db), async def session_schedule(request: Request, session_id: int, db=Depends(get_db),
date_prevue: str = Form(""), heure_prevue: str = Form("")): date_prevue: str = Form(""), heure_prevue: str = Form("")):
@ -282,4 +408,4 @@ async def session_schedule(request: Request, session_id: int, db=Depends(get_db)
update_session_schedule(db, session_id, date_prevue or None, heure_prevue or None) 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"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=scheduled#row-{session_id}", status_code=303) return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=scheduled", status_code=303)

View File

@ -4,7 +4,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from ..dependencies import get_db, get_current_user from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..config import APP_NAME from ..config import APP_NAME
router = APIRouter() router = APIRouter()
@ -83,8 +83,9 @@ async def planning_page(request: Request, db=Depends(get_db),
if next_week > 53: if next_week > 53:
next_week = 1 next_week = 1
perms = get_user_perms(db, user)
return templates.TemplateResponse("planning.html", { return templates.TemplateResponse("planning.html", {
"request": request, "user": user, "app_name": APP_NAME, "request": request, "user": user, "perms": perms, "app_name": APP_NAME,
"year": year, "domains": domains, "grid": grid, "year": year, "domains": domains, "grid": grid,
"freeze_weeks": freeze_weeks, "months": MONTHS, "freeze_weeks": freeze_weeks, "months": MONTHS,
"domain_colors": DOMAIN_COLORS, "weeks": range(1, 54), "domain_colors": DOMAIN_COLORS, "weeks": range(1, 54),

View File

@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from ..dependencies import get_db, get_current_user from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..config import APP_NAME from ..config import APP_NAME
router = APIRouter() router = APIRouter()

View File

@ -3,14 +3,14 @@ from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from ..dependencies import get_db, get_current_user from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..auth import hash_password from ..auth import hash_password
from ..config import APP_NAME from ..config import APP_NAME
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users"] MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users", "planning", "specifics"]
LEVELS = ["view", "edit", "admin"] LEVELS = ["view", "edit", "admin"]
@ -30,44 +30,67 @@ def _get_users_with_perms(db):
return result return result
@router.get("/users", response_class=HTMLResponse) def _check_access(request, db):
async def users_page(request: Request, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return None, None, RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "users"):
return None, None, RedirectResponse(url="/dashboard")
return user, perms, None
@router.get("/users", response_class=HTMLResponse)
async def users_page(request: Request, db=Depends(get_db)):
user, perms, redirect = _check_access(request, db)
if redirect:
return redirect
users_data = _get_users_with_perms(db) users_data = _get_users_with_perms(db)
return templates.TemplateResponse("users.html", { ctx = base_context(request, db, user)
"request": request, "user": user, "app_name": APP_NAME, ctx.update({
"users_data": users_data, "modules": MODULES, "levels": LEVELS, "app_name": APP_NAME, "users_data": users_data,
"saved": None, "modules": MODULES, "levels": LEVELS,
"can_edit_users": can_edit(perms, "users"),
"msg": request.query_params.get("msg"),
}) })
return templates.TemplateResponse("users.html", ctx)
@router.post("/users/add", response_class=HTMLResponse) @router.post("/users/add")
async def user_add(request: Request, db=Depends(get_db), async def user_add(request: Request, db=Depends(get_db),
new_username: str = Form(...), new_display_name: str = Form(...), new_username: str = Form(...), new_display_name: str = Form(...),
new_email: str = Form(""), new_password: str = Form(...), new_email: str = Form(""), new_password: str = Form(...),
new_role: str = Form("operator")): new_role: str = Form("operator")):
user = get_current_user(request) user, perms, redirect = _check_access(request, db)
if not user: if redirect:
return RedirectResponse(url="/login") return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
# Verifier si username existe deja
existing = db.execute(text("SELECT id, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
{"u": new_username.strip()}).fetchone()
if existing:
if not existing.is_active:
return RedirectResponse(url=f"/users?msg=exists_inactive", status_code=303)
return RedirectResponse(url=f"/users?msg=exists", status_code=303)
pw_hash = hash_password(new_password) pw_hash = hash_password(new_password)
db.execute(text(""" db.execute(text("""
INSERT INTO users (username, display_name, email, password_hash, role) INSERT INTO users (username, display_name, email, password_hash, role)
VALUES (:u, :dn, :e, :ph, :r) VALUES (:u, :dn, :e, :ph, :r)
"""), {"u": new_username, "dn": new_display_name, "e": new_email or None, """), {"u": new_username.strip(), "dn": new_display_name, "e": new_email or None,
"ph": pw_hash, "r": new_role}) "ph": pw_hash, "r": new_role})
# Recuperer l'id du nouveau user row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username.strip()}).fetchone()
row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username}).fetchone()
if row: if row:
# Permissions par defaut selon role
default_perms = { default_perms = {
"admin": {m: "admin" for m in MODULES}, "admin": {m: "admin" for m in MODULES},
"coordinator": {"servers": "edit", "campaigns": "admin", "qualys": "edit", "audit": "view", "settings": "view", "users": "view"}, "coordinator": {"servers": "admin", "campaigns": "admin", "qualys": "admin", "audit": "admin",
"operator": {"servers": "edit", "campaigns": "edit", "qualys": "view", "audit": "view"}, "settings": "view", "users": "view", "planning": "admin", "specifics": "admin"},
"viewer": {"servers": "view", "campaigns": "view", "qualys": "view", "audit": "view"}, "operator": {"servers": "admin", "campaigns": "view", "qualys": "admin", "audit": "admin",
"settings": "view", "planning": "view", "specifics": "admin"},
"viewer": {"servers": "view", "campaigns": "view", "audit": "view", "planning": "view"},
} }
for mod, lvl in default_perms.get(new_role, {}).items(): for mod, lvl in default_perms.get(new_role, {}).items():
db.execute(text( db.execute(text(
@ -75,24 +98,19 @@ async def user_add(request: Request, db=Depends(get_db),
), {"uid": row.id, "m": mod, "l": lvl}) ), {"uid": row.id, "m": mod, "l": lvl})
db.commit() db.commit()
users_data = _get_users_with_perms(db) return RedirectResponse(url="/users?msg=added", status_code=303)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS,
"saved": "add",
})
@router.post("/users/{user_id}/permissions", response_class=HTMLResponse) @router.post("/users/{user_id}/permissions")
async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)): async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)):
user = get_current_user(request) user, perms, redirect = _check_access(request, db)
if not user: if redirect:
return RedirectResponse(url="/login") return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
form = await request.form() form = await request.form()
# Supprimer les anciennes permissions
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id}) db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
# Inserer les nouvelles
for mod in MODULES: for mod in MODULES:
lvl = form.get(f"perm_{mod}", "") lvl = form.get(f"perm_{mod}", "")
if lvl and lvl in LEVELS: if lvl and lvl in LEVELS:
@ -100,43 +118,73 @@ async def user_permissions_save(request: Request, user_id: int, db=Depends(get_d
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l)" "INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l)"
), {"uid": user_id, "m": mod, "l": lvl}) ), {"uid": user_id, "m": mod, "l": lvl})
db.commit() db.commit()
return RedirectResponse(url=f"/users?msg=perms_saved", status_code=303)
users_data = _get_users_with_perms(db)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS,
"saved": f"perms_{user_id}",
})
@router.post("/users/{user_id}/toggle", response_class=HTMLResponse) @router.post("/users/{user_id}/edit")
async def user_edit(request: Request, user_id: int, db=Depends(get_db),
display_name: str = Form(""), email: str = Form(""),
role: str = Form("")):
user, perms, redirect = _check_access(request, db)
if redirect:
return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
updates = []
params = {"id": user_id}
if display_name:
updates.append("display_name = :dn"); params["dn"] = display_name
if email:
updates.append("email = :em"); params["em"] = email
if role:
updates.append("role = :r"); params["r"] = role
if updates:
db.execute(text(f"UPDATE users SET {', '.join(updates)} WHERE id = :id"), params)
db.commit()
return RedirectResponse(url="/users?msg=edited", status_code=303)
@router.post("/users/{user_id}/toggle")
async def user_toggle(request: Request, user_id: int, db=Depends(get_db)): async def user_toggle(request: Request, user_id: int, db=Depends(get_db)):
user = get_current_user(request) user, perms, redirect = _check_access(request, db)
if not user: if redirect:
return RedirectResponse(url="/login") return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
# Empecher de se desactiver soi-meme
if user_id == user.get("uid"):
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id = :id"), {"id": user_id}) db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id = :id"), {"id": user_id})
db.commit() db.commit()
users_data = _get_users_with_perms(db) return RedirectResponse(url="/users?msg=toggled", status_code=303)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS,
"saved": "toggle",
})
@router.post("/users/{user_id}/password", response_class=HTMLResponse) @router.post("/users/{user_id}/password")
async def user_password(request: Request, user_id: int, db=Depends(get_db), async def user_password(request: Request, user_id: int, db=Depends(get_db),
new_password: str = Form(...)): new_password: str = Form(...)):
user = get_current_user(request) user, perms, redirect = _check_access(request, db)
if not user: if redirect:
return RedirectResponse(url="/login") return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
pw_hash = hash_password(new_password) pw_hash = hash_password(new_password)
db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"), db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"),
{"ph": pw_hash, "id": user_id}) {"ph": pw_hash, "id": user_id})
db.commit() db.commit()
users_data = _get_users_with_perms(db) return RedirectResponse(url="/users?msg=password_changed", status_code=303)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS, @router.post("/users/{user_id}/delete")
"saved": "password", async def user_delete(request: Request, user_id: int, db=Depends(get_db)):
}) user, perms, redirect = _check_access(request, db)
if redirect:
return redirect
if not can_admin(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
if user_id == user.get("uid"):
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
db.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id})
db.commit()
return RedirectResponse(url="/users?msg=deleted", status_code=303)

View File

@ -0,0 +1,139 @@
"""Service audit — log centralise de toutes les actions pour Splunk"""
import json
import logging
from datetime import datetime
from fastapi import Request
from sqlalchemy import text
logger = logging.getLogger("patchcenter.audit")
# Format JSON structure pour Splunk (une ligne par event)
LOG_FORMAT = '%(message)s'
handler = logging.FileHandler("/var/log/patchcenter_audit.json")
handler.setFormatter(logging.Formatter(LOG_FORMAT))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
def log_action(db, request: Request, user: dict, action: str,
entity_type: str = None, entity_id: int = None,
details: dict = None):
"""Log une action dans la base ET dans le fichier JSON pour Splunk"""
username = user.get("sub", "system") if user else "system"
uid = user.get("uid") if user else None
ip = _get_client_ip(request) if request else None
# Insert en base
db.execute(text("""
INSERT INTO audit_log (user_id, username, action, entity_type, entity_id, details, ip_address)
VALUES (:uid, :un, :action, :et, :eid, :details, :ip)
"""), {
"uid": uid, "un": username, "action": action,
"et": entity_type, "eid": entity_id,
"details": json.dumps(details) if details else None,
"ip": ip,
})
# Log fichier JSON (Splunk-ready)
event = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"app": "patchcenter",
"action": action,
"username": username,
"user_id": uid,
"entity_type": entity_type,
"entity_id": entity_id,
"ip": ip,
"details": details,
}
logger.info(json.dumps(event, ensure_ascii=False))
def _get_client_ip(request: Request):
"""Extrait l'IP client (supporte X-Forwarded-For derriere nginx)"""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return None
# === Actions predefinies ===
def log_login(db, request, user):
log_action(db, request, user, "LOGIN", "user", user.get("uid"))
def log_logout(db, request, user):
log_action(db, request, user, "LOGOUT", "user", user.get("uid"))
def log_login_failed(db, request, username):
log_action(db, request, None, "LOGIN_FAILED", "user", None,
{"username": username})
def log_campaign_create(db, request, user, campaign_id, label):
log_action(db, request, user, "CAMPAIGN_CREATE", "campaign", campaign_id,
{"label": label})
def log_campaign_status(db, request, user, campaign_id, old_status, new_status):
log_action(db, request, user, "CAMPAIGN_STATUS", "campaign", campaign_id,
{"old": old_status, "new": new_status})
def log_campaign_delete(db, request, user, campaign_id, label):
log_action(db, request, user, "CAMPAIGN_DELETE", "campaign", campaign_id,
{"label": label})
def log_session_exclude(db, request, user, session_id, hostname, reason):
log_action(db, request, user, "SESSION_EXCLUDE", "patch_session", session_id,
{"hostname": hostname, "reason": reason})
def log_session_assign(db, request, user, session_id, hostname, operator):
log_action(db, request, user, "SESSION_ASSIGN", "patch_session", session_id,
{"hostname": hostname, "operator": operator})
def log_session_take(db, request, user, session_id, hostname):
log_action(db, request, user, "SESSION_TAKE", "patch_session", session_id,
{"hostname": hostname})
def log_session_release(db, request, user, session_id, hostname):
log_action(db, request, user, "SESSION_RELEASE", "patch_session", session_id,
{"hostname": hostname})
def log_server_edit(db, request, user, server_id, hostname, changes):
log_action(db, request, user, "SERVER_EDIT", "server", server_id,
{"hostname": hostname, "changes": changes})
def log_prereq_check(db, request, user, campaign_id, checked, excluded):
log_action(db, request, user, "PREREQ_CHECK", "campaign", campaign_id,
{"checked": checked, "auto_excluded": excluded})
def log_user_create(db, request, user, new_user_id, new_username):
log_action(db, request, user, "USER_CREATE", "user", new_user_id,
{"new_username": new_username})
def log_user_edit(db, request, user, target_user_id, changes):
log_action(db, request, user, "USER_EDIT", "user", target_user_id,
{"changes": changes})
def log_user_delete(db, request, user, target_user_id, username):
log_action(db, request, user, "USER_DELETE", "user", target_user_id,
{"deleted_username": username})
def log_user_toggle(db, request, user, target_user_id, new_state):
log_action(db, request, user, "USER_TOGGLE", "user", target_user_id,
{"active": new_state})
def log_permissions_change(db, request, user, target_user_id, perms):
log_action(db, request, user, "PERMISSIONS_CHANGE", "user", target_user_id,
{"permissions": perms})
def log_setting_change(db, request, user, section):
log_action(db, request, user, "SETTING_CHANGE", "settings", None,
{"section": section})
def log_planning_change(db, request, user, action_type, entry_id=None, details=None):
log_action(db, request, user, f"PLANNING_{action_type}", "planning", entry_id, details)
def log_qualys_sync(db, request, user, server_id, hostname, result):
log_action(db, request, user, "QUALYS_SYNC", "server", server_id,
{"hostname": hostname, "result": result})

View File

@ -127,7 +127,7 @@ def get_servers_for_planning(db, year, week_number):
servers = db.execute(text(f""" servers = db.execute(text(f"""
SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier, 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.licence_support, s.ssh_method, s.machine_type,
s.pref_patch_jour, s.pref_patch_heure, s.default_intervenant_id, s.pref_patch_jour, s.pref_patch_heure, s.default_intervenant_id, s.app_group,
d.name as domaine, d.code as domain_code, e.name as environnement d.name as domaine, d.code as domain_code, e.name as environnement
FROM servers s FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id LEFT JOIN domain_environments de ON s.domain_env_id = de.id
@ -140,6 +140,29 @@ def get_servers_for_planning(db, year, week_number):
return servers, planning return servers, planning
def _generate_slots():
"""Genere les creneaux horaires de la journee: 09h00-12h30 + 14h00-16h45 par pas de 15min"""
slots = []
# Matin: 09h00 a 12h15 (dernier debut = 12h15, fin 12h30)
h, m = 9, 0
while h < 12 or (h == 12 and m <= 15):
slots.append(f"{h:02d}h{m:02d}")
m += 15
if m >= 60:
m = 0; h += 1
# Apres-midi: 14h00 a 16h30 (dernier debut = 16h30, fin 16h45)
h, m = 14, 0
while h < 16 or (h == 16 and m <= 30):
slots.append(f"{h:02d}h{m:02d}")
m += 15
if m >= 60:
m = 0; h += 1
return slots
DAILY_SLOTS = _generate_slots() # 27 slots par jour
def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None): def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None):
servers, planning = get_servers_for_planning(db, year, week_number) servers, planning = get_servers_for_planning(db, year, week_number)
if not servers: if not servers:
@ -147,7 +170,6 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude
wc = f"S{week_number:02d}" wc = f"S{week_number:02d}"
lun, mar, mer, jeu = _week_dates(year, week_number) lun, mar, mer, jeu = _week_dates(year, week_number)
p = planning[0] if planning else None
row = db.execute(text(""" row = db.execute(text("""
INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by) INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by)
@ -155,32 +177,90 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude
RETURNING id RETURNING id
"""), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone() """), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone()
cid = row.id cid = row.id
excluded = set(excluded_ids or []) excluded = set(excluded_ids or [])
for s in servers:
status = 'excluded' if s.id in excluded else 'pending' # Separer hors-prod et prod
# Date par defaut : hors-prod = lun/mar, prod = mer/jeu hprod_servers = [s for s in servers if s.environnement != 'Production' and s.id not in excluded]
is_prod = (s.environnement == 'Production') prod_servers = [s for s in servers if s.environnement == 'Production' and s.id not in excluded]
excluded_servers = [s for s in servers if s.id in excluded]
# Trier par app_group pour grouper les serveurs du meme applicatif
hprod_servers.sort(key=lambda s: (s.app_group or '', s.hostname))
prod_servers.sort(key=lambda s: (s.app_group or '', s.hostname))
# Attribuer les creneaux
# Hors-prod: lundi + mardi
hprod_jours = [lun, mar]
slot_idx = 0
jour_idx = 0
for s in hprod_servers:
jour = hprod_jours[jour_idx]
heure = DAILY_SLOTS[slot_idx]
# Preference serveur
if s.pref_patch_jour and s.pref_patch_jour != 'indifferent': if s.pref_patch_jour and s.pref_patch_jour != 'indifferent':
jour_map = {"lundi": lun, "mardi": mar, "mercredi": mer, "jeudi": jeu} jour_map = {"lundi": lun, "mardi": mar}
date_prevue = jour_map.get(s.pref_patch_jour, mer if is_prod else lun) jour = jour_map.get(s.pref_patch_jour, jour)
else: if s.pref_patch_heure and s.pref_patch_heure != 'indifferent':
date_prevue = mer if is_prod else lun heure = s.pref_patch_heure
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 default_op = s.default_intervenant_id if hasattr(s, 'default_intervenant_id') else None
forced = True if default_op else False forced = True if default_op else False
db.execute(text(""" db.execute(text("""
INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue, heure_prevue, INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue, heure_prevue,
intervenant_id, forced_assignment, assigned_at) intervenant_id, forced_assignment, assigned_at)
VALUES (:cid, :sid, :st, :dp, :hp, :oid, :forced, CASE WHEN :oid IS NOT NULL THEN now() END) VALUES (:cid, :sid, 'pending', :dp, :hp, :oid, :forced, CASE WHEN :oid IS NOT NULL THEN now() END)
ON CONFLICT (campaign_id, server_id) DO NOTHING ON CONFLICT (campaign_id, server_id) DO NOTHING
"""), {"cid": cid, "sid": s.id, "st": status, "dp": date_prevue, "hp": heure, """), {"cid": cid, "sid": s.id, "dp": jour, "hp": heure,
"oid": default_op, "forced": forced}) "oid": default_op, "forced": forced})
slot_idx += 1
if slot_idx >= len(DAILY_SLOTS):
slot_idx = 0
jour_idx = min(jour_idx + 1, len(hprod_jours) - 1)
# Prod: mercredi + jeudi
prod_jours = [mer, jeu]
slot_idx = 0
jour_idx = 0
for s in prod_servers:
jour = prod_jours[jour_idx]
heure = DAILY_SLOTS[slot_idx]
if s.pref_patch_jour and s.pref_patch_jour != 'indifferent':
jour_map = {"mercredi": mer, "jeudi": jeu}
jour = jour_map.get(s.pref_patch_jour, jour)
if s.pref_patch_heure and s.pref_patch_heure != 'indifferent':
heure = s.pref_patch_heure
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, 'pending', :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, "dp": jour, "hp": heure,
"oid": default_op, "forced": forced})
slot_idx += 1
if slot_idx >= len(DAILY_SLOTS):
slot_idx = 0
jour_idx = min(jour_idx + 1, len(prod_jours) - 1)
# Exclus
for s in excluded_servers:
db.execute(text("""
INSERT INTO patch_sessions (campaign_id, server_id, status)
VALUES (:cid, :sid, 'excluded')
ON CONFLICT (campaign_id, server_id) DO NOTHING
"""), {"cid": cid, "sid": s.id})
# Appliquer les regles d'assignation par defaut puis propager par app_group
_apply_default_assignments(db, cid)
count = db.execute(text( count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'" "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'"
), {"cid": cid}).scalar() ), {"cid": cid}).scalar()
@ -191,6 +271,89 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude
return cid return cid
def _apply_default_assignments(db, campaign_id):
"""Applique les regles d'assignation par defaut (table default_assignments).
Priorite: server > app_type > app_group > domain > zone"""
rules = db.execute(text("""
SELECT da.rule_type, da.rule_value, da.user_id
FROM default_assignments da
JOIN users u ON da.user_id = u.id AND u.is_active = true
ORDER BY da.priority ASC, da.rule_type
""")).fetchall()
for rule in rules:
if rule.rule_type == 'server':
db.execute(text("""
UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now()
FROM servers s
WHERE ps.server_id = s.id AND ps.campaign_id = :cid
AND LOWER(s.hostname) = LOWER(:val)
AND ps.intervenant_id IS NULL AND ps.status = 'pending'
"""), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value})
elif rule.rule_type == 'app_type':
db.execute(text("""
UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now()
FROM servers s
LEFT JOIN server_specifics ss ON ss.server_id = s.id
WHERE ps.server_id = s.id AND ps.campaign_id = :cid
AND UPPER(ss.app_type) = UPPER(:val)
AND ps.intervenant_id IS NULL AND ps.status = 'pending'
"""), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value})
elif rule.rule_type == 'app_group':
db.execute(text("""
UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now()
FROM servers s
WHERE ps.server_id = s.id AND ps.campaign_id = :cid
AND s.app_group = :val
AND ps.intervenant_id IS NULL AND ps.status = 'pending'
"""), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value})
elif rule.rule_type == 'domain':
db.execute(text("""
UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now()
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
WHERE ps.server_id = s.id AND ps.campaign_id = :cid
AND d.code = :val
AND ps.intervenant_id IS NULL AND ps.status = 'pending'
"""), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value})
elif rule.rule_type == 'zone':
db.execute(text("""
UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now()
FROM servers s
LEFT JOIN zones z ON s.zone_id = z.id
WHERE ps.server_id = s.id AND ps.campaign_id = :cid
AND z.name = :val
AND ps.intervenant_id IS NULL AND ps.status = 'pending'
"""), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value})
# Ensuite propager par app_group (meme operateur pour recette+prod)
_auto_link_app_groups(db, campaign_id)
def _auto_link_app_groups(db, campaign_id):
"""Propage les intervenants entre recette et prod du meme app_group"""
assigned = db.execute(text("""
SELECT ps.intervenant_id, s.app_group
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
WHERE ps.campaign_id = :cid AND ps.intervenant_id IS NOT NULL AND s.app_group IS NOT NULL
GROUP BY ps.intervenant_id, s.app_group
"""), {"cid": campaign_id}).fetchall()
for a in assigned:
db.execute(text("""
UPDATE patch_sessions ps SET intervenant_id = :oid, assigned_at = now()
FROM servers s
WHERE ps.server_id = s.id AND ps.campaign_id = :cid
AND s.app_group = :ag AND ps.intervenant_id IS NULL AND ps.status = 'pending'
"""), {"cid": campaign_id, "oid": a.intervenant_id, "ag": a.app_group})
def exclude_session(db, session_id, reason, detail, username): def exclude_session(db, session_id, reason, detail, username):
db.execute(text(""" db.execute(text("""
UPDATE patch_sessions SET UPDATE patch_sessions SET
@ -225,12 +388,27 @@ def _recalc_total(db, session_id):
def assign_operator(db, session_id, operator_id, forced=False): def assign_operator(db, session_id, operator_id, forced=False):
"""Assigne un operateur a un serveur""" """Assigne un operateur a un serveur + auto-assigne le meme groupe applicatif"""
db.execute(text(""" db.execute(text("""
UPDATE patch_sessions SET intervenant_id = :oid, assigned_at = now(), UPDATE patch_sessions SET intervenant_id = :oid, assigned_at = now(),
forced_assignment = :forced forced_assignment = :forced
WHERE id = :id WHERE id = :id
"""), {"id": session_id, "oid": operator_id, "forced": forced}) """), {"id": session_id, "oid": operator_id, "forced": forced})
# Auto-assigner les serveurs du meme app_group dans cette campagne
row = db.execute(text("""
SELECT ps.campaign_id, s.app_group FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id WHERE ps.id = :id
"""), {"id": session_id}).fetchone()
if row and row.app_group:
db.execute(text("""
UPDATE patch_sessions ps SET intervenant_id = :oid, assigned_at = now()
FROM servers s
WHERE ps.server_id = s.id AND ps.campaign_id = :cid
AND s.app_group = :ag AND ps.intervenant_id IS NULL
AND ps.status = 'pending'
"""), {"cid": row.campaign_id, "oid": operator_id, "ag": row.app_group})
db.commit() db.commit()

View File

@ -5,6 +5,9 @@ import paramiko
import os import os
from sqlalchemy import text from sqlalchemy import text
# Mode demo : tout passe OK sans verifier (hors reseau SANEF)
DEMO_MODE = True # Passer a False en production SANEF
# Seuils espace disque (Mo) # Seuils espace disque (Mo)
DISK_ROOT_MIN_MB = 1200 # 1.2 Go minimum sur / DISK_ROOT_MIN_MB = 1200 # 1.2 Go minimum sur /
DISK_VAR_MIN_MB = 800 # 800 Mo minimum sur /var ou /var/log DISK_VAR_MIN_MB = 800 # 800 Mo minimum sur /var ou /var/log
@ -130,6 +133,25 @@ def _check_server(s):
"eligible": True, "exclude_reason": None, "exclude_detail": None, "eligible": True, "exclude_reason": None, "exclude_detail": None,
} }
# Mode demo : tout OK
if DEMO_MODE:
result["ssh"] = "ok"
result["satellite"] = "ok" if s.os_family == 'linux' else "na"
result["rollback"] = "snapshot" if s.machine_type == 'vm' else "na"
result["disk_root_mb"] = 5000
result["disk_var_mb"] = 3000
result["disk_ok"] = True
# Quand meme exclure les EOL et decom
if s.licence_support == 'eol':
result["eligible"] = False
result["exclude_reason"] = "eol"
result["exclude_detail"] = "Licence EOL"
elif s.etat != 'en_production':
result["eligible"] = False
result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}"
return result
# 1. Eligibilite de base # 1. Eligibilite de base
if s.licence_support == 'eol': if s.licence_support == 'eol':
result["eligible"] = False result["eligible"] = False

View File

@ -0,0 +1,96 @@
{% extends 'base.html' %}
{% block title %}Assignations par defaut{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/campaigns" class="text-xs text-gray-500 hover:text-gray-300">← Campagnes</a>
<h2 class="text-xl font-bold text-cyber-accent">Assignations par defaut</h2>
<p class="text-xs text-gray-500 mt-1">Regles appliquees automatiquement a la creation de chaque campagne. Priorite : serveur > application > app_group > domaine > zone.</p>
</div>
</div>
{% if msg %}
<div class="mb-4 p-2 rounded text-sm {% if msg == 'error' %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'added' %}Regle ajoutee.{% elif msg == 'deleted' %}Regle supprimee.{% elif msg == 'error' %}Erreur (regle dupliquee ?).{% endif %}
</div>
{% endif %}
<!-- Regles existantes -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber">
<thead><tr>
<th class="p-2">Priorite</th>
<th class="p-2">Type</th>
<th class="text-left p-2">Valeur</th>
<th class="p-2">Intervenant</th>
<th class="text-left p-2">Note</th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for r in rules %}
<tr>
<td class="p-2 text-center">{{ r.priority }}</td>
<td class="p-2 text-center"><span class="badge {% if r.rule_type == 'server' %}badge-red{% elif r.rule_type == 'app_type' %}badge-yellow{% elif r.rule_type == 'domain' %}badge-blue{% elif r.rule_type == 'zone' %}badge-green{% else %}badge-gray{% endif %}">{{ r.rule_type }}</span></td>
<td class="p-2 font-mono text-sm text-cyber-accent">{{ r.rule_value }}</td>
<td class="p-2 text-center">{{ r.display_name }}</td>
<td class="p-2 text-xs text-gray-400">{{ r.note or '' }}</td>
<td class="p-2 text-center">
<form method="POST" action="/assignments/{{ r.id }}/delete" style="display:inline">
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Supprimer ?')">Suppr</button>
</form>
</td>
</tr>
{% endfor %}
{% if not rules %}
<tr><td colspan="6" class="p-4 text-center text-gray-500">Aucune regle definie</td></tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Ajouter une regle -->
<div class="card p-4 mt-4">
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter une regle</h4>
<form method="POST" action="/assignments/add" class="flex gap-3 items-end flex-wrap">
<div>
<label class="text-xs text-gray-500">Type</label>
<select name="rule_type" class="text-xs py-1 px-2" id="rule-type" onchange="
var v = document.getElementById('rule-value');
var dl = document.getElementById('values-list');
if (this.value === 'server') { v.removeAttribute('list'); v.placeholder = 'ex: vpdsiawsus1'; dl.innerHTML = ''; }
else { v.setAttribute('list', 'values-list'); v.placeholder = 'Sélectionner...'; }
">
<option value="domain">Domaine</option>
<option value="app_type">Application (spécifiques)</option>
<option value="app_group">Groupe applicatif</option>
<option value="zone">Zone</option>
<option value="server">Serveur spécifique</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500">Valeur</label>
<input type="text" name="rule_value" id="rule-value" class="text-xs py-1 px-2 w-44" required list="values-list" placeholder="Sélectionner...">
<datalist id="values-list">
{% for d in domains %}<option value="{{ d.code }}">{{ d.name }}</option>{% endfor %}
{% for z in zones %}<option value="{{ z.name }}">{{ z.name }}</option>{% endfor %}
{% for a in app_types %}<option value="{{ a }}">{{ a }}</option>{% endfor %}
</datalist>
</div>
<div>
<label class="text-xs text-gray-500">Intervenant</label>
<select name="user_id" class="text-xs py-1 px-2">
{% for u in intervenants %}<option value="{{ u.id }}">{{ u.display_name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Priorite</label>
<input type="number" name="priority" value="10" min="1" max="99" class="text-xs py-1 px-2 w-16">
</div>
<div class="flex-1">
<label class="text-xs text-gray-500">Note</label>
<input type="text" name="note" class="text-xs py-1 px-2 w-full" placeholder="ex: Referent EMV">
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
</form>
</div>
{% endblock %}

View File

@ -11,7 +11,7 @@
</a> </a>
<a href="/audit?filter=failed" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'failed' %}border-cyber-accent{% endif %}"> <a href="/audit?filter=failed" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'failed' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-red">{{ stats.failed }}</div> <div class="text-lg font-bold text-cyber-red">{{ stats.failed }}</div>
<div class="text-[10px] text-gray-500">Echoues</div> <div class="text-[10px] text-gray-500">Échoués</div>
</a> </a>
<a href="/audit?filter=disk" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'disk' %}border-cyber-accent{% endif %}"> <a href="/audit?filter=disk" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'disk' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-yellow">{{ stats.disk_alerts }}</div> <div class="text-lg font-bold text-cyber-yellow">{{ stats.disk_alerts }}</div>

View File

@ -53,15 +53,16 @@
<p class="text-xs text-gray-500">v2.0 — SecOps</p> <p class="text-xs text-gray-500">v2.0 — SecOps</p>
</div> </div>
<nav class="flex-1 p-3 space-y-1"> <nav class="flex-1 p-3 space-y-1">
{% set p = perms if perms is defined else request.state.perms %}
<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a> <a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>
<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a> {% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Specifiques</a> {% if p.specifics %}<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifiques</a>{% endif %}
<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a> {% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a> {% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'assignments' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Assignations</a>{% endif %}
<a href="#" class="block px-3 py-2 rounded-md text-sm text-gray-600">Tags Qualys</a> {% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' or '/audit/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a> {% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' or '/audit/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a> {% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a> {% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
</nav> </nav>
</aside> </aside>
<main class="flex-1 flex flex-col overflow-hidden"> <main class="flex-1 flex flex-col overflow-hidden">
@ -70,7 +71,7 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-sm text-gray-400">{{ user.sub }}</span> <span class="text-sm text-gray-400">{{ user.sub }}</span>
<span class="badge badge-blue">{{ user.role }}</span> <span class="badge badge-blue">{{ user.role }}</span>
<a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Deconnexion</a> <a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Déconnexion</a>
</div> </div>
</header> </header>
<div class="flex flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden">

View File

@ -15,7 +15,7 @@
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
{% if is_coordinator %} {% if can_edit_campaigns %}
{% if c.status == 'draft' %} {% if c.status == 'draft' %}
{% if can_plan %} {% if can_plan %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="pending_validation"> <form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="pending_validation">
@ -37,7 +37,11 @@
{% endif %} {% endif %}
{% if c.status in ('draft', 'pending_validation', 'planned') %} {% if c.status in ('draft', 'pending_validation', 'planned') %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="cancelled"> <form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="cancelled">
<button class="btn-sm bg-red-900/30 text-cyber-red px-4 py-2" onclick="return confirm('Annuler ?')">Annuler</button></form> <button class="btn-sm bg-red-900/30 text-cyber-red px-4 py-2" onclick="return confirm('Annuler cette campagne ?')">Annuler</button></form>
{% endif %}
{% if c.status in ('draft', 'cancelled') %}
<form method="POST" action="/campaigns/{{ c.id }}/delete">
<button class="btn-sm bg-red-900/50 text-cyber-red px-4 py-2" onclick="return confirm('SUPPRIMER définitivement cette campagne ? Cette action est irréversible.')">Supprimer</button></form>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
@ -45,7 +49,7 @@
{% if msg %} {% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if msg in ('prereq_needed','already_taken','limit_reached') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}"> <div class="mb-3 p-2 rounded text-sm {% if msg in ('prereq_needed','already_taken','limit_reached') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% 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 %} {% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restauré.{% elif msg == 'prereq_saved' %}Prérequis sauvegardés.{% elif msg == 'prereq_checked' %}Prérequis vérifié.{% elif msg == 'prereq_needed' %}Prérequis requis avant soumission COMEP.{% elif msg == 'taken' %}Serveur pris.{% elif msg == 'released' %}Serveur libéré.{% elif msg == 'assigned' %}Intervenant assigné.{% elif msg == 'scheduled' %}Planning ajusté.{% elif msg == 'limit_set' %}Limite intervenant définie.{% elif msg == 'already_taken' %}Ce serveur est déjà pris.{% elif msg == 'limit_reached' %}Limite de serveurs atteinte pour cette campagne.{% elif msg == 'forced_cant_release' %}Assignation forcée — seul le coordinateur peut modifier.{% elif msg.startswith('checked_') %}Vérification: {{ msg.split('_')[1] }} vérifiés, {{ msg.split('_')[2] }} auto-exclus.{% endif %}
</div> </div>
{% endif %} {% endif %}
@ -53,10 +57,10 @@
<div class="grid grid-cols-8 gap-2 mb-4"> <div class="grid grid-cols-8 gap-2 mb-4">
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-accent">{{ stats.total }}</div><div class="text-[10px] text-gray-500">Total</div></div> <div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-accent">{{ stats.total }}</div><div class="text-[10px] text-gray-500">Total</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-green">{{ stats.patched }}</div><div class="text-[10px] text-gray-500">Patches</div></div> <div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-green">{{ stats.patched }}</div><div class="text-[10px] text-gray-500">Patches</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-red">{{ stats.failed }}</div><div class="text-[10px] text-gray-500">Echoues</div></div> <div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-red">{{ stats.failed }}</div><div class="text-[10px] text-gray-500">Échoués</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-yellow">{{ stats.pending }}</div><div class="text-[10px] text-gray-500">En attente</div></div> <div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-yellow">{{ stats.pending }}</div><div class="text-[10px] text-gray-500">En attente</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-gray-500">{{ stats.excluded }}</div><div class="text-[10px] text-gray-500">Exclus</div></div> <div class="card p-2 text-center"><div class="text-xl font-bold text-gray-500">{{ stats.excluded }}</div><div class="text-[10px] text-gray-500">Exclus</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-accent">{{ stats.assigned }}</div><div class="text-[10px] text-gray-500">Assignes</div></div> <div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-accent">{{ stats.assignéd }}</div><div class="text-[10px] text-gray-500">Assignés</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-gray-400">{{ stats.unassigned }}</div><div class="text-[10px] text-gray-500">Libres</div></div> <div class="card p-2 text-center"><div class="text-xl font-bold text-gray-400">{{ stats.unassigned }}</div><div class="text-[10px] text-gray-500">Libres</div></div>
<div class="card p-2 text-center"> <div class="card p-2 text-center">
{% set patchable = stats.total - stats.excluded - stats.cancelled %} {% set patchable = stats.total - stats.excluded - stats.cancelled %}
@ -65,17 +69,35 @@
</div> </div>
</div> </div>
<!-- Prereqs (draft) --> <!-- Repartition intervenants -->
{% if c.status == 'draft' and prereq and is_coordinator %} {% if op_counts %}
<div class="flex gap-2 mb-4 flex-wrap">
{% for oc in op_counts %}
<div class="card px-3 py-1 flex items-center gap-2">
<span class="text-sm {% if oc.display_name == user.sub %}text-cyber-accent font-bold{% else %}text-gray-300{% endif %}">{{ oc.display_name }}</span>
<span class="badge badge-blue">{{ oc.count }}</span>
</div>
{% endfor %}
{% if stats.unassigned > 0 %}
<div class="card px-3 py-1 flex items-center gap-2">
<span class="text-sm text-gray-500">Non assignés</span>
<span class="badge badge-gray">{{ stats.unassigned }}</span>
</div>
{% endif %}
</div>
{% endif %}
<!-- Prérequis (draft) -->
{% if c.status == 'draft' and prereq and can_edit_campaigns %}
<div class="card p-4 mb-4"> <div class="card p-4 mb-4">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<h3 class="text-sm font-bold text-cyber-accent">Prerequis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)</h3> <h3 class="text-sm font-bold text-cyber-accent">Prérequisuis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)</h3>
<form method="POST" action="/campaigns/{{ c.id }}/check-prereqs"> <form method="POST" action="/campaigns/{{ c.id }}/check-prereqs">
<button class="btn-primary px-3 py-1 text-sm">Verifier prereqs</button> <button class="btn-primary px-3 py-1 text-sm">Vérifier prereqs</button>
</form> </form>
</div> </div>
<div class="grid grid-cols-5 gap-3 text-sm"> <div class="grid grid-cols-5 gap-3 text-sm">
<div class="flex justify-between"><span class="text-gray-500">A verifier</span><span class="text-cyber-yellow">{{ prereq.prereq_todo }}</span></div> <div class="flex justify-between"><span class="text-gray-500">A vérifiér</span><span class="text-cyber-yellow">{{ prereq.prereq_todo }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">SSH</span><span class="text-cyber-green">{{ prereq.ssh_ok }}</span></div> <div class="flex justify-between"><span class="text-gray-500">SSH</span><span class="text-cyber-green">{{ prereq.ssh_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Satellite</span><span class="text-cyber-green">{{ prereq.sat_ok }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Satellite</span><span class="text-cyber-green">{{ prereq.sat_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Rollback</span><span class="text-cyber-green">{{ prereq.rollback_ok }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Rollback</span><span class="text-cyber-green">{{ prereq.rollback_ok }}</span></div>
@ -99,7 +121,7 @@
<th class="p-2">Tier</th> <th class="p-2">Tier</th>
<th class="p-2">Jour prevu</th> <th class="p-2">Jour prevu</th>
<th class="p-2">Heure</th> <th class="p-2">Heure</th>
<th class="p-2">Operateur</th> <th class="p-2">Intervenant</th>
{% if c.status == 'draft' %} {% if c.status == 'draft' %}
<th class="p-2">SSH</th> <th class="p-2">SSH</th>
<th class="p-2">Sat</th> <th class="p-2">Sat</th>
@ -120,7 +142,7 @@
<td class="p-2 text-center"> <td class="p-2 text-center">
{% if s.intervenant_name %} {% if s.intervenant_name %}
<span class="text-cyber-accent">{{ s.intervenant_name }}</span> <span class="text-cyber-accent">{{ s.intervenant_name }}</span>
{% if s.forced_assignment %}<span class="text-cyber-yellow text-[9px] ml-0.5" title="Assignation forcee">&#128274;</span>{% endif %} {% if s.forced_assignment %}<span class="text-cyber-yellow text-[9px] ml-0.5" title="Intervenant référent">&#128274;</span>{% endif %}
{% else %}<span class="text-gray-600"></span>{% endif %} {% else %}<span class="text-gray-600"></span>{% endif %}
</td> </td>
{% if c.status == 'draft' %} {% if c.status == 'draft' %}
@ -132,30 +154,30 @@
<span class="badge {% if s.status == 'patched' %}badge-green{% elif s.status == 'failed' %}badge-red{% elif s.status == 'excluded' %}badge-gray{% elif s.status == 'in_progress' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.status }}</span> <span class="badge {% if s.status == 'patched' %}badge-green{% elif s.status == 'failed' %}badge-red{% elif s.status == 'excluded' %}badge-gray{% elif s.status == 'in_progress' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.status }}</span>
{% if s.exclusion_reason %} {% if s.exclusion_reason %}
<div class="text-[9px] text-gray-500" title="{{ s.exclusion_detail or '' }}"> <div class="text-[9px] text-gray-500" title="{{ s.exclusion_detail or '' }}">
{% 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.exclusion_reason == 'eol' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prérequis KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %}
{% if s.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %} {% if s.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %}
</div> </div>
{% endif %} {% endif %}
</td> </td>
<td class="p-2 text-center"> <td class="p-2 text-center">
{% if s.status == 'excluded' and is_coordinator %} {% if s.status == 'excluded' and can_edit_campaigns %}
<form method="POST" action="/campaigns/session/{{ s.id }}/restore" style="display:inline"><button class="btn-sm bg-green-900/30 text-cyber-green">Restaurer</button></form> <form method="POST" action="/campaigns/session/{{ s.id }}/restore" style="display:inline"><button class="btn-sm bg-green-900/30 text-cyber-green">Restaurer</button></form>
{% elif s.status == 'pending' %} {% elif s.status == 'pending' %}
{% if c.status == 'planned' %} {% if c.status == 'planned' %}
{# Operateur: prendre/liberer #} {# Intervenant: prendre/liberer #}
{% if not s.intervenant_id %} {% if not s.intervenant_id %}
<form method="POST" action="/campaigns/session/{{ s.id }}/take" style="display:inline"><button class="btn-sm bg-cyber-accent text-black">Prendre</button></form> <form method="POST" action="/campaigns/session/{{ s.id }}/take" style="display:inline"><button class="btn-sm bg-cyber-accent text-black">Prendre</button></form>
{% elif s.intervenant_id == user.uid and not s.forced_assignment %} {% elif s.intervenant_id == user.uid and not s.forced_assignment %}
<form method="POST" action="/campaigns/session/{{ s.id }}/release" style="display:inline"><button class="btn-sm bg-cyber-border text-gray-400">Liberer</button></form> <form method="POST" action="/campaigns/session/{{ s.id }}/release" style="display:inline"><button class="btn-sm bg-cyber-border text-gray-400">Liberer</button></form>
{% endif %} {% endif %}
{# Coordinateur: assigner + planifier #} {# Coordinateur: assigner + planifier #}
{% if is_coordinator %} {% if can_edit_campaigns %}
<button @click="action = 'assign'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Assigner</button> <button @click="action = 'assign'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Assigner</button>
<button @click="action = 'schedule'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Planifier</button> <button @click="action = 'schedule'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Planifier</button>
{% endif %} {% endif %}
{% elif c.status == 'draft' and is_coordinator %} {% elif c.status == 'draft' and can_edit_campaigns %}
<div class="flex gap-1 justify-center"> <div class="flex gap-1 justify-center">
<form method="POST" action="/campaigns/session/{{ s.id }}/check-prereq" style="display:inline"><button class="btn-sm bg-cyber-border text-cyber-accent">Check</button></form> <form method="POST" action="/campaigns/session/{{ s.id }}/check-prereq" style="display:inline"><button class="btn-sm bg-cyber-border text-cyber-accent">Check</button></form>
<button @click="action = 'exclude'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Exclure</button> <button @click="action = 'exclude'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Exclure</button>
@ -180,8 +202,8 @@
<tr x-show="target === {{ s.id }} && action === 'assign'" class="bg-cyber-bg"> <tr x-show="target === {{ s.id }} && action === 'assign'" class="bg-cyber-bg">
<td colspan="12" class="p-2"> <td colspan="12" class="p-2">
<form method="POST" action="/campaigns/session/{{ s.id }}/assign" class="flex gap-2 items-center"> <form method="POST" action="/campaigns/session/{{ s.id }}/assign" class="flex gap-2 items-center">
<select name="operator_id" class="text-xs py-1 px-2"> <select name="intervenant_id" class="text-xs py-1 px-2">
<option value="">— Desassigner —</option> <option value="">— Désassigner —</option>
{% for u in intervenants %}<option value="{{ u.id }}" {% if s.intervenant_id == u.id %}selected{% endif %}>{{ u.display_name }}</option>{% endfor %} {% for u in intervenants %}<option value="{{ u.id }}" {% if s.intervenant_id == u.id %}selected{% endif %}>{{ u.display_name }}</option>{% endfor %}
</select> </select>
<label class="flex items-center gap-1 text-xs text-gray-400"><input type="checkbox" name="forced" {% if s.forced_assignment %}checked{% endif %}> Forcer</label> <label class="flex items-center gap-1 text-xs text-gray-400"><input type="checkbox" name="forced" {% if s.forced_assignment %}checked{% endif %}> Forcer</label>
@ -206,10 +228,10 @@
</table> </table>
</div> </div>
<!-- Limites operateurs (coordinateur, planned) --> <!-- Limites intervenants (coordinateur, planned) -->
{% if is_coordinator and c.status in ('planned', 'pending_validation') %} {% if can_edit_campaigns and c.status in ('planned', 'pending_validation') %}
<div class="card p-4 mt-4"> <div class="card p-4 mt-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Limites operateurs pour cette campagne</h3> <h3 class="text-sm font-bold text-cyber-accent mb-3">Limites intervenants pour cette campagne</h3>
{% if op_limits %} {% if op_limits %}
<div class="grid grid-cols-3 gap-2 text-xs mb-3"> <div class="grid grid-cols-3 gap-2 text-xs mb-3">
{% for ol in op_limits %} {% for ol in op_limits %}
@ -220,10 +242,10 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<form method="POST" action="/campaigns/{{ c.id }}/operator-limit" class="flex gap-2 items-end"> <form method="POST" action="/campaigns/{{ c.id }}/intervenant-limit" class="flex gap-2 items-end">
<div> <div>
<label class="text-xs text-gray-500">Operateur</label> <label class="text-xs text-gray-500">Intervenant</label>
<select name="operator_id" class="text-xs py-1 px-2"> <select name="intervenant_id" class="text-xs py-1 px-2">
{% for u in intervenants %}<option value="{{ u.id }}">{{ u.display_name }}</option>{% endfor %} {% for u in intervenants %}<option value="{{ u.id }}">{{ u.display_name }}</option>{% endfor %}
</select> </select>
</div> </div>
@ -235,7 +257,7 @@
<label class="text-xs text-gray-500">Raison</label> <label class="text-xs text-gray-500">Raison</label>
<input type="text" name="note" placeholder="ex: autre mission en parallele" class="text-xs py-1 px-2 w-full"> <input type="text" name="note" placeholder="ex: autre mission en parallele" class="text-xs py-1 px-2 w-full">
</div> </div>
<button type="submit" class="btn-primary px-3 py-1 text-sm">Definir</button> <button type="submit" class="btn-primary px-3 py-1 text-sm">Définir</button>
</form> </form>
</div> </div>
{% endif %} {% endif %}

View File

@ -9,6 +9,13 @@
</div> </div>
</div> </div>
{% set msg = request.query_params.get('msg') %}
{% if msg %}
<div class="mb-4 p-2 rounded text-sm {% if msg in ('already_exists','no_servers','create_error') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'deleted' %}Campagne supprimée.{% elif msg == 'already_exists' %}Une campagne existe déjà pour cette semaine. Supprimez-la d'abord.{% elif msg == 'no_servers' %}Aucun serveur éligible pour cette semaine.{% elif msg == 'create_error' %}Erreur à la création. Vérifiez les logs.{% endif %}
</div>
{% endif %}
<!-- Filtres statut --> <!-- Filtres statut -->
<div class="flex gap-2 mb-4"> <div class="flex gap-2 mb-4">
<a href="?year={{ year }}" class="btn-sm {% if not status_filter %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">Toutes</a> <a href="?year={{ year }}" class="btn-sm {% if not status_filter %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">Toutes</a>
@ -49,12 +56,13 @@
{% endif %} {% endif %}
</div> </div>
<!-- Nouvelle campagne depuis le planning --> <!-- Nouvelle campagne depuis le planning (admin/coordinateur seulement) -->
{% if perms.campaigns in ('edit', 'admin') %}
<div x-data="{ showCreate: false }" class="mt-6"> <div x-data="{ showCreate: false }" class="mt-6">
<button @click="showCreate = !showCreate" class="btn-primary px-4 py-2 text-sm">Nouvelle campagne</button> <button @click="showCreate = !showCreate" class="btn-primary px-4 py-2 text-sm">Nouvelle campagne</button>
<div x-show="showCreate" class="card p-5 mt-3 space-y-4"> <div x-show="showCreate" class="card p-5 mt-3 space-y-4">
<h3 class="text-lg font-bold text-cyber-accent">Creer depuis le planning</h3> <h3 class="text-lg font-bold text-cyber-accent">Créer depuis le planning</h3>
{% if planned_weeks %} {% if planned_weeks %}
<div> <div>
@ -75,8 +83,9 @@
</div> </div>
<div id="preview-zone"></div> <div id="preview-zone"></div>
{% else %} {% else %}
<p class="text-gray-500 text-sm">Aucune semaine planifiee a venir pour {{ year }}. Verifiez le planning.</p> <p class="text-gray-500 text-sm">Aucune semaine planifiee a venir pour {{ year }}. Vérifiez le planning.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

28
app/templates/error.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>PatchCenter — Maintenance</title>
<style>
body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
.container { text-align: center; max-width: 500px; }
h1 { color: #00d4ff; font-size: 2rem; margin-bottom: 0.5rem; }
.code { font-size: 5rem; color: #1e3a5f; font-weight: bold; }
p { color: #94a3b8; line-height: 1.6; }
.contact { margin-top: 2rem; padding: 1rem; background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; }
.contact span { color: #00d4ff; }
a { color: #00d4ff; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="code">{{ code }}</div>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
<div class="contact">
<p>Contacter le <span>Responsable SecOps</span></p>
<p><a href="/">Retour à l'accueil</a></p>
</div>
</div>
</body>
</html>

View File

@ -44,6 +44,6 @@
</div> </div>
<p class="text-xs text-gray-600">Decochez les serveurs a exclure. Vous pourrez aussi exclure/reporter individuellement apres creation.</p> <p class="text-xs text-gray-600">Decochez les serveurs a exclure. Vous pourrez aussi exclure/reporter individuellement apres creation.</p>
<button type="submit" class="btn-primary px-6 py-2 text-sm">Creer la campagne ({{ count }} serveurs)</button> <button type="submit" class="btn-primary px-6 py-2 text-sm">Créer la campagne ({{ count }} serveurs)</button>
</form> </form>
</div> </div>

View File

@ -70,9 +70,9 @@
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Patching</h4> <h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Patching</h4>
<div class="space-y-1 text-sm"> <div class="space-y-1 text-sm">
<div class="flex justify-between"><span class="text-gray-500">Owner OS</span><span>{{ s.patch_os_owner }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Owner OS</span><span>{{ s.patch_os_owner }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Frequence</span><span>{{ s.patch_frequency }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Fréquence</span><span>{{ s.patch_frequency }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Podman</span><span>{{ 'Oui' if s.is_podman else 'Non' }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Podman</span><span>{{ 'Oui' if s.is_podman else 'Non' }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Prevenance</span><span>{{ 'Oui' if s.need_pct else 'Non' }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Prévenance</span><span>{{ 'Oui' if s.need_pct else 'Non' }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Satellite</span><span>{% if s.satellite_host %}{% if 'sat1' in s.satellite_host %}SAT1 (DMZ){% elif 'sat2' in s.satellite_host %}SAT2 (LAN){% else %}{{ s.satellite_host }}{% endif %}{% else %}N/A{% endif %}</span></div> <div class="flex justify-between"><span class="text-gray-500">Satellite</span><span>{% if s.satellite_host %}{% if 'sat1' in s.satellite_host %}SAT1 (DMZ){% elif 'sat2' in s.satellite_host %}SAT2 (LAN){% else %}{{ s.satellite_host }}{% endif %}{% else %}N/A{% endif %}</span></div>
</div> </div>
</div> </div>
@ -82,7 +82,7 @@
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Responsables</h4> <h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Responsables</h4>
<div class="space-y-1 text-sm"> <div class="space-y-1 text-sm">
<div><span class="text-gray-500">Responsable:</span> <span>{{ s.responsable_nom or '-' }}</span></div> <div><span class="text-gray-500">Responsable:</span> <span>{{ s.responsable_nom or '-' }}</span></div>
<div><span class="text-gray-500">Referent:</span> <span>{{ s.referent_nom or '-' }}</span></div> <div><span class="text-gray-500">Référent:</span> <span>{{ s.referent_nom or '-' }}</span></div>
</div> </div>
</div> </div>
@ -112,7 +112,7 @@
<!-- Actions --> <!-- Actions -->
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<button class="btn-primary px-3 py-1 text-sm flex-1" hx-get="/servers/{{ s.id }}/edit" hx-target="#detail-panel" hx-swap="innerHTML">Editer</button> <button class="btn-primary px-3 py-1 text-sm flex-1" hx-get="/servers/{{ s.id }}/edit" hx-target="#detail-panel" hx-swap="innerHTML">Éditer</button>
<button class="btn-sm bg-cyber-border text-cyber-accent" hx-post="/servers/{{ s.id }}/sync-qualys" hx-target="#detail-panel" hx-swap="innerHTML" hx-indicator="#sync-spin">Sync Qualys</button> <button class="btn-sm bg-cyber-border text-cyber-accent" hx-post="/servers/{{ s.id }}/sync-qualys" hx-target="#detail-panel" hx-swap="innerHTML" hx-indicator="#sync-spin">Sync Qualys</button>
<button class="btn-sm bg-cyber-border text-gray-300" onclick="closePanel()">Fermer</button> <button class="btn-sm bg-cyber-border text-gray-300" onclick="closePanel()">Fermer</button>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="p-4"> <div class="p-4">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-cyber-accent">Editer {{ s.hostname }}</h3> <h3 class="text-lg font-bold text-cyber-accent">Éditer {{ s.hostname }}</h3>
<button onclick="closePanel()" class="text-gray-500 hover:text-white text-xl">&times;</button> <button onclick="closePanel()" class="text-gray-500 hover:text-white text-xl">&times;</button>
</div> </div>
@ -65,7 +65,7 @@
<input type="text" name="responsable_nom" value="{{ s.responsable_nom or '' }}" class="w-full"> <input type="text" name="responsable_nom" value="{{ s.responsable_nom or '' }}" class="w-full">
</div> </div>
<div> <div>
<label class="text-xs text-gray-500">Referent technique</label> <label class="text-xs text-gray-500">Référent technique</label>
<input type="text" name="referent_nom" value="{{ s.referent_nom or '' }}" class="w-full"> <input type="text" name="referent_nom" value="{{ s.referent_nom or '' }}" class="w-full">
</div> </div>
<div> <div>

View File

@ -79,7 +79,7 @@
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="is_db" {% if sp.is_db %}checked{% endif %}> BDD</label> <label class="flex items-center gap-2 text-xs"><input type="checkbox" name="is_db" {% if sp.is_db %}checked{% endif %}> BDD</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="is_middleware" {% if sp.is_middleware %}checked{% endif %}> Middleware</label> <label class="flex items-center gap-2 text-xs"><input type="checkbox" name="is_middleware" {% if sp.is_middleware %}checked{% endif %}> Middleware</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="kernel_update_blocked" {% if sp.kernel_update_blocked %}checked{% endif %}> Kernel bloque</label> <label class="flex items-center gap-2 text-xs"><input type="checkbox" name="kernel_update_blocked" {% if sp.kernel_update_blocked %}checked{% endif %}> Kernel bloque</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="sentinel" {% if sp.sentinel_disable_required %}checked{% endif %}> Desactiver S1</label> <label class="flex items-center gap-2 text-xs"><input type="checkbox" name="sentinel" {% if sp.sentinel_disable_required %}checked{% endif %}> Désactiver S1</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="ip_fwd" {% if sp.ip_forwarding_required %}checked{% endif %}> IP Forwarding</label> <label class="flex items-center gap-2 text-xs"><input type="checkbox" name="ip_fwd" {% if sp.ip_forwarding_required %}checked{% endif %}> IP Forwarding</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="rolling" {% if sp.rolling_update %}checked{% endif %}> Rolling update</label> <label class="flex items-center gap-2 text-xs"><input type="checkbox" name="rolling" {% if sp.rolling_update %}checked{% endif %}> Rolling update</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="has_agent_special" {% if sp.has_agent_special %}checked{% endif %}> Agent special</label> <label class="flex items-center gap-2 text-xs"><input type="checkbox" name="has_agent_special" {% if sp.has_agent_special %}checked{% endif %}> Agent special</label>

View File

@ -6,8 +6,8 @@
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<a href="?year={{ year - 1 }}" class="btn-sm bg-cyber-border text-gray-300">{{ year - 1 }}</a> <a href="?year={{ year - 1 }}" class="btn-sm bg-cyber-border text-gray-300">{{ year - 1 }}</a>
<a href="?year={{ year + 1 }}" class="btn-sm bg-cyber-border text-gray-300">{{ year + 1 }}</a> <a href="?year={{ year + 1 }}" class="btn-sm bg-cyber-border text-gray-300">{{ year + 1 }}</a>
<!-- Dupliquer --> <!-- Dupliquer (admin/coordinateur) -->
{% if entries %} {% if entries and perms.planning in ('edit', 'admin') %}
<form method="POST" action="/planning/duplicate" class="flex gap-1 items-center ml-4"> <form method="POST" action="/planning/duplicate" class="flex gap-1 items-center ml-4">
<input type="hidden" name="source_year" value="{{ year }}"> <input type="hidden" name="source_year" value="{{ year }}">
<input type="number" name="target_year" value="{{ year + 1 }}" class="text-xs py-1 px-2 w-20"> <input type="number" name="target_year" value="{{ year + 1 }}" class="text-xs py-1 px-2 w-20">
@ -19,7 +19,7 @@
{% if msg %} {% if msg %}
<div class="mb-4 p-2 rounded text-sm {% if msg in ('exists', 'err_week', 'err_domain', 'err_past', 'err_past_wed') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}"> <div class="mb-4 p-2 rounded text-sm {% if msg in ('exists', 'err_week', 'err_domain', 'err_past', 'err_past_wed') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'add' %}Entree ajoutee.{% elif msg == 'edit' %}Entree modifiee.{% elif msg == 'delete' %}Entree supprimee.{% elif msg == 'duplicate' %}Planning duplique avec succes.{% elif msg == 'exists' %}L'annee cible contient deja des entrees. Supprimez-les d'abord.{% elif msg == 'err_week' %}Numero de semaine invalide (1-53).{% elif msg == 'err_domain' %}Domaine requis pour une entree ouverte.{% elif msg == 'err_past' %}Impossible d'ajouter dans le passe (semaine deja ecoulee).{% elif msg == 'err_past_wed' %}Semaine en cours : ajout possible uniquement lundi et mardi (MEP urgente).{% endif %} {% if msg == 'add' %}Entrée ajoutée.{% elif msg == 'edit' %}Entrée modifiée.{% elif msg == 'delete' %}Entrée supprimée.{% elif msg == 'duplicate' %}Planning dupliqué avec succès.{% elif msg == 'exists' %}L'annee cible contient déjà des entrées. Supprimez-les d'abord.{% elif msg == 'err_week' %}Numéro de semaine invalide (1-53).{% elif msg == 'err_domain' %}Domaine requis pour une entrée ouverte.{% elif msg == 'err_past' %}Impossible d'ajouter dans le passé (semaine déjà écoulée).{% elif msg == 'err_past_wed' %}Semaine en cours : ajout possible uniquement lundi et mardi (MEP urgente).{% endif %}
</div> </div>
{% endif %} {% endif %}
@ -137,7 +137,7 @@
<th class="p-2">Cycle</th> <th class="p-2">Cycle</th>
<th class="p-2">Statut</th> <th class="p-2">Statut</th>
<th class="text-left p-2">Note</th> <th class="text-left p-2">Note</th>
<th class="p-2">Actions</th> {% if perms.planning in ('edit', 'admin') %}<th class="p-2">Actions</th>{% endif %}
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for e in entries %} {% for e in entries %}
@ -164,6 +164,7 @@
<template x-if="editing !== {{ e.id }}"> <template x-if="editing !== {{ e.id }}">
<td class="p-2 text-xs text-gray-400">{{ e.note or '' }}</td> <td class="p-2 text-xs text-gray-400">{{ e.note or '' }}</td>
</template> </template>
{% if perms.planning in ('edit', 'admin') %}
<template x-if="editing !== {{ e.id }}"> <template x-if="editing !== {{ e.id }}">
<td class="p-2 text-center"> <td class="p-2 text-center">
<button @click="editing = {{ e.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Edit</button> <button @click="editing = {{ e.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Edit</button>
@ -195,12 +196,14 @@
</form> </form>
</td> </td>
</template> </template>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if perms.planning in ('edit', 'admin') %}
<!-- Ajouter une entree --> <!-- Ajouter une entree -->
<div class="card p-4 mt-4"> <div class="card p-4 mt-4">
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter une entree</h4> <h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter une entree</h4>
@ -240,4 +243,5 @@
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button> <button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
</form> </form>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -5,7 +5,7 @@
{% if saved %} {% if saved %}
<div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm"> <div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm">
Section "{{ saved }}" sauvegardee. Section "{{ saved }}" sauvegardée.
</div> </div>
{% endif %} {% endif %}
@ -93,7 +93,7 @@
<label class="text-xs text-gray-500">Password par defaut</label> <label class="text-xs text-gray-500">Password par defaut</label>
<input type="password" name="ssh_pwd_default_pass" value="{{ vals.ssh_pwd_default_pass }}" class="w-full" {% if not editable.ssh_pwd %}disabled{% endif %}> <input type="password" name="ssh_pwd_default_pass" value="{{ vals.ssh_pwd_default_pass }}" class="w-full" {% if not editable.ssh_pwd %}disabled{% endif %}>
</div> </div>
<p class="text-xs text-gray-600">Pour les environnements recette sans cle SSH. Chaque operateur peut configurer son propre compte.</p> <p class="text-xs text-gray-600">Pour les environnements recette sans cle SSH. Chaque opérateur peut configurer son propre compte.</p>
{% if editable.ssh_pwd %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %} {% if editable.ssh_pwd %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form> </form>
</div> </div>
@ -134,7 +134,7 @@
<input type="text" name="psmp_default_safe" value="{{ vals.psmp_default_safe }}" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}> <input type="text" name="psmp_default_safe" value="{{ vals.psmp_default_safe }}" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}>
</div> </div>
</div> </div>
<p class="text-xs text-gray-600">Auth keyboard-interactive. Chaque operateur configure son propre compte CyberArk. MDP saisi en session.</p> <p class="text-xs text-gray-600">Auth keyboard-interactive. Chaque opérateur configure son propre compte CyberArk. MDP saisi en session.</p>
{% if editable.ssh_psmp %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %} {% if editable.ssh_psmp %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form> </form>
</div> </div>
@ -228,7 +228,7 @@
<td class="p-2 text-center"> <td class="p-2 text-center">
{% if vc.is_active %} {% if vc.is_active %}
<form method="POST" action="/settings/vcenter/{{ vc.id }}/delete" style="display:inline"> <form method="POST" action="/settings/vcenter/{{ vc.id }}/delete" style="display:inline">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Desactiver ce vCenter ?')">Desactiver</button> <button type="submit" class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Désactiver ce vCenter ?')">sactiver</button>
</form> </form>
{% endif %} {% endif %}
</td> </td>
@ -298,7 +298,7 @@
</div> </div>
</div> </div>
<div> <div>
<label class="text-xs text-gray-500">Verifier SSL (true/false)</label> <label class="text-xs text-gray-500">Vérifier SSL (true/false)</label>
<input type="text" name="splunk_verify_ssl" value="{{ vals.splunk_verify_ssl }}" placeholder="true" class="w-full" {% if not editable.splunk %}disabled{% endif %}> <input type="text" name="splunk_verify_ssl" value="{{ vals.splunk_verify_ssl }}" placeholder="true" class="w-full" {% if not editable.splunk %}disabled{% endif %}>
</div> </div>
<p class="text-xs text-gray-600">Envoie les evenements de patching vers Splunk via HEC.</p> <p class="text-xs text-gray-600">Envoie les evenements de patching vers Splunk via HEC.</p>

View File

@ -8,7 +8,7 @@
{% set msg = request.query_params.get('msg') %} {% set msg = request.query_params.get('msg') %}
{% if msg %} {% if msg %}
<div class="mb-4 p-2 rounded text-sm {% if msg in ('not_found','exists') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}"> <div class="mb-4 p-2 rounded text-sm {% if msg in ('not_found','exists') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'saved' %}Specificites sauvegardees.{% elif msg == 'added' %}Serveur ajoute.{% elif msg == 'not_found' %}Hostname non trouve en base.{% elif msg == 'exists' %}Ce serveur a deja des specificites.{% endif %} {% if msg == 'saved' %}Spécificités sauvegardées.{% elif msg == 'added' %}Serveur ajouté.{% elif msg == 'not_found' %}Hostname non trouvé en base.{% elif msg == 'exists' %}Ce serveur a déjà des spécificités.{% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -3,29 +3,28 @@
{% block content %} {% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-6">Utilisateurs & Permissions</h2> <h2 class="text-xl font-bold text-cyber-accent mb-6">Utilisateurs & Permissions</h2>
{% if saved %} {% if msg %}
<div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm"> <div class="mb-4 p-3 rounded text-sm {% if msg in ('forbidden','exists','exists_inactive','cant_self') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if saved == 'add' %}Utilisateur cree.{% elif saved == 'password' %}Mot de passe modifie.{% elif saved == 'toggle' %}Statut modifie.{% else %}Permissions sauvegardees.{% endif %} {% if msg == 'added' %}Utilisateur créé.{% elif msg == 'edited' %}Utilisateur modifié.{% elif msg == 'password_changed' %}Mot de passe modifié.{% elif msg == 'toggled' %}Statut modifié.{% elif msg == 'perms_saved' %}Permissions sauvegardées.{% elif msg == 'deleted' %}Utilisateur supprimé.{% elif msg == 'exists' %}Ce nom d'utilisateur existe déjà.{% elif msg == 'exists_inactive' %}Ce nom existe déjà (désactivé). Réactivez-le plutôt.{% elif msg == 'cant_self' %}Vous ne pouvez pas vous désactiver/supprimer vous-même.{% elif msg == 'forbidden' %}Action non autorisée.{% endif %}
</div> </div>
{% endif %} {% endif %}
<!-- Liste utilisateurs --> <!-- Liste utilisateurs -->
<div x-data="{ editing: '' }" class="space-y-3"> <div x-data="{ editing: '', editUser: null }" class="space-y-3">
{% for ud in users_data %} {% for ud in users_data %}
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="flex items-center justify-between p-4 cursor-pointer hover:bg-cyber-border/20" @click="editing = editing === '{{ ud.user.id }}' ? '' : '{{ ud.user.id }}'"> <div class="flex items-center justify-between p-4 cursor-pointer hover:bg-cyber-border/20" @click="editing = editing === '{{ ud.user.id }}' ? '' : '{{ ud.user.id }}'">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="font-bold {% if ud.user.is_active %}text-cyber-accent{% else %}text-gray-600 line-through{% endif %}">{{ ud.user.username }}</span> <span class="font-bold {% if ud.user.is_active %}text-cyber-accent{% else %}text-gray-600 line-through{% endif %}">{{ ud.user.username }}</span>
<span class="text-sm text-gray-400">{{ ud.user.display_name }}</span> <span class="text-sm text-gray-400">{{ ud.user.display_name }}</span>
<span class="badge {% if ud.user.role == 'admin' %}badge-red{% elif ud.user.role == 'coordinator' %}badge-yellow{% elif ud.user.role == 'operator' %}badge-blue{% else %}badge-gray{% endif %}">{{ ud.user.role }}</span> <span class="badge {% if ud.user.role == 'admin' %}badge-red{% elif ud.user.role == 'coordinator' %}badge-yellow{% elif ud.user.role == 'operator' %}badge-blue{% else %}badge-gray{% endif %}">{% if ud.user.role == "operator" %}intervenant{% else %}{{ ud.user.role }}{% endif %}</span>
<span class="badge {% if ud.user.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if ud.user.is_active else 'Inactif' }}</span> <span class="badge {% if ud.user.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if ud.user.is_active else 'Inactif' }}</span>
{% if ud.user.email %}<span class="text-xs text-gray-500">{{ ud.user.email }}</span>{% endif %} {% if ud.user.email %}<span class="text-xs text-gray-500">{{ ud.user.email }}</span>{% endif %}
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Permissions resumees -->
{% for m in modules %} {% for m in modules %}
{% if ud.perms.get(m) %} {% if ud.perms.get(m) %}
<span class="text-xs px-1 rounded {% if ud.perms[m] == 'admin' %}bg-red-900/30 text-cyber-red{% elif ud.perms[m] == 'edit' %}bg-blue-900/30 text-cyber-accent{% else %}bg-gray-800 text-gray-500{% endif %}">{{ m[:3] }}</span> <span class="text-xs px-1 rounded {% if ud.perms[m] == 'admin' %}bg-red-900/30 text-cyber-red{% elif ud.perms[m] == 'edit' %}bg-blue-900/30 text-cyber-accent{% else %}bg-gray-800 text-gray-500{% endif %}" title="{{ m }}:{{ ud.perms[m] }}">{{ m[:3] }}</span>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<span class="text-gray-500 text-lg" x-text="editing === '{{ ud.user.id }}' ? '&#9660;' : '&#9654;'"></span> <span class="text-gray-500 text-lg" x-text="editing === '{{ ud.user.id }}' ? '&#9660;' : '&#9654;'"></span>
@ -33,15 +32,37 @@
</div> </div>
<div x-show="editing === '{{ ud.user.id }}'" class="border-t border-cyber-border p-4 space-y-4"> <div x-show="editing === '{{ ud.user.id }}'" class="border-t border-cyber-border p-4 space-y-4">
{% if can_edit_users %}
<!-- Éditer infos user -->
<form method="POST" action="/users/{{ ud.user.id }}/edit" class="flex gap-3 items-end">
<div>
<label class="text-xs text-gray-500">Nom complet</label>
<input type="text" name="display_name" value="{{ ud.user.display_name }}" class="text-xs py-1 px-2 w-40">
</div>
<div>
<label class="text-xs text-gray-500">Email</label>
<input type="email" name="email" value="{{ ud.user.email or '' }}" class="text-xs py-1 px-2 w-44">
</div>
<div>
<label class="text-xs text-gray-500">Role</label>
<select name="role" class="text-xs py-1 px-2">
{% for r in ['admin','coordinator','operator','viewer'] %}
<option value="{{ r }}" {% if r == ud.user.role %}selected{% endif %}>{% if r == "operator" %}intervenant{% else %}{{ r }}{% endif %}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn-sm bg-cyber-accent text-black">Modifier</button>
</form>
<!-- Permissions par module --> <!-- Permissions par module -->
<form method="POST" action="/users/{{ ud.user.id }}/permissions"> <form method="POST" action="/users/{{ ud.user.id }}/permissions">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Permissions par module</h4> <h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Permissions par module</h4>
<div class="grid grid-cols-6 gap-2"> <div class="grid grid-cols-8 gap-2">
{% for m in modules %} {% for m in modules %}
<div> <div>
<label class="text-xs text-gray-500 block mb-1">{{ m }}</label> <label class="text-xs text-gray-500 block mb-1">{{ m }}</label>
<select name="perm_{{ m }}" class="w-full text-xs py-1"> <select name="perm_{{ m }}" class="w-full text-xs py-1">
<option value="">-</option> <option value=""></option>
{% for l in levels %} {% for l in levels %}
<option value="{{ l }}" {% if ud.perms.get(m) == l %}selected{% endif %}>{{ l }}</option> <option value="{{ l }}" {% if ud.perms.get(m) == l %}selected{% endif %}>{{ l }}</option>
{% endfor %} {% endfor %}
@ -53,26 +74,30 @@
</form> </form>
<!-- Actions --> <!-- Actions -->
<div class="flex gap-3 pt-2 border-t border-cyber-border"> <div class="flex gap-3 pt-2 border-t border-cyber-border items-center">
<!-- Reset password -->
<form method="POST" action="/users/{{ ud.user.id }}/password" class="flex gap-2 items-center"> <form method="POST" action="/users/{{ ud.user.id }}/password" class="flex gap-2 items-center">
<input type="password" name="new_password" placeholder="Nouveau mot de passe" class="text-xs py-1 px-2 w-48"> <input type="password" name="new_password" placeholder="Nouveau mot de passe" class="text-xs py-1 px-2 w-48">
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Changer MDP</button> <button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Changer MDP</button>
</form> </form>
<!-- Activer/Desactiver -->
<form method="POST" action="/users/{{ ud.user.id }}/toggle"> <form method="POST" action="/users/{{ ud.user.id }}/toggle">
<button type="submit" class="btn-sm {% if ud.user.is_active %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}"> <button type="submit" class="btn-sm {% if ud.user.is_active %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{{ 'Desactiver' if ud.user.is_active else 'Activer' }} {{ 'Désactiver' if ud.user.is_active else 'Activer' }}
</button> </button>
</form> </form>
<form method="POST" action="/users/{{ ud.user.id }}/delete">
<button type="submit" class="btn-sm bg-red-900/50 text-cyber-red" onclick="return confirm('SUPPRIMER définitivement {{ ud.user.username }} ?')">Supprimer</button>
</form>
</div> </div>
{% else %}
<p class="text-xs text-gray-500">Permissions en lecture seule</p>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Ajouter un utilisateur --> <!-- Ajouter un utilisateur -->
{% if can_edit_users %}
<div class="card p-5 mt-6"> <div class="card p-5 mt-6">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un utilisateur</h3> <h3 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un utilisateur</h3>
<form method="POST" action="/users/add" class="space-y-3"> <form method="POST" action="/users/add" class="space-y-3">
@ -90,9 +115,9 @@
<input type="email" name="new_email" class="w-full"> <input type="email" name="new_email" class="w-full">
</div> </div>
<div> <div>
<label class="text-xs text-gray-500">Role global</label> <label class="text-xs text-gray-500">Role</label>
<select name="new_role" class="w-full"> <select name="new_role" class="w-full">
<option value="operator">operator</option> <option value="operator">intervenant</option>
<option value="coordinator">coordinator</option> <option value="coordinator">coordinator</option>
<option value="admin">admin</option> <option value="admin">admin</option>
<option value="viewer">viewer</option> <option value="viewer">viewer</option>
@ -103,8 +128,9 @@
<label class="text-xs text-gray-500">Mot de passe</label> <label class="text-xs text-gray-500">Mot de passe</label>
<input type="password" name="new_password" required class="w-full"> <input type="password" name="new_password" required class="w-full">
</div> </div>
<p class="text-xs text-gray-600">Les permissions par module seront pre-remplies selon le role choisi. Modifiables ensuite.</p> <p class="text-xs text-gray-600">Permissions pre-remplies selon le role. Modifiables ensuite.</p>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Creer</button> <button type="submit" class="btn-primary px-4 py-2 text-sm">Créer</button>
</form> </form>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}