from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import jwt, JWTError from passlib.context import CryptContext from pydantic import BaseModel from sqlalchemy.orm import Session from config import settings from database import get_db from models.manager_account import ManagerAccount, manager_site_access from models.site import Site from auth_utils import get_current_admin from schemas.manager import ( ManagerRegisterRequest, ManagerLoginRequest, ManagerTokenOut, ManagerOut, ) router = APIRouter() _pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") _bearer = HTTPBearer() ALGORITHM = "HS256" # ── Shared dependency: decode manager JWT ───────────────────────────────────── def _get_current_manager(credentials: HTTPAuthorizationCredentials = Depends(_bearer), db: Session = Depends(get_db)) -> ManagerAccount: try: payload = jwt.decode(credentials.credentials, settings.MANAGER_JWT_SECRET, algorithms=[ALGORITHM]) manager_id = int(payload.get("sub", 0)) except (JWTError, ValueError): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") manager = db.query(ManagerAccount).filter(ManagerAccount.id == manager_id).first() if not manager or not manager.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Manager not found or inactive") return manager def _create_manager_token(manager: ManagerAccount) -> str: site_ids = [s.id for s in manager.sites] payload = { "sub": str(manager.id), "email": manager.email, "site_ids": site_ids, "exp": datetime.now(timezone.utc) + timedelta(hours=settings.MANAGER_JWT_EXPIRE_HOURS), } return jwt.encode(payload, settings.MANAGER_JWT_SECRET, algorithm=ALGORITHM) # ── Admin-only: register a manager ─────────────────────────────────────────── @router.post("/register", response_model=ManagerOut, status_code=status.HTTP_201_CREATED) def register_manager( body: ManagerRegisterRequest, db: Session = Depends(get_db), _admin=Depends(get_current_admin), ): existing = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first() sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all() if existing: # Email already exists — just add the new site access links, don't recreate the account for site in sites: if site not in existing.sites: existing.sites.append(site) db.commit() db.refresh(existing) return existing manager = ManagerAccount( email=body.email, password_hash=_pwd.hash(body.password), full_name=body.full_name, sites=sites, ) db.add(manager) db.commit() db.refresh(manager) return manager # ── Public: manager login ───────────────────────────────────────────────────── @router.post("/login", response_model=ManagerTokenOut) def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)): manager = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first() if not manager or not _pwd.verify(body.password, manager.password_hash): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") if not manager.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled") return ManagerTokenOut(access_token=_create_manager_token(manager)) # ── Public: refresh ─────────────────────────────────────────────────────────── @router.post("/refresh", response_model=ManagerTokenOut) def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)): return ManagerTokenOut(access_token=_create_manager_token(manager)) # ── Admin-only: list managers for a site ───────────────────────────────────── class ManagerBySiteOut(BaseModel): id: int email: str full_name: Optional[str] = None is_active: int model_config = {"from_attributes": True} @router.get("/by-site/{site_id}", response_model=list[ManagerBySiteOut]) def get_managers_by_site( site_id: int, db: Session = Depends(get_db), _admin=Depends(get_current_admin), ): site = db.query(Site).filter(Site.id == site_id).first() if not site: raise HTTPException(status_code=404, detail="Site not found") return site.manager_accounts # ── Admin-only: remove a manager's access to a site ────────────────────────── class SiteAccessRemoveRequest(BaseModel): manager_id: int site_id: int @router.delete("/site-access", status_code=status.HTTP_204_NO_CONTENT) def remove_manager_site_access( body: SiteAccessRemoveRequest, db: Session = Depends(get_db), _admin=Depends(get_current_admin), ): manager = db.query(ManagerAccount).filter(ManagerAccount.id == body.manager_id).first() if not manager: raise HTTPException(status_code=404, detail="Manager not found") site = db.query(Site).filter(Site.id == body.site_id).first() if not site: raise HTTPException(status_code=404, detail="Site not found") if site in manager.sites: manager.sites.remove(site) db.commit()