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>
202 lines
7.5 KiB
Python
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),
|
|
)
|