GET /portal/summary liefert Resturlaub und Überstundenguthaben als Firmensummen, keine Einzelpersonen und keine Krankheitsdaten. Firmen-Token (SHA-256) wie beim Busylight, Verwaltung unter /companies/me/portal-token, Migration 0049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
"""Buchhaltungsportal: read-only Summen (Pull-Endpoint + Token-Verwaltung).
|
||
|
||
- Pull: GET /portal/summary – Auth via per-Firma Bearer-Token (SHA-256 in DB)
|
||
- Verwaltung: GET/POST/DELETE /companies/me/portal-token – COMPANY_ADMIN/SUPER_ADMIN
|
||
|
||
Liefert nur Firmensummen zu Resturlaub und Überstundenguthaben. Keine Einzelpersonen,
|
||
keine Krankheits- oder Abwesenheitsdaten.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import secrets
|
||
from datetime import date, datetime, timezone
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.database import get_db
|
||
from app.core.dependencies import get_client_ip, require_role
|
||
from app.core.limiter import limiter
|
||
from app.models.audit_log import AuditLog
|
||
from app.models.company import Company
|
||
from app.models.overtime_balance import OvertimeBalance
|
||
from app.models.user import User, UserRole
|
||
from app.models.vacation_balance import VacationBalance
|
||
from app.schemas.portal import (
|
||
PortalSummary,
|
||
PortalTokenRotated,
|
||
PortalTokenStatus,
|
||
PortalUeberstunden,
|
||
PortalUrlaub,
|
||
)
|
||
from app.services.absence_service import absence_service
|
||
|
||
router = APIRouter(tags=["Buchhaltungsportal"])
|
||
|
||
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||
_pull_bearer = HTTPBearer(auto_error=False)
|
||
KLEINE_GRUPPE = 3
|
||
|
||
|
||
def _hash_token(token: str) -> str:
|
||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||
|
||
|
||
@router.get("/companies/me/portal-token", response_model=PortalTokenStatus)
|
||
async def get_portal_token_status(
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
company = await db.get(Company, current_user.company_id)
|
||
return PortalTokenStatus(configured=company.portal_token_hash is not None, created_at=company.portal_token_created_at)
|
||
|
||
|
||
@router.post("/companies/me/portal-token/rotate", response_model=PortalTokenRotated)
|
||
async def rotate_portal_token(
|
||
request: Request,
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
company = await db.get(Company, current_user.company_id)
|
||
token = secrets.token_urlsafe(32)
|
||
company.portal_token_hash = _hash_token(token)
|
||
company.portal_token_created_at = datetime.now(timezone.utc)
|
||
db.add(AuditLog(
|
||
company_id=company.id,
|
||
user_id=current_user.id,
|
||
action="portal_token_rotated",
|
||
entity_type="company",
|
||
entity_id=company.id,
|
||
ip=get_client_ip(request),
|
||
))
|
||
await db.commit()
|
||
return PortalTokenRotated(token=token, created_at=company.portal_token_created_at)
|
||
|
||
|
||
@router.delete("/companies/me/portal-token", status_code=204)
|
||
async def delete_portal_token(
|
||
request: Request,
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
company = await db.get(Company, current_user.company_id)
|
||
if company.portal_token_hash is None:
|
||
return
|
||
company.portal_token_hash = None
|
||
company.portal_token_created_at = None
|
||
db.add(AuditLog(
|
||
company_id=company.id,
|
||
user_id=current_user.id,
|
||
action="portal_token_revoked",
|
||
entity_type="company",
|
||
entity_id=company.id,
|
||
ip=get_client_ip(request),
|
||
))
|
||
await db.commit()
|
||
|
||
|
||
async def _company_from_token(credentials: HTTPAuthorizationCredentials | None, db: AsyncSession) -> Company:
|
||
if credentials is None or not credentials.credentials:
|
||
raise HTTPException(status_code=401, detail="Missing token")
|
||
company = await db.scalar(
|
||
select(Company).where(Company.portal_token_hash == _hash_token(credentials.credentials), Company.is_active.is_(True))
|
||
)
|
||
if company is None:
|
||
raise HTTPException(status_code=401, detail="Invalid token")
|
||
return company
|
||
|
||
|
||
@router.get("/portal/summary", response_model=PortalSummary)
|
||
@limiter.limit("30/minute")
|
||
async def portal_summary(
|
||
request: Request,
|
||
year: int | None = None,
|
||
credentials: HTTPAuthorizationCredentials | None = Depends(_pull_bearer),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
company = await _company_from_token(credentials, db)
|
||
today = date.today()
|
||
jahr = year or today.year
|
||
if not 2000 <= jahr <= 2200:
|
||
raise HTTPException(status_code=422, detail="Ungültiges Jahr.")
|
||
|
||
user_ids = list(await db.scalars(select(User.id).where(User.company_id == company.id, User.is_active.is_(True))))
|
||
|
||
balances = list(await db.scalars(
|
||
select(VacationBalance).where(VacationBalance.user_id.in_(user_ids), VacationBalance.year == jahr)
|
||
)) if user_ids else []
|
||
_, verfallen = absence_service._carryover_expired(company, jahr)
|
||
rest = sum(absence_service.effective_available(b, verfallen) for b in balances)
|
||
|
||
overtime = list(await db.scalars(
|
||
select(OvertimeBalance).where(OvertimeBalance.company_id == company.id, OvertimeBalance.user_id.in_(user_ids))
|
||
)) if user_ids else []
|
||
verfuegbar = [float(o.available_hours) for o in overtime]
|
||
|
||
return PortalSummary(
|
||
stichtag=today,
|
||
firma=company.name,
|
||
mitarbeiter_aktiv=len(user_ids),
|
||
kleine_gruppe=len(user_ids) < KLEINE_GRUPPE,
|
||
urlaub=PortalUrlaub(
|
||
jahr=jahr,
|
||
konten=len(balances),
|
||
ohne_konto=len(user_ids) - len(balances),
|
||
anspruch_tage=float(sum(b.entitled_days + b.special_days for b in balances)),
|
||
uebertrag_tage=float(sum(b.carried_over for b in balances)),
|
||
genommen_tage=float(sum(b.used_days for b in balances)),
|
||
resttage=float(rest),
|
||
),
|
||
ueberstunden=PortalUeberstunden(
|
||
konten=len(overtime),
|
||
guthaben_gesamt_h=round(sum(verfuegbar), 2),
|
||
guthaben_positiv_h=round(sum(v for v in verfuegbar if v > 0), 2),
|
||
mitarbeiter_mit_guthaben=sum(1 for v in verfuegbar if v > 0),
|
||
),
|
||
)
|