Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
3.5 KiB
Python
110 lines
3.5 KiB
Python
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.errors import (
|
|
ConflictError,
|
|
DomainValidationError,
|
|
ForbiddenError,
|
|
NotFoundError,
|
|
UnauthorizedError,
|
|
)
|
|
from app.core.security import (
|
|
generate_device_key,
|
|
hash_password,
|
|
verify_password,
|
|
)
|
|
from app.core.timeutils import utcnow
|
|
from app.modules.auth.models import DeviceApiKey, User
|
|
from app.modules.auth.schemas import DeviceKeyCreate, SetupRequest, UserUpdate
|
|
|
|
|
|
def setup_required(db: Session) -> bool:
|
|
return (db.scalar(select(func.count()).select_from(User)) or 0) == 0
|
|
|
|
|
|
def create_first_user(db: Session, data: SetupRequest) -> User:
|
|
if not setup_required(db):
|
|
raise ForbiddenError(
|
|
"Un utilisateur existe déjà : l'assistant de configuration est désactivé."
|
|
)
|
|
user = User(
|
|
email=data.email.strip().lower(),
|
|
password_hash=hash_password(data.password),
|
|
display_name=data.display_name.strip(),
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
return user
|
|
|
|
|
|
def authenticate(db: Session, email: str, password: str) -> User:
|
|
user = db.scalar(select(User).where(User.email == email.strip().lower()))
|
|
if (
|
|
user is None
|
|
or not user.is_active
|
|
or not verify_password(password, user.password_hash)
|
|
):
|
|
raise UnauthorizedError("Adresse e-mail ou mot de passe incorrect.")
|
|
return user
|
|
|
|
|
|
def update_me(db: Session, user: User, data: UserUpdate) -> User:
|
|
if data.password is not None or (
|
|
data.email is not None and data.email.strip().lower() != user.email
|
|
):
|
|
if not data.current_password:
|
|
raise DomainValidationError(
|
|
"Le mot de passe actuel est requis pour cette modification."
|
|
)
|
|
if not verify_password(data.current_password, user.password_hash):
|
|
raise UnauthorizedError("Mot de passe actuel incorrect.")
|
|
if data.email is not None:
|
|
email = data.email.strip().lower()
|
|
if email != user.email:
|
|
existing = db.scalar(select(User).where(User.email == email))
|
|
if existing is not None:
|
|
raise ConflictError("Cette adresse e-mail est déjà utilisée.")
|
|
user.email = email
|
|
if data.display_name is not None:
|
|
user.display_name = data.display_name.strip()
|
|
if data.password is not None:
|
|
user.password_hash = hash_password(data.password)
|
|
db.commit()
|
|
return user
|
|
|
|
|
|
def list_device_keys(db: Session, user_id: int) -> list[DeviceApiKey]:
|
|
stmt = (
|
|
select(DeviceApiKey)
|
|
.where(DeviceApiKey.user_id == user_id)
|
|
.order_by(DeviceApiKey.created_at.desc(), DeviceApiKey.id.desc())
|
|
)
|
|
return list(db.scalars(stmt).all())
|
|
|
|
|
|
def create_device_key(
|
|
db: Session, user_id: int, data: DeviceKeyCreate
|
|
) -> tuple[DeviceApiKey, str]:
|
|
"""Create a device key; returns (row, plaintext_key). The plaintext key is
|
|
returned exactly once and never stored."""
|
|
full_key, prefix, key_hash = generate_device_key()
|
|
key = DeviceApiKey(
|
|
user_id=user_id,
|
|
name=data.name.strip(),
|
|
key_prefix=prefix,
|
|
key_hash=key_hash,
|
|
scopes=list(data.scopes),
|
|
)
|
|
db.add(key)
|
|
db.commit()
|
|
return key, full_key
|
|
|
|
|
|
def revoke_device_key(db: Session, user_id: int, key_id: int) -> None:
|
|
key = db.get(DeviceApiKey, key_id)
|
|
if key is None or key.user_id != user_id:
|
|
raise NotFoundError("Clé d'appareil introuvable.")
|
|
if key.revoked_at is None:
|
|
key.revoked_at = utcnow()
|
|
db.commit()
|