feat(repairs): add status timeline and public link preparation
This commit is contained in:
parent
e9ec207617
commit
09cce1f2b6
22 changed files with 1027 additions and 28 deletions
|
|
@ -163,6 +163,8 @@ def action_title(action: str) -> str:
|
|||
"repairs.cancel": "Reparatur storniert",
|
||||
"repairs.intake_processed": "Reparatur-Intake verarbeitet",
|
||||
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
|
||||
"repairs.public_link.create": "Reparatur-Statuslink erstellt",
|
||||
"repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert",
|
||||
}
|
||||
return labels.get(action, action)
|
||||
|
||||
|
|
|
|||
126
backend/hermes/app/services/repair_notification_service.py
Normal file
126
backend/hermes/app/services/repair_notification_service.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.repair import Repair
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus
|
||||
|
||||
|
||||
STATUS_LABELS: dict[str, str] = {
|
||||
"new": "Neu",
|
||||
"accepted": "Angenommen",
|
||||
"diagnosis": "Diagnose",
|
||||
"estimate": "Kostenvoranschlag",
|
||||
"waiting_for_customer": "Wartet auf Kunde",
|
||||
"approved": "Freigegeben",
|
||||
"repair": "Reparatur",
|
||||
"final_test": "Endprüfung",
|
||||
"ready_for_pickup": "Abholbereit",
|
||||
"shipped": "Versand",
|
||||
"completed": "Abgeschlossen",
|
||||
"cancelled": "Storniert",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepairNotificationTemplate:
|
||||
event_type: str
|
||||
status: RepairStatus | None
|
||||
subject: str
|
||||
text: str
|
||||
html: str | None = None
|
||||
|
||||
|
||||
TEMPLATES: tuple[RepairNotificationTemplate, ...] = (
|
||||
RepairNotificationTemplate(
|
||||
event_type="repair_created",
|
||||
status="new",
|
||||
subject="Reparatur {repair_number} wurde erfasst",
|
||||
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} für {device} wurde erfasst. Den aktuellen Status finden Sie später unter {public_status_url}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="device_accepted",
|
||||
status="accepted",
|
||||
subject="Gerät zu Reparatur {repair_number} angenommen",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde angenommen. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="diagnosis_started",
|
||||
status="diagnosis",
|
||||
subject="Diagnose für Reparatur {repair_number} läuft",
|
||||
text="Guten Tag {customer_name},\n\nwir prüfen Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="estimate_created",
|
||||
status="estimate",
|
||||
subject="Kostenvoranschlag zu Reparatur {repair_number}",
|
||||
text="Guten Tag {customer_name},\n\nfür Ihr Gerät {device} wurde ein Kostenvoranschlag vorbereitet. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="waiting_for_customer",
|
||||
status="waiting_for_customer",
|
||||
subject="Freigabe für Reparatur {repair_number} erforderlich",
|
||||
text="Guten Tag {customer_name},\n\nfür Ihre Reparatur {repair_number} warten wir auf Ihre Rückmeldung. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="repair_started",
|
||||
status="repair",
|
||||
subject="Reparatur {repair_number} läuft",
|
||||
text="Guten Tag {customer_name},\n\nwir bearbeiten Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="final_test",
|
||||
status="final_test",
|
||||
subject="Endprüfung für Reparatur {repair_number}",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} befindet sich in der Endprüfung. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="ready_for_pickup",
|
||||
status="ready_for_pickup",
|
||||
subject="Reparatur {repair_number} ist abholbereit",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} ist abholbereit. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="shipped",
|
||||
status="shipped",
|
||||
subject="Reparatur {repair_number} wurde versendet",
|
||||
text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde versendet. Aktueller Status: {status_label}.\n\n{company_name}",
|
||||
),
|
||||
RepairNotificationTemplate(
|
||||
event_type="completed",
|
||||
status="completed",
|
||||
subject="Reparatur {repair_number} abgeschlossen",
|
||||
text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} wurde abgeschlossen. Vielen Dank.\n\n{company_name}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RepairNotificationService:
|
||||
@staticmethod
|
||||
def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]:
|
||||
placeholders = {
|
||||
"repair_number": repair.repair_number,
|
||||
"customer_name": repair.customer_name,
|
||||
"device": f"{repair.device_manufacturer} {repair.device_model}".strip(),
|
||||
"status_label": STATUS_LABELS.get(repair.status, repair.status),
|
||||
"public_status_url": public_status_url or "wird später bereitgestellt",
|
||||
"company_name": "Olympus CRM",
|
||||
}
|
||||
return [
|
||||
RepairNotificationTemplateResponse(
|
||||
event_type=template.event_type,
|
||||
status=template.status,
|
||||
subject=template.subject.format(**placeholders),
|
||||
text=template.text.format(**placeholders),
|
||||
html=template.html.format(**placeholders) if template.html else None,
|
||||
)
|
||||
for template in TEMPLATES
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def overview(db: Session, repair: Repair, *, public_status_url: str = "") -> RepairNotificationOverviewResponse:
|
||||
return RepairNotificationOverviewResponse(
|
||||
templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url),
|
||||
events=RepairRepository.list_notification_events(db, repair.id),
|
||||
)
|
||||
124
backend/hermes/app/services/repair_public_link_service.py
Normal file
124
backend/hermes/app/services/repair_public_link_service.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
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_notification_service import STATUS_LABELS
|
||||
from app.services.repair_service import repair_label
|
||||
|
||||
|
||||
def public_status_path(token: str) -> str:
|
||||
return f"/status/{token}"
|
||||
|
||||
|
||||
class RepairPublicLinkService:
|
||||
@staticmethod
|
||||
def hash_token(token: str) -> str:
|
||||
return hmac.new(settings.secret_key.encode("utf-8"), 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:
|
||||
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="repairs.public_link.create",
|
||||
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:
|
||||
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(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")
|
||||
|
||||
RepairRepository.mark_public_link_used(db, public_link)
|
||||
repair = public_link.repair
|
||||
history = RepairRepository.get_history_public(db, repair.id)
|
||||
return 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue