Files
F-r-ldraledigplannerare/backend/app/utils/errors.py
T
jacobh2 8ebfcb9462 Add rename and remove for a workspace's placeholder partner
Settings is gaining a partner display, which hides the add-form once a
partner exists. Without these routes a typo'd partner name would be
permanently unfixable, since no endpoint could change or drop one.

PATCH /workspace/{id}/parents/{uuid} renames; DELETE soft-deletes.
Soft rather than hard delete because PersonalData holds PII and the
repository is a SoftDeleteRepository specifically so erasure stays
auditable.

Removal refuses (422) when the member still has leave wishes, planned
leave, parental-leave rows or vacation days, naming the blockers, so a
removal can never orphan plan data. It also refuses for the caller's own
profile, and for a member who has their own login -- the invite/redeem
flow re-points a real user's personal_data into the inviter's workspace,
so soft-deleting that row would silently break their login.

Errors carry message_key + data rather than prose, per the no-Swedish-on
-the-wire rule; NotFoundError/ValidationError gained the same optional
shape DubbeldagError already had, defaulting empty so existing call
sites are unaffected.

Also fixes seed_demo.py constructing WorkspaceService with a stale
3-arg signature, which would have crashed at runtime -- invisible to
pytest since it is a standalone script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:34 +02:00

93 lines
2.9 KiB
Python

"""Domain error types.
Use these instead of bare exceptions so services/routers can map them to
meaningful HTTP responses. Never swallow errors silently (see ARCHITECTURE.md §10).
"""
class DomainError(Exception):
"""Base class for all application-domain errors."""
class NotFoundError(DomainError):
"""Raised when an entity cannot be found by its identifier.
May optionally carry a `message_key` + `data` pair (§3.5) so a router can
surface a structured, i18n-ready refusal instead of a hardcoded string —
see `DubbeldagError` for the established shape. Existing call sites that
only pass `message` are unaffected: `message_key` defaults to `""`.
"""
def __init__(
self,
message: str,
message_key: str = "",
data: dict[str, object] | None = None,
) -> None:
super().__init__(message)
self.message_key = message_key
self.data: dict[str, object] = data if data is not None else {}
class ValidationError(DomainError):
"""Raised when input fails a business rule (not just schema validation).
May optionally carry a `message_key` + `data` pair (§3.5); see
`NotFoundError` above for the same shape and rationale.
"""
def __init__(
self,
message: str,
message_key: str = "",
data: dict[str, object] | None = None,
) -> None:
super().__init__(message)
self.message_key = message_key
self.data: dict[str, object] = data if data is not None else {}
class InsufficientDaysError(DomainError):
"""Raised when a parent doesn't have enough remaining days to plan."""
class InvalidDayTypeError(ValidationError):
"""Raised when a planned-day type is not recognized."""
class DubbeldagError(ValidationError):
"""Raised when a dubbeldag breaks the 15-month window or the 60-day quota.
Carries an optional `message_key` + `data` pair (§3.5) alongside the
human-readable `message` (kept for anything that only logs/prints the
exception) so callers that need to surface this to the UI — see
`PlanningService._validate_dubbeldagar_batch` — can build an i18n-ready
`Rejection` instead of leaking this English string past the backend.
"""
def __init__(
self,
message: str,
message_key: str = "",
data: dict[str, object] | None = None,
) -> None:
super().__init__(message)
self.message_key = message_key
self.data: dict[str, object] = data if data is not None else {}
class AuthError(DomainError):
"""Raised when credentials are invalid or an auth operation fails."""
class UnauthorizedError(DomainError):
"""Raised when the caller lacks permission to perform an action."""
class ConflictError(DomainError):
"""Raised when a resource already exists (e.g. duplicate email on register)."""
class InvitationError(ValidationError):
"""Raised when an invitation token is invalid, expired, or already redeemed."""