TorbatYar/backend/services/loyalty/app/validators/foundation.py
Mortezakoohjani e41ecfad4c Sync platform docs, infra, and module services with Accounting integration.
Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:35:23 +03:30

248 lines
7.7 KiB
Python

"""Phase 7.0 Loyalty validators."""
from __future__ import annotations
import re
from datetime import date
from shared.exceptions import AppError
from app.models.types import (
CampaignStatus,
MemberStatus,
PointAccountStatus,
ProgramStatus,
RewardStatus,
RewardType,
TierStatus,
)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
PHONE_RE = re.compile(r"^[\d\s\+\-\(\)]{5,30}$")
CODE_RE = re.compile(r"^[A-Za-z0-9_\-]{2,50}$")
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_email(email: str | None, *, required: bool = False) -> str | None:
if email is None or not email.strip():
if required:
raise AppError(
"ایمیل الزامی است",
status_code=422,
error_code="email_required",
)
return None
cleaned = email.strip().lower()
if not EMAIL_RE.match(cleaned):
raise AppError(
"فرمت ایمیل نامعتبر است",
status_code=422,
error_code="invalid_email",
)
return cleaned
def validate_phone(phone: str | None, *, field: str = "phone") -> str | None:
if phone is None or not phone.strip():
return None
cleaned = phone.strip()
if not PHONE_RE.match(cleaned):
raise AppError(
f"فرمت {field} نامعتبر است",
status_code=422,
error_code="invalid_phone",
details={"field": field},
)
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:
resolved = 0 if value is None else int(value)
if resolved < 0:
raise AppError(
f"{field} نمی‌تواند منفی باشد",
status_code=422,
error_code="invalid_amount",
details={"field": field},
)
return resolved
def validate_program_status(status: ProgramStatus | str) -> ProgramStatus:
if isinstance(status, ProgramStatus):
return status
try:
return ProgramStatus(status)
except ValueError as exc:
raise AppError(
"وضعیت برنامه وفاداری نامعتبر است",
status_code=422,
error_code="invalid_program_status",
) from exc
def validate_member_status(status: MemberStatus | str) -> MemberStatus:
if isinstance(status, MemberStatus):
return status
try:
return MemberStatus(status)
except ValueError as exc:
raise AppError(
"وضعیت عضو نامعتبر است",
status_code=422,
error_code="invalid_member_status",
) from exc
def validate_tier_status(status: TierStatus | str) -> TierStatus:
if isinstance(status, TierStatus):
return status
try:
return TierStatus(status)
except ValueError as exc:
raise AppError(
"وضعیت سطح عضویت نامعتبر است",
status_code=422,
error_code="invalid_tier_status",
) from exc
def validate_point_account_status(
status: PointAccountStatus | str,
) -> PointAccountStatus:
if isinstance(status, PointAccountStatus):
return status
try:
return PointAccountStatus(status)
except ValueError as exc:
raise AppError(
"وضعیت حساب امتیاز نامعتبر است",
status_code=422,
error_code="invalid_point_account_status",
) from exc
def validate_reward_type(reward_type: RewardType | str) -> RewardType:
if isinstance(reward_type, RewardType):
return reward_type
try:
return RewardType(reward_type)
except ValueError as exc:
raise AppError(
"نوع پاداش نامعتبر است",
status_code=422,
error_code="invalid_reward_type",
) from exc
def validate_reward_status(status: RewardStatus | str) -> RewardStatus:
if isinstance(status, RewardStatus):
return status
try:
return RewardStatus(status)
except ValueError as exc:
raise AppError(
"وضعیت پاداش نامعتبر است",
status_code=422,
error_code="invalid_reward_status",
) from exc
def validate_campaign_status(status: CampaignStatus | str) -> CampaignStatus:
if isinstance(status, CampaignStatus):
return status
try:
return CampaignStatus(status)
except ValueError as exc:
raise AppError(
"وضعیت کمپین نامعتبر است",
status_code=422,
error_code="invalid_campaign_status",
) from exc
def validate_campaign_dates(
starts_on: date | None, ends_on: date | None
) -> tuple[date | None, date | None]:
if starts_on and ends_on and ends_on < starts_on:
raise AppError(
"تاریخ پایان کمپین نمی‌تواند قبل از تاریخ شروع باشد",
status_code=422,
error_code="invalid_campaign_dates",
)
return starts_on, ends_on
def ensure_optimistic_version(
entity_version: int, expected: int | None, *, required: bool = True
) -> None:
if expected is None:
if required:
raise AppError(
"فیلد version برای به‌روزرسانی الزامی است",
status_code=422,
error_code="version_required",
)
return
if entity_version != expected:
raise AppError(
"نسخه رکورد تغییر کرده است؛ دوباره تلاش کنید",
status_code=409,
error_code="version_conflict",
details={"expected": expected, "actual": entity_version},
)
def release_unique_token(value: str, entity_id) -> str:
"""Free unique business keys on soft-delete so codes can be reused."""
suffix = f"__del__{entity_id.hex[:8]}"
if value.endswith(suffix):
return value
# Keep within typical 50-char code columns
base = value[: max(0, 50 - len(suffix))]
return f"{base}{suffix}"
def forbid_direct_balance_mutation(payload: dict) -> None:
forbidden = {"balance", "points_balance", "available_balance", "ledger_balance"}
present = forbidden.intersection(payload.keys())
if present:
raise AppError(
"تغییر مستقیم موجودی امتیاز ممنوع است؛ فقط از طریق ledger",
status_code=422,
error_code="direct_balance_forbidden",
details={"fields": sorted(present)},
)