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:
parent
ba8a969366
commit
53c393b49b
@ -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"
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
60
app/main.py
60
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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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_", "")))
|
||||
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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
139
app/services/audit_service.py
Normal file
139
app/services/audit_service.py
Normal 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})
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
96
app/templates/assignments.html
Normal file
96
app/templates/assignments.html
Normal 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 %}
|
||||
@ -11,7 +11,7 @@
|
||||
</a>
|
||||
<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-[10px] text-gray-500">Echoues</div>
|
||||
<div class="text-[10px] text-gray-500">Échoués</div>
|
||||
</a>
|
||||
<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>
|
||||
|
||||
@ -53,15 +53,16 @@
|
||||
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
|
||||
</div>
|
||||
<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="/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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="#" class="block px-3 py-2 rounded-md text-sm text-gray-600">Tags Qualys</a>
|
||||
<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>
|
||||
<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>
|
||||
<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.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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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>
|
||||
</aside>
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
@ -70,7 +71,7 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-400">{{ user.sub }}</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>
|
||||
</header>
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if is_coordinator %}
|
||||
{% if can_edit_campaigns %}
|
||||
{% if c.status == 'draft' %}
|
||||
{% if can_plan %}
|
||||
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="pending_validation">
|
||||
@ -37,7 +37,11 @@
|
||||
{% endif %}
|
||||
{% if c.status in ('draft', 'pending_validation', 'planned') %}
|
||||
<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 %}
|
||||
</div>
|
||||
@ -45,7 +49,7 @@
|
||||
|
||||
{% 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 %}">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
@ -53,10 +57,10 @@
|
||||
<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-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-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">
|
||||
{% set patchable = stats.total - stats.excluded - stats.cancelled %}
|
||||
@ -65,17 +69,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prereqs (draft) -->
|
||||
{% if c.status == 'draft' and prereq and is_coordinator %}
|
||||
<!-- Repartition intervenants -->
|
||||
{% 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="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">
|
||||
<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>
|
||||
</div>
|
||||
<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">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>
|
||||
@ -99,7 +121,7 @@
|
||||
<th class="p-2">Tier</th>
|
||||
<th class="p-2">Jour prevu</th>
|
||||
<th class="p-2">Heure</th>
|
||||
<th class="p-2">Operateur</th>
|
||||
<th class="p-2">Intervenant</th>
|
||||
{% if c.status == 'draft' %}
|
||||
<th class="p-2">SSH</th>
|
||||
<th class="p-2">Sat</th>
|
||||
@ -120,7 +142,7 @@
|
||||
<td class="p-2 text-center">
|
||||
{% if s.intervenant_name %}
|
||||
<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">🔒</span>{% endif %}
|
||||
{% if s.forced_assignment %}<span class="text-cyber-yellow text-[9px] ml-0.5" title="Intervenant référent">🔒</span>{% endif %}
|
||||
{% else %}<span class="text-gray-600">—</span>{% endif %}
|
||||
</td>
|
||||
{% 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>
|
||||
{% if s.exclusion_reason %}
|
||||
<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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<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>
|
||||
|
||||
{% elif s.status == 'pending' %}
|
||||
{% if c.status == 'planned' %}
|
||||
{# Operateur: prendre/liberer #}
|
||||
{# Intervenant: prendre/liberer #}
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{# 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 = 'schedule'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Planifier</button>
|
||||
{% endif %}
|
||||
|
||||
{% elif c.status == 'draft' and is_coordinator %}
|
||||
{% elif c.status == 'draft' and can_edit_campaigns %}
|
||||
<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>
|
||||
<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">
|
||||
<td colspan="12" class="p-2">
|
||||
<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">
|
||||
<option value="">— Desassigner —</option>
|
||||
<select name="intervenant_id" class="text-xs py-1 px-2">
|
||||
<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 %}
|
||||
</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>
|
||||
@ -206,10 +228,10 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Limites operateurs (coordinateur, planned) -->
|
||||
{% if is_coordinator and c.status in ('planned', 'pending_validation') %}
|
||||
<!-- Limites intervenants (coordinateur, planned) -->
|
||||
{% if can_edit_campaigns and c.status in ('planned', 'pending_validation') %}
|
||||
<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 %}
|
||||
<div class="grid grid-cols-3 gap-2 text-xs mb-3">
|
||||
{% for ol in op_limits %}
|
||||
@ -220,10 +242,10 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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>
|
||||
<label class="text-xs text-gray-500">Operateur</label>
|
||||
<select name="operator_id" class="text-xs py-1 px-2">
|
||||
<label class="text-xs text-gray-500">Intervenant</label>
|
||||
<select name="intervenant_id" class="text-xs py-1 px-2">
|
||||
{% for u in intervenants %}<option value="{{ u.id }}">{{ u.display_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@ -235,7 +257,7 @@
|
||||
<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">
|
||||
</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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -9,6 +9,13 @@
|
||||
</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 -->
|
||||
<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>
|
||||
@ -49,12 +56,13 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
<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">
|
||||
<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 %}
|
||||
<div>
|
||||
@ -75,8 +83,9 @@
|
||||
</div>
|
||||
<div id="preview-zone"></div>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
28
app/templates/error.html
Normal file
28
app/templates/error.html
Normal 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>
|
||||
@ -44,6 +44,6 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
<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">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">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>
|
||||
</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>
|
||||
<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">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>
|
||||
|
||||
@ -112,7 +112,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<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-gray-300" onclick="closePanel()">Fermer</button>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<div class="p-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">×</button>
|
||||
</div>
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
<input type="text" name="responsable_nom" value="{{ s.responsable_nom or '' }}" class="w-full">
|
||||
</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">
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -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_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="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="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>
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
<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>
|
||||
<!-- Dupliquer -->
|
||||
{% if entries %}
|
||||
<!-- Dupliquer (admin/coordinateur) -->
|
||||
{% if entries and perms.planning in ('edit', 'admin') %}
|
||||
<form method="POST" action="/planning/duplicate" class="flex gap-1 items-center ml-4">
|
||||
<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">
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
{% 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 %}">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
@ -137,7 +137,7 @@
|
||||
<th class="p-2">Cycle</th>
|
||||
<th class="p-2">Statut</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>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
@ -164,6 +164,7 @@
|
||||
<template x-if="editing !== {{ e.id }}">
|
||||
<td class="p-2 text-xs text-gray-400">{{ e.note or '' }}</td>
|
||||
</template>
|
||||
{% if perms.planning in ('edit', 'admin') %}
|
||||
<template x-if="editing !== {{ e.id }}">
|
||||
<td class="p-2 text-center">
|
||||
<button @click="editing = {{ e.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Edit</button>
|
||||
@ -195,12 +196,14 @@
|
||||
</form>
|
||||
</td>
|
||||
</template>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if perms.planning in ('edit', 'admin') %}
|
||||
<!-- Ajouter une entree -->
|
||||
<div class="card p-4 mt-4">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
{% if saved %}
|
||||
<div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm">
|
||||
Section "{{ saved }}" sauvegardee.
|
||||
Section "{{ saved }}" sauvegardée.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -93,7 +93,7 @@
|
||||
<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 %}>
|
||||
</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 %}
|
||||
</form>
|
||||
</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 %}>
|
||||
</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 %}
|
||||
</form>
|
||||
</div>
|
||||
@ -228,7 +228,7 @@
|
||||
<td class="p-2 text-center">
|
||||
{% if vc.is_active %}
|
||||
<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 ?')">Désactiver</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
@ -298,7 +298,7 @@
|
||||
</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 %}>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">Envoie les evenements de patching vers Splunk via HEC.</p>
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
{% set msg = request.query_params.get('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 %}">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@ -3,29 +3,28 @@
|
||||
{% block content %}
|
||||
<h2 class="text-xl font-bold text-cyber-accent mb-6">Utilisateurs & Permissions</h2>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm">
|
||||
{% if saved == 'add' %}Utilisateur cree.{% elif saved == 'password' %}Mot de passe modifie.{% elif saved == 'toggle' %}Statut modifie.{% else %}Permissions sauvegardees.{% endif %}
|
||||
{% if msg %}
|
||||
<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 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>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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 %}
|
||||
<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 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="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>
|
||||
{% if ud.user.email %}<span class="text-xs text-gray-500">{{ ud.user.email }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Permissions resumees -->
|
||||
{% for m in modules %}
|
||||
{% 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 %}
|
||||
{% endfor %}
|
||||
<span class="text-gray-500 text-lg" x-text="editing === '{{ ud.user.id }}' ? '▼' : '▶'"></span>
|
||||
@ -33,15 +32,37 @@
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<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>
|
||||
<div class="grid grid-cols-6 gap-2">
|
||||
<div class="grid grid-cols-8 gap-2">
|
||||
{% for m in modules %}
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 block mb-1">{{ m }}</label>
|
||||
<select name="perm_{{ m }}" class="w-full text-xs py-1">
|
||||
<option value="">-</option>
|
||||
<option value="">—</option>
|
||||
{% for l in levels %}
|
||||
<option value="{{ l }}" {% if ud.perms.get(m) == l %}selected{% endif %}>{{ l }}</option>
|
||||
{% endfor %}
|
||||
@ -53,26 +74,30 @@
|
||||
</form>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-2 border-t border-cyber-border">
|
||||
<!-- Reset password -->
|
||||
<div class="flex gap-3 pt-2 border-t border-cyber-border 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">
|
||||
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Changer MDP</button>
|
||||
</form>
|
||||
|
||||
<!-- Activer/Desactiver -->
|
||||
<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 %}">
|
||||
{{ 'Desactiver' if ud.user.is_active else 'Activer' }}
|
||||
{{ 'Désactiver' if ud.user.is_active else 'Activer' }}
|
||||
</button>
|
||||
</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>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500">Permissions en lecture seule</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Ajouter un utilisateur -->
|
||||
{% if can_edit_users %}
|
||||
<div class="card p-5 mt-6">
|
||||
<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">
|
||||
@ -90,9 +115,9 @@
|
||||
<input type="email" name="new_email" class="w-full">
|
||||
</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">
|
||||
<option value="operator">operator</option>
|
||||
<option value="operator">intervenant</option>
|
||||
<option value="coordinator">coordinator</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="viewer">viewer</option>
|
||||
@ -103,8 +128,9 @@
|
||||
<label class="text-xs text-gray-500">Mot de passe</label>
|
||||
<input type="password" name="new_password" required class="w-full">
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">Les permissions par module seront pre-remplies selon le role choisi. Modifiables ensuite.</p>
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm">Creer</button>
|
||||
<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">Créer</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user