Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
"""Phase 7.1 membership lifecycle validators."""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from shared.exceptions import AppError
|
||
|
||
from app.models.types import MemberStatus, MembershipLifecycleAction
|
||
|
||
DEFAULT_TERM_DAYS = 365
|
||
|
||
# Canonical transitions for the Membership Engine.
|
||
ALLOWED_TRANSITIONS: dict[MembershipLifecycleAction, set[MemberStatus]] = {
|
||
MembershipLifecycleAction.ENROLL: {MemberStatus.PENDING},
|
||
MembershipLifecycleAction.ACTIVATE: {MemberStatus.PENDING, MemberStatus.EXPIRED},
|
||
MembershipLifecycleAction.RENEW: {
|
||
MemberStatus.ACTIVE,
|
||
MemberStatus.EXPIRED,
|
||
MemberStatus.FROZEN,
|
||
MemberStatus.SUSPENDED,
|
||
},
|
||
MembershipLifecycleAction.FREEZE: {MemberStatus.ACTIVE},
|
||
MembershipLifecycleAction.RESUME: {
|
||
MemberStatus.FROZEN,
|
||
MemberStatus.SUSPENDED,
|
||
},
|
||
MembershipLifecycleAction.CANCEL: {
|
||
MemberStatus.PENDING,
|
||
MemberStatus.ACTIVE,
|
||
MemberStatus.FROZEN,
|
||
MemberStatus.SUSPENDED,
|
||
MemberStatus.EXPIRED,
|
||
},
|
||
MembershipLifecycleAction.EXPIRE: {
|
||
MemberStatus.ACTIVE,
|
||
MemberStatus.FROZEN,
|
||
MemberStatus.SUSPENDED,
|
||
},
|
||
MembershipLifecycleAction.TRANSFER: {
|
||
MemberStatus.ACTIVE,
|
||
MemberStatus.FROZEN,
|
||
MemberStatus.SUSPENDED,
|
||
},
|
||
}
|
||
|
||
TERMINAL_STATUSES = frozenset(
|
||
{
|
||
MemberStatus.CANCELLED,
|
||
MemberStatus.TRANSFERRED,
|
||
MemberStatus.CLOSED,
|
||
}
|
||
)
|
||
|
||
ACTIVE_MEMBER_STATUSES = frozenset(
|
||
{
|
||
MemberStatus.PENDING,
|
||
MemberStatus.ACTIVE,
|
||
MemberStatus.FROZEN,
|
||
MemberStatus.SUSPENDED,
|
||
MemberStatus.EXPIRED,
|
||
}
|
||
)
|
||
|
||
|
||
def ensure_lifecycle_transition(
|
||
*,
|
||
action: MembershipLifecycleAction,
|
||
current: MemberStatus,
|
||
) -> None:
|
||
if current in TERMINAL_STATUSES:
|
||
raise AppError(
|
||
"عضویت در وضعیت پایانی است و قابل تغییر نیست",
|
||
status_code=409,
|
||
error_code="membership_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_lifecycle_transition",
|
||
details={
|
||
"from_status": current.value,
|
||
"action": action.value,
|
||
"allowed_from": sorted(s.value for s in allowed),
|
||
},
|
||
)
|
||
|
||
|
||
def target_status_for(action: MembershipLifecycleAction) -> MemberStatus:
|
||
mapping = {
|
||
MembershipLifecycleAction.ENROLL: MemberStatus.ACTIVE,
|
||
MembershipLifecycleAction.ACTIVATE: MemberStatus.ACTIVE,
|
||
MembershipLifecycleAction.RENEW: MemberStatus.ACTIVE,
|
||
MembershipLifecycleAction.FREEZE: MemberStatus.FROZEN,
|
||
MembershipLifecycleAction.RESUME: MemberStatus.ACTIVE,
|
||
MembershipLifecycleAction.CANCEL: MemberStatus.CANCELLED,
|
||
MembershipLifecycleAction.EXPIRE: MemberStatus.EXPIRED,
|
||
MembershipLifecycleAction.TRANSFER: MemberStatus.TRANSFERRED,
|
||
}
|
||
return mapping[action]
|
||
|
||
|
||
def compute_expiry(
|
||
*,
|
||
from_when: datetime | None = None,
|
||
term_days: int = DEFAULT_TERM_DAYS,
|
||
explicit: datetime | None = None,
|
||
) -> datetime:
|
||
if explicit is not None:
|
||
if explicit.tzinfo is None:
|
||
explicit = explicit.replace(tzinfo=timezone.utc)
|
||
return explicit
|
||
if term_days < 1:
|
||
raise AppError(
|
||
"مدت عضویت باید حداقل ۱ روز باشد",
|
||
status_code=422,
|
||
error_code="invalid_term_days",
|
||
)
|
||
base = from_when or datetime.now(timezone.utc)
|
||
if base.tzinfo is None:
|
||
base = base.replace(tzinfo=timezone.utc)
|
||
return base + timedelta(days=term_days)
|
||
|
||
|
||
def validate_reason(reason: str | None, *, required: bool = False, field: str = "reason") -> str | None:
|
||
if reason is None or not str(reason).strip():
|
||
if required:
|
||
raise AppError(
|
||
f"{field} الزامی است",
|
||
status_code=422,
|
||
error_code="validation_error",
|
||
details={"field": field},
|
||
)
|
||
return None
|
||
cleaned = str(reason).strip()
|
||
if len(cleaned) > 500:
|
||
raise AppError(
|
||
f"{field} بیش از حد طولانی است",
|
||
status_code=422,
|
||
error_code="validation_error",
|
||
details={"field": field},
|
||
)
|
||
return cleaned
|