TorbatYar/backend/services/hospitality/app/validators/foundation.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

410 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Phase 12.0 Hospitality validators."""
from __future__ import annotations
import re
from datetime import datetime
from shared.exceptions import AppError
from app.models.types import (
BundleKey,
BundleStatus,
ConnectorKind,
ConnectorStatus,
DispatchStatus,
KitchenItemStatus,
KitchenTicketStatus,
LifecycleStatus,
MenuStatus,
ModifierSelectionMode,
ReservationStatus,
ServiceRequestKind,
ServiceRequestStatus,
TableAssignmentStatus,
TableStatus,
VenueFormat,
WaitlistStatus,
)
CODE_RE = re.compile(r"^[A-Za-z0-9_\-]{2,50}$")
SETTING_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,100}$")
FEATURE_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,100}$")
def validate_non_empty(value: str | None, *, field: str) -> str:
if value is None or not str(value).strip():
raise AppError(
f"{field} الزامی است",
status_code=422,
error_code="validation_error",
details={"field": field},
)
return str(value).strip()
def validate_code(code: str | None, *, field: str = "code") -> str:
cleaned = validate_non_empty(code, field=field).upper()
if not CODE_RE.match(cleaned):
raise AppError(
f"فرمت {field} نامعتبر است",
status_code=422,
error_code="invalid_code",
details={"field": field},
)
return cleaned
def validate_setting_key(key: str | None) -> str:
cleaned = validate_non_empty(key, field="key")
if not SETTING_KEY_RE.match(cleaned):
raise AppError(
"فرمت key نامعتبر است",
status_code=422,
error_code="invalid_setting_key",
)
return cleaned
def validate_feature_key(key: str | None) -> str:
cleaned = validate_non_empty(key, field="feature_key")
if not FEATURE_KEY_RE.match(cleaned):
raise AppError(
"فرمت feature_key نامعتبر است",
status_code=422,
error_code="invalid_feature_key",
)
return cleaned
def validate_currency_code(code: str) -> str:
cleaned = code.strip().upper()
if len(cleaned) != 3:
raise AppError(
"کد ارز باید ۳ حرفی باشد",
status_code=422,
error_code="invalid_currency_code",
)
return cleaned
def validate_non_negative_int(value: int | None, *, field: str) -> int | None:
if value is None:
return None
resolved = int(value)
if resolved < 0:
raise AppError(
f"{field} نمی‌تواند منفی باشد",
status_code=422,
error_code="invalid_amount",
details={"field": field},
)
return resolved
def validate_positive_int(value: int | None, *, field: str) -> int:
if value is None:
raise AppError(
f"{field} الزامی است",
status_code=422,
error_code="validation_error",
details={"field": field},
)
resolved = int(value)
if resolved < 1:
raise AppError(
f"{field} باید حداقل ۱ باشد",
status_code=422,
error_code="invalid_amount",
details={"field": field},
)
return resolved
def _enum_or_error(enum_cls, value, *, error_code: str, message: str):
if isinstance(value, enum_cls):
return value
try:
return enum_cls(value)
except ValueError as exc:
raise AppError(message, status_code=422, error_code=error_code) from exc
def validate_lifecycle_status(status: LifecycleStatus | str) -> LifecycleStatus:
return _enum_or_error(
LifecycleStatus,
status,
error_code="invalid_lifecycle_status",
message="وضعیت نامعتبر است",
)
def validate_venue_format(value: VenueFormat | str) -> VenueFormat:
return _enum_or_error(
VenueFormat,
value,
error_code="invalid_venue_format",
message="فرمت واحد پذیرایی نامعتبر است",
)
def validate_bundle_key(value: BundleKey | str) -> BundleKey:
return _enum_or_error(
BundleKey,
value,
error_code="invalid_bundle_key",
message="کلید بسته نامعتبر است",
)
def validate_bundle_status(value: BundleStatus | str) -> BundleStatus:
return _enum_or_error(
BundleStatus,
value,
error_code="invalid_bundle_status",
message="وضعیت بسته نامعتبر است",
)
def validate_menu_status(value: MenuStatus | str) -> MenuStatus:
return _enum_or_error(
MenuStatus,
value,
error_code="invalid_menu_status",
message="وضعیت منو نامعتبر است",
)
def validate_table_status(value: TableStatus | str) -> TableStatus:
return _enum_or_error(
TableStatus,
value,
error_code="invalid_table_status",
message="وضعیت میز نامعتبر است",
)
def ensure_optimistic_version(entity_version: int, expected: int | None) -> None:
if expected is None:
return
if entity_version != expected:
raise AppError(
"نسخه رکورد تغییر کرده است؛ دوباره تلاش کنید",
status_code=409,
error_code="version_conflict",
details={"expected": expected, "actual": entity_version},
)
def forbid_format_specific_engines(payload: dict) -> None:
"""Reject payloads that inject format-specific engine keys into foundation."""
forbidden = {
"pos_engine",
"kitchen_engine",
"ordering_engine",
"hardcoded_venue_rules",
"vendor_sdk",
}
present = forbidden.intersection(payload.keys())
if present:
raise AppError(
"موتورهای اختصاصی یا SDK فروشنده در لایه foundation مجاز نیست",
status_code=422,
error_code="format_specific_forbidden",
details={"fields": sorted(present)},
)
TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$")
LOCALE_RE = re.compile(r"^[a-z]{2}(-[A-Z]{2})?$")
def validate_time_hhmm(value: str, *, field: str) -> str:
cleaned = validate_non_empty(value, field=field)
if not TIME_RE.match(cleaned):
raise AppError(
f"فرمت {field} باید HH:MM یا HH:MM:SS باشد",
status_code=422,
error_code="invalid_time",
details={"field": field},
)
if len(cleaned) == 5:
return f"{cleaned}:00"
return cleaned
def validate_time_range(start_time: str, end_time: str) -> tuple[str, str]:
start = validate_time_hhmm(start_time, field="start_time")
end = validate_time_hhmm(end_time, field="end_time")
if end <= start:
raise AppError(
"زمان پایان باید بعد از زمان شروع باشد",
status_code=422,
error_code="invalid_time_range",
)
return start, end
def validate_weekday(value: int | None) -> int | None:
if value is None:
return None
if value < 0 or value > 6:
raise AppError(
"weekday باید بین ۰ تا ۶ باشد",
status_code=422,
error_code="invalid_weekday",
)
return value
def validate_locale(locale: str) -> str:
cleaned = validate_non_empty(locale, field="locale")
if not LOCALE_RE.match(cleaned):
raise AppError(
"فرمت locale نامعتبر است",
status_code=422,
error_code="invalid_locale",
)
return cleaned
def validate_file_ref(file_ref: str) -> str:
cleaned = validate_non_empty(file_ref, field="file_ref")
if len(cleaned) > 255:
raise AppError(
"file_ref بیش از حد طولانی است",
status_code=422,
error_code="invalid_file_ref",
)
return cleaned
def validate_modifier_selection(
mode: ModifierSelectionMode | str,
*,
min_select: int,
max_select: int | None,
is_required: bool,
) -> tuple[ModifierSelectionMode, int, int | None]:
resolved = _enum_or_error(
ModifierSelectionMode,
mode,
error_code="invalid_selection_mode",
message="حالت انتخاب modifier نامعتبر است",
)
min_v = validate_non_negative_int(min_select, field="min_select") or 0
if is_required and min_v < 1:
min_v = 1
max_v = None
if max_select is not None:
max_v = validate_non_negative_int(max_select, field="max_select")
if max_v is not None and max_v < min_v:
raise AppError(
"max_select نمی‌تواند کمتر از min_select باشد",
status_code=422,
error_code="invalid_selection_bounds",
)
return resolved, min_v, max_v
# Phase 12.3 — Table Service & Reservations
def validate_reservation_status(value: ReservationStatus | str) -> ReservationStatus:
return _enum_or_error(
ReservationStatus,
value,
error_code="invalid_reservation_status",
message="وضعیت رزرو نامعتبر است",
)
def validate_table_assignment_status(value: TableAssignmentStatus | str) -> TableAssignmentStatus:
return _enum_or_error(
TableAssignmentStatus,
value,
error_code="invalid_table_assignment_status",
message="وضعیت تخصیص میز نامعتبر است",
)
def validate_service_request_kind(value: ServiceRequestKind | str) -> ServiceRequestKind:
return _enum_or_error(
ServiceRequestKind,
value,
error_code="invalid_service_request_kind",
message="نوع درخواست خدمت نامعتبر است",
)
def validate_service_request_status(value: ServiceRequestStatus | str) -> ServiceRequestStatus:
return _enum_or_error(
ServiceRequestStatus,
value,
error_code="invalid_service_request_status",
message="وضعیت درخواست خدمت نامعتبر است",
)
def validate_waitlist_status(value: WaitlistStatus | str) -> WaitlistStatus:
return _enum_or_error(
WaitlistStatus,
value,
error_code="invalid_waitlist_status",
message="وضعیت لیست انتظار نامعتبر است",
)
def validate_reservation_window(reserved_from: datetime, reserved_to: datetime) -> tuple[datetime, datetime]:
if reserved_to <= reserved_from:
raise AppError(
"زمان پایان رزرو باید بعد از زمان شروع باشد",
status_code=422,
error_code="invalid_reservation_window",
)
return reserved_from, reserved_to
# Phase 12.6 — Kitchen
def validate_kitchen_ticket_status(value: KitchenTicketStatus | str) -> KitchenTicketStatus:
return _enum_or_error(
KitchenTicketStatus,
value,
error_code="invalid_kitchen_ticket_status",
message="وضعیت تیکت آشپزخانه نامعتبر است",
)
def validate_kitchen_item_status(value: KitchenItemStatus | str) -> KitchenItemStatus:
return _enum_or_error(
KitchenItemStatus,
value,
error_code="invalid_kitchen_item_status",
message="وضعیت آیتم آشپزخانه نامعتبر است",
)
# Phase 12.7 — Connectors
def validate_connector_kind(value: ConnectorKind | str) -> ConnectorKind:
return _enum_or_error(
ConnectorKind,
value,
error_code="invalid_connector_kind",
message="نوع رابط اتصال نامعتبر است",
)
def validate_connector_status(value: ConnectorStatus | str) -> ConnectorStatus:
return _enum_or_error(
ConnectorStatus,
value,
error_code="invalid_connector_status",
message="وضعیت رابط اتصال نامعتبر است",
)
def validate_dispatch_status(value: DispatchStatus | str) -> DispatchStatus:
return _enum_or_error(
DispatchStatus,
value,
error_code="invalid_dispatch_status",
message="وضعیت ارسال نامعتبر است",
)