Push V1 app
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Cellar API - backend router
|
||||
@@ -0,0 +1,116 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import require_admin
|
||||
from database import get_db
|
||||
from models import Drink, Invitation, User
|
||||
from auth import hash_password
|
||||
from schemas import AdminResetPassword, AdminToggleAdmin, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
def list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
users = db.query(User).order_by(User.created_at.desc()).all()
|
||||
return [UserResponse.model_validate(u) for u in users]
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Vous ne pouvez pas vous supprimer vous-même",
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
uploads_dir = os.path.join(os.path.dirname(__file__), "..", "uploads")
|
||||
|
||||
drinks = db.query(Drink).filter(Drink.owner_id == user_id).all()
|
||||
for drink in drinks:
|
||||
if drink.image_path:
|
||||
filepath = os.path.join(os.path.dirname(__file__), "..", drink.image_path)
|
||||
resolved = os.path.realpath(filepath)
|
||||
if resolved.startswith(os.path.realpath(uploads_dir)) and os.path.exists(resolved):
|
||||
os.remove(resolved)
|
||||
|
||||
db.query(Drink).filter(Drink.owner_id == user_id).delete()
|
||||
db.query(Invitation).filter(
|
||||
(Invitation.created_by == user_id) | (Invitation.used_by == user_id)
|
||||
).delete()
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"detail": "User deleted"}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
def reset_user_password(
|
||||
user_id: int,
|
||||
body: AdminResetPassword,
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Utilisez /api/auth/change-password pour changer votre propre mot de passe",
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.hashed_password = hash_password(body.new_password)
|
||||
user.token_version += 1
|
||||
db.commit()
|
||||
return {"detail": "Password reset"}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/toggle-admin")
|
||||
def toggle_admin(
|
||||
user_id: int,
|
||||
body: AdminToggleAdmin,
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Vous ne pouvez pas modifier votre propre statut admin",
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.is_admin = body.is_admin
|
||||
user.token_version += 1
|
||||
db.commit()
|
||||
return {"detail": f"Admin status set to {body.is_admin}"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def stats(
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return {
|
||||
"users": db.query(User).count(),
|
||||
"drinks": db.query(Drink).count(),
|
||||
"invitations": db.query(Invitation).count(),
|
||||
"invitations_used": db.query(Invitation).filter(Invitation.used_by.isnot(None)).count(),
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import secrets as secrets_mod
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import (
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
hash_password,
|
||||
require_admin,
|
||||
verify_password,
|
||||
)
|
||||
from database import get_db
|
||||
from models import Invitation, User
|
||||
from ratelimit import change_password_limiter, login_limiter, rate_limit, register_limiter
|
||||
from schemas import (
|
||||
InvitationCreate,
|
||||
InvitationResponse,
|
||||
PasswordChange,
|
||||
TokenResponse,
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
INVITATION_EXPIRY_DAYS = 7
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
@rate_limit(register_limiter)
|
||||
def register(request: Request, data: UserCreate, db: Session = Depends(get_db)):
|
||||
invite = (
|
||||
db.query(Invitation)
|
||||
.filter(
|
||||
Invitation.token == data.invite_token,
|
||||
Invitation.used_by.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not invite:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation invalide ou déjà utilisée",
|
||||
)
|
||||
|
||||
if invite.expires_at and invite.expires_at < datetime.utcnow():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation expirée",
|
||||
)
|
||||
|
||||
if db.query(User).filter(
|
||||
(User.username == data.username) | (User.email == data.email)
|
||||
).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Un compte avec ces informations existe déjà",
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
hashed_password=hash_password(data.password),
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
invite.used_by = user.id
|
||||
invite.used_at = datetime.utcnow()
|
||||
db.flush()
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token = create_access_token({"sub": str(user.id), "token_version": user.token_version})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user=UserResponse.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@rate_limit(login_limiter)
|
||||
def login(request: Request, data: UserLogin, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == data.username).first()
|
||||
if not user or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Identifiants incorrects",
|
||||
)
|
||||
|
||||
token = create_access_token({"sub": str(user.id), "token_version": user.token_version})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user=UserResponse.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
@rate_limit(change_password_limiter)
|
||||
def change_password(
|
||||
request: Request,
|
||||
data: PasswordChange,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not verify_password(data.current_password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Mot de passe actuel incorrect",
|
||||
)
|
||||
user.hashed_password = hash_password(data.new_password)
|
||||
user.token_version += 1
|
||||
db.commit()
|
||||
return {"detail": "Mot de passe modifié"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
user.token_version += 1
|
||||
db.commit()
|
||||
return {"detail": "Déconnexion réussie"}
|
||||
|
||||
|
||||
@router.post("/invitations", response_model=InvitationResponse)
|
||||
def create_invitation(
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
invitation = Invitation(
|
||||
token=secrets_mod.token_urlsafe(32),
|
||||
created_by=admin.id,
|
||||
expires_at=datetime.utcnow() + timedelta(days=INVITATION_EXPIRY_DAYS),
|
||||
)
|
||||
db.add(invitation)
|
||||
db.commit()
|
||||
db.refresh(invitation)
|
||||
return InvitationResponse.model_validate(invitation)
|
||||
|
||||
|
||||
@router.get("/invitations", response_model=list[InvitationResponse])
|
||||
def list_invitations(
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
invitations = (
|
||||
db.query(Invitation)
|
||||
.filter(Invitation.created_by == admin.id)
|
||||
.order_by(Invitation.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [InvitationResponse.model_validate(i) for i in invitations]
|
||||
@@ -0,0 +1,221 @@
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import get_current_user, get_user_from_token
|
||||
from database import get_db
|
||||
from models import Drink, DrinkCategory, User
|
||||
from schemas import DrinkCreate, DrinkResponse, DrinkUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/drinks", tags=["drinks"])
|
||||
|
||||
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "..", "uploads")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ALLOWED_MIMES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def get_user_drink(drink_id: int, user: User, db: Session) -> Drink:
|
||||
drink = db.query(Drink).filter(Drink.id == drink_id, Drink.owner_id == user.id).first()
|
||||
if not drink:
|
||||
raise HTTPException(status_code=404, detail="Drink not found")
|
||||
return drink
|
||||
|
||||
|
||||
@router.post("", response_model=DrinkResponse)
|
||||
async def create_drink(
|
||||
drink: DrinkCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
db_drink = Drink(**drink.model_dump(), owner_id=user.id)
|
||||
db.add(db_drink)
|
||||
db.commit()
|
||||
db.refresh(db_drink)
|
||||
return db_drink
|
||||
|
||||
|
||||
@router.get("", response_model=list[DrinkResponse])
|
||||
def list_drinks(
|
||||
category: DrinkCategory | None = None,
|
||||
search: str | None = None,
|
||||
min_rating: float | None = Query(None, ge=0, le=5),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Drink).filter(Drink.owner_id == user.id)
|
||||
|
||||
if category:
|
||||
query = query.filter(Drink.category == category)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Drink.name.ilike(search_term),
|
||||
Drink.region.ilike(search_term),
|
||||
Drink.producer.ilike(search_term),
|
||||
Drink.brewery.ilike(search_term),
|
||||
Drink.distillery.ilike(search_term),
|
||||
Drink.grape_variety.ilike(search_term),
|
||||
Drink.country.ilike(search_term),
|
||||
Drink.notes.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
if min_rating is not None:
|
||||
query = query.filter(Drink.rating >= min_rating)
|
||||
|
||||
return query.order_by(Drink.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{drink_id}", response_model=DrinkResponse)
|
||||
def get_drink(
|
||||
drink_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return get_user_drink(drink_id, user, db)
|
||||
|
||||
|
||||
@router.put("/{drink_id}", response_model=DrinkResponse)
|
||||
def update_drink(
|
||||
drink_id: int,
|
||||
updates: DrinkUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drink = get_user_drink(drink_id, user, db)
|
||||
|
||||
update_data = updates.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(drink, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(drink)
|
||||
return drink
|
||||
|
||||
|
||||
@router.delete("/{drink_id}")
|
||||
def delete_drink(
|
||||
drink_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drink = get_user_drink(drink_id, user, db)
|
||||
|
||||
if drink.image_path:
|
||||
filepath = os.path.join(os.path.dirname(__file__), "..", drink.image_path)
|
||||
resolved = os.path.realpath(filepath)
|
||||
if resolved.startswith(os.path.realpath(UPLOAD_DIR)) and os.path.exists(resolved):
|
||||
os.remove(resolved)
|
||||
|
||||
db.delete(drink)
|
||||
db.commit()
|
||||
return {"detail": "Drink deleted"}
|
||||
|
||||
|
||||
@router.post("/{drink_id}/upload-image", response_model=DrinkResponse)
|
||||
async def upload_image(
|
||||
drink_id: int,
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
drink = get_user_drink(drink_id, user, db)
|
||||
|
||||
if file.content_type not in ALLOWED_MIMES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Type de fichier non autorisé. Utilisez: {', '.join(ALLOWED_MIMES)}",
|
||||
)
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Extension non autorisée. Utilisez: {', '.join(ALLOWED_EXTENSIONS)}",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Fichier trop volumineux. Taille maximale: {MAX_UPLOAD_SIZE // (1024 * 1024)} MB",
|
||||
)
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(content))
|
||||
img.verify()
|
||||
real_format = img.format.lower() if img.format else ""
|
||||
if real_format not in {"jpeg", "png", "webp", "gif"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Format d'image non autorisé: {real_format}. Utilisez: JPEG, PNG, WebP ou GIF",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Le fichier n'est pas une image valide",
|
||||
)
|
||||
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
if drink.image_path:
|
||||
old_path = os.path.join(os.path.dirname(__file__), "..", drink.image_path)
|
||||
resolved = os.path.realpath(old_path)
|
||||
if resolved.startswith(os.path.realpath(UPLOAD_DIR)) and os.path.exists(resolved):
|
||||
os.remove(resolved)
|
||||
|
||||
drink.image_path = f"uploads/{filename}"
|
||||
db.commit()
|
||||
db.refresh(drink)
|
||||
return drink
|
||||
|
||||
|
||||
MIME_MAP = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{drink_id}/image")
|
||||
def get_drink_image(
|
||||
drink_id: int,
|
||||
token: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = get_user_from_token(token, db)
|
||||
drink = db.query(Drink).filter(Drink.id == drink_id, Drink.owner_id == user.id).first()
|
||||
if not drink or not drink.image_path:
|
||||
raise HTTPException(status_code=404, detail="Image non trouvée")
|
||||
|
||||
filename = os.path.basename(drink.image_path)
|
||||
filepath = os.path.realpath(os.path.join(UPLOAD_DIR, filename))
|
||||
|
||||
if not filepath.startswith(os.path.realpath(UPLOAD_DIR)) or not os.path.isfile(filepath):
|
||||
raise HTTPException(status_code=404, detail="Image non trouvée")
|
||||
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
media_type = MIME_MAP.get(ext, "application/octet-stream")
|
||||
|
||||
return FileResponse(filepath, media_type=media_type)
|
||||
Reference in New Issue
Block a user