"""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