Spaces:
Runtime error
Etape 11 : authentification reelle branchee (demande n°2)
Browse filesapp/core/auth.py (188 lignes, JWT + bcrypt + roles) etait complet et importe
par PERSONNE. Les 9 endpoints d'administration passaient par deux stubs
renvoyant {"id": 1, "role": "admin"} : ils etaient ouverts a tous.
BUG BLOQUANT decouvert en chemin : passlib 1.7.4 (derniere version, 2020) est
incompatible avec bcrypt >= 4.1 — il lit bcrypt.__about__.__version__, supprime
depuis. Avec bcrypt 5.x installe, hash_password echouait avec "password cannot
be longer than 72 bytes" MEME pour 13 caracteres : l'authentification aurait ete
inutilisable telle que declaree.
-> bcrypt utilise directement, passlib retire des manifestes. Pre-hachage
SHA-256 + base64 avant bcrypt : entree de longueur fixe, donc support des
phrases de passe longues sans la troncature silencieuse a 72 octets de bcrypt
(deux mots de passe partageant leurs 72 premiers octets etaient equivalents).
Ajouts :
- app/api/routes/auth.py : POST /login (formulaire OAuth2, l'URL exacte attendue
par oauth2_scheme, donc le bouton Authorize de /docs fonctionne),
POST /login/json (pour le front), GET /me, POST /logout.
Meme message d'erreur pour email inconnu et mot de passe faux : les distinguer
permettrait d'enumerer les comptes. Pas d'inscription publique.
- scripts/create_admin.py, idempotent, valide le mot de passe via UserCreate pour
que le script et l'API appliquent la meme politique.
Corrections :
- core/auth.py codait ALGORITHM et ACCESS_TOKEN_EXPIRE_MINUTES en dur (7 jours)
alors que config.py declare 30 minutes : la configuration n'avait aucun effet.
- datetime.utcnow (deprecie) -> datetime.now(timezone.utc), stocke en UTC naif
car les colonnes DateTime du schema sont sans fuseau.
- admin.py : liste _mock_users en memoire remplacee par le modele User ; les
schemas locaux masquaient app/schemas/user.py et le UserCreate local n'avait
AUCUN champ mot de passe (un compte cree par l'API n'aurait jamais pu se
connecter). Garde-fous ajoutes : pas d'auto-elevation, pas d'auto-suppression,
pas de suppression du dernier superadmin actif.
Endpoints fermes (12/12 verifies a 401 en acces anonyme) : ocr/detect,
ocr/process, upload/{id} DELETE, upload/cleanup, search/reindex, search/stats,
categories POST/PUT/DELETE, batch-upload upload+status, laws/admin *, admin/users.
FAILLE CORRIGEE : /ocr/detect et /ocr/process acceptaient un chemin serveur
arbitraire et renvoyaient le texte de tout fichier lisible par Tesseract.
resolve_upload_path() contraint desormais le fichier au repertoire d'upload —
motif strict, resolution des liens symboliques, verification is_file. Refus
verifie meme AVEC un jeton administrateur : l'auth seule ne suffisait pas.
Fixtures d'authentification ajoutees au conftest. Surcharger get_current_user
suffit aux trois niveaux d'acces, qui en dependent tous.
Verifie de bout en bout : mauvais mot de passe 401, email inconnu 401 (message
identique), connexion 200, /auth/me 200, endpoint admin 200 avec jeton, jeton
invalide 401, path traversal 400, connexion formulaire 200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- app/api/routes/admin.py +139 -236
- app/api/routes/auth.py +133 -0
- app/api/routes/batch_upload.py +8 -3
- app/api/routes/categories.py +8 -3
- app/api/routes/laws.py +10 -15
- app/api/routes/ocr.py +36 -17
- app/api/routes/search.py +8 -2
- app/api/routes/upload.py +11 -3
- app/core/auth.py +39 -16
- app/main.py +2 -1
- app/schemas/ocr.py +6 -4
- app/utils/file_utils.py +49 -0
- pyproject.toml +2 -1
- requirements.txt +4 -1
- scripts/create_admin.py +134 -0
- tests/conftest.py +87 -0
- tests/test_api/test_category_routes.py +43 -43
- tests/test_api/test_laws_api.py +21 -21
|
@@ -19,9 +19,17 @@ from typing import Any, Dict, List, TypedDict, cast
|
|
| 19 |
|
| 20 |
from fastapi import APIRouter, Depends, HTTPException, status
|
| 21 |
from pydantic import BaseModel, EmailStr, Field
|
|
|
|
| 22 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from app.core.database import get_db, health_check_db
|
|
|
|
|
|
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
|
@@ -29,84 +37,15 @@ router = APIRouter(tags=["Admin"])
|
|
| 29 |
|
| 30 |
|
| 31 |
# ==================== SCHEMAS ====================
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
class UserUpdate(BaseModel):
|
| 44 |
-
"""Schema for updating a user."""
|
| 45 |
-
|
| 46 |
-
email: EmailStr | None = None
|
| 47 |
-
username: str | None = Field(None, min_length=3, max_length=50)
|
| 48 |
-
role: str | None = None
|
| 49 |
-
is_active: bool | None = None
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
class UserResponse(BaseModel):
|
| 53 |
-
"""Schema for user response."""
|
| 54 |
-
|
| 55 |
-
id: int
|
| 56 |
-
email: str
|
| 57 |
-
username: str
|
| 58 |
-
role: str
|
| 59 |
-
is_active: bool
|
| 60 |
-
created_at: datetime
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
class UserDict(TypedDict):
|
| 64 |
-
"""Internal dictionary type for mock storage."""
|
| 65 |
-
id: int
|
| 66 |
-
email: str
|
| 67 |
-
username: str
|
| 68 |
-
role: str
|
| 69 |
-
is_active: bool
|
| 70 |
-
created_at: datetime
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
# ==================== MOCK USER STORAGE ====================
|
| 74 |
-
# TODO: Replace with actual database model
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
_mock_users: List[UserDict] = [
|
| 78 |
-
{
|
| 79 |
-
"id": 1,
|
| 80 |
-
"email": "admin@jurix.cm",
|
| 81 |
-
"username": "admin",
|
| 82 |
-
"role": "admin",
|
| 83 |
-
"is_active": True,
|
| 84 |
-
"created_at": datetime.now(),
|
| 85 |
-
},
|
| 86 |
-
{
|
| 87 |
-
"id": 2,
|
| 88 |
-
"email": "user@jurix.cm",
|
| 89 |
-
"username": "user1",
|
| 90 |
-
"role": "user",
|
| 91 |
-
"is_active": True,
|
| 92 |
-
"created_at": datetime.now(),
|
| 93 |
-
},
|
| 94 |
-
]
|
| 95 |
-
|
| 96 |
-
_next_user_id = 3
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
# ==================== AUTHENTICATION ====================
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
async def get_current_admin():
|
| 103 |
-
"""
|
| 104 |
-
Dependency for admin authentication.
|
| 105 |
-
|
| 106 |
-
TODO: Implement actual JWT authentication.
|
| 107 |
-
For now, returns mock admin user.
|
| 108 |
-
"""
|
| 109 |
-
return {"id": 1, "role": "admin", "username": "admin"}
|
| 110 |
|
| 111 |
|
| 112 |
# ==================== USER MANAGEMENT ====================
|
|
@@ -116,191 +55,155 @@ async def get_current_admin():
|
|
| 116 |
async def list_users(
|
| 117 |
skip: int = 0,
|
| 118 |
limit: int = 100,
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
**Parameters:**
|
| 125 |
-
- skip: Number of records to skip
|
| 126 |
-
- limit: Maximum records to return
|
| 127 |
-
|
| 128 |
-
**Returns:**
|
| 129 |
-
- List of users
|
| 130 |
-
|
| 131 |
-
**Requires:** Admin authentication
|
| 132 |
-
"""
|
| 133 |
logger.info(f"📋 GET /admin/users - skip={skip}, limit={limit}")
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
id=u["id"],
|
| 139 |
-
email=u["email"],
|
| 140 |
-
username=u["username"],
|
| 141 |
-
role=u["role"],
|
| 142 |
-
is_active=u["is_active"],
|
| 143 |
-
created_at=u["created_at"]
|
| 144 |
-
)
|
| 145 |
-
for u in users
|
| 146 |
-
]
|
| 147 |
|
| 148 |
|
| 149 |
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
| 150 |
async def create_user(
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
| 154 |
"""
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
- role: User role (user or admin)
|
| 161 |
-
- is_active: Active status
|
| 162 |
-
|
| 163 |
-
**Returns:**
|
| 164 |
-
- Created user
|
| 165 |
-
|
| 166 |
-
**Raises:**
|
| 167 |
-
- 409: Email or username already exists
|
| 168 |
-
|
| 169 |
-
**Requires:** Admin authentication
|
| 170 |
"""
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
if existing_user["email"] == user.email:
|
| 182 |
-
raise HTTPException(
|
| 183 |
-
status_code=status.HTTP_409_CONFLICT,
|
| 184 |
-
detail=f"Email already exists: {user.email}",
|
| 185 |
-
)
|
| 186 |
-
if existing_user["username"] == user.username:
|
| 187 |
-
raise HTTPException(
|
| 188 |
-
status_code=status.HTTP_409_CONFLICT,
|
| 189 |
-
detail=f"Username already exists: {user.username}",
|
| 190 |
-
)
|
| 191 |
-
|
| 192 |
-
# Create new user
|
| 193 |
-
new_user: UserDict = {
|
| 194 |
-
"id": _next_user_id,
|
| 195 |
-
"email": str(user.email),
|
| 196 |
-
"username": user.username,
|
| 197 |
-
"role": user.role,
|
| 198 |
-
"is_active": user.is_active,
|
| 199 |
-
"created_at": datetime.now(),
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
_mock_users.append(new_user)
|
| 203 |
-
_next_user_id += 1
|
| 204 |
-
|
| 205 |
-
logger.info(f"✅ User created: ID={new_user['id']}, email={new_user['email']}")
|
| 206 |
-
return UserResponse(
|
| 207 |
-
id=new_user["id"],
|
| 208 |
-
email=new_user["email"],
|
| 209 |
-
username=new_user["username"],
|
| 210 |
-
role=new_user["role"],
|
| 211 |
-
is_active=new_user["is_active"],
|
| 212 |
-
created_at=new_user["created_at"]
|
| 213 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
|
| 216 |
@router.put("/users/{user_id}", response_model=UserResponse)
|
| 217 |
async def update_user(
|
| 218 |
user_id: int,
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
| 222 |
"""
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
**Returns:**
|
| 230 |
-
- Updated user
|
| 231 |
-
|
| 232 |
-
**Raises:**
|
| 233 |
-
- 404: User not found
|
| 234 |
-
|
| 235 |
-
**Requires:** Admin authentication
|
| 236 |
"""
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
user = next((u for u in _mock_users if u["id"] == user_id), None)
|
| 244 |
-
|
| 245 |
-
if not user:
|
| 246 |
raise HTTPException(
|
| 247 |
-
status_code=status.
|
| 248 |
-
detail=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
)
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
for field, value in
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
username=user["username"],
|
| 262 |
-
role=user["role"],
|
| 263 |
-
is_active=user["is_active"],
|
| 264 |
-
created_at=user["created_at"]
|
| 265 |
-
)
|
| 266 |
|
| 267 |
|
| 268 |
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 269 |
async def delete_user(
|
| 270 |
user_id: int,
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
Delete a user (admin only).
|
| 275 |
-
|
| 276 |
-
**Parameters:**
|
| 277 |
-
- user_id: User ID
|
| 278 |
-
|
| 279 |
-
**Returns:**
|
| 280 |
-
- 204 No Content
|
| 281 |
-
|
| 282 |
-
**Raises:**
|
| 283 |
-
- 404: User not found
|
| 284 |
-
|
| 285 |
-
**Requires:** Admin authentication
|
| 286 |
"""
|
| 287 |
-
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
user =
|
| 293 |
-
|
| 294 |
-
|
|
|
|
| 295 |
raise HTTPException(
|
| 296 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 297 |
-
detail=f"User not found: {user_id}",
|
| 298 |
)
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
return None
|
| 305 |
|
| 306 |
|
|
@@ -310,7 +213,7 @@ async def delete_user(
|
|
| 310 |
@router.get("/system")
|
| 311 |
async def get_system_info(
|
| 312 |
db: AsyncSession = Depends(get_db),
|
| 313 |
-
current_admin:
|
| 314 |
) -> Dict:
|
| 315 |
"""
|
| 316 |
Get system information (admin only).
|
|
|
|
| 19 |
|
| 20 |
from fastapi import APIRouter, Depends, HTTPException, status
|
| 21 |
from pydantic import BaseModel, EmailStr, Field
|
| 22 |
+
from sqlalchemy import or_, select
|
| 23 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 24 |
|
| 25 |
+
from app.core.auth import (
|
| 26 |
+
get_current_admin_user,
|
| 27 |
+
get_current_superadmin_user,
|
| 28 |
+
hash_password,
|
| 29 |
+
)
|
| 30 |
from app.core.database import get_db, health_check_db
|
| 31 |
+
from app.models.user import User
|
| 32 |
+
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
| 33 |
|
| 34 |
logger = logging.getLogger(__name__)
|
| 35 |
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
# ==================== SCHEMAS ====================
|
| 40 |
+
#
|
| 41 |
+
# Les schemas UserCreate / UserUpdate / UserResponse etaient redefinis ici et
|
| 42 |
+
# MASQUAIENT ceux de app/schemas/user.py. Le UserCreate local n'avait aucun champ
|
| 43 |
+
# mot de passe : un utilisateur cree par cette API n'aurait jamais pu se
|
| 44 |
+
# connecter. Ils sont desormais importes depuis la source unique.
|
| 45 |
+
#
|
| 46 |
+
# La liste _mock_users (en memoire, perdue a chaque redemarrage, non partagee
|
| 47 |
+
# entre workers) a ete supprimee au profit du modele User et de la table users,
|
| 48 |
+
# qui existaient tous deux depuis la migration 0b21fb6a3651 sans etre utilises.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
# ==================== USER MANAGEMENT ====================
|
|
|
|
| 55 |
async def list_users(
|
| 56 |
skip: int = 0,
|
| 57 |
limit: int = 100,
|
| 58 |
+
db: AsyncSession = Depends(get_db),
|
| 59 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 60 |
+
) -> List[User]:
|
| 61 |
+
"""Liste les comptes (administrateurs)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
logger.info(f"📋 GET /admin/users - skip={skip}, limit={limit}")
|
| 63 |
+
result = await db.execute(
|
| 64 |
+
select(User).order_by(User.id).offset(skip).limit(min(limit, 500))
|
| 65 |
+
)
|
| 66 |
+
return list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
| 70 |
async def create_user(
|
| 71 |
+
payload: UserCreate,
|
| 72 |
+
db: AsyncSession = Depends(get_db),
|
| 73 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 74 |
+
) -> User:
|
| 75 |
"""
|
| 76 |
+
Crée un compte (administrateurs).
|
| 77 |
+
|
| 78 |
+
Un administrateur ne peut pas créer de compte plus privilégié que le sien :
|
| 79 |
+
sans cette règle, tout admin pourrait se hisser au rang de superadmin en
|
| 80 |
+
créant un second compte.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
"""
|
| 82 |
+
if payload.role in ("admin", "superadmin") and not current_admin.is_superadmin():
|
| 83 |
+
raise HTTPException(
|
| 84 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 85 |
+
detail="Seul un superadmin peut créer un compte privilégié",
|
| 86 |
+
)
|
| 87 |
|
| 88 |
+
existing = await db.execute(
|
| 89 |
+
select(User).where(
|
| 90 |
+
or_(User.email == payload.email, User.username == payload.username)
|
| 91 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
)
|
| 93 |
+
if existing.scalar_one_or_none() is not None:
|
| 94 |
+
raise HTTPException(
|
| 95 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 96 |
+
detail="Un compte existe déjà avec cet email ou ce nom d'utilisateur",
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
user = User(
|
| 100 |
+
email=payload.email,
|
| 101 |
+
username=payload.username,
|
| 102 |
+
full_name=payload.full_name,
|
| 103 |
+
role=payload.role,
|
| 104 |
+
is_active=payload.is_active,
|
| 105 |
+
hashed_password=hash_password(payload.password),
|
| 106 |
+
)
|
| 107 |
+
db.add(user)
|
| 108 |
+
await db.commit()
|
| 109 |
+
await db.refresh(user)
|
| 110 |
+
logger.info(f"✅ Compte créé : {user.email} (rôle {user.role})")
|
| 111 |
+
return user
|
| 112 |
|
| 113 |
|
| 114 |
@router.put("/users/{user_id}", response_model=UserResponse)
|
| 115 |
async def update_user(
|
| 116 |
user_id: int,
|
| 117 |
+
payload: UserUpdate,
|
| 118 |
+
db: AsyncSession = Depends(get_db),
|
| 119 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 120 |
+
) -> User:
|
| 121 |
"""
|
| 122 |
+
Met à jour un compte (administrateurs).
|
| 123 |
+
|
| 124 |
+
Deux garde-fous : seul un superadmin peut modifier un rôle, et personne ne
|
| 125 |
+
peut se retirer ses propres droits — sinon la première erreur de manipulation
|
| 126 |
+
ferme définitivement l'accès à l'instance.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
"""
|
| 128 |
+
user = (
|
| 129 |
+
await db.execute(select(User).where(User.id == user_id))
|
| 130 |
+
).scalar_one_or_none()
|
| 131 |
+
if user is None:
|
| 132 |
+
raise HTTPException(
|
| 133 |
+
status_code=status.HTTP_404_NOT_FOUND, detail=f"Compte {user_id} introuvable"
|
| 134 |
+
)
|
| 135 |
|
| 136 |
+
changes = payload.model_dump(exclude_unset=True)
|
| 137 |
+
|
| 138 |
+
if "role" in changes and not current_admin.is_superadmin():
|
|
|
|
|
|
|
|
|
|
| 139 |
raise HTTPException(
|
| 140 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 141 |
+
detail="Seul un superadmin peut modifier un rôle",
|
| 142 |
+
)
|
| 143 |
+
if user.id == current_admin.id and (
|
| 144 |
+
changes.get("role") not in (None, current_admin.role)
|
| 145 |
+
or changes.get("is_active") is False
|
| 146 |
+
):
|
| 147 |
+
raise HTTPException(
|
| 148 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 149 |
+
detail="Impossible de réduire ses propres droits",
|
| 150 |
)
|
| 151 |
+
|
| 152 |
+
password = changes.pop("password", None)
|
| 153 |
+
if password:
|
| 154 |
+
user.hashed_password = hash_password(password)
|
| 155 |
+
for field, value in changes.items():
|
| 156 |
+
setattr(user, field, value)
|
| 157 |
+
|
| 158 |
+
await db.commit()
|
| 159 |
+
await db.refresh(user)
|
| 160 |
+
logger.info(f"✏️ Compte mis à jour : {user.email}")
|
| 161 |
+
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 165 |
async def delete_user(
|
| 166 |
user_id: int,
|
| 167 |
+
db: AsyncSession = Depends(get_db),
|
| 168 |
+
current_admin: User = Depends(get_current_superadmin_user),
|
| 169 |
+
) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
"""
|
| 171 |
+
Supprime un compte (superadministrateurs).
|
| 172 |
|
| 173 |
+
Refuse la suppression de soi-même et celle du dernier superadmin actif : les
|
| 174 |
+
deux mènent à une instance dont plus personne n'a les clés.
|
| 175 |
+
"""
|
| 176 |
+
user = (
|
| 177 |
+
await db.execute(select(User).where(User.id == user_id))
|
| 178 |
+
).scalar_one_or_none()
|
| 179 |
+
if user is None:
|
| 180 |
raise HTTPException(
|
| 181 |
+
status_code=status.HTTP_404_NOT_FOUND, detail=f"Compte {user_id} introuvable"
|
|
|
|
| 182 |
)
|
| 183 |
+
if user.id == current_admin.id:
|
| 184 |
+
raise HTTPException(
|
| 185 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 186 |
+
detail="Impossible de supprimer son propre compte",
|
| 187 |
+
)
|
| 188 |
+
if user.role == "superadmin":
|
| 189 |
+
remaining = (
|
| 190 |
+
await db.execute(
|
| 191 |
+
select(User).where(
|
| 192 |
+
User.role == "superadmin",
|
| 193 |
+
User.is_active.is_(True),
|
| 194 |
+
User.id != user.id,
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
).scalars().first()
|
| 198 |
+
if remaining is None:
|
| 199 |
+
raise HTTPException(
|
| 200 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 201 |
+
detail="Impossible de supprimer le dernier superadmin actif",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
await db.delete(user)
|
| 205 |
+
await db.commit()
|
| 206 |
+
logger.info(f"🗑️ Compte supprimé : {user.email}")
|
| 207 |
return None
|
| 208 |
|
| 209 |
|
|
|
|
| 213 |
@router.get("/system")
|
| 214 |
async def get_system_info(
|
| 215 |
db: AsyncSession = Depends(get_db),
|
| 216 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 217 |
) -> Dict:
|
| 218 |
"""
|
| 219 |
Get system information (admin only).
|
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Routes d'authentification.
|
| 3 |
+
|
| 4 |
+
`app/core/auth.py` implémentait déjà l'intégralité de la chaîne JWT — hachage
|
| 5 |
+
bcrypt, création de jeton, dépendances par rôle — mais n'était importé nulle
|
| 6 |
+
part, et aucune route de connexion n'existait. Les endpoints « admin » passaient
|
| 7 |
+
par deux stubs renvoyant un dictionnaire en dur.
|
| 8 |
+
|
| 9 |
+
Deux points d'entrée de connexion, volontairement :
|
| 10 |
+
|
| 11 |
+
- `POST /login` accepte un formulaire OAuth2. C'est exactement l'URL déclarée
|
| 12 |
+
par `oauth2_scheme` (`tokenUrl="/api/v1/auth/login"`), donc le bouton
|
| 13 |
+
*Authorize* de /docs fonctionne — précieux pour administrer l'instance tant
|
| 14 |
+
que l'interface d'administration n'est pas terminée.
|
| 15 |
+
- `POST /login/json` accepte du JSON, plus naturel pour le front SvelteKit.
|
| 16 |
+
|
| 17 |
+
Les deux partagent le même `_authenticate`.
|
| 18 |
+
|
| 19 |
+
Il n'y a **pas** de route d'inscription publique : la création de comptes passe
|
| 20 |
+
par `POST /api/v1/admin/users` ou par `scripts/create_admin.py`. Une inscription
|
| 21 |
+
ouverte donnerait à n'importe qui l'accès au RAG, donc à la facturation Gemini.
|
| 22 |
+
|
| 23 |
+
Author: JuriX Team
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import logging
|
| 27 |
+
from datetime import datetime, timezone
|
| 28 |
+
|
| 29 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 30 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 31 |
+
from sqlalchemy import select
|
| 32 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 33 |
+
|
| 34 |
+
from app.core.auth import (
|
| 35 |
+
create_access_token,
|
| 36 |
+
get_current_active_user,
|
| 37 |
+
verify_password,
|
| 38 |
+
)
|
| 39 |
+
from app.core.database import get_db
|
| 40 |
+
from app.models.user import User
|
| 41 |
+
from app.schemas.user import UserLogin, UserResponse, UserWithToken
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
router = APIRouter()
|
| 46 |
+
|
| 47 |
+
# Message unique pour « email inconnu » et « mot de passe faux » : les
|
| 48 |
+
# distinguer permettrait d'énumérer les comptes existants.
|
| 49 |
+
_INVALID = "Identifiants invalides"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def _authenticate(db: AsyncSession, email: str, password: str) -> User:
|
| 53 |
+
"""
|
| 54 |
+
Vérifie un couple email / mot de passe.
|
| 55 |
+
|
| 56 |
+
Raises:
|
| 57 |
+
HTTPException 401: identifiants invalides
|
| 58 |
+
HTTPException 403: compte désactivé
|
| 59 |
+
"""
|
| 60 |
+
result = await db.execute(select(User).where(User.email == email.lower().strip()))
|
| 61 |
+
user = result.scalar_one_or_none()
|
| 62 |
+
|
| 63 |
+
if user is None or not verify_password(password, user.hashed_password):
|
| 64 |
+
logger.warning(f"🔒 Échec de connexion pour {email!r}")
|
| 65 |
+
raise HTTPException(
|
| 66 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 67 |
+
detail=_INVALID,
|
| 68 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
if not user.is_active:
|
| 72 |
+
raise HTTPException(
|
| 73 |
+
status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Les colonnes DateTime du modele sont SANS fuseau : y ecrire un datetime
|
| 77 |
+
# avec fuseau leve "can't subtract offset-naive and offset-aware datetimes"
|
| 78 |
+
# au moment du flush. On stocke donc de l'UTC naif, comme le reste du schema.
|
| 79 |
+
user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
| 80 |
+
await db.commit()
|
| 81 |
+
await db.refresh(user)
|
| 82 |
+
|
| 83 |
+
logger.info(f"✅ Connexion de {user.email} (rôle {user.role})")
|
| 84 |
+
return user
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.post("/login", response_model=UserWithToken, status_code=status.HTTP_200_OK)
|
| 88 |
+
async def login(
|
| 89 |
+
form_data: OAuth2PasswordRequestForm = Depends(),
|
| 90 |
+
db: AsyncSession = Depends(get_db),
|
| 91 |
+
) -> UserWithToken:
|
| 92 |
+
"""
|
| 93 |
+
Connexion au format formulaire OAuth2 (utilisée par /docs).
|
| 94 |
+
|
| 95 |
+
Le champ `username` porte l'adresse email.
|
| 96 |
+
"""
|
| 97 |
+
user = await _authenticate(db, form_data.username, form_data.password)
|
| 98 |
+
return UserWithToken(
|
| 99 |
+
**UserResponse.model_validate(user).model_dump(),
|
| 100 |
+
access_token=create_access_token({"sub": user.email}),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.post("/login/json", response_model=UserWithToken, status_code=status.HTTP_200_OK)
|
| 105 |
+
async def login_json(
|
| 106 |
+
credentials: UserLogin,
|
| 107 |
+
db: AsyncSession = Depends(get_db),
|
| 108 |
+
) -> UserWithToken:
|
| 109 |
+
"""Connexion au format JSON (utilisée par le front)."""
|
| 110 |
+
user = await _authenticate(db, credentials.email, credentials.password)
|
| 111 |
+
return UserWithToken(
|
| 112 |
+
**UserResponse.model_validate(user).model_dump(),
|
| 113 |
+
access_token=create_access_token({"sub": user.email}),
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@router.get("/me", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
| 118 |
+
async def read_me(current_user: User = Depends(get_current_active_user)) -> User:
|
| 119 |
+
"""Renvoie l'utilisateur associé au jeton fourni."""
|
| 120 |
+
return current_user
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
| 124 |
+
async def logout(current_user: User = Depends(get_current_active_user)) -> None:
|
| 125 |
+
"""
|
| 126 |
+
Déconnexion.
|
| 127 |
+
|
| 128 |
+
Les JWT sont sans état : rien n'est révoqué côté serveur, le client jette son
|
| 129 |
+
jeton. L'endpoint existe pour donner un point d'appel explicite au front et
|
| 130 |
+
pour tracer la déconnexion.
|
| 131 |
+
"""
|
| 132 |
+
logger.info(f"👋 Déconnexion de {current_user.email}")
|
| 133 |
+
return None
|
|
@@ -21,6 +21,8 @@ from app.core.database import AsyncSessionLocal, get_db
|
|
| 21 |
from app.models.law import Law
|
| 22 |
from app.tasks.process_law import process_law_async
|
| 23 |
from app.core.config import settings
|
|
|
|
|
|
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
|
@@ -89,7 +91,8 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str):
|
|
| 89 |
async def batch_upload(
|
| 90 |
files: List[UploadFile] = File(...),
|
| 91 |
session_id: str | None = None,
|
| 92 |
-
db: AsyncSession = Depends(get_db)
|
|
|
|
| 93 |
):
|
| 94 |
"""
|
| 95 |
Batch upload multiple PDF files.
|
|
@@ -241,7 +244,8 @@ async def process_batch(laws: List[dict], session_id: str) -> None:
|
|
| 241 |
|
| 242 |
law.status = "processing"
|
| 243 |
law.processing_progress = 0
|
| 244 |
-
|
|
|
|
| 245 |
await db.commit()
|
| 246 |
|
| 247 |
await manager.send_progress(session_id, {
|
|
@@ -305,7 +309,8 @@ async def process_batch(laws: List[dict], session_id: str) -> None:
|
|
| 305 |
@router.get("/status")
|
| 306 |
async def get_batch_status(
|
| 307 |
status: str | None = None,
|
| 308 |
-
db: AsyncSession = Depends(get_db)
|
|
|
|
| 309 |
):
|
| 310 |
"""
|
| 311 |
Get all laws with optional status filter.
|
|
|
|
| 21 |
from app.models.law import Law
|
| 22 |
from app.tasks.process_law import process_law_async
|
| 23 |
from app.core.config import settings
|
| 24 |
+
from app.core.auth import get_current_admin_user
|
| 25 |
+
from app.models.user import User
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
|
|
|
| 91 |
async def batch_upload(
|
| 92 |
files: List[UploadFile] = File(...),
|
| 93 |
session_id: str | None = None,
|
| 94 |
+
db: AsyncSession = Depends(get_db),
|
| 95 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 96 |
):
|
| 97 |
"""
|
| 98 |
Batch upload multiple PDF files.
|
|
|
|
| 244 |
|
| 245 |
law.status = "processing"
|
| 246 |
law.processing_progress = 0
|
| 247 |
+
# UTC naif : la colonne est un DateTime sans fuseau.
|
| 248 |
+
law.processing_started_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
| 249 |
await db.commit()
|
| 250 |
|
| 251 |
await manager.send_progress(session_id, {
|
|
|
|
| 309 |
@router.get("/status")
|
| 310 |
async def get_batch_status(
|
| 311 |
status: str | None = None,
|
| 312 |
+
db: AsyncSession = Depends(get_db),
|
| 313 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 314 |
):
|
| 315 |
"""
|
| 316 |
Get all laws with optional status filter.
|
|
@@ -31,6 +31,8 @@ from app.services.category_service import (
|
|
| 31 |
CategoryServiceError
|
| 32 |
)
|
| 33 |
from app.schemas.law import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryStats
|
|
|
|
|
|
|
| 34 |
|
| 35 |
# Configure logger
|
| 36 |
logger = logging.getLogger(__name__)
|
|
@@ -59,7 +61,8 @@ router = APIRouter(
|
|
| 59 |
)
|
| 60 |
async def create_category(
|
| 61 |
category_data: CategoryCreate,
|
| 62 |
-
db: AsyncSession = Depends(get_db)
|
|
|
|
| 63 |
) -> CategoryResponse:
|
| 64 |
"""
|
| 65 |
Create a new legal category.
|
|
@@ -274,7 +277,8 @@ async def get_category(
|
|
| 274 |
async def update_category(
|
| 275 |
category_id: int,
|
| 276 |
category_data: CategoryUpdate,
|
| 277 |
-
db: AsyncSession = Depends(get_db)
|
|
|
|
| 278 |
) -> CategoryResponse:
|
| 279 |
"""
|
| 280 |
Update a category.
|
|
@@ -341,7 +345,8 @@ async def update_category(
|
|
| 341 |
async def delete_category(
|
| 342 |
category_id: int,
|
| 343 |
force: bool = Query(False, description="Force delete even if laws are associated"),
|
| 344 |
-
db: AsyncSession = Depends(get_db)
|
|
|
|
| 345 |
):
|
| 346 |
"""
|
| 347 |
Delete a category.
|
|
|
|
| 31 |
CategoryServiceError
|
| 32 |
)
|
| 33 |
from app.schemas.law import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryStats
|
| 34 |
+
from app.core.auth import get_current_admin_user
|
| 35 |
+
from app.models.user import User
|
| 36 |
|
| 37 |
# Configure logger
|
| 38 |
logger = logging.getLogger(__name__)
|
|
|
|
| 61 |
)
|
| 62 |
async def create_category(
|
| 63 |
category_data: CategoryCreate,
|
| 64 |
+
db: AsyncSession = Depends(get_db),
|
| 65 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 66 |
) -> CategoryResponse:
|
| 67 |
"""
|
| 68 |
Create a new legal category.
|
|
|
|
| 277 |
async def update_category(
|
| 278 |
category_id: int,
|
| 279 |
category_data: CategoryUpdate,
|
| 280 |
+
db: AsyncSession = Depends(get_db),
|
| 281 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 282 |
) -> CategoryResponse:
|
| 283 |
"""
|
| 284 |
Update a category.
|
|
|
|
| 345 |
async def delete_category(
|
| 346 |
category_id: int,
|
| 347 |
force: bool = Query(False, description="Force delete even if laws are associated"),
|
| 348 |
+
db: AsyncSession = Depends(get_db),
|
| 349 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 350 |
):
|
| 351 |
"""
|
| 352 |
Delete a category.
|
|
@@ -23,6 +23,8 @@ from sqlalchemy import select
|
|
| 23 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 24 |
from sqlalchemy.orm import selectinload
|
| 25 |
|
|
|
|
|
|
|
| 26 |
from app.core.database import get_db, AsyncSessionLocal
|
| 27 |
from app.models.law import Category, Law
|
| 28 |
from app.schemas.law import (
|
|
@@ -54,17 +56,10 @@ router = APIRouter(tags=["Laws"])
|
|
| 54 |
# ==================== DEPENDENCIES ====================
|
| 55 |
|
| 56 |
|
| 57 |
-
#
|
| 58 |
-
#
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
Dependency for admin authentication.
|
| 62 |
-
|
| 63 |
-
TODO: Implement actual authentication with JWT tokens.
|
| 64 |
-
For now, returns a mock admin user.
|
| 65 |
-
"""
|
| 66 |
-
# In production, verify JWT token and check admin role
|
| 67 |
-
return {"id": 1, "role": "admin"}
|
| 68 |
|
| 69 |
|
| 70 |
# ==================== PUBLIC ENDPOINTS ====================
|
|
@@ -502,7 +497,7 @@ async def get_law_pdf_page_image(
|
|
| 502 |
async def ingest_law(
|
| 503 |
request: LawIngestRequest,
|
| 504 |
db: AsyncSession = Depends(get_db),
|
| 505 |
-
current_user:
|
| 506 |
):
|
| 507 |
"""
|
| 508 |
Ingest a new law from an uploaded file.
|
|
@@ -632,7 +627,7 @@ async def ingest_law(
|
|
| 632 |
async def create_law(
|
| 633 |
law: LawCreate,
|
| 634 |
db: AsyncSession = Depends(get_db),
|
| 635 |
-
current_user:
|
| 636 |
):
|
| 637 |
"""
|
| 638 |
Create a new law (admin only).
|
|
@@ -709,7 +704,7 @@ async def update_law(
|
|
| 709 |
law_id: int,
|
| 710 |
law_update: LawUpdate,
|
| 711 |
db: AsyncSession = Depends(get_db),
|
| 712 |
-
current_user:
|
| 713 |
):
|
| 714 |
"""
|
| 715 |
Update an existing law (admin only).
|
|
@@ -771,7 +766,7 @@ async def update_law(
|
|
| 771 |
async def delete_law(
|
| 772 |
law_id: int,
|
| 773 |
db: AsyncSession = Depends(get_db),
|
| 774 |
-
current_user:
|
| 775 |
):
|
| 776 |
"""
|
| 777 |
Delete a law (admin only).
|
|
|
|
| 23 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 24 |
from sqlalchemy.orm import selectinload
|
| 25 |
|
| 26 |
+
from app.core.auth import get_current_admin_user
|
| 27 |
+
from app.models.user import User
|
| 28 |
from app.core.database import get_db, AsyncSessionLocal
|
| 29 |
from app.models.law import Category, Law
|
| 30 |
from app.schemas.law import (
|
|
|
|
| 56 |
# ==================== DEPENDENCIES ====================
|
| 57 |
|
| 58 |
|
| 59 |
+
# L'authentification reelle vit dans app/core/auth.py (JWT + bcrypt + roles).
|
| 60 |
+
# Un stub renvoyant {"id": 1, "role": "admin"} occupait cette place : les quatre
|
| 61 |
+
# endpoints d'administration ci-dessous etaient donc ouverts a tous.
|
| 62 |
+
# get_current_admin_user est importe en tete de fichier.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
# ==================== PUBLIC ENDPOINTS ====================
|
|
|
|
| 497 |
async def ingest_law(
|
| 498 |
request: LawIngestRequest,
|
| 499 |
db: AsyncSession = Depends(get_db),
|
| 500 |
+
current_user: User = Depends(get_current_admin_user),
|
| 501 |
):
|
| 502 |
"""
|
| 503 |
Ingest a new law from an uploaded file.
|
|
|
|
| 627 |
async def create_law(
|
| 628 |
law: LawCreate,
|
| 629 |
db: AsyncSession = Depends(get_db),
|
| 630 |
+
current_user: User = Depends(get_current_admin_user),
|
| 631 |
):
|
| 632 |
"""
|
| 633 |
Create a new law (admin only).
|
|
|
|
| 704 |
law_id: int,
|
| 705 |
law_update: LawUpdate,
|
| 706 |
db: AsyncSession = Depends(get_db),
|
| 707 |
+
current_user: User = Depends(get_current_admin_user),
|
| 708 |
):
|
| 709 |
"""
|
| 710 |
Update an existing law (admin only).
|
|
|
|
| 766 |
async def delete_law(
|
| 767 |
law_id: int,
|
| 768 |
db: AsyncSession = Depends(get_db),
|
| 769 |
+
current_user: User = Depends(get_current_admin_user),
|
| 770 |
):
|
| 771 |
"""
|
| 772 |
Delete a law (admin only).
|
|
@@ -13,7 +13,7 @@ Date: 2026-01-11
|
|
| 13 |
import logging
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
-
from fastapi import APIRouter, HTTPException, status
|
| 17 |
from fastapi.responses import JSONResponse
|
| 18 |
|
| 19 |
from app.schemas.ocr import (
|
|
@@ -23,6 +23,9 @@ from app.schemas.ocr import (
|
|
| 23 |
PDFTypeDetectionRequest,
|
| 24 |
PDFTypeDetectionResult,
|
| 25 |
)
|
|
|
|
|
|
|
|
|
|
| 26 |
from app.services.ocr_service import OCRError, OCRService
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
|
@@ -65,7 +68,10 @@ def get_ocr_service() -> OCRService:
|
|
| 65 |
- OCR recommendation
|
| 66 |
""",
|
| 67 |
)
|
| 68 |
-
async def detect_pdf_type(
|
|
|
|
|
|
|
|
|
|
| 69 |
"""
|
| 70 |
Detect PDF type without processing.
|
| 71 |
|
|
@@ -81,19 +87,24 @@ async def detect_pdf_type(request: PDFTypeDetectionRequest) -> PDFTypeDetectionR
|
|
| 81 |
500: Detection error
|
| 82 |
"""
|
| 83 |
assert request is not None, "PDFTypeDetectionRequest must not be None"
|
| 84 |
-
assert isinstance(request.pdf_path, str) and len(request.pdf_path) > 0, "pdf_path must be a non-empty string"
|
| 85 |
|
| 86 |
-
logger.info(f"🔍 PDF type detection request: {request.
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
#
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
raise HTTPException(
|
| 93 |
status_code=status.HTTP_404_NOT_FOUND,
|
| 94 |
-
detail=f"
|
| 95 |
)
|
| 96 |
|
|
|
|
|
|
|
| 97 |
try:
|
| 98 |
service = get_ocr_service()
|
| 99 |
result = await service.detect_pdf_type(pdf_path)
|
|
@@ -126,7 +137,10 @@ async def detect_pdf_type(request: PDFTypeDetectionRequest) -> PDFTypeDetectionR
|
|
| 126 |
**Languages:** French (fra), English (eng), or both (fra+eng)
|
| 127 |
""",
|
| 128 |
)
|
| 129 |
-
async def process_pdf(
|
|
|
|
|
|
|
|
|
|
| 130 |
"""
|
| 131 |
Process PDF with OCR.
|
| 132 |
|
|
@@ -142,22 +156,27 @@ async def process_pdf(request: OCRRequest) -> OCRResult:
|
|
| 142 |
500: Processing error
|
| 143 |
"""
|
| 144 |
assert request is not None, "OCRRequest must not be None"
|
| 145 |
-
assert isinstance(request.pdf_path, str) and len(request.pdf_path) > 0, "pdf_path must be a non-empty string"
|
| 146 |
|
| 147 |
logger.info(
|
| 148 |
-
f"📄 OCR processing request: {request.
|
| 149 |
f"(lang: {request.language}, dpi: {request.dpi})"
|
| 150 |
)
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
#
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
raise HTTPException(
|
| 157 |
status_code=status.HTTP_404_NOT_FOUND,
|
| 158 |
-
detail=f"
|
| 159 |
)
|
| 160 |
|
|
|
|
|
|
|
| 161 |
try:
|
| 162 |
service = get_ocr_service()
|
| 163 |
result = await service.process_pdf(
|
|
|
|
| 13 |
import logging
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 17 |
from fastapi.responses import JSONResponse
|
| 18 |
|
| 19 |
from app.schemas.ocr import (
|
|
|
|
| 23 |
PDFTypeDetectionRequest,
|
| 24 |
PDFTypeDetectionResult,
|
| 25 |
)
|
| 26 |
+
from app.core.auth import get_current_admin_user
|
| 27 |
+
from app.models.user import User
|
| 28 |
+
from app.utils.file_utils import resolve_upload_path
|
| 29 |
from app.services.ocr_service import OCRError, OCRService
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
|
|
|
| 68 |
- OCR recommendation
|
| 69 |
""",
|
| 70 |
)
|
| 71 |
+
async def detect_pdf_type(
|
| 72 |
+
request: PDFTypeDetectionRequest,
|
| 73 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 74 |
+
) -> PDFTypeDetectionResult:
|
| 75 |
"""
|
| 76 |
Detect PDF type without processing.
|
| 77 |
|
|
|
|
| 87 |
500: Detection error
|
| 88 |
"""
|
| 89 |
assert request is not None, "PDFTypeDetectionRequest must not be None"
|
|
|
|
| 90 |
|
| 91 |
+
logger.info(f"🔍 PDF type detection request: {request.file_id}")
|
| 92 |
|
| 93 |
+
# resolve_upload_path contraint le fichier au repertoire d'upload.
|
| 94 |
+
# Auparavant : Path(request.pdf_path) sur un chemin serveur arbitraire, sans
|
| 95 |
+
# authentification — enumeration et lecture de n'importe quel fichier lisible.
|
| 96 |
+
try:
|
| 97 |
+
pdf_path = resolve_upload_path(request.file_id)
|
| 98 |
+
except ValueError as e:
|
| 99 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 100 |
+
except FileNotFoundError:
|
| 101 |
raise HTTPException(
|
| 102 |
status_code=status.HTTP_404_NOT_FOUND,
|
| 103 |
+
detail=f"Fichier introuvable : {request.file_id}",
|
| 104 |
)
|
| 105 |
|
| 106 |
+
# Validate path
|
| 107 |
+
|
| 108 |
try:
|
| 109 |
service = get_ocr_service()
|
| 110 |
result = await service.detect_pdf_type(pdf_path)
|
|
|
|
| 137 |
**Languages:** French (fra), English (eng), or both (fra+eng)
|
| 138 |
""",
|
| 139 |
)
|
| 140 |
+
async def process_pdf(
|
| 141 |
+
request: OCRRequest,
|
| 142 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 143 |
+
) -> OCRResult:
|
| 144 |
"""
|
| 145 |
Process PDF with OCR.
|
| 146 |
|
|
|
|
| 156 |
500: Processing error
|
| 157 |
"""
|
| 158 |
assert request is not None, "OCRRequest must not be None"
|
|
|
|
| 159 |
|
| 160 |
logger.info(
|
| 161 |
+
f"📄 OCR processing request: {request.file_id} "
|
| 162 |
f"(lang: {request.language}, dpi: {request.dpi})"
|
| 163 |
)
|
| 164 |
|
| 165 |
+
# resolve_upload_path contraint le fichier au repertoire d'upload.
|
| 166 |
+
# Auparavant : Path(request.pdf_path) sur un chemin serveur arbitraire, sans
|
| 167 |
+
# authentification — enumeration et lecture de n'importe quel fichier lisible.
|
| 168 |
+
try:
|
| 169 |
+
pdf_path = resolve_upload_path(request.file_id)
|
| 170 |
+
except ValueError as e:
|
| 171 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 172 |
+
except FileNotFoundError:
|
| 173 |
raise HTTPException(
|
| 174 |
status_code=status.HTTP_404_NOT_FOUND,
|
| 175 |
+
detail=f"Fichier introuvable : {request.file_id}",
|
| 176 |
)
|
| 177 |
|
| 178 |
+
# Validate path
|
| 179 |
+
|
| 180 |
try:
|
| 181 |
service = get_ocr_service()
|
| 182 |
result = await service.process_pdf(
|
|
@@ -26,6 +26,8 @@ from app.schemas.search import (
|
|
| 26 |
SearchStats,
|
| 27 |
)
|
| 28 |
from app.services.search_service import SearchService, SearchServiceError
|
|
|
|
|
|
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
|
@@ -345,7 +347,8 @@ async def find_article(
|
|
| 345 |
# dependencies=[Depends(require_admin)]
|
| 346 |
)
|
| 347 |
async def reindex_all(
|
| 348 |
-
background_tasks: BackgroundTasks, search_service: SearchService = Depends(get_search_service)
|
|
|
|
| 349 |
) -> ReindexResponse:
|
| 350 |
"""
|
| 351 |
Reconstruit les tsvector de toutes les lois publiées (admin uniquement).
|
|
@@ -434,7 +437,10 @@ async def reindex_all(
|
|
| 434 |
response_model=SearchStats,
|
| 435 |
status_code=status.HTTP_200_OK,
|
| 436 |
)
|
| 437 |
-
async def search_stats(
|
|
|
|
|
|
|
|
|
|
| 438 |
"""
|
| 439 |
Statistiques d'indexation (administrateurs).
|
| 440 |
|
|
|
|
| 26 |
SearchStats,
|
| 27 |
)
|
| 28 |
from app.services.search_service import SearchService, SearchServiceError
|
| 29 |
+
from app.core.auth import get_current_admin_user
|
| 30 |
+
from app.models.user import User
|
| 31 |
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
|
|
|
| 347 |
# dependencies=[Depends(require_admin)]
|
| 348 |
)
|
| 349 |
async def reindex_all(
|
| 350 |
+
background_tasks: BackgroundTasks, search_service: SearchService = Depends(get_search_service),
|
| 351 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 352 |
) -> ReindexResponse:
|
| 353 |
"""
|
| 354 |
Reconstruit les tsvector de toutes les lois publiées (admin uniquement).
|
|
|
|
| 437 |
response_model=SearchStats,
|
| 438 |
status_code=status.HTTP_200_OK,
|
| 439 |
)
|
| 440 |
+
async def search_stats(
|
| 441 |
+
db: AsyncSession = Depends(get_db),
|
| 442 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 443 |
+
) -> SearchStats:
|
| 444 |
"""
|
| 445 |
Statistiques d'indexation (administrateurs).
|
| 446 |
|
|
@@ -15,11 +15,13 @@ import logging
|
|
| 15 |
from pathlib import Path
|
| 16 |
from typing import Dict
|
| 17 |
|
| 18 |
-
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
| 19 |
from fastapi.responses import JSONResponse
|
| 20 |
|
| 21 |
from app.schemas.file_upload import FileUploadResult, UploadServiceHealth
|
| 22 |
from app.services.file_upload_service import FileUploadError, FileUploadService
|
|
|
|
|
|
|
| 23 |
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
|
@@ -189,7 +191,10 @@ async def get_upload_status(file_id: str) -> Dict:
|
|
| 189 |
summary="Delete uploaded file",
|
| 190 |
description="Delete an uploaded file from temporary storage.",
|
| 191 |
)
|
| 192 |
-
async def delete_upload(
|
|
|
|
|
|
|
|
|
|
| 193 |
"""
|
| 194 |
Delete an uploaded file.
|
| 195 |
|
|
@@ -233,7 +238,10 @@ async def delete_upload(file_id: str) -> Dict:
|
|
| 233 |
summary="Cleanup old files",
|
| 234 |
description="Remove files older than specified hours (default: 24h).",
|
| 235 |
)
|
| 236 |
-
async def cleanup_old_files(
|
|
|
|
|
|
|
|
|
|
| 237 |
"""
|
| 238 |
Cleanup old uploaded files.
|
| 239 |
|
|
|
|
| 15 |
from pathlib import Path
|
| 16 |
from typing import Dict
|
| 17 |
|
| 18 |
+
from fastapi import Depends, APIRouter, File, HTTPException, UploadFile, status
|
| 19 |
from fastapi.responses import JSONResponse
|
| 20 |
|
| 21 |
from app.schemas.file_upload import FileUploadResult, UploadServiceHealth
|
| 22 |
from app.services.file_upload_service import FileUploadError, FileUploadService
|
| 23 |
+
from app.core.auth import get_current_admin_user
|
| 24 |
+
from app.models.user import User
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
|
|
|
| 191 |
summary="Delete uploaded file",
|
| 192 |
description="Delete an uploaded file from temporary storage.",
|
| 193 |
)
|
| 194 |
+
async def delete_upload(
|
| 195 |
+
file_id: str,
|
| 196 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 197 |
+
) -> Dict:
|
| 198 |
"""
|
| 199 |
Delete an uploaded file.
|
| 200 |
|
|
|
|
| 238 |
summary="Cleanup old files",
|
| 239 |
description="Remove files older than specified hours (default: 24h).",
|
| 240 |
)
|
| 241 |
+
async def cleanup_old_files(
|
| 242 |
+
max_age_hours: int = 24,
|
| 243 |
+
current_admin: User = Depends(get_current_admin_user),
|
| 244 |
+
) -> Dict:
|
| 245 |
"""
|
| 246 |
Cleanup old uploaded files.
|
| 247 |
|
|
@@ -7,13 +7,16 @@ Author: JuriX Team
|
|
| 7 |
Date: 2026-01-12
|
| 8 |
"""
|
| 9 |
|
| 10 |
-
from datetime import datetime, timedelta
|
| 11 |
from typing import Optional
|
| 12 |
|
| 13 |
from fastapi import Depends, HTTPException, status
|
| 14 |
from fastapi.security import OAuth2PasswordBearer
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from jose import JWTError, jwt
|
| 16 |
-
from passlib.context import CryptContext
|
| 17 |
from sqlalchemy import select
|
| 18 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 19 |
|
|
@@ -21,20 +24,36 @@ from app.core.config import settings
|
|
| 21 |
from app.core.database import get_db
|
| 22 |
from app.models.user import User
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# OAuth2 scheme
|
| 28 |
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
| 29 |
|
| 30 |
-
# JWT settings
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
ALGORITHM =
|
| 37 |
-
ACCESS_TOKEN_EXPIRE_MINUTES =
|
| 38 |
|
| 39 |
|
| 40 |
def hash_password(password: str) -> str:
|
|
@@ -47,7 +66,7 @@ def hash_password(password: str) -> str:
|
|
| 47 |
Returns:
|
| 48 |
Hashed password
|
| 49 |
"""
|
| 50 |
-
return
|
| 51 |
|
| 52 |
|
| 53 |
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
@@ -61,7 +80,11 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
| 61 |
Returns:
|
| 62 |
True if password matches, False otherwise
|
| 63 |
"""
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
@@ -77,9 +100,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
|
| 77 |
"""
|
| 78 |
to_encode = data.copy()
|
| 79 |
if expires_delta:
|
| 80 |
-
expire = datetime.
|
| 81 |
else:
|
| 82 |
-
expire = datetime.
|
| 83 |
|
| 84 |
to_encode.update({"exp": expire})
|
| 85 |
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
|
|
| 7 |
Date: 2026-01-12
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
from datetime import datetime, timedelta, timezone
|
| 11 |
from typing import Optional
|
| 12 |
|
| 13 |
from fastapi import Depends, HTTPException, status
|
| 14 |
from fastapi.security import OAuth2PasswordBearer
|
| 15 |
+
import base64
|
| 16 |
+
import hashlib
|
| 17 |
+
|
| 18 |
+
import bcrypt
|
| 19 |
from jose import JWTError, jwt
|
|
|
|
| 20 |
from sqlalchemy import select
|
| 21 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 22 |
|
|
|
|
| 24 |
from app.core.database import get_db
|
| 25 |
from app.models.user import User
|
| 26 |
|
| 27 |
+
# ==================== HACHAGE DES MOTS DE PASSE ====================
|
| 28 |
+
#
|
| 29 |
+
# bcrypt est utilise directement, sans passlib. passlib 1.7.4 (derniere version,
|
| 30 |
+
# 2020) lit `bcrypt.__about__.__version__` pour detecter le backend — attribut
|
| 31 |
+
# supprime depuis bcrypt 4.1. Avec bcrypt 5.x, le hachage echouait totalement
|
| 32 |
+
# avec "password cannot be longer than 72 bytes" MEME pour un mot de passe de
|
| 33 |
+
# 13 caracteres. L'authentification etait donc inutilisable telle que declaree.
|
| 34 |
+
#
|
| 35 |
+
# bcrypt ignore silencieusement tout ce qui depasse 72 octets : deux mots de
|
| 36 |
+
# passe partageant les 72 premiers octets seraient equivalents. On pre-hache donc
|
| 37 |
+
# en SHA-256 puis on encode en base64, ce qui donne une entree de longueur fixe
|
| 38 |
+
# (44 octets) et supporte les phrases de passe de n'importe quelle longueur.
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _prepare(password: str) -> bytes:
|
| 42 |
+
"""Normalise un mot de passe en une entree bcrypt de longueur fixe."""
|
| 43 |
+
digest = hashlib.sha256(password.encode("utf-8")).digest()
|
| 44 |
+
return base64.b64encode(digest)
|
| 45 |
|
| 46 |
# OAuth2 scheme
|
| 47 |
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
| 48 |
|
| 49 |
+
# JWT settings — lus depuis la configuration.
|
| 50 |
+
# Auparavant ALGORITHM et ACCESS_TOKEN_EXPIRE_MINUTES etaient codes en dur ici
|
| 51 |
+
# (7 jours) alors que config.py declare 30 minutes : les deux valeurs se
|
| 52 |
+
# contredisaient et celle du fichier de configuration n'avait aucun effet.
|
| 53 |
+
# Le garde-fou hasattr etait vestigial : SECRET_KEY existe toujours.
|
| 54 |
+
SECRET_KEY = settings.SECRET_KEY
|
| 55 |
+
ALGORITHM = settings.ALGORITHM
|
| 56 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
| 57 |
|
| 58 |
|
| 59 |
def hash_password(password: str) -> str:
|
|
|
|
| 66 |
Returns:
|
| 67 |
Hashed password
|
| 68 |
"""
|
| 69 |
+
return bcrypt.hashpw(_prepare(password), bcrypt.gensalt()).decode("utf-8")
|
| 70 |
|
| 71 |
|
| 72 |
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
|
|
| 80 |
Returns:
|
| 81 |
True if password matches, False otherwise
|
| 82 |
"""
|
| 83 |
+
try:
|
| 84 |
+
return bcrypt.checkpw(_prepare(plain_password), hashed_password.encode("utf-8"))
|
| 85 |
+
except (ValueError, TypeError):
|
| 86 |
+
# Empreinte illisible ou tronquee : on refuse plutot que de propager.
|
| 87 |
+
return False
|
| 88 |
|
| 89 |
|
| 90 |
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
|
|
| 100 |
"""
|
| 101 |
to_encode = data.copy()
|
| 102 |
if expires_delta:
|
| 103 |
+
expire = datetime.now(timezone.utc) + expires_delta
|
| 104 |
else:
|
| 105 |
+
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 106 |
|
| 107 |
to_encode.update({"exp": expire})
|
| 108 |
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from app.core.config import settings
|
| 9 |
from app.core.database import close_db
|
| 10 |
-
from app.api.routes import language, classifier, articles, search, rag, categories, personas, upload, ocr, laws, analytics, admin, batch_upload
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
|
@@ -75,6 +75,7 @@ async def health():
|
|
| 75 |
|
| 76 |
|
| 77 |
# Include routers with global /api/v1 prefix
|
|
|
|
| 78 |
app.include_router(language.router, prefix="/api/v1/language", tags=["language"])
|
| 79 |
app.include_router(classifier.router, prefix="/api/v1/classifier", tags=["classifier"])
|
| 80 |
app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
|
|
|
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from app.core.config import settings
|
| 9 |
from app.core.database import close_db
|
| 10 |
+
from app.api.routes import auth, language, classifier, articles, search, rag, categories, personas, upload, ocr, laws, analytics, admin, batch_upload
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
# Include routers with global /api/v1 prefix
|
| 78 |
+
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
| 79 |
app.include_router(language.router, prefix="/api/v1/language", tags=["language"])
|
| 80 |
app.include_router(classifier.router, prefix="/api/v1/classifier", tags=["classifier"])
|
| 81 |
app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
|
|
@@ -112,7 +112,8 @@ class OCRRequest(BaseModel):
|
|
| 112 |
Request schema for OCR processing.
|
| 113 |
"""
|
| 114 |
|
| 115 |
-
|
|
|
|
| 116 |
language: str = Field(
|
| 117 |
default="fra+eng",
|
| 118 |
description="Tesseract language codes (e.g., 'fra', 'eng', 'fra+eng')",
|
|
@@ -138,7 +139,7 @@ class OCRRequest(BaseModel):
|
|
| 138 |
class Config:
|
| 139 |
json_schema_extra = {
|
| 140 |
"example": {
|
| 141 |
-
"
|
| 142 |
"language": "fra+eng",
|
| 143 |
"dpi": 300,
|
| 144 |
"timeout_seconds": 60,
|
|
@@ -149,10 +150,11 @@ class OCRRequest(BaseModel):
|
|
| 149 |
class PDFTypeDetectionRequest(BaseModel):
|
| 150 |
"""Request schema for PDF type detection only."""
|
| 151 |
|
| 152 |
-
|
|
|
|
| 153 |
|
| 154 |
class Config:
|
| 155 |
-
json_schema_extra = {"example": {"
|
| 156 |
|
| 157 |
|
| 158 |
# ============================================================================
|
|
|
|
| 112 |
Request schema for OCR processing.
|
| 113 |
"""
|
| 114 |
|
| 115 |
+
file_id: str = Field(
|
| 116 |
+
..., min_length=1, description="Identifiant du fichier uploadé (jamais un chemin serveur)")
|
| 117 |
language: str = Field(
|
| 118 |
default="fra+eng",
|
| 119 |
description="Tesseract language codes (e.g., 'fra', 'eng', 'fra+eng')",
|
|
|
|
| 139 |
class Config:
|
| 140 |
json_schema_extra = {
|
| 141 |
"example": {
|
| 142 |
+
"file_id": "/path/to/document.pdf",
|
| 143 |
"language": "fra+eng",
|
| 144 |
"dpi": 300,
|
| 145 |
"timeout_seconds": 60,
|
|
|
|
| 150 |
class PDFTypeDetectionRequest(BaseModel):
|
| 151 |
"""Request schema for PDF type detection only."""
|
| 152 |
|
| 153 |
+
file_id: str = Field(
|
| 154 |
+
..., min_length=1, description="Identifiant du fichier uploadé (jamais un chemin serveur)")
|
| 155 |
|
| 156 |
class Config:
|
| 157 |
+
json_schema_extra = {"example": {"file_id": "/path/to/document.pdf"}}
|
| 158 |
|
| 159 |
|
| 160 |
# ============================================================================
|
|
@@ -14,6 +14,7 @@ Date: 2026-01-11
|
|
| 14 |
|
| 15 |
import hashlib
|
| 16 |
import logging
|
|
|
|
| 17 |
import uuid
|
| 18 |
from pathlib import Path
|
| 19 |
from typing import Optional, Tuple
|
|
@@ -353,3 +354,51 @@ def get_mime_type(file_type: str) -> str:
|
|
| 353 |
raise ValueError(f"Unsupported file type: {file_type}")
|
| 354 |
|
| 355 |
return MIME_TYPES[file_type]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
import hashlib
|
| 16 |
import logging
|
| 17 |
+
import re
|
| 18 |
import uuid
|
| 19 |
from pathlib import Path
|
| 20 |
from typing import Optional, Tuple
|
|
|
|
| 354 |
raise ValueError(f"Unsupported file type: {file_type}")
|
| 355 |
|
| 356 |
return MIME_TYPES[file_type]
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
# ==================== RESOLUTION SECURISEE DES FICHIERS ====================
|
| 360 |
+
|
| 361 |
+
_FILE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def resolve_upload_path(file_id: str) -> Path:
|
| 365 |
+
"""
|
| 366 |
+
Résout un identifiant de fichier en chemin, à l'intérieur du répertoire d'upload.
|
| 367 |
+
|
| 368 |
+
Motivation : `/api/v1/ocr/detect` et `/api/v1/ocr/process` acceptaient un
|
| 369 |
+
`pdf_path` arbitraire — un chemin du système de fichiers du serveur — et se
|
| 370 |
+
contentaient d'un `.exists()`. N'importe qui pouvait donc énumérer les
|
| 371 |
+
fichiers de la machine (404 contre 500) et se faire renvoyer le texte de tout
|
| 372 |
+
document lisible par Tesseract. L'authentification seule ne suffit pas : un
|
| 373 |
+
jeton d'administration volé rouvrirait la faille.
|
| 374 |
+
|
| 375 |
+
Trois barrières indépendantes :
|
| 376 |
+
1. le motif rejette `..`, `/`, `%2f`, les chemins absolus et les octets nuls
|
| 377 |
+
AVANT tout accès au disque ;
|
| 378 |
+
2. `resolve()` + `is_relative_to` bloquent les évasions par lien symbolique ;
|
| 379 |
+
3. `is_file()` écarte les répertoires et les fichiers spéciaux.
|
| 380 |
+
|
| 381 |
+
Args:
|
| 382 |
+
file_id: Identifiant du fichier, sans extension
|
| 383 |
+
|
| 384 |
+
Returns:
|
| 385 |
+
Chemin absolu du fichier
|
| 386 |
+
|
| 387 |
+
Raises:
|
| 388 |
+
ValueError: identifiant invalide ou chemin sortant du répertoire
|
| 389 |
+
FileNotFoundError: aucun fichier correspondant
|
| 390 |
+
"""
|
| 391 |
+
if not _FILE_ID_RE.match(file_id or ""):
|
| 392 |
+
raise ValueError(f"Identifiant de fichier invalide : {file_id!r}")
|
| 393 |
+
|
| 394 |
+
from app.services.file_upload_service import get_upload_service
|
| 395 |
+
|
| 396 |
+
root = get_upload_service().storage_path.resolve()
|
| 397 |
+
for extension in (".pdf", ".docx"):
|
| 398 |
+
candidate = (root / f"{file_id}{extension}").resolve()
|
| 399 |
+
if not candidate.is_relative_to(root):
|
| 400 |
+
raise ValueError("Le chemin sort du répertoire d'upload")
|
| 401 |
+
if candidate.is_file():
|
| 402 |
+
return candidate
|
| 403 |
+
|
| 404 |
+
raise FileNotFoundError(file_id)
|
|
@@ -25,7 +25,8 @@ pgvector = "^0.2.3"
|
|
| 25 |
|
| 26 |
# Security
|
| 27 |
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
|
| 28 |
-
passlib
|
|
|
|
| 29 |
python-multipart = "^0.0.6"
|
| 30 |
|
| 31 |
# (Meilisearch replaced by PostgreSQL tsvector + pg_trgm)
|
|
|
|
| 25 |
|
| 26 |
# Security
|
| 27 |
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
|
| 28 |
+
# bcrypt direct : passlib est incompatible avec bcrypt >= 4.1
|
| 29 |
+
bcrypt = ">=4.0,<6.0"
|
| 30 |
python-multipart = "^0.0.6"
|
| 31 |
|
| 32 |
# (Meilisearch replaced by PostgreSQL tsvector + pg_trgm)
|
|
@@ -19,7 +19,10 @@ pgvector>=0.2.3,<1.0.0
|
|
| 19 |
|
| 20 |
# ── Security ──────────────────────────────────────────────────────────────────
|
| 21 |
python-jose[cryptography]>=3.3.0,<4.0.0
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
| 23 |
python-multipart>=0.0.6,<1.0.0
|
| 24 |
|
| 25 |
# ── Document Processing ───────────────────────────────────────────────────────
|
|
|
|
| 19 |
|
| 20 |
# ── Security ──────────────────────────────────────────────────────────────────
|
| 21 |
python-jose[cryptography]>=3.3.0,<4.0.0
|
| 22 |
+
# bcrypt utilise directement, sans passlib : passlib 1.7.4 (derniere version,
|
| 23 |
+
# 2020) est incompatible avec bcrypt >= 4.1 (il lit bcrypt.__about__, supprime),
|
| 24 |
+
# et le hachage echouait totalement avec bcrypt 5.x.
|
| 25 |
+
bcrypt>=4.0,<6.0
|
| 26 |
python-multipart>=0.0.6,<1.0.0
|
| 27 |
|
| 28 |
# ── Document Processing ───────────────────────────────────────────────────────
|
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Crée ou met à jour le compte administrateur initial.
|
| 3 |
+
|
| 4 |
+
Aucun utilisateur n'était créé nulle part dans le projet : le modèle `User` et la
|
| 5 |
+
table `users` existaient depuis la migration 0b21fb6a3651 sans qu'aucun code ne
|
| 6 |
+
les alimente. Une fois l'authentification branchée, sans ce script il devient
|
| 7 |
+
impossible de se connecter à sa propre instance.
|
| 8 |
+
|
| 9 |
+
Idempotent : relancé, il ne duplique rien et ne modifie que ce qui doit l'être.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python scripts/create_admin.py
|
| 13 |
+
python scripts/create_admin.py --email admin@jurix.cm --username admin
|
| 14 |
+
python scripts/create_admin.py --reset-password
|
| 15 |
+
python scripts/create_admin.py --role superadmin
|
| 16 |
+
|
| 17 |
+
Les valeurs par défaut viennent de .env : ADMIN_EMAIL, ADMIN_USERNAME,
|
| 18 |
+
ADMIN_PASSWORD. Si aucun mot de passe n'est fourni ni par option ni par
|
| 19 |
+
l'environnement, il est demandé de façon interactive — jamais de valeur par
|
| 20 |
+
défaut en dur.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import asyncio
|
| 25 |
+
import getpass
|
| 26 |
+
import os
|
| 27 |
+
import sys
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 31 |
+
|
| 32 |
+
from dotenv import load_dotenv # noqa: E402
|
| 33 |
+
from sqlalchemy import select # noqa: E402
|
| 34 |
+
|
| 35 |
+
load_dotenv()
|
| 36 |
+
|
| 37 |
+
from app.core.auth import hash_password # noqa: E402
|
| 38 |
+
from app.core.database import AsyncSessionLocal # noqa: E402
|
| 39 |
+
from app.models.user import User # noqa: E402
|
| 40 |
+
from app.schemas.user import UserCreate # noqa: E402
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def parse_args() -> argparse.Namespace:
|
| 44 |
+
p = argparse.ArgumentParser(description="Crée ou met à jour un administrateur JuriX")
|
| 45 |
+
p.add_argument("--email", default=os.getenv("ADMIN_EMAIL", "admin@jurix.cm"))
|
| 46 |
+
p.add_argument("--username", default=os.getenv("ADMIN_USERNAME", "admin"))
|
| 47 |
+
p.add_argument("--password", default=os.getenv("ADMIN_PASSWORD"))
|
| 48 |
+
p.add_argument(
|
| 49 |
+
"--role",
|
| 50 |
+
default=os.getenv("ADMIN_ROLE", "superadmin"),
|
| 51 |
+
choices=["admin", "superadmin"],
|
| 52 |
+
)
|
| 53 |
+
p.add_argument(
|
| 54 |
+
"--reset-password",
|
| 55 |
+
action="store_true",
|
| 56 |
+
help="Réinitialise le mot de passe d'un compte existant",
|
| 57 |
+
)
|
| 58 |
+
return p.parse_args()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def main() -> int:
|
| 62 |
+
args = parse_args()
|
| 63 |
+
|
| 64 |
+
password = args.password
|
| 65 |
+
if not password:
|
| 66 |
+
password = getpass.getpass(f"Mot de passe pour {args.email} : ")
|
| 67 |
+
if not password:
|
| 68 |
+
print("❌ Aucun mot de passe fourni.", file=sys.stderr)
|
| 69 |
+
return 1
|
| 70 |
+
|
| 71 |
+
# On valide via UserCreate pour que le script et l'API appliquent
|
| 72 |
+
# exactement la même politique de mot de passe.
|
| 73 |
+
try:
|
| 74 |
+
UserCreate(
|
| 75 |
+
email=args.email,
|
| 76 |
+
username=args.username,
|
| 77 |
+
password=password,
|
| 78 |
+
role=args.role,
|
| 79 |
+
)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"❌ Données invalides : {e}", file=sys.stderr)
|
| 82 |
+
return 1
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
async with AsyncSessionLocal() as db:
|
| 86 |
+
existing = (
|
| 87 |
+
await db.execute(select(User).where(User.email == args.email.lower()))
|
| 88 |
+
).scalar_one_or_none()
|
| 89 |
+
|
| 90 |
+
if existing is None:
|
| 91 |
+
db.add(
|
| 92 |
+
User(
|
| 93 |
+
email=args.email.lower(),
|
| 94 |
+
username=args.username.lower(),
|
| 95 |
+
hashed_password=hash_password(password),
|
| 96 |
+
role=args.role,
|
| 97 |
+
is_active=True,
|
| 98 |
+
is_verified=True,
|
| 99 |
+
)
|
| 100 |
+
)
|
| 101 |
+
await db.commit()
|
| 102 |
+
print(f"✅ Compte créé : {args.email} (rôle {args.role})")
|
| 103 |
+
return 0
|
| 104 |
+
|
| 105 |
+
changed = []
|
| 106 |
+
if existing.role != args.role:
|
| 107 |
+
existing.role = args.role
|
| 108 |
+
changed.append("rôle")
|
| 109 |
+
if not existing.is_active:
|
| 110 |
+
existing.is_active = True
|
| 111 |
+
changed.append("réactivation")
|
| 112 |
+
if args.reset_password:
|
| 113 |
+
existing.hashed_password = hash_password(password)
|
| 114 |
+
changed.append("mot de passe")
|
| 115 |
+
|
| 116 |
+
if changed:
|
| 117 |
+
await db.commit()
|
| 118 |
+
print(f"✅ Compte mis à jour : {args.email} ({', '.join(changed)})")
|
| 119 |
+
else:
|
| 120 |
+
print(
|
| 121 |
+
f"ℹ️ Compte déjà conforme : {args.email} (rôle {existing.role}). "
|
| 122 |
+
"Utilisez --reset-password pour changer le mot de passe."
|
| 123 |
+
)
|
| 124 |
+
return 0
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
print(f"❌ Base de données inaccessible : {e}", file=sys.stderr)
|
| 128 |
+
print(" Vérifiez DATABASE_URL dans .env et que les migrations sont appliquées.",
|
| 129 |
+
file=sys.stderr)
|
| 130 |
+
return 1
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
sys.exit(asyncio.run(main()))
|
|
@@ -89,6 +89,7 @@ _DB_FIXTURES = {
|
|
| 89 |
"test_superadmin_user",
|
| 90 |
"auth_headers",
|
| 91 |
"admin_headers",
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
_pg_available: bool | None = None
|
|
@@ -332,3 +333,89 @@ def override_get_db(async_db_session: AsyncSession):
|
|
| 332 |
yield async_db_session
|
| 333 |
|
| 334 |
return _override_get_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
"test_superadmin_user",
|
| 90 |
"auth_headers",
|
| 91 |
"admin_headers",
|
| 92 |
+
"as_admin",
|
| 93 |
}
|
| 94 |
|
| 95 |
_pg_available: bool | None = None
|
|
|
|
| 333 |
yield async_db_session
|
| 334 |
|
| 335 |
return _override_get_db
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# ==================== AUTHENTIFICATION ====================
|
| 339 |
+
#
|
| 340 |
+
# Surcharger `get_current_user` suffit à débloquer les trois niveaux d'accès :
|
| 341 |
+
# `get_current_active_user`, `get_current_admin_user` et
|
| 342 |
+
# `get_current_superadmin_user` en dépendent tous. Les surcharger un par un
|
| 343 |
+
# obligerait à traiter chaque endpoint séparément.
|
| 344 |
+
#
|
| 345 |
+
# `client` reste volontairement anonyme, pour que les tests qui vérifient les
|
| 346 |
+
# 401 continuent de fonctionner.
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
async def _make_user(db: AsyncSession, email: str, username: str, role: str):
|
| 350 |
+
from app.core.auth import hash_password
|
| 351 |
+
from app.models.user import User
|
| 352 |
+
|
| 353 |
+
user = User(
|
| 354 |
+
email=email,
|
| 355 |
+
username=username,
|
| 356 |
+
hashed_password=hash_password("MotDePasseTest123"),
|
| 357 |
+
role=role,
|
| 358 |
+
is_active=True,
|
| 359 |
+
is_verified=True,
|
| 360 |
+
)
|
| 361 |
+
db.add(user)
|
| 362 |
+
await db.commit()
|
| 363 |
+
await db.refresh(user)
|
| 364 |
+
return user
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
@pytest_asyncio.fixture
|
| 368 |
+
async def test_user(db_session: AsyncSession):
|
| 369 |
+
"""Compte sans privilège."""
|
| 370 |
+
return await _make_user(db_session, "user@test.cm", "usertest", "user")
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
@pytest_asyncio.fixture
|
| 374 |
+
async def test_admin_user(db_session: AsyncSession):
|
| 375 |
+
"""Compte administrateur."""
|
| 376 |
+
return await _make_user(db_session, "admin@test.cm", "admintest", "admin")
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
@pytest_asyncio.fixture
|
| 380 |
+
async def test_superadmin_user(db_session: AsyncSession):
|
| 381 |
+
"""Compte superadministrateur."""
|
| 382 |
+
return await _make_user(db_session, "super@test.cm", "supertest", "superadmin")
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
@pytest.fixture
|
| 386 |
+
def auth_headers(test_user):
|
| 387 |
+
"""En-tête Authorization pour un compte sans privilège (jeton réel)."""
|
| 388 |
+
from app.core.auth import create_access_token
|
| 389 |
+
|
| 390 |
+
return {"Authorization": f"Bearer {create_access_token({'sub': test_user.email})}"}
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
@pytest.fixture
|
| 394 |
+
def admin_headers(test_admin_user):
|
| 395 |
+
"""En-tête Authorization pour un administrateur (jeton réel)."""
|
| 396 |
+
from app.core.auth import create_access_token
|
| 397 |
+
|
| 398 |
+
return {"Authorization": f"Bearer {create_access_token({'sub': test_admin_user.email})}"}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
@pytest.fixture
|
| 402 |
+
def as_admin(test_admin_user):
|
| 403 |
+
"""
|
| 404 |
+
Court-circuite l'authentification en se faisant passer pour un administrateur.
|
| 405 |
+
|
| 406 |
+
Utile aux tests qui portent sur la logique métier et non sur l'authentification :
|
| 407 |
+
ils n'ont pas à fabriquer un jeton ni à gérer son expiration.
|
| 408 |
+
"""
|
| 409 |
+
from app.core.auth import get_current_user
|
| 410 |
+
|
| 411 |
+
app.dependency_overrides[get_current_user] = lambda: test_admin_user
|
| 412 |
+
try:
|
| 413 |
+
yield test_admin_user
|
| 414 |
+
finally:
|
| 415 |
+
app.dependency_overrides.pop(get_current_user, None)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
@pytest_asyncio.fixture
|
| 419 |
+
async def admin_client(client: AsyncClient, as_admin) -> AsyncClient:
|
| 420 |
+
"""Client HTTP authentifié comme administrateur."""
|
| 421 |
+
return client
|
|
@@ -11,9 +11,9 @@ class TestCreateCategoryAPI:
|
|
| 11 |
"""Tests for POST /api/v1/categories endpoint."""
|
| 12 |
|
| 13 |
@pytest.mark.asyncio
|
| 14 |
-
async def test_create_category_success(self,
|
| 15 |
"""Test successful category creation via API."""
|
| 16 |
-
response = await
|
| 17 |
"/api/v1/categories",
|
| 18 |
json={"name": "API Test Category", "description": "Created via API"}
|
| 19 |
)
|
|
@@ -26,16 +26,16 @@ class TestCreateCategoryAPI:
|
|
| 26 |
assert "created_at" in data
|
| 27 |
|
| 28 |
@pytest.mark.asyncio
|
| 29 |
-
async def test_create_category_duplicate_name(self,
|
| 30 |
"""Test creating duplicate category returns 409."""
|
| 31 |
# First create
|
| 32 |
-
await
|
| 33 |
"/api/v1/categories",
|
| 34 |
json={"name": "Duplicate Test", "description": "First"}
|
| 35 |
)
|
| 36 |
|
| 37 |
# Try to create again
|
| 38 |
-
response = await
|
| 39 |
"/api/v1/categories",
|
| 40 |
json={"name": "Duplicate Test", "description": "Second"}
|
| 41 |
)
|
|
@@ -44,9 +44,9 @@ class TestCreateCategoryAPI:
|
|
| 44 |
assert "already exists" in response.json()["detail"].lower()
|
| 45 |
|
| 46 |
@pytest.mark.asyncio
|
| 47 |
-
async def test_create_category_invalid_data(self,
|
| 48 |
"""Test creating category with invalid data returns 422."""
|
| 49 |
-
response = await
|
| 50 |
"/api/v1/categories",
|
| 51 |
json={} # Missing required 'name' field
|
| 52 |
)
|
|
@@ -99,17 +99,17 @@ class TestGetCategoryAPI:
|
|
| 99 |
"""Tests for GET /api/v1/categories/{id} endpoint."""
|
| 100 |
|
| 101 |
@pytest.mark.asyncio
|
| 102 |
-
async def test_get_category_success(self,
|
| 103 |
"""Test getting single category by ID."""
|
| 104 |
# First create a category
|
| 105 |
-
create_response = await
|
| 106 |
"/api/v1/categories",
|
| 107 |
json={"name": "Get Test Category"}
|
| 108 |
)
|
| 109 |
created = create_response.json()
|
| 110 |
|
| 111 |
# Now get it
|
| 112 |
-
response = await
|
| 113 |
|
| 114 |
assert response.status_code == 200
|
| 115 |
data = response.json()
|
|
@@ -129,17 +129,17 @@ class TestUpdateCategoryAPI:
|
|
| 129 |
"""Tests for PUT /api/v1/categories/{id} endpoint."""
|
| 130 |
|
| 131 |
@pytest.mark.asyncio
|
| 132 |
-
async def test_update_category_success(self,
|
| 133 |
"""Test successful category update."""
|
| 134 |
# Create a category
|
| 135 |
-
create_response = await
|
| 136 |
"/api/v1/categories",
|
| 137 |
json={"name": "Update Test", "description": "Original"}
|
| 138 |
)
|
| 139 |
created = create_response.json()
|
| 140 |
|
| 141 |
# Update it
|
| 142 |
-
response = await
|
| 143 |
f"/api/v1/categories/{created['id']}",
|
| 144 |
json={"name": "Updated Name", "description": "Updated Desc"}
|
| 145 |
)
|
|
@@ -151,9 +151,9 @@ class TestUpdateCategoryAPI:
|
|
| 151 |
assert data["description"] == "Updated Desc"
|
| 152 |
|
| 153 |
@pytest.mark.asyncio
|
| 154 |
-
async def test_update_category_not_found(self,
|
| 155 |
"""Test updating non-existent category returns 404."""
|
| 156 |
-
response = await
|
| 157 |
"/api/v1/categories/999999",
|
| 158 |
json={"name": "New Name"}
|
| 159 |
)
|
|
@@ -161,21 +161,21 @@ class TestUpdateCategoryAPI:
|
|
| 161 |
assert response.status_code == 404
|
| 162 |
|
| 163 |
@pytest.mark.asyncio
|
| 164 |
-
async def test_update_category_duplicate_name(self,
|
| 165 |
"""Test updating to duplicate name returns 409."""
|
| 166 |
# Create two categories
|
| 167 |
-
cat1 = (await
|
| 168 |
"/api/v1/categories",
|
| 169 |
json={"name": "Category 1"}
|
| 170 |
)).json()
|
| 171 |
|
| 172 |
-
cat2 = (await
|
| 173 |
"/api/v1/categories",
|
| 174 |
json={"name": "Category 2"}
|
| 175 |
)).json()
|
| 176 |
|
| 177 |
# Try to update cat2 to have same name as cat1
|
| 178 |
-
response = await
|
| 179 |
f"/api/v1/categories/{cat2['id']}",
|
| 180 |
json={"name": "Category 1"}
|
| 181 |
)
|
|
@@ -187,43 +187,43 @@ class TestDeleteCategoryAPI:
|
|
| 187 |
"""Tests for DELETE /api/v1/categories/{id} endpoint."""
|
| 188 |
|
| 189 |
@pytest.mark.asyncio
|
| 190 |
-
async def test_delete_category_success(self,
|
| 191 |
"""Test successful category deletion."""
|
| 192 |
# Create a category
|
| 193 |
-
create_response = await
|
| 194 |
"/api/v1/categories",
|
| 195 |
json={"name": "Delete Test"}
|
| 196 |
)
|
| 197 |
created = create_response.json()
|
| 198 |
|
| 199 |
# Delete it
|
| 200 |
-
response = await
|
| 201 |
|
| 202 |
assert response.status_code == 204
|
| 203 |
|
| 204 |
# Verify it's gone
|
| 205 |
-
get_response = await
|
| 206 |
assert get_response.status_code == 404
|
| 207 |
|
| 208 |
@pytest.mark.asyncio
|
| 209 |
-
async def test_delete_category_not_found(self,
|
| 210 |
"""Test deleting non-existent category returns 404."""
|
| 211 |
-
response = await
|
| 212 |
|
| 213 |
assert response.status_code == 404
|
| 214 |
|
| 215 |
@pytest.mark.asyncio
|
| 216 |
-
async def test_delete_category_with_force(self,
|
| 217 |
"""Test force delete parameter."""
|
| 218 |
# Create a category
|
| 219 |
-
create_response = await
|
| 220 |
"/api/v1/categories",
|
| 221 |
json={"name": "Force Delete Test"}
|
| 222 |
)
|
| 223 |
created = create_response.json()
|
| 224 |
|
| 225 |
# Force delete it
|
| 226 |
-
response = await
|
| 227 |
f"/api/v1/categories/{created['id']}?force=true"
|
| 228 |
)
|
| 229 |
|
|
@@ -383,9 +383,9 @@ class TestErrorHandling:
|
|
| 383 |
assert response.status_code in [200, 422]
|
| 384 |
|
| 385 |
@pytest.mark.asyncio
|
| 386 |
-
async def test_create_category_with_extra_fields(self,
|
| 387 |
"""Test creating category with extra fields ignores them."""
|
| 388 |
-
response = await
|
| 389 |
"/api/v1/categories",
|
| 390 |
json={
|
| 391 |
"name": "Extra Fields Test",
|
|
@@ -404,10 +404,10 @@ class TestEndToEndWorkflow:
|
|
| 404 |
"""End-to-end workflow tests."""
|
| 405 |
|
| 406 |
@pytest.mark.asyncio
|
| 407 |
-
async def test_full_crud_workflow(self,
|
| 408 |
"""Test complete CRUD workflow: create → read → update → delete."""
|
| 409 |
# 1. Create
|
| 410 |
-
create_response = await
|
| 411 |
"/api/v1/categories",
|
| 412 |
json={"name": "E2E Test Category", "description": "End to end test"}
|
| 413 |
)
|
|
@@ -416,18 +416,18 @@ class TestEndToEndWorkflow:
|
|
| 416 |
category_id = created["id"]
|
| 417 |
|
| 418 |
# 2. Read (single)
|
| 419 |
-
get_response = await
|
| 420 |
assert get_response.status_code == 200
|
| 421 |
assert get_response.json()["name"] == "E2E Test Category"
|
| 422 |
|
| 423 |
# 3. Read (list)
|
| 424 |
-
list_response = await
|
| 425 |
assert list_response.status_code == 200
|
| 426 |
category_ids = [cat["id"] for cat in list_response.json()]
|
| 427 |
assert category_id in category_ids
|
| 428 |
|
| 429 |
# 4. Update
|
| 430 |
-
update_response = await
|
| 431 |
f"/api/v1/categories/{category_id}",
|
| 432 |
json={"name": "E2E Updated", "description": "Updated description"}
|
| 433 |
)
|
|
@@ -435,15 +435,15 @@ class TestEndToEndWorkflow:
|
|
| 435 |
assert update_response.json()["name"] == "E2E Updated"
|
| 436 |
|
| 437 |
# 5. Verify update
|
| 438 |
-
get_updated = await
|
| 439 |
assert get_updated.json()["name"] == "E2E Updated"
|
| 440 |
|
| 441 |
# 6. Delete
|
| 442 |
-
delete_response = await
|
| 443 |
assert delete_response.status_code == 204
|
| 444 |
|
| 445 |
# 7. Verify deletion
|
| 446 |
-
get_deleted = await
|
| 447 |
assert get_deleted.status_code == 404
|
| 448 |
|
| 449 |
@pytest.mark.asyncio
|
|
@@ -464,28 +464,28 @@ class TestEndToEndWorkflow:
|
|
| 464 |
assert single_stats.json()["category_id"] == first_cat["category_id"]
|
| 465 |
|
| 466 |
@pytest.mark.asyncio
|
| 467 |
-
async def test_mapping_workflow(self,
|
| 468 |
"""Test mapping utilities workflow."""
|
| 469 |
# 1. Create a new category
|
| 470 |
-
create_response = await
|
| 471 |
"/api/v1/categories",
|
| 472 |
json={"name": "Mapping Test Category"}
|
| 473 |
)
|
| 474 |
created = create_response.json()
|
| 475 |
|
| 476 |
# 2. Get ID-to-name mapping
|
| 477 |
-
id_to_name = await
|
| 478 |
assert id_to_name.status_code == 200
|
| 479 |
assert "Mapping Test Category" in id_to_name.json().values()
|
| 480 |
|
| 481 |
# 3. Get name-to-ID mapping
|
| 482 |
-
name_to_id = await
|
| 483 |
assert name_to_id.status_code == 200
|
| 484 |
assert "Mapping Test Category" in name_to_id.json().keys()
|
| 485 |
assert name_to_id.json()["Mapping Test Category"] == created["id"]
|
| 486 |
|
| 487 |
# Cleanup
|
| 488 |
-
await
|
| 489 |
|
| 490 |
|
| 491 |
# Summary:
|
|
|
|
| 11 |
"""Tests for POST /api/v1/categories endpoint."""
|
| 12 |
|
| 13 |
@pytest.mark.asyncio
|
| 14 |
+
async def test_create_category_success(self, admin_client: AsyncClient):
|
| 15 |
"""Test successful category creation via API."""
|
| 16 |
+
response = await admin_client.post(
|
| 17 |
"/api/v1/categories",
|
| 18 |
json={"name": "API Test Category", "description": "Created via API"}
|
| 19 |
)
|
|
|
|
| 26 |
assert "created_at" in data
|
| 27 |
|
| 28 |
@pytest.mark.asyncio
|
| 29 |
+
async def test_create_category_duplicate_name(self, admin_client: AsyncClient):
|
| 30 |
"""Test creating duplicate category returns 409."""
|
| 31 |
# First create
|
| 32 |
+
await admin_client.post(
|
| 33 |
"/api/v1/categories",
|
| 34 |
json={"name": "Duplicate Test", "description": "First"}
|
| 35 |
)
|
| 36 |
|
| 37 |
# Try to create again
|
| 38 |
+
response = await admin_client.post(
|
| 39 |
"/api/v1/categories",
|
| 40 |
json={"name": "Duplicate Test", "description": "Second"}
|
| 41 |
)
|
|
|
|
| 44 |
assert "already exists" in response.json()["detail"].lower()
|
| 45 |
|
| 46 |
@pytest.mark.asyncio
|
| 47 |
+
async def test_create_category_invalid_data(self, admin_client: AsyncClient):
|
| 48 |
"""Test creating category with invalid data returns 422."""
|
| 49 |
+
response = await admin_client.post(
|
| 50 |
"/api/v1/categories",
|
| 51 |
json={} # Missing required 'name' field
|
| 52 |
)
|
|
|
|
| 99 |
"""Tests for GET /api/v1/categories/{id} endpoint."""
|
| 100 |
|
| 101 |
@pytest.mark.asyncio
|
| 102 |
+
async def test_get_category_success(self, admin_client: AsyncClient):
|
| 103 |
"""Test getting single category by ID."""
|
| 104 |
# First create a category
|
| 105 |
+
create_response = await admin_client.post(
|
| 106 |
"/api/v1/categories",
|
| 107 |
json={"name": "Get Test Category"}
|
| 108 |
)
|
| 109 |
created = create_response.json()
|
| 110 |
|
| 111 |
# Now get it
|
| 112 |
+
response = await admin_client.get(f"/api/v1/categories/{created['id']}")
|
| 113 |
|
| 114 |
assert response.status_code == 200
|
| 115 |
data = response.json()
|
|
|
|
| 129 |
"""Tests for PUT /api/v1/categories/{id} endpoint."""
|
| 130 |
|
| 131 |
@pytest.mark.asyncio
|
| 132 |
+
async def test_update_category_success(self, admin_client: AsyncClient):
|
| 133 |
"""Test successful category update."""
|
| 134 |
# Create a category
|
| 135 |
+
create_response = await admin_client.post(
|
| 136 |
"/api/v1/categories",
|
| 137 |
json={"name": "Update Test", "description": "Original"}
|
| 138 |
)
|
| 139 |
created = create_response.json()
|
| 140 |
|
| 141 |
# Update it
|
| 142 |
+
response = await admin_client.put(
|
| 143 |
f"/api/v1/categories/{created['id']}",
|
| 144 |
json={"name": "Updated Name", "description": "Updated Desc"}
|
| 145 |
)
|
|
|
|
| 151 |
assert data["description"] == "Updated Desc"
|
| 152 |
|
| 153 |
@pytest.mark.asyncio
|
| 154 |
+
async def test_update_category_not_found(self, admin_client: AsyncClient):
|
| 155 |
"""Test updating non-existent category returns 404."""
|
| 156 |
+
response = await admin_client.put(
|
| 157 |
"/api/v1/categories/999999",
|
| 158 |
json={"name": "New Name"}
|
| 159 |
)
|
|
|
|
| 161 |
assert response.status_code == 404
|
| 162 |
|
| 163 |
@pytest.mark.asyncio
|
| 164 |
+
async def test_update_category_duplicate_name(self, admin_client: AsyncClient):
|
| 165 |
"""Test updating to duplicate name returns 409."""
|
| 166 |
# Create two categories
|
| 167 |
+
cat1 = (await admin_client.post(
|
| 168 |
"/api/v1/categories",
|
| 169 |
json={"name": "Category 1"}
|
| 170 |
)).json()
|
| 171 |
|
| 172 |
+
cat2 = (await admin_client.post(
|
| 173 |
"/api/v1/categories",
|
| 174 |
json={"name": "Category 2"}
|
| 175 |
)).json()
|
| 176 |
|
| 177 |
# Try to update cat2 to have same name as cat1
|
| 178 |
+
response = await admin_client.put(
|
| 179 |
f"/api/v1/categories/{cat2['id']}",
|
| 180 |
json={"name": "Category 1"}
|
| 181 |
)
|
|
|
|
| 187 |
"""Tests for DELETE /api/v1/categories/{id} endpoint."""
|
| 188 |
|
| 189 |
@pytest.mark.asyncio
|
| 190 |
+
async def test_delete_category_success(self, admin_client: AsyncClient):
|
| 191 |
"""Test successful category deletion."""
|
| 192 |
# Create a category
|
| 193 |
+
create_response = await admin_client.post(
|
| 194 |
"/api/v1/categories",
|
| 195 |
json={"name": "Delete Test"}
|
| 196 |
)
|
| 197 |
created = create_response.json()
|
| 198 |
|
| 199 |
# Delete it
|
| 200 |
+
response = await admin_client.delete(f"/api/v1/categories/{created['id']}")
|
| 201 |
|
| 202 |
assert response.status_code == 204
|
| 203 |
|
| 204 |
# Verify it's gone
|
| 205 |
+
get_response = await admin_client.get(f"/api/v1/categories/{created['id']}")
|
| 206 |
assert get_response.status_code == 404
|
| 207 |
|
| 208 |
@pytest.mark.asyncio
|
| 209 |
+
async def test_delete_category_not_found(self, admin_client: AsyncClient):
|
| 210 |
"""Test deleting non-existent category returns 404."""
|
| 211 |
+
response = await admin_client.delete("/api/v1/categories/999999")
|
| 212 |
|
| 213 |
assert response.status_code == 404
|
| 214 |
|
| 215 |
@pytest.mark.asyncio
|
| 216 |
+
async def test_delete_category_with_force(self, admin_client: AsyncClient):
|
| 217 |
"""Test force delete parameter."""
|
| 218 |
# Create a category
|
| 219 |
+
create_response = await admin_client.post(
|
| 220 |
"/api/v1/categories",
|
| 221 |
json={"name": "Force Delete Test"}
|
| 222 |
)
|
| 223 |
created = create_response.json()
|
| 224 |
|
| 225 |
# Force delete it
|
| 226 |
+
response = await admin_client.delete(
|
| 227 |
f"/api/v1/categories/{created['id']}?force=true"
|
| 228 |
)
|
| 229 |
|
|
|
|
| 383 |
assert response.status_code in [200, 422]
|
| 384 |
|
| 385 |
@pytest.mark.asyncio
|
| 386 |
+
async def test_create_category_with_extra_fields(self, admin_client: AsyncClient):
|
| 387 |
"""Test creating category with extra fields ignores them."""
|
| 388 |
+
response = await admin_client.post(
|
| 389 |
"/api/v1/categories",
|
| 390 |
json={
|
| 391 |
"name": "Extra Fields Test",
|
|
|
|
| 404 |
"""End-to-end workflow tests."""
|
| 405 |
|
| 406 |
@pytest.mark.asyncio
|
| 407 |
+
async def test_full_crud_workflow(self, admin_client: AsyncClient):
|
| 408 |
"""Test complete CRUD workflow: create → read → update → delete."""
|
| 409 |
# 1. Create
|
| 410 |
+
create_response = await admin_client.post(
|
| 411 |
"/api/v1/categories",
|
| 412 |
json={"name": "E2E Test Category", "description": "End to end test"}
|
| 413 |
)
|
|
|
|
| 416 |
category_id = created["id"]
|
| 417 |
|
| 418 |
# 2. Read (single)
|
| 419 |
+
get_response = await admin_client.get(f"/api/v1/categories/{category_id}")
|
| 420 |
assert get_response.status_code == 200
|
| 421 |
assert get_response.json()["name"] == "E2E Test Category"
|
| 422 |
|
| 423 |
# 3. Read (list)
|
| 424 |
+
list_response = await admin_client.get("/api/v1/categories")
|
| 425 |
assert list_response.status_code == 200
|
| 426 |
category_ids = [cat["id"] for cat in list_response.json()]
|
| 427 |
assert category_id in category_ids
|
| 428 |
|
| 429 |
# 4. Update
|
| 430 |
+
update_response = await admin_client.put(
|
| 431 |
f"/api/v1/categories/{category_id}",
|
| 432 |
json={"name": "E2E Updated", "description": "Updated description"}
|
| 433 |
)
|
|
|
|
| 435 |
assert update_response.json()["name"] == "E2E Updated"
|
| 436 |
|
| 437 |
# 5. Verify update
|
| 438 |
+
get_updated = await admin_client.get(f"/api/v1/categories/{category_id}")
|
| 439 |
assert get_updated.json()["name"] == "E2E Updated"
|
| 440 |
|
| 441 |
# 6. Delete
|
| 442 |
+
delete_response = await admin_client.delete(f"/api/v1/categories/{category_id}")
|
| 443 |
assert delete_response.status_code == 204
|
| 444 |
|
| 445 |
# 7. Verify deletion
|
| 446 |
+
get_deleted = await admin_client.get(f"/api/v1/categories/{category_id}")
|
| 447 |
assert get_deleted.status_code == 404
|
| 448 |
|
| 449 |
@pytest.mark.asyncio
|
|
|
|
| 464 |
assert single_stats.json()["category_id"] == first_cat["category_id"]
|
| 465 |
|
| 466 |
@pytest.mark.asyncio
|
| 467 |
+
async def test_mapping_workflow(self, admin_client: AsyncClient):
|
| 468 |
"""Test mapping utilities workflow."""
|
| 469 |
# 1. Create a new category
|
| 470 |
+
create_response = await admin_client.post(
|
| 471 |
"/api/v1/categories",
|
| 472 |
json={"name": "Mapping Test Category"}
|
| 473 |
)
|
| 474 |
created = create_response.json()
|
| 475 |
|
| 476 |
# 2. Get ID-to-name mapping
|
| 477 |
+
id_to_name = await admin_client.get("/api/v1/categories/mapping/id-to-name")
|
| 478 |
assert id_to_name.status_code == 200
|
| 479 |
assert "Mapping Test Category" in id_to_name.json().values()
|
| 480 |
|
| 481 |
# 3. Get name-to-ID mapping
|
| 482 |
+
name_to_id = await admin_client.get("/api/v1/categories/mapping/name-to-id")
|
| 483 |
assert name_to_id.status_code == 200
|
| 484 |
assert "Mapping Test Category" in name_to_id.json().keys()
|
| 485 |
assert name_to_id.json()["Mapping Test Category"] == created["id"]
|
| 486 |
|
| 487 |
# Cleanup
|
| 488 |
+
await admin_client.delete(f"/api/v1/categories/{created['id']}")
|
| 489 |
|
| 490 |
|
| 491 |
# Summary:
|
|
@@ -133,7 +133,7 @@ class TestCreateLaw:
|
|
| 133 |
"""Tests pour POST /api/v1/admin/laws."""
|
| 134 |
|
| 135 |
@pytest.mark.asyncio
|
| 136 |
-
async def test_create_law_success(self,
|
| 137 |
"""Test création loi."""
|
| 138 |
new_law = {
|
| 139 |
"reference": "LOI-2024-002",
|
|
@@ -144,7 +144,7 @@ class TestCreateLaw:
|
|
| 144 |
"status": "active"
|
| 145 |
}
|
| 146 |
|
| 147 |
-
response = await
|
| 148 |
|
| 149 |
assert response.status_code == 201
|
| 150 |
law = response.json()
|
|
@@ -153,7 +153,7 @@ class TestCreateLaw:
|
|
| 153 |
assert "id" in law
|
| 154 |
|
| 155 |
@pytest.mark.asyncio
|
| 156 |
-
async def test_create_law_duplicate_reference(self,
|
| 157 |
"""Test création avec référence existante."""
|
| 158 |
duplicate_law = {
|
| 159 |
"reference": sample_law.reference,
|
|
@@ -163,20 +163,20 @@ class TestCreateLaw:
|
|
| 163 |
"language": "fr"
|
| 164 |
}
|
| 165 |
|
| 166 |
-
response = await
|
| 167 |
|
| 168 |
assert response.status_code == 409
|
| 169 |
assert "already exists" in response.json()["detail"].lower()
|
| 170 |
|
| 171 |
@pytest.mark.asyncio
|
| 172 |
-
async def test_create_law_validation_error(self,
|
| 173 |
"""Test création avec données invalides."""
|
| 174 |
invalid_law = {
|
| 175 |
"reference": "", # Invalid: empty
|
| 176 |
"title": "Test"
|
| 177 |
}
|
| 178 |
|
| 179 |
-
response = await
|
| 180 |
|
| 181 |
assert response.status_code == 422
|
| 182 |
|
|
@@ -188,14 +188,14 @@ class TestUpdateLaw:
|
|
| 188 |
"""Tests pour PUT /api/v1/admin/laws/{id}."""
|
| 189 |
|
| 190 |
@pytest.mark.asyncio
|
| 191 |
-
async def test_update_law_success(self,
|
| 192 |
"""Test mise à jour loi."""
|
| 193 |
update_data = {
|
| 194 |
"title": "Titre mis à jour",
|
| 195 |
"status": "archived"
|
| 196 |
}
|
| 197 |
|
| 198 |
-
response = await
|
| 199 |
f"/api/v1/admin/laws/{sample_law.id}",
|
| 200 |
json=update_data
|
| 201 |
)
|
|
@@ -206,9 +206,9 @@ class TestUpdateLaw:
|
|
| 206 |
assert law["status"] == update_data["status"]
|
| 207 |
|
| 208 |
@pytest.mark.asyncio
|
| 209 |
-
async def test_update_law_not_found(self,
|
| 210 |
"""Test mise à jour loi non trouvée."""
|
| 211 |
-
response = await
|
| 212 |
"/api/v1/admin/laws/99999",
|
| 213 |
json={"title": "Test"}
|
| 214 |
)
|
|
@@ -223,20 +223,20 @@ class TestDeleteLaw:
|
|
| 223 |
"""Tests pour DELETE /api/v1/admin/laws/{id}."""
|
| 224 |
|
| 225 |
@pytest.mark.asyncio
|
| 226 |
-
async def test_delete_law_success(self,
|
| 227 |
"""Test suppression loi."""
|
| 228 |
-
response = await
|
| 229 |
|
| 230 |
assert response.status_code == 204
|
| 231 |
|
| 232 |
# Vérifier que la loi est supprimée
|
| 233 |
-
get_response = await
|
| 234 |
assert get_response.status_code == 404
|
| 235 |
|
| 236 |
@pytest.mark.asyncio
|
| 237 |
-
async def test_delete_law_not_found(self,
|
| 238 |
"""Test suppression loi non trouvée."""
|
| 239 |
-
response = await
|
| 240 |
|
| 241 |
assert response.status_code == 404
|
| 242 |
|
|
@@ -248,7 +248,7 @@ class TestLawsIntegration:
|
|
| 248 |
"""Tests d'intégration."""
|
| 249 |
|
| 250 |
@pytest.mark.asyncio
|
| 251 |
-
async def test_full_crud_workflow(self,
|
| 252 |
"""Test workflow CRUD complet."""
|
| 253 |
# 1. Create
|
| 254 |
new_law = {
|
|
@@ -259,17 +259,17 @@ class TestLawsIntegration:
|
|
| 259 |
"language": "fr",
|
| 260 |
"status": "active"
|
| 261 |
}
|
| 262 |
-
create_response = await
|
| 263 |
assert create_response.status_code == 201
|
| 264 |
law_id = create_response.json()["id"]
|
| 265 |
|
| 266 |
# 2. Read
|
| 267 |
-
read_response = await
|
| 268 |
assert read_response.status_code == 200
|
| 269 |
assert read_response.json()["title"] == new_law["title"]
|
| 270 |
|
| 271 |
# 3. Update
|
| 272 |
-
update_response = await
|
| 273 |
f"/api/v1/admin/laws/{law_id}",
|
| 274 |
json={"title": "Titre modifié"}
|
| 275 |
)
|
|
@@ -277,9 +277,9 @@ class TestLawsIntegration:
|
|
| 277 |
assert update_response.json()["title"] == "Titre modifié"
|
| 278 |
|
| 279 |
# 4. Delete
|
| 280 |
-
delete_response = await
|
| 281 |
assert delete_response.status_code == 204
|
| 282 |
|
| 283 |
# 5. Verify deletion
|
| 284 |
-
verify_response = await
|
| 285 |
assert verify_response.status_code == 404
|
|
|
|
| 133 |
"""Tests pour POST /api/v1/admin/laws."""
|
| 134 |
|
| 135 |
@pytest.mark.asyncio
|
| 136 |
+
async def test_create_law_success(self, admin_client: AsyncClient):
|
| 137 |
"""Test création loi."""
|
| 138 |
new_law = {
|
| 139 |
"reference": "LOI-2024-002",
|
|
|
|
| 144 |
"status": "active"
|
| 145 |
}
|
| 146 |
|
| 147 |
+
response = await admin_client.post("/api/v1/admin/laws", json=new_law)
|
| 148 |
|
| 149 |
assert response.status_code == 201
|
| 150 |
law = response.json()
|
|
|
|
| 153 |
assert "id" in law
|
| 154 |
|
| 155 |
@pytest.mark.asyncio
|
| 156 |
+
async def test_create_law_duplicate_reference(self, admin_client: AsyncClient, sample_law):
|
| 157 |
"""Test création avec référence existante."""
|
| 158 |
duplicate_law = {
|
| 159 |
"reference": sample_law.reference,
|
|
|
|
| 163 |
"language": "fr"
|
| 164 |
}
|
| 165 |
|
| 166 |
+
response = await admin_client.post("/api/v1/admin/laws", json=duplicate_law)
|
| 167 |
|
| 168 |
assert response.status_code == 409
|
| 169 |
assert "already exists" in response.json()["detail"].lower()
|
| 170 |
|
| 171 |
@pytest.mark.asyncio
|
| 172 |
+
async def test_create_law_validation_error(self, admin_client: AsyncClient):
|
| 173 |
"""Test création avec données invalides."""
|
| 174 |
invalid_law = {
|
| 175 |
"reference": "", # Invalid: empty
|
| 176 |
"title": "Test"
|
| 177 |
}
|
| 178 |
|
| 179 |
+
response = await admin_client.post("/api/v1/admin/laws", json=invalid_law)
|
| 180 |
|
| 181 |
assert response.status_code == 422
|
| 182 |
|
|
|
|
| 188 |
"""Tests pour PUT /api/v1/admin/laws/{id}."""
|
| 189 |
|
| 190 |
@pytest.mark.asyncio
|
| 191 |
+
async def test_update_law_success(self, admin_client: AsyncClient, sample_law):
|
| 192 |
"""Test mise à jour loi."""
|
| 193 |
update_data = {
|
| 194 |
"title": "Titre mis à jour",
|
| 195 |
"status": "archived"
|
| 196 |
}
|
| 197 |
|
| 198 |
+
response = await admin_client.put(
|
| 199 |
f"/api/v1/admin/laws/{sample_law.id}",
|
| 200 |
json=update_data
|
| 201 |
)
|
|
|
|
| 206 |
assert law["status"] == update_data["status"]
|
| 207 |
|
| 208 |
@pytest.mark.asyncio
|
| 209 |
+
async def test_update_law_not_found(self, admin_client: AsyncClient):
|
| 210 |
"""Test mise à jour loi non trouvée."""
|
| 211 |
+
response = await admin_client.put(
|
| 212 |
"/api/v1/admin/laws/99999",
|
| 213 |
json={"title": "Test"}
|
| 214 |
)
|
|
|
|
| 223 |
"""Tests pour DELETE /api/v1/admin/laws/{id}."""
|
| 224 |
|
| 225 |
@pytest.mark.asyncio
|
| 226 |
+
async def test_delete_law_success(self, admin_client: AsyncClient, sample_law):
|
| 227 |
"""Test suppression loi."""
|
| 228 |
+
response = await admin_client.delete(f"/api/v1/admin/laws/{sample_law.id}")
|
| 229 |
|
| 230 |
assert response.status_code == 204
|
| 231 |
|
| 232 |
# Vérifier que la loi est supprimée
|
| 233 |
+
get_response = await admin_client.get(f"/api/v1/laws/{sample_law.id}")
|
| 234 |
assert get_response.status_code == 404
|
| 235 |
|
| 236 |
@pytest.mark.asyncio
|
| 237 |
+
async def test_delete_law_not_found(self, admin_client: AsyncClient):
|
| 238 |
"""Test suppression loi non trouvée."""
|
| 239 |
+
response = await admin_client.delete("/api/v1/admin/laws/99999")
|
| 240 |
|
| 241 |
assert response.status_code == 404
|
| 242 |
|
|
|
|
| 248 |
"""Tests d'intégration."""
|
| 249 |
|
| 250 |
@pytest.mark.asyncio
|
| 251 |
+
async def test_full_crud_workflow(self, admin_client: AsyncClient):
|
| 252 |
"""Test workflow CRUD complet."""
|
| 253 |
# 1. Create
|
| 254 |
new_law = {
|
|
|
|
| 259 |
"language": "fr",
|
| 260 |
"status": "active"
|
| 261 |
}
|
| 262 |
+
create_response = await admin_client.post("/api/v1/admin/laws", json=new_law)
|
| 263 |
assert create_response.status_code == 201
|
| 264 |
law_id = create_response.json()["id"]
|
| 265 |
|
| 266 |
# 2. Read
|
| 267 |
+
read_response = await admin_client.get(f"/api/v1/laws/{law_id}")
|
| 268 |
assert read_response.status_code == 200
|
| 269 |
assert read_response.json()["title"] == new_law["title"]
|
| 270 |
|
| 271 |
# 3. Update
|
| 272 |
+
update_response = await admin_client.put(
|
| 273 |
f"/api/v1/admin/laws/{law_id}",
|
| 274 |
json={"title": "Titre modifié"}
|
| 275 |
)
|
|
|
|
| 277 |
assert update_response.json()["title"] == "Titre modifié"
|
| 278 |
|
| 279 |
# 4. Delete
|
| 280 |
+
delete_response = await admin_client.delete(f"/api/v1/admin/laws/{law_id}")
|
| 281 |
assert delete_response.status_code == 204
|
| 282 |
|
| 283 |
# 5. Verify deletion
|
| 284 |
+
verify_response = await admin_client.get(f"/api/v1/laws/{law_id}")
|
| 285 |
assert verify_response.status_code == 404
|