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>
410 lines
12 KiB
Python
410 lines
12 KiB
Python
"""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="وضعیت ارسال نامعتبر است",
|
||
)
|