feat(repairs): add status timeline and public link preparation

This commit is contained in:
Schubert Ferenc 2026-07-04 12:25:06 +02:00
parent e9ec207617
commit 09cce1f2b6
22 changed files with 1027 additions and 28 deletions

View file

@ -0,0 +1,126 @@
"""add repair public links and notifications
Revision ID: d4f6a2b8c901
Revises: c8e2f1a9b704
Create Date: 2026-07-04 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "d4f6a2b8c901"
down_revision: Union[str, Sequence[str], None] = "c8e2f1a9b704"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PUBLIC_LINK_PERMISSION = (
"repairs.public_link.manage",
"Reparatur-Statuslinks verwalten",
"Öffentliche Statuslinks für Reparaturen verwalten",
"repairs",
)
ROLE_PERMISSIONS = {
"administrator": ["repairs.public_link.manage"],
"management": ["repairs.public_link.manage"],
"support": ["repairs.public_link.manage"],
}
def upgrade() -> None:
op.create_table(
"repair_public_access_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("token_hint", sa.String(length=16), nullable=True),
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index(op.f("ix_repair_public_access_tokens_is_active"), "repair_public_access_tokens", ["is_active"], unique=False)
op.create_index(op.f("ix_repair_public_access_tokens_repair_id"), "repair_public_access_tokens", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_public_access_tokens_token_hash"), "repair_public_access_tokens", ["token_hash"], unique=True)
op.create_table(
"repair_notification_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("channel", sa.String(length=40), nullable=False),
sa.Column("recipient", sa.String(length=255), server_default="", nullable=False),
sa.Column("subject", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status in ('pending','sent','failed','skipped')", name="ck_repair_notification_events_status"),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_notification_events_channel"), "repair_notification_events", ["channel"], unique=False)
op.create_index(op.f("ix_repair_notification_events_event_type"), "repair_notification_events", ["event_type"], unique=False)
op.create_index(op.f("ix_repair_notification_events_repair_id"), "repair_notification_events", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_notification_events_status"), "repair_notification_events", ["status"], unique=False)
name, display_name, description, module = PUBLIC_LINK_PERMISSION
op.execute(
sa.text(
"""
insert into permissions (name, display_name, description, module)
values (:name, :display_name, :description, :module)
on conflict (name) do update set
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
for role_name, permissions in ROLE_PERMISSIONS.items():
for permission_name in permissions:
op.execute(
sa.text(
"""
insert into role_permissions (role_id, permission_id)
select roles.id, permissions.id
from roles, permissions
where roles.name = :role_name and permissions.name = :permission_name
on conflict do nothing
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
delete from role_permissions
using permissions
where role_permissions.permission_id = permissions.id
and permissions.name = :permission_name
"""
).bindparams(permission_name=PUBLIC_LINK_PERMISSION[0])
)
op.execute(sa.text("delete from permissions where name = :name").bindparams(name=PUBLIC_LINK_PERMISSION[0]))
op.drop_index(op.f("ix_repair_notification_events_status"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_repair_id"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_event_type"), table_name="repair_notification_events")
op.drop_index(op.f("ix_repair_notification_events_channel"), table_name="repair_notification_events")
op.drop_table("repair_notification_events")
op.drop_index(op.f("ix_repair_public_access_tokens_token_hash"), table_name="repair_public_access_tokens")
op.drop_index(op.f("ix_repair_public_access_tokens_repair_id"), table_name="repair_public_access_tokens")
op.drop_index(op.f("ix_repair_public_access_tokens_is_active"), table_name="repair_public_access_tokens")
op.drop_table("repair_public_access_tokens")

View file

