Mode Demo/Reel au login + base patchcenter_demo

- Choix Production/Demo sur la page de login
- Base patchcenter_demo avec schema identique + 10 serveurs mpcz
- Le mode est stocke dans le JWT token
- La session DB bascule automatiquement selon le mode
This commit is contained in:
Pierre & Lumière 2026-04-10 19:39:35 +02:00
parent e96d79aae3
commit c2f3d669eb
5 changed files with 92 additions and 46 deletions

View File

@ -1,6 +1,7 @@
import os import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_db") DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_db")
DATABASE_URL_DEMO = os.getenv("DATABASE_URL_DEMO", "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_demo")
SECRET_KEY = os.getenv("SECRET_KEY", "slpm-patchcenter-secret-key-2026-change-in-production") SECRET_KEY = os.getenv("SECRET_KEY", "slpm-patchcenter-secret-key-2026-change-in-production")
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 8 heures ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 8 heures

View File

@ -1,7 +1,12 @@
"""SQLAlchemy engine et session""" """SQLAlchemy engine et session — supporte mode reel et demo"""
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from .config import DATABASE_URL from .config import DATABASE_URL, DATABASE_URL_DEMO
# Production engine
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_size=10) engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_size=10)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Demo engine
engine_demo = create_engine(DATABASE_URL_DEMO, pool_pre_ping=True, pool_size=5)
SessionLocalDemo = sessionmaker(autocommit=False, autoflush=False, bind=engine_demo)

View File

@ -2,11 +2,28 @@
from fastapi import Request from fastapi import Request
from sqlalchemy import text from sqlalchemy import text
from .auth import decode_token from .auth import decode_token
from .database import SessionLocal from .database import SessionLocal, SessionLocalDemo
def get_db(): def get_db(request: Request = None):
db = SessionLocal() """Retourne la session DB selon le mode (demo/reel) stocke dans le cookie JWT."""
demo = False
if request:
user = get_current_user(request)
if user and user.get("mode") == "demo":
demo = True
factory = SessionLocalDemo if demo else SessionLocal
db = factory()
try:
yield db
finally:
db.close()
def get_db_for_login(demo: bool = False):
"""Session DB pour le login (avant que le cookie existe)."""
factory = SessionLocalDemo if demo else SessionLocal
db = factory()
try: try:
yield db yield db
finally: finally:

View File

@ -2,7 +2,8 @@ from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from ..dependencies import get_db, get_current_user from ..dependencies import get_current_user
from ..database import SessionLocal, SessionLocalDemo
from ..auth import verify_password, create_access_token, hash_password from ..auth import verify_password, create_access_token, hash_password
from ..services.audit_service import log_login, log_logout, log_login_failed from ..services.audit_service import log_login, log_logout, log_login_failed
from ..config import APP_NAME, APP_VERSION from ..config import APP_NAME, APP_VERSION
@ -17,7 +18,12 @@ async def login_page(request: Request):
}) })
@router.post("/login") @router.post("/login")
async def login(request: Request, username: str = Form(...), password: str = Form(...), db=Depends(get_db)): async def login(request: Request, username: str = Form(...), password: str = Form(...),
mode: str = Form("reel")):
# Select DB based on mode
factory = SessionLocalDemo if mode == "demo" else SessionLocal
db = factory()
try:
row = db.execute(text("SELECT id, username, password_hash, role, is_active FROM users WHERE LOWER(username) = LOWER(:u)"), row = db.execute(text("SELECT id, username, password_hash, role, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
{"u": username}).fetchone() {"u": username}).fetchone()
if not row: if not row:
@ -42,27 +48,33 @@ async def login(request: Request, username: str = Form(...), password: str = For
return templates.TemplateResponse("login.html", { return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect" "request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect"
}) })
token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id}) # Include mode in JWT token
user = {"sub": row.username, "role": row.role, "uid": row.id} token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id, "mode": mode})
user = {"sub": row.username, "role": row.role, "uid": row.id, "mode": mode}
log_login(db, request, user) log_login(db, request, user)
db.commit() db.commit()
# Redirect qw_only users to quickwin # Redirect
perms = db.execute(text("SELECT module FROM user_permissions WHERE user_id = :uid"), {"uid": row.id}).fetchall() perms = db.execute(text("SELECT module FROM user_permissions WHERE user_id = :uid"), {"uid": row.id}).fetchall()
modules = {r.module for r in perms} modules = {r.module for r in perms}
if modules == {"quickwin"}: redirect_url = "/quickwin" if modules == {"quickwin"} else "/dashboard"
redirect_url = "/quickwin"
else:
redirect_url = "/dashboard"
response = RedirectResponse(url=redirect_url, status_code=303) response = RedirectResponse(url=redirect_url, status_code=303)
response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600) response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600)
return response return response
finally:
db.close()
@router.get("/logout") @router.get("/logout")
async def logout(request: Request, db=Depends(get_db)): async def logout(request: Request):
user = get_current_user(request) user = get_current_user(request)
if user: if user:
mode = user.get("mode", "reel")
factory = SessionLocalDemo if mode == "demo" else SessionLocal
db = factory()
try:
log_logout(db, request, user) log_logout(db, request, user)
db.commit() db.commit()
finally:
db.close()
response = RedirectResponse(url="/login", status_code=302) response = RedirectResponse(url="/login", status_code=302)
response.delete_cookie("access_token") response.delete_cookie("access_token")
return response return response

View File

@ -20,6 +20,17 @@
<label class="text-xs text-gray-400 block mb-1">Mot de passe</label> <label class="text-xs text-gray-400 block mb-1">Mot de passe</label>
<input type="password" name="password" required class="w-full" placeholder="password"> <input type="password" name="password" required class="w-full" placeholder="password">
</div> </div>
<div>
<label class="text-xs text-gray-400 block mb-1">Mode</label>
<div class="flex gap-4">
<label class="flex items-center gap-1 text-sm text-gray-300 cursor-pointer">
<input type="radio" name="mode" value="reel" checked class="accent-cyan-500"> Production
</label>
<label class="flex items-center gap-1 text-sm text-gray-300 cursor-pointer">
<input type="radio" name="mode" value="demo" class="accent-yellow-500"> Demo
</label>
</div>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded-md">Connexion</button> <button type="submit" class="btn-primary w-full py-2 rounded-md">Connexion</button>
</form> </form>
<p class="text-center text-xs text-gray-600 mt-4">SANEF — Direction des Systèmes d'Information</p> <p class="text-center text-xs text-gray-600 mt-4">SANEF — Direction des Systèmes d'Information</p>