import hashlib import hmac import secrets from datetime import UTC, datetime from fastapi import HTTPException, status from sqlalchemy.orm import Session from starlette.requests import Request from app.core.config import settings from app.models.repair import Repair, RepairPublicAccessToken from app.models.user import User from app.repositories.repair_repository import RepairRepository from app.schemas.repair import ( RepairPublicLinkCreateResponse, RepairPublicLinkResponse, RepairPublicStatusHistoryItem, RepairPublicStatusResponse, ) from app.services.audit_service import write_audit_log from app.services.repair_status_labels import STATUS_LABELS def normalize_token(token: str) -> str: return token.strip() def repair_label(repair: Repair) -> str: return f"{repair.repair_number} ยท {repair.customer_name}" def public_status_path(token: str) -> str: normalized_token = normalize_token(token) path = f"/status/{normalized_token}" base_url = (settings.public_repair_status_base_url or "").strip().rstrip("/") if not base_url: return path return f"{base_url}/{normalized_token}" class RepairPublicLinkService: @staticmethod def hash_token(token: str) -> str: normalized_token = normalize_token(token) return hmac.new(settings.secret_key.encode("utf-8"), normalized_token.encode("utf-8"), hashlib.sha256).hexdigest() @staticmethod def create_token() -> str: return secrets.token_urlsafe(48) @staticmethod def response_from_link(public_link: RepairPublicAccessToken | None) -> RepairPublicLinkResponse: if public_link is None: return RepairPublicLinkResponse(is_active=False) return RepairPublicLinkResponse( is_active=public_link.is_active, token_hint=public_link.token_hint, expires_at=public_link.expires_at, last_used_at=public_link.last_used_at, created_at=public_link.created_at, revoked_at=public_link.revoked_at, ) @staticmethod def get(db: Session, repair: Repair) -> RepairPublicLinkResponse: return RepairPublicLinkService.response_from_link(RepairRepository.get_active_public_link(db, repair.id)) @staticmethod def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse: return RepairPublicLinkService.create_with_audit( db, repair, actor=actor, request=request, audit_action="repairs.public_link.create", ) @staticmethod def create_with_audit( db: Session, repair: Repair, *, actor: User | None, request: Request | None, audit_action: str, ) -> RepairPublicLinkCreateResponse: token = RepairPublicLinkService.create_token() public_link = RepairRepository.create_public_link( db, repair_id=repair.id, token_hash=RepairPublicLinkService.hash_token(token), token_hint=token[-6:], ) write_audit_log( db, action=audit_action, entity_type="repairs", entity_id=repair.id, entity_label=repair_label(repair), actor=actor, request=request, metadata={"token_hint": public_link.token_hint}, ) return RepairPublicLinkCreateResponse( is_active=True, token=token, token_hint=public_link.token_hint, expires_at=public_link.expires_at, last_used_at=public_link.last_used_at, created_at=public_link.created_at, revoked_at=public_link.revoked_at, public_status_path=public_status_path(token), ) @staticmethod def revoke(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkResponse: active_link = RepairRepository.get_active_public_link(db, repair.id) RepairRepository.revoke_public_links(db, repair_id=repair.id) write_audit_log( db, action="repairs.public_link.revoke", entity_type="repairs", entity_id=repair.id, entity_label=repair_label(repair), actor=actor, request=request, metadata={"token_hint": active_link.token_hint if active_link else None}, ) return RepairPublicLinkResponse(is_active=False, token_hint=active_link.token_hint if active_link else None, revoked_at=datetime.now(UTC)) @staticmethod def public_status(db: Session, token: str) -> RepairPublicStatusResponse: normalized_token = normalize_token(token) if not normalized_token: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token)) if public_link is None or public_link.repair is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") repair = public_link.repair history = RepairRepository.get_history_public(db, repair.id) response = RepairPublicStatusResponse( repair_number=repair.repair_number, public_status_label=STATUS_LABELS.get(repair.status, repair.status), device_manufacturer=repair.device_manufacturer, device_model=repair.device_model, status_history_public=[ RepairPublicStatusHistoryItem( status=item.new_status, status_label=STATUS_LABELS.get(item.new_status, item.new_status), created_at=item.created_at, ) for item in history ], updated_at=repair.updated_at, ) RepairRepository.mark_public_link_used(db, public_link) return response