"""SUPER_ADMIN: Mandantenübersicht (alle Firmen) + Reseller-Verwaltung. SUPER_ADMIN läuft mit aktivem RLS-Bypass und sieht daher alle Firmen. Nur SUPER_ADMIN darf Reseller anlegen und Firmen einem Reseller zuordnen. """ import secrets from datetime import datetime, timedelta, timezone from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import require_role from app.core.security import generate_invite_token, hash_password from app.models.audit_log import AuditLog from app.models.company import Company from app.models.user import User, UserRole from app.schemas.reseller import ( ResellerCreate, ResellerOut, TenantAssignReseller, TenantCreate, TenantOut, TenantUpdate, ) from app.services.tenant_service import tenant_service router = APIRouter(prefix="/admin", tags=["Admin · Tenants"]) _sa = (UserRole.SUPER_ADMIN,) # ── Mandanten ──────────────────────────────────────────────────────────────── @router.get("/tenants", response_model=list[TenantOut]) async def list_tenants( current_user: User = require_role(*_sa), db: AsyncSession = Depends(get_db), ): return await tenant_service.list_tenants(db) @router.post("/tenants", response_model=TenantOut, status_code=201) async def create_tenant( data: TenantCreate, reseller_id: UUID | None = None, current_user: User = require_role(*_sa), db: AsyncSession = Depends(get_db), ): if reseller_id is not None: reseller = await db.get(User, reseller_id) if reseller is None or reseller.role != UserRole.RESELLER: raise HTTPException(status_code=400, detail="Ungültiger Reseller.") company, admin, initial_password = await tenant_service.create_tenant( data, actor=current_user, db=db, reseller_id=reseller_id, ) tenants = {t.id: t for t in await tenant_service.list_tenants(db)} result = tenants[company.id] result.initial_password = initial_password return result @router.patch("/tenants/{company_id}", response_model=TenantOut) async def update_tenant( company_id: UUID, data: TenantUpdate, current_user: User = require_role(*_sa), db: AsyncSession = Depends(get_db), ): company = await db.get(Company, company_id) if company is None: raise HTTPException(status_code=404, detail="Firma nicht gefunden.") changes = data.model_dump(exclude_none=True) for field, value in changes.items(): setattr(company, field, value) if changes: db.add(AuditLog( company_id=company.id, user_id=current_user.id, action="tenant_updated", entity_type="company", entity_id=company.id, new_value=changes, )) await db.flush() tenants = {t.id: t for t in await tenant_service.list_tenants(db)} return tenants[company.id] @router.patch("/tenants/{company_id}/reseller", response_model=TenantOut) async def assign_reseller( company_id: UUID, data: TenantAssignReseller, current_user: User = require_role(*_sa), db: AsyncSession = Depends(get_db), ): company = await db.get(Company, company_id) if company is None: raise HTTPException(status_code=404, detail="Firma nicht gefunden.") if data.reseller_id is not None: reseller = await db.get(User, data.reseller_id) if reseller is None or reseller.role != UserRole.RESELLER: raise HTTPException(status_code=400, detail="Ungültiger Reseller.") company.reseller_id = data.reseller_id db.add(AuditLog( company_id=company.id, user_id=current_user.id, action="tenant_reseller_assigned", entity_type="company", entity_id=company.id, new_value={"reseller_id": str(data.reseller_id) if data.reseller_id else None}, )) await db.flush() tenants = {t.id: t for t in await tenant_service.list_tenants(db)} return tenants[company.id] # ── Reseller ───────────────────────────────────────────────────────────────── async def _reseller_company_counts(db: AsyncSession) -> dict[UUID, int]: rows = await db.execute( select(Company.reseller_id, func.count(Company.id)) .where(Company.reseller_id.isnot(None)) .group_by(Company.reseller_id) ) return {rid: cnt for rid, cnt in rows} @router.get("/resellers", response_model=list[ResellerOut]) async def list_resellers( current_user: User = require_role(*_sa), db: AsyncSession = Depends(get_db), ): resellers = (await db.scalars( select(User).where(User.role == UserRole.RESELLER).order_by(User.last_name, User.first_name) )).all() counts = await _reseller_company_counts(db) return [ ResellerOut( id=r.id, email=r.email, first_name=r.first_name, last_name=r.last_name, is_active=r.is_active, created_at=r.created_at, company_count=counts.get(r.id, 0), ) for r in resellers ] @router.post("/resellers", response_model=ResellerOut, status_code=201) async def create_reseller( data: ResellerCreate, current_user: User = require_role(*_sa), db: AsyncSession = Depends(get_db), ): if data.email and await db.scalar(select(User).where(User.email == data.email)): raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.") initial_password: str | None = None if data.email: raw_token, token_hash = generate_invite_token() reseller = User( company_id=None, email=data.email, first_name=data.first_name, last_name=data.last_name, role=UserRole.RESELLER, password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben invite_token_hash=token_hash, invite_expires=datetime.now(timezone.utc) + timedelta(days=7), is_active=False, ) else: # Kein Mailversand: interne Kennung + sofort nutzbares Temp-Passwort login = await tenant_service._unique_email( f"{data.first_name}.{data.last_name}", "reseller", db ) initial_password = secrets.token_urlsafe(9) reseller = User( company_id=None, email=login, first_name=data.first_name, last_name=data.last_name, role=UserRole.RESELLER, password_hash=hash_password(initial_password), is_active=True, ) db.add(reseller) await db.flush() db.add(AuditLog( company_id=None, user_id=current_user.id, action="reseller_created", entity_type="user", entity_id=reseller.id, new_value={"email": reseller.email, "invited": bool(data.email)}, )) await db.flush() if data.email: from app.services.email_service import email_service await email_service.send_invite(reseller, current_user, raw_token, db) return ResellerOut( id=reseller.id, email=reseller.email, first_name=reseller.first_name, last_name=reseller.last_name, is_active=reseller.is_active, created_at=reseller.created_at, company_count=0, initial_password=initial_password, ) @router.patch("/resellers/{reseller_id}", response_model=ResellerOut) async def update_reseller( reseller_id: UUID, is_active: bool, current_user: User = require_role(*_sa), db: AsyncSession = Depends(get_db), ): reseller = await db.get(User, reseller_id) if reseller is None or reseller.role != UserRole.RESELLER: raise HTTPException(status_code=404, detail="Reseller nicht gefunden.") reseller.is_active = is_active db.add(AuditLog( company_id=None, user_id=current_user.id, action="reseller_updated", entity_type="user", entity_id=reseller.id, new_value={"is_active": is_active}, )) await db.flush() counts = await _reseller_company_counts(db) return ResellerOut( id=reseller.id, email=reseller.email, first_name=reseller.first_name, last_name=reseller.last_name, is_active=reseller.is_active, created_at=reseller.created_at, company_count=counts.get(reseller.id, 0), ) @router.post("/run-retention-purge") async def run_global_retention_purge( current_user: User = require_role(*_sa), db: AsyncSession = Depends(get_db), ): """Globaler DSGVO-Purge über alle Firmen: Zeiterfassung/Auszahlungen je Firma (Frist konfigurierbar) plus firmenübergreifende technische Tabellen (audit_logs > 3 Jahre, abgelaufene sessions/password_resets). Gleiche Logik wie der tägliche Scheduler-Job.""" from app.services.retention_service import run_retention_purge result = await run_retention_purge(db, company_id=None) await db.commit() return {"deleted": result}