TorbatYar/backend/services/delivery/app/validators/drivers.py
Mortezakoohjani 5c6a2e78cf feat(platform): seed service registry, deploy all modules, and fix homepage catalog.
Register all active platform services and base features in core DB so admin can manage them, add delivery/hospitality/sports-center backend phases, update apps catalog and production deploy tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:39:51 +03:30

141 lines
4.6 KiB
Python

"""Driver lifecycle validators — Phase 10.1."""
from __future__ import annotations
import re
from datetime import date
from shared.exceptions import AppError
from app.models.types import DriverLifecycleAction, DriverStatus
from app.validators.foundation import validate_non_empty
PHONE_RE = re.compile(r"^\+?[0-9]{8,15}$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
ALLOWED_TRANSITIONS: dict[DriverLifecycleAction, set[DriverStatus]] = {
DriverLifecycleAction.ACTIVATE: {
DriverStatus.PENDING,
DriverStatus.INACTIVE,
DriverStatus.SUSPENDED,
},
DriverLifecycleAction.SUSPEND: {DriverStatus.ACTIVE},
DriverLifecycleAction.RESUME: {DriverStatus.SUSPENDED},
DriverLifecycleAction.DEACTIVATE: {
DriverStatus.ACTIVE,
DriverStatus.SUSPENDED,
},
DriverLifecycleAction.BLOCK: {
DriverStatus.PENDING,
DriverStatus.ACTIVE,
DriverStatus.SUSPENDED,
DriverStatus.INACTIVE,
},
DriverLifecycleAction.UNBLOCK: {DriverStatus.BLOCKED},
DriverLifecycleAction.ARCHIVE: {
DriverStatus.INACTIVE,
DriverStatus.BLOCKED,
},
}
TERMINAL_STATUSES = frozenset({DriverStatus.ARCHIVED})
TARGET_STATUS: dict[DriverLifecycleAction, DriverStatus] = {
DriverLifecycleAction.ACTIVATE: DriverStatus.ACTIVE,
DriverLifecycleAction.SUSPEND: DriverStatus.SUSPENDED,
DriverLifecycleAction.RESUME: DriverStatus.ACTIVE,
DriverLifecycleAction.DEACTIVATE: DriverStatus.INACTIVE,
DriverLifecycleAction.BLOCK: DriverStatus.BLOCKED,
DriverLifecycleAction.UNBLOCK: DriverStatus.INACTIVE,
DriverLifecycleAction.ARCHIVE: DriverStatus.ARCHIVED,
}
def ensure_driver_lifecycle_transition(
*, action: DriverLifecycleAction, current: DriverStatus
) -> None:
if current in TERMINAL_STATUSES:
raise AppError(
"راننده در وضعیت پایانی است و قابل تغییر نیست",
status_code=409,
error_code="driver_terminal",
details={"status": current.value, "action": action.value},
)
allowed = ALLOWED_TRANSITIONS.get(action, set())
if current not in allowed:
raise AppError(
"انتقال وضعیت راننده مجاز نیست",
status_code=409,
error_code="invalid_driver_lifecycle_transition",
details={
"from_status": current.value,
"action": action.value,
"allowed_from": sorted(s.value for s in allowed),
},
)
def target_status_for(action: DriverLifecycleAction) -> DriverStatus:
return TARGET_STATUS[action]
def validate_reason(reason: str | None, *, required: bool = False) -> str | None:
if reason is None or not str(reason).strip():
if required:
raise AppError(
"دلیل الزامی است",
status_code=422,
error_code="reason_required",
)
return None
text = str(reason).strip()
if len(text) > 500:
raise AppError(
"دلیل بیش از حد طولانی است",
status_code=422,
error_code="reason_too_long",
)
return text
def validate_mobile(mobile: str | None) -> str | None:
if mobile is None or not str(mobile).strip():
return None
value = str(mobile).strip()
if not PHONE_RE.match(value):
raise AppError(
"شماره موبایل نامعتبر است",
status_code=422,
error_code="invalid_mobile",
details={"field": "mobile"},
)
return value
def validate_email(email: str | None) -> str | None:
if email is None or not str(email).strip():
return None
value = str(email).strip()
if not EMAIL_RE.match(value):
raise AppError(
"ایمیل نامعتبر است",
status_code=422,
error_code="invalid_email",
details={"field": "email"},
)
return value
def validate_credential_dates(
*, issued_at: date | None, expires_at: date | None
) -> None:
if issued_at and expires_at and expires_at < issued_at:
raise AppError(
"تاریخ انقضا نمی‌تواند قبل از تاریخ صدور باشد",
status_code=422,
error_code="invalid_credential_dates",
)
def validate_display_name(name: str) -> str:
return validate_non_empty(name, "display_name")