diff --git a/app/config.py b/app/config.py
index 3c9aa2a..20b9c7f 100644
--- a/app/config.py
+++ b/app/config.py
@@ -3,7 +3,7 @@ import os
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")
ALGORITHM = "HS256"
-ACCESS_TOKEN_EXPIRE_MINUTES = 480 # 8 heures
+ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 8 heures
APP_NAME = "PatchCenter"
APP_VERSION = "2.0"
diff --git a/app/dependencies.py b/app/dependencies.py
index ab23deb..4773fbe 100644
--- a/app/dependencies.py
+++ b/app/dependencies.py
@@ -1,5 +1,6 @@
"""Dependances communes pour les routers"""
from fastapi import Request
+from sqlalchemy import text
from .auth import decode_token
from .database import SessionLocal
@@ -18,3 +19,42 @@ def get_current_user(request: Request):
if not token:
return None
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,
+ }
diff --git a/app/main.py b/app/main.py
index d7d917b..e9b06cf 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,11 +1,33 @@
"""PatchCenter v2 — Entry point FastAPI"""
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
+from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION
+from .dependencies import get_current_user, get_user_perms
+from .database import SessionLocal
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.add_middleware(PermissionsMiddleware)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router)
@@ -20,10 +42,44 @@ app.include_router(audit.router)
@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")
@app.get("/health")
async def health():
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)
diff --git a/app/routers/audit.py b/app/routers/audit.py
index a4c9d84..0fd1461 100644
--- a/app/routers/audit.py
+++ b/app/routers/audit.py
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
-from ..dependencies import get_db, get_current_user
+from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..config import APP_NAME
router = APIRouter()
diff --git a/app/routers/auth.py b/app/routers/auth.py
index 82cc0b9..2934fc5 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -4,6 +4,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
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
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)"),
{"u": username}).fetchone()
if not row:
+ log_login_failed(db, request, username)
+ db.commit()
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
})
- # Verifier mot de passe (bcrypt pour web, PBKDF2 legacy pour SLPM)
try:
ok = verify_password(password, row.password_hash)
except Exception:
ok = False
if not ok:
+ log_login_failed(db, request, username)
+ db.commit()
return templates.TemplateResponse("login.html", {
"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})
+ 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.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
@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.delete_cookie("access_token")
return response
diff --git a/app/routers/campaigns.py b/app/routers/campaigns.py
index 463eb7d..d57e131 100644
--- a/app/routers/campaigns.py
+++ b/app/routers/campaigns.py
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
-from ..dependencies import get_db, get_current_user
+from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..services.campaign_service import (
list_campaigns, get_campaign, get_campaign_sessions, get_campaign_stats,
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.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
router = APIRouter()
@@ -47,6 +52,10 @@ async def campaigns_list(request: Request, db=Depends(get_db),
return RedirectResponse(url="/login")
if not 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)
now = datetime.now()
@@ -62,11 +71,13 @@ async def campaigns_list(request: Request, db=Depends(get_db),
ORDER BY pp.week_number
"""), {"y": year, "cw": current_week}).fetchall()
- return templates.TemplateResponse("campaigns.html", {
- "request": request, "user": user, "app_name": APP_NAME,
+ ctx = base_context(request, db, user)
+ ctx.update({
+ "app_name": APP_NAME,
"campaigns": campaigns, "year": year, "status_filter": status,
"planned_weeks": planned_weeks,
})
+ return templates.TemplateResponse("campaigns.html", ctx)
@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():
if key.startswith("exclude_"):
excluded.append(int(key.replace("exclude_", "")))
- cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded)
+ try:
+ 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:
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)
@@ -113,25 +134,35 @@ async def campaign_detail(request: Request, campaign_id: int, db=Depends(get_db)
sessions = get_campaign_sessions(db, campaign_id)
stats = get_campaign_stats(db, campaign_id)
prereq = get_prereq_stats(db, campaign_id)
- can_plan = can_plan_campaign(db, campaign_id)
- role = user.get("role", "viewer")
- is_coordinator = role in ("admin", "coordinator")
- max_srv = _get_max_servers(db)
+ can_plan_flag = can_plan_campaign(db, campaign_id)
+ perms = get_user_perms(db, user)
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()
op_limits = get_campaign_operator_limits(db, campaign_id)
- return templates.TemplateResponse("campaign_detail.html", {
- "request": request, "user": user, "app_name": APP_NAME,
+ # Compteur par operateur
+ 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,
- "prereq": prereq, "can_plan": can_plan,
+ "prereq": prereq, "can_plan": can_plan_flag,
"exclusion_reasons": EXCLUSION_REASONS,
- "is_coordinator": is_coordinator, "intervenants": intervenants,
- "op_limits": op_limits,
+ "can_edit_campaigns": can_edit(perms, "campaigns"),
+ "can_admin_campaigns": can_admin(perms, "campaigns"),
+ "intervenants": intervenants, "op_limits": op_limits, "op_counts": op_counts,
"msg": request.query_params.get("msg"),
})
+ return templates.TemplateResponse("campaign_detail.html", ctx)
@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)
if not user:
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):
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)
+ log_campaign_status(db, request, user, campaign_id, old_status, new_status)
+ db.commit()
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")
async def session_prereq(request: Request, session_id: int, db=Depends(get_db),
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"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
- return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved#row-{session_id}", status_code=303)
+ return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved", status_code=303)
@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:
return RedirectResponse(url="/login")
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)
@@ -177,7 +238,7 @@ async def session_check_prereq(request: Request, session_id: int, db=Depends(get
check_single_prereq(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
- return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked#row-{session_id}", status_code=303)
+ return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked", status_code=303)
@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"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
- return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded#row-{session_id}", status_code=303)
+ return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded", status_code=303)
@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)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
- return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored#row-{session_id}", status_code=303)
+ return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored", status_code=303)
# --- Assignation operateurs ---
@@ -222,7 +283,7 @@ async def session_take(request: Request, session_id: int, db=Depends(get_db)):
if current >= limit:
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=limit_reached", status_code=303)
assign_operator(db, session_id, user.get("uid"))
- return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=taken#row-{session_id}", status_code=303)
+ return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=taken", status_code=303)
@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)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
- return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=released#row-{session_id}", status_code=303)
+ return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=released", status_code=303)
@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)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
- return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=assigned#row-{session_id}", status_code=303)
+ return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=assigned", status_code=303)
# --- 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)
+# --- 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")
async def session_schedule(request: Request, session_id: int, db=Depends(get_db),
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)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
- return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=scheduled#row-{session_id}", status_code=303)
+ return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=scheduled", status_code=303)
diff --git a/app/routers/planning.py b/app/routers/planning.py
index 31aacf0..fe61781 100644
--- a/app/routers/planning.py
+++ b/app/routers/planning.py
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
-from ..dependencies import get_db, get_current_user
+from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..config import APP_NAME
router = APIRouter()
@@ -83,8 +83,9 @@ async def planning_page(request: Request, db=Depends(get_db),
if next_week > 53:
next_week = 1
+ perms = get_user_perms(db, user)
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,
"freeze_weeks": freeze_weeks, "months": MONTHS,
"domain_colors": DOMAIN_COLORS, "weeks": range(1, 54),
diff --git a/app/routers/specifics.py b/app/routers/specifics.py
index 38643b2..10fb739 100644
--- a/app/routers/specifics.py
+++ b/app/routers/specifics.py
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
-from ..dependencies import get_db, get_current_user
+from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..config import APP_NAME
router = APIRouter()
diff --git a/app/routers/users.py b/app/routers/users.py
index f38de91..f381ac9 100644
--- a/app/routers/users.py
+++ b/app/routers/users.py
@@ -3,14 +3,14 @@ from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
-from ..dependencies import get_db, get_current_user
+from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..auth import hash_password
from ..config import APP_NAME
router = APIRouter()
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"]
@@ -30,44 +30,67 @@ def _get_users_with_perms(db):
return result
-@router.get("/users", response_class=HTMLResponse)
-async def users_page(request: Request, db=Depends(get_db)):
+def _check_access(request, db):
user = get_current_user(request)
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)
- return templates.TemplateResponse("users.html", {
- "request": request, "user": user, "app_name": APP_NAME,
- "users_data": users_data, "modules": MODULES, "levels": LEVELS,
- "saved": None,
+ ctx = base_context(request, db, user)
+ ctx.update({
+ "app_name": APP_NAME, "users_data": users_data,
+ "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),
new_username: str = Form(...), new_display_name: str = Form(...),
new_email: str = Form(""), new_password: str = Form(...),
new_role: str = Form("operator")):
- user = get_current_user(request)
- if not user:
- return RedirectResponse(url="/login")
+ 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)
+
+ # 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)
db.execute(text("""
INSERT INTO users (username, display_name, email, password_hash, role)
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})
- # Recuperer l'id du nouveau user
- row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username}).fetchone()
+ row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username.strip()}).fetchone()
if row:
- # Permissions par defaut selon role
default_perms = {
"admin": {m: "admin" for m in MODULES},
- "coordinator": {"servers": "edit", "campaigns": "admin", "qualys": "edit", "audit": "view", "settings": "view", "users": "view"},
- "operator": {"servers": "edit", "campaigns": "edit", "qualys": "view", "audit": "view"},
- "viewer": {"servers": "view", "campaigns": "view", "qualys": "view", "audit": "view"},
+ "coordinator": {"servers": "admin", "campaigns": "admin", "qualys": "admin", "audit": "admin",
+ "settings": "view", "users": "view", "planning": "admin", "specifics": "admin"},
+ "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():
db.execute(text(
@@ -75,24 +98,19 @@ async def user_add(request: Request, db=Depends(get_db),
), {"uid": row.id, "m": mod, "l": lvl})
db.commit()
- 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": "add",
- })
+ return RedirectResponse(url="/users?msg=added", status_code=303)
-@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)):
- user = get_current_user(request)
- if not user:
- return RedirectResponse(url="/login")
+ 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)
form = await request.form()
- # Supprimer les anciennes permissions
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
- # Inserer les nouvelles
for mod in MODULES:
lvl = form.get(f"perm_{mod}", "")
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)"
), {"uid": user_id, "m": mod, "l": lvl})
db.commit()
-
- 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}",
- })
+ return RedirectResponse(url=f"/users?msg=perms_saved", status_code=303)
-@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)):
- user = get_current_user(request)
- if not user:
- return RedirectResponse(url="/login")
+ 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)
+ # 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.commit()
- 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": "toggle",
- })
+ return RedirectResponse(url="/users?msg=toggled", status_code=303)
-@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),
new_password: str = Form(...)):
- user = get_current_user(request)
- if not user:
- return RedirectResponse(url="/login")
+ 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)
pw_hash = hash_password(new_password)
db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"),
{"ph": pw_hash, "id": user_id})
db.commit()
- 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": "password",
- })
+ return RedirectResponse(url="/users?msg=password_changed", status_code=303)
+
+
+@router.post("/users/{user_id}/delete")
+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)
diff --git a/app/services/audit_service.py b/app/services/audit_service.py
new file mode 100644
index 0000000..8ac173b
--- /dev/null
+++ b/app/services/audit_service.py
@@ -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})
diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py
index 3c0b915..542d6cb 100644
--- a/app/services/campaign_service.py
+++ b/app/services/campaign_service.py
@@ -127,7 +127,7 @@ def get_servers_for_planning(db, year, week_number):
servers = db.execute(text(f"""
SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier,
s.licence_support, s.ssh_method, s.machine_type,
- s.pref_patch_jour, s.pref_patch_heure, s.default_intervenant_id,
+ 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
FROM servers s
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
+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):
servers, planning = get_servers_for_planning(db, year, week_number)
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}"
lun, mar, mer, jeu = _week_dates(year, week_number)
- p = planning[0] if planning else None
row = db.execute(text("""
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
"""), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone()
cid = row.id
-
excluded = set(excluded_ids or [])
- for s in servers:
- status = 'excluded' if s.id in excluded else 'pending'
- # Date par defaut : hors-prod = lun/mar, prod = mer/jeu
- is_prod = (s.environnement == 'Production')
+
+ # Separer hors-prod et prod
+ hprod_servers = [s for s in servers if s.environnement != 'Production' and s.id not in excluded]
+ 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':
- jour_map = {"lundi": lun, "mardi": mar, "mercredi": mer, "jeudi": jeu}
- date_prevue = jour_map.get(s.pref_patch_jour, mer if is_prod else lun)
- else:
- date_prevue = mer if is_prod else lun
+ jour_map = {"lundi": lun, "mardi": mar}
+ 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
- heure = s.pref_patch_heure if s.pref_patch_heure and s.pref_patch_heure != 'indifferent' else None
-
- # Auto-assigner le default intervenant si defini
default_op = s.default_intervenant_id if hasattr(s, 'default_intervenant_id') else None
forced = True if default_op else False
db.execute(text("""
INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue, heure_prevue,
intervenant_id, forced_assignment, assigned_at)
- VALUES (:cid, :sid, :st, :dp, :hp, :oid, :forced, CASE WHEN :oid IS NOT NULL THEN now() END)
+ 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, "st": status, "dp": date_prevue, "hp": heure,
+ """), {"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(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(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'"
), {"cid": cid}).scalar()
@@ -191,6 +271,89 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude
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):
db.execute(text("""
UPDATE patch_sessions SET
@@ -225,12 +388,27 @@ def _recalc_total(db, session_id):
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("""
UPDATE patch_sessions SET intervenant_id = :oid, assigned_at = now(),
forced_assignment = :forced
WHERE id = :id
"""), {"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()
diff --git a/app/services/prereq_service.py b/app/services/prereq_service.py
index 2744b6a..5d2a349 100644
--- a/app/services/prereq_service.py
+++ b/app/services/prereq_service.py
@@ -5,6 +5,9 @@ import paramiko
import os
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)
DISK_ROOT_MIN_MB = 1200 # 1.2 Go minimum sur /
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,
}
+ # 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
if s.licence_support == 'eol':
result["eligible"] = False
diff --git a/app/templates/assignments.html b/app/templates/assignments.html
new file mode 100644
index 0000000..c764d7f
--- /dev/null
+++ b/app/templates/assignments.html
@@ -0,0 +1,96 @@
+{% extends 'base.html' %}
+{% block title %}Assignations par defaut{% endblock %}
+{% block content %}
+
+
+
← Campagnes
+
Assignations par defaut
+
Regles appliquees automatiquement a la creation de chaque campagne. Priorite : serveur > application > app_group > domaine > zone.
+
+
+
+{% if msg %}
+
+ {% if msg == 'added' %}Regle ajoutee.{% elif msg == 'deleted' %}Regle supprimee.{% elif msg == 'error' %}Erreur (regle dupliquee ?).{% endif %}
+
+{% endif %}
+
+
+
+
+
+ Priorite
+ Type
+ Valeur
+ Intervenant
+ Note
+ Actions
+
+
+ {% for r in rules %}
+
+ {{ r.priority }}
+ {{ r.rule_type }}
+ {{ r.rule_value }}
+ {{ r.display_name }}
+ {{ r.note or '' }}
+
+
+
+
+ {% endfor %}
+ {% if not rules %}
+ Aucune regle definie
+ {% endif %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/audit.html b/app/templates/audit.html
index f7c0879..94ca19d 100644
--- a/app/templates/audit.html
+++ b/app/templates/audit.html
@@ -11,7 +11,7 @@
{{ stats.failed }}
- Echoues
+ Échoués
{{ stats.disk_alerts }}
diff --git a/app/templates/base.html b/app/templates/base.html
index c02ee87..a63eb6e 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -53,15 +53,16 @@
v2.0 — SecOps
+ {% set p = perms if perms is defined else request.state.perms %}
Dashboard
- Serveurs
- Specifiques
- Campagnes
- Planning
- Tags Qualys
- Audit
- Utilisateurs
- Settings
+ {% if p.servers %}Serveurs {% endif %}
+ {% if p.specifics %}Spécifiques {% endif %}
+ {% if p.campaigns %}Campagnes {% endif %}
+ {% if p.campaigns in ('edit', 'admin') %}Assignations {% endif %}
+ {% if p.planning %}Planning {% endif %}
+ {% if p.audit %}Audit {% endif %}
+ {% if p.users %}Utilisateurs {% endif %}
+ {% if p.settings %}Settings {% endif %}
@@ -70,7 +71,7 @@
diff --git a/app/templates/campaign_detail.html b/app/templates/campaign_detail.html
index 0f61f6f..6624664 100644
--- a/app/templates/campaign_detail.html
+++ b/app/templates/campaign_detail.html
@@ -15,7 +15,7 @@
- {% if is_coordinator %}
+ {% if can_edit_campaigns %}
{% if c.status == 'draft' %}
{% if can_plan %}
+ Annuler
+ {% endif %}
+ {% if c.status in ('draft', 'cancelled') %}
+
{% endif %}
{% endif %}
@@ -45,7 +49,7 @@
{% if msg %}
- {% 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 %}
{% endif %}
@@ -53,10 +57,10 @@
{{ stats.patched }}
Patches
-
{{ stats.failed }}
Echoues
+
{{ stats.failed }}
Échoués
{{ stats.pending }}
En attente
{{ stats.excluded }}
Exclus
-
{{ stats.assigned }}
Assignes
+
{{ stats.assignéd }}
Assignés
{{ stats.unassigned }}
Libres
{% set patchable = stats.total - stats.excluded - stats.cancelled %}
@@ -65,17 +69,35 @@
-
-{% if c.status == 'draft' and prereq and is_coordinator %}
+
+{% if op_counts %}
+
+ {% for oc in op_counts %}
+
+ {{ oc.display_name }}
+ {{ oc.count }}
+
+ {% endfor %}
+ {% if stats.unassigned > 0 %}
+
+ Non assignés
+ {{ stats.unassigned }}
+
+ {% endif %}
+
+{% endif %}
+
+
+{% if c.status == 'draft' and prereq and can_edit_campaigns %}
-
Prerequis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)
+ Prérequisuis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)
-
A verifier {{ prereq.prereq_todo }}
+
A vérifiér {{ prereq.prereq_todo }}
SSH {{ prereq.ssh_ok }}
Satellite {{ prereq.sat_ok }}
Rollback {{ prereq.rollback_ok }}
@@ -99,7 +121,7 @@
Tier
Jour prevu
Heure
-
Operateur
+
Intervenant
{% if c.status == 'draft' %}
SSH
Sat
@@ -120,7 +142,7 @@
{% if s.intervenant_name %}
{{ s.intervenant_name }}
- {% if s.forced_assignment %}🔒 {% endif %}
+ {% if s.forced_assignment %}🔒 {% endif %}
{% else %}— {% endif %}
{% if c.status == 'draft' %}
@@ -132,30 +154,30 @@
{{ s.status }}
{% if s.exclusion_reason %}
- {% 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 %}({{ s.excluded_by }}) {% endif %}
{% endif %}
- {% if s.status == 'excluded' and is_coordinator %}
+ {% if s.status == 'excluded' and can_edit_campaigns %}
{% elif s.status == 'pending' %}
{% if c.status == 'planned' %}
- {# Operateur: prendre/liberer #}
+ {# Intervenant: prendre/liberer #}
{% if not s.intervenant_id %}
{% elif s.intervenant_id == user.uid and not s.forced_assignment %}
{% endif %}
{# Coordinateur: assigner + planifier #}
- {% if is_coordinator %}
+ {% if can_edit_campaigns %}
Assigner
Planifier
{% endif %}
- {% elif c.status == 'draft' and is_coordinator %}
+ {% elif c.status == 'draft' and can_edit_campaigns %}
Exclure
@@ -180,8 +202,8 @@
{% endif %}
diff --git a/app/templates/campaigns.html b/app/templates/campaigns.html
index 97e1b16..1a6745c 100644
--- a/app/templates/campaigns.html
+++ b/app/templates/campaigns.html
@@ -9,6 +9,13 @@
+{% set msg = request.query_params.get('msg') %}
+{% if msg %}
+
+ {% 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 %}
+
+{% endif %}
+
Toutes
@@ -49,12 +56,13 @@
{% endif %}
-
+
+{% if perms.campaigns in ('edit', 'admin') %}
Nouvelle campagne
-
Creer depuis le planning
+
Créer depuis le planning
{% if planned_weeks %}
@@ -75,8 +83,9 @@
{% else %}
-
Aucune semaine planifiee a venir pour {{ year }}. Verifiez le planning.
+
Aucune semaine planifiee a venir pour {{ year }}. Vérifiez le planning.
{% endif %}
+{% endif %}
{% endblock %}
diff --git a/app/templates/error.html b/app/templates/error.html
new file mode 100644
index 0000000..50ba703
--- /dev/null
+++ b/app/templates/error.html
@@ -0,0 +1,28 @@
+
+
+
+
+ PatchCenter — Maintenance
+
+
+
+
+
{{ code }}
+
{{ title }}
+
{{ message }}
+
+
+
+
diff --git a/app/templates/partials/campaign_preview.html b/app/templates/partials/campaign_preview.html
index bebddca..79ebf6a 100644
--- a/app/templates/partials/campaign_preview.html
+++ b/app/templates/partials/campaign_preview.html
@@ -44,6 +44,6 @@
Decochez les serveurs a exclure. Vous pourrez aussi exclure/reporter individuellement apres creation.
- Creer la campagne ({{ count }} serveurs)
+ Créer la campagne ({{ count }} serveurs)
diff --git a/app/templates/partials/server_detail.html b/app/templates/partials/server_detail.html
index aef5cdb..719d1a6 100644
--- a/app/templates/partials/server_detail.html
+++ b/app/templates/partials/server_detail.html
@@ -70,9 +70,9 @@
Patching
Owner OS {{ s.patch_os_owner }}
-
Frequence {{ s.patch_frequency }}
+
Fréquence {{ s.patch_frequency }}
Podman {{ 'Oui' if s.is_podman else 'Non' }}
-
Prevenance {{ 'Oui' if s.need_pct else 'Non' }}
+
Prévenance {{ 'Oui' if s.need_pct else 'Non' }}
Satellite {% 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 %}
@@ -82,7 +82,7 @@
Responsables
Responsable: {{ s.responsable_nom or '-' }}
-
Referent: {{ s.referent_nom or '-' }}
+
Référent: {{ s.referent_nom or '-' }}
@@ -112,7 +112,7 @@
- Editer
+ Éditer
Sync Qualys
Fermer
diff --git a/app/templates/partials/server_edit.html b/app/templates/partials/server_edit.html
index 1b95436..21819db 100644
--- a/app/templates/partials/server_edit.html
+++ b/app/templates/partials/server_edit.html
@@ -1,6 +1,6 @@
- Referent technique
+ Référent technique
{% for e in entries %}
@@ -164,6 +164,7 @@
{{ e.note or '' }}
+ {% if perms.planning in ('edit', 'admin') %}
Edit
@@ -195,12 +196,14 @@
+ {% endif %}
{% endfor %}
+{% if perms.planning in ('edit', 'admin') %}
Ajouter une entree
@@ -240,4 +243,5 @@
Ajouter
+{% endif %}
{% endblock %}
diff --git a/app/templates/settings.html b/app/templates/settings.html
index 4ad16ba..94ebed6 100644
--- a/app/templates/settings.html
+++ b/app/templates/settings.html
@@ -5,7 +5,7 @@
{% if saved %}
- Section "{{ saved }}" sauvegardee.
+ Section "{{ saved }}" sauvegardée.
{% endif %}
@@ -93,7 +93,7 @@
Password par defaut
- Pour les environnements recette sans cle SSH. Chaque operateur peut configurer son propre compte.
+ Pour les environnements recette sans cle SSH. Chaque opérateur peut configurer son propre compte.
{% if editable.ssh_pwd %}Sauvegarder {% endif %}
@@ -134,7 +134,7 @@
- Auth keyboard-interactive. Chaque operateur configure son propre compte CyberArk. MDP saisi en session.
+ Auth keyboard-interactive. Chaque opérateur configure son propre compte CyberArk. MDP saisi en session.
{% if editable.ssh_psmp %}Sauvegarder {% endif %}
@@ -228,7 +228,7 @@
{% if vc.is_active %}
- Desactiver
+ Désactiver
{% endif %}
@@ -298,7 +298,7 @@
- Verifier SSL (true/false)
+ Vérifier SSL (true/false)
Envoie les evenements de patching vers Splunk via HEC.
diff --git a/app/templates/specifics.html b/app/templates/specifics.html
index fd74cf5..5f3b161 100644
--- a/app/templates/specifics.html
+++ b/app/templates/specifics.html
@@ -8,7 +8,7 @@
{% set msg = request.query_params.get('msg') %}
{% if msg %}
- {% 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 %}
{% endif %}
diff --git a/app/templates/users.html b/app/templates/users.html
index 8974421..f174288 100644
--- a/app/templates/users.html
+++ b/app/templates/users.html
@@ -3,29 +3,28 @@
{% block content %}
Utilisateurs & Permissions
-{% if saved %}
-
- {% if saved == 'add' %}Utilisateur cree.{% elif saved == 'password' %}Mot de passe modifie.{% elif saved == 'toggle' %}Statut modifie.{% else %}Permissions sauvegardees.{% endif %}
+{% if msg %}
+
+ {% 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 %}
{% endif %}
-
+
{% for ud in users_data %}
{{ ud.user.username }}
{{ ud.user.display_name }}
- {{ ud.user.role }}
+ {% if ud.user.role == "operator" %}intervenant{% else %}{{ ud.user.role }}{% endif %}
{{ 'Actif' if ud.user.is_active else 'Inactif' }}
{% if ud.user.email %}{{ ud.user.email }} {% endif %}
-
{% for m in modules %}
{% if ud.perms.get(m) %}
- {{ m[:3] }}
+ {{ m[:3] }}
{% endif %}
{% endfor %}
@@ -33,15 +32,37 @@
+ {% if can_edit_users %}
+
+
+
+ Nom complet
+
+
+
+ Email
+
+
+
+ Role
+
+ {% for r in ['admin','coordinator','operator','viewer'] %}
+ {% if r == "operator" %}intervenant{% else %}{{ r }}{% endif %}
+ {% endfor %}
+
+
+ Modifier
+
+
Permissions par module
-
+
{% for m in modules %}
{{ m }}
- -
+ —
{% for l in levels %}
{{ l }}
{% endfor %}
@@ -53,26 +74,30 @@
-
-
+
Changer MDP
-
-
- {{ 'Desactiver' if ud.user.is_active else 'Activer' }}
+ {{ 'Désactiver' if ud.user.is_active else 'Activer' }}
+
+ Supprimer
+
+ {% else %}
+
Permissions en lecture seule
+ {% endif %}
{% endfor %}
+{% if can_edit_users %}
Ajouter un utilisateur
@@ -90,9 +115,9 @@
- Role global
+ Role
- operator
+ intervenant
coordinator
admin
viewer
@@ -103,8 +128,9 @@
Mot de passe
-
Les permissions par module seront pre-remplies selon le role choisi. Modifiables ensuite.
-
Creer
+
Permissions pre-remplies selon le role. Modifiables ensuite.
+
Créer
+{% endif %}
{% endblock %}