Files
timemaster/backend/app/routers/tenants.py
T
patrickandClaude Opus 4.8 fcda813ba6 feat: Reseller-Rolle + SUPER_ADMIN-Mandantenübersicht
Mandantenfähigkeit ausgebaut:
- Neue Rolle RESELLER (company_id NULL); companies.reseller_id + is_active
- RLS-Erweiterung (Migration 0034): companies/users zusätzlich auf app.reseller_id
  gefenced → Reseller sieht/verwaltet DB-seitig nur eigene Firmen, keine
  personenbezogenen Zeit-/Abwesenheitsdaten (DSGVO: nur Verwaltung)
- get_current_user setzt app.reseller_id + Bypass aus für RESELLER
- tenant_service: Firma + Erst-Admin (Einladung), Übersicht mit Kennzahlen
- Router /reseller/* (Self-Service) und /admin/* (SUPER_ADMIN: Mandanten + Reseller)
- Login-Sperre bei deaktiviertem Mandanten
- Frontend: TenantsPage (/admin/tenants), eigene ResellerCompaniesPage (/reseller),
  rollenbasierte Login-Weiterleitung, Nav "Mandanten" für SUPER_ADMIN
- 4 neue Tests inkl. Cross-Reseller-RLS-Isolation; 172/172 grün

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:15:51 +02:00

202 lines
7.5 KiB
Python

"""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.
"""
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 = 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)}
return tenants[company.id]
@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 await db.scalar(select(User).where(User.email == data.email)):
raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.")
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,
)
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},
))
await db.flush()
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,
)
@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),
)