Files
timemaster/backend/app/routers/tenants.py
T
patrickandClaude Opus 4.8 98cb2f867a
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
feat: Mandant/Reseller ohne E-Mail anlegbar (Temp-Passwort statt Einladung)
- admin_email bzw. reseller email optional; ohne E-Mail wird interne Login-
  Kennung (<name>@<slug>.local) + einmaliges Temp-Passwort erzeugt, Account
  sofort aktiv, kein Mailversand
- TenantOut/ResellerOut: initial_password (einmalig) ergänzt
- LoginRequest.email: str statt EmailStr (Kennung muss kein zustellbares
  Postfach sein; .local-Domains sind sonst nicht einloggbar)
- Frontend: E-Mail-Felder optional, CredDialog zeigt Login + Temp-Passwort einmalig
- Test: Anlage ohne E-Mail + Login mit generierten Zugangsdaten; 173/173 grün

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

220 lines
8.3 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.
"""
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),
)