Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
248 lines
7.7 KiB
Python
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)},
|
|
)
|