@ -60,7 +60,9 @@ def get_dashboard_summary(
repairs = [
MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")),
MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")),
MetricCard(label="Warten auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
MetricCard(label="Wartet auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")),
MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")),
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
]

View file

@ -14,12 +14,18 @@ from app.schemas.repair import (
RepairIntakePayload,
RepairIntakeResponse,
RepairListResponse,
RepairNotificationOverviewResponse,
RepairPublicLinkCreateResponse,
RepairPublicLinkResponse,
RepairPublicStatusResponse,
RepairResponse,
RepairStatus,
RepairStatusHistoryResponse,
RepairStatusUpdate,
RepairUpdate,
)
from app.services.repair_notification_service import RepairNotificationService
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.repair_service import RepairService
router = APIRouter(tags=["Repairs"])
@ -123,6 +129,48 @@ def get_repair_history(
return RepairRepository.get_history(db, repair_id)
@router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
def get_public_link(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.get(db, db_repair)
@router.post("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkCreateResponse, status_code=status.HTTP_201_CREATED)
def create_public_link(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.create(db, db_repair, actor=current_user, request=request)
@router.delete("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse)
def revoke_public_link(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.public_link.manage")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairPublicLinkService.revoke(db, db_repair, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/notifications", response_model=RepairNotificationOverviewResponse)
def get_repair_notifications(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairNotificationService.overview(db, db_repair)
@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED)
def create_repair_intake(
payload: RepairIntakePayload,
@ -140,3 +188,11 @@ def create_repair_intake(
status=cast(RepairStatus, repair.status),
message="Reparaturanfrage übernommen",
)
@router.get("/public/repairs/status/{token}", response_model=RepairPublicStatusResponse)
def get_public_repair_status(
token: str,
db: Session = Depends(get_db),
):
return RepairPublicLinkService.public_status(db, token)

View file

@ -39,6 +39,8 @@ class Repair(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan")
class RepairStatusHistory(Base):
@ -53,6 +55,18 @@ class RepairStatusHistory(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
repair: Mapped[Repair] = relationship(back_populates="history")
actor = relationship("User", lazy="joined")
@property
def actor_username(self) -> str:
return self.actor.username if self.actor is not None else ""
@property
def actor_display_name(self) -> str:
if self.actor is None:
return ""
display_name = f"{self.actor.first_name} {self.actor.last_name}".strip()
return display_name or self.actor.username
class RepairIntakeEvent(Base):
@ -79,3 +93,36 @@ class RepairDocument(Base):
title: Mapped[str] = mapped_column(String(255))
document_type: Mapped[str] = mapped_column(String(80), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class RepairPublicAccessToken(Base):
__tablename__ = "repair_public_access_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
token_hint: Mapped[str | None] = mapped_column(String(16), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
repair: Mapped[Repair] = relationship(back_populates="public_access_tokens")
class RepairNotificationEvent(Base):
__tablename__ = "repair_notification_events"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
event_type: Mapped[str] = mapped_column(String(80), index=True)
channel: Mapped[str] = mapped_column(String(40), index=True)
recipient: Mapped[str] = mapped_column(String(255), default="", server_default="")
subject: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(40), index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
repair: Mapped[Repair] = relationship(back_populates="notification_events")

View file

@ -83,6 +83,7 @@ STANDARD_PERMISSIONS = [
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"),
]
ROLE_PERMISSION_NAMES = {
@ -99,6 +100,7 @@ ROLE_PERMISSION_NAMES = {
"repairs.create",
"repairs.update",
"repairs.status.update",
"repairs.public_link.manage",
},
"sales": {
"dashboard.read",
@ -137,6 +139,7 @@ ROLE_PERMISSION_NAMES = {
"repairs.update",
"repairs.status.update",
"repairs.intake",
"repairs.public_link.manage",
},
"warehouse": {
"dashboard.read",

View file

@ -3,7 +3,7 @@ from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.repair import Repair, RepairIntakeEvent, RepairStatusHistory
from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
@ -158,11 +158,22 @@ class RepairRepository:
return list(
db.scalars(
select(RepairStatusHistory)
.options(selectinload(RepairStatusHistory.actor))
.where(RepairStatusHistory.repair_id == repair_id)
.order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc())
)
)
@staticmethod
def get_history_public(db: Session, repair_id: int) -> list[RepairStatusHistory]:
return list(
db.scalars(
select(RepairStatusHistory)
.where(RepairStatusHistory.repair_id == repair_id)
.order_by(RepairStatusHistory.created_at.asc(), RepairStatusHistory.id.asc())
)
)
@staticmethod
def count_by_status(db: Session, status: str) -> int:
return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0
@ -203,6 +214,110 @@ class RepairRepository:
db.refresh(event)
return event
@staticmethod
def get_active_public_link(db: Session, repair_id: int) -> RepairPublicAccessToken | None:
now = datetime.now(UTC)
return db.scalar(
select(RepairPublicAccessToken)
.where(RepairPublicAccessToken.repair_id == repair_id)
.where(RepairPublicAccessToken.is_active.is_(True))
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
.order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc())
.limit(1)
)
@staticmethod
def get_public_link_by_hash(db: Session, token_hash: str) -> RepairPublicAccessToken | None:
now = datetime.now(UTC)
return db.scalar(
select(RepairPublicAccessToken)
.options(selectinload(RepairPublicAccessToken.repair))
.where(RepairPublicAccessToken.token_hash == token_hash)
.where(RepairPublicAccessToken.is_active.is_(True))
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
.limit(1)
)
@staticmethod
def create_public_link(
db: Session,
*,
repair_id: int,
token_hash: str,
token_hint: str,
expires_at: datetime | None = None,
) -> RepairPublicAccessToken:
RepairRepository.revoke_public_links(db, repair_id=repair_id, commit=False)
public_link = RepairPublicAccessToken(
repair_id=repair_id,
token_hash=token_hash,
token_hint=token_hint,
expires_at=expires_at,
)
db.add(public_link)
db.commit()
db.refresh(public_link)
return public_link
@staticmethod
def revoke_public_links(db: Session, *, repair_id: int, commit: bool = True) -> None:
now = datetime.now(UTC)
links = list(
db.scalars(
select(RepairPublicAccessToken)
.where(RepairPublicAccessToken.repair_id == repair_id)
.where(RepairPublicAccessToken.is_active.is_(True))
)
)
for link in links:
link.is_active = False
link.revoked_at = now
if commit:
db.commit()
@staticmethod
def mark_public_link_used(db: Session, public_link: RepairPublicAccessToken) -> None:
public_link.last_used_at = datetime.now(UTC)
db.commit()
@staticmethod
def list_notification_events(db: Session, repair_id: int) -> list[RepairNotificationEvent]:
return list(
db.scalars(
select(RepairNotificationEvent)
.where(RepairNotificationEvent.repair_id == repair_id)
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
)
)
@staticmethod
def create_notification_event(
db: Session,
*,
repair_id: int,
event_type: str,
channel: str,
recipient: str,
subject: str,
status: str,
error_message: str | None = None,
sent_at: datetime | None = None,
) -> RepairNotificationEvent:
event = RepairNotificationEvent(
repair_id=repair_id,
event_type=event_type,
channel=channel,
recipient=recipient,
subject=subject,
status=status,
error_message=error_message,
sent_at=sent_at,
)
db.add(event)
db.commit()
db.refresh(event)
return event
@staticmethod
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
data = payload.model_dump()

View file

@ -129,6 +129,8 @@ class RepairStatusHistoryResponse(BaseModel):
new_status: str
note: str | None
actor_user_id: int | None
actor_username: str = ""
actor_display_name: str = ""
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@ -160,3 +162,61 @@ class RepairIntakeResponse(BaseModel):
repair_number: str
status: RepairStatus
message: str
class RepairPublicLinkResponse(BaseModel):
is_active: bool
token_hint: str | None = None
expires_at: datetime | None = None
last_used_at: datetime | None = None
created_at: datetime | None = None
revoked_at: datetime | None = None
public_status_path: str | None = None
class RepairPublicLinkCreateResponse(RepairPublicLinkResponse):
token: str
public_status_path: str
class RepairPublicStatusHistoryItem(BaseModel):
status: RepairStatus
status_label: str
created_at: datetime
class RepairPublicStatusResponse(BaseModel):
repair_number: str
public_status_label: str
device_manufacturer: str
device_model: str
status_history_public: list[RepairPublicStatusHistoryItem]
updated_at: datetime
class RepairNotificationTemplateResponse(BaseModel):
event_type: str
status: RepairStatus | None = None
subject: str
text: str
html: str | None = None
class RepairNotificationEventResponse(BaseModel):
id: int
repair_id: int
event_type: str
channel: str
recipient: str
subject: str
status: str
error_message: str | None
created_at: datetime
sent_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class RepairNotificationOverviewResponse(BaseModel):
templates: list[RepairNotificationTemplateResponse]
events: list[RepairNotificationEventResponse]

View file

@ -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)

View 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),
)

View 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,
)