"""Phase 6.1 CRM validators.""" from __future__ import annotations import re from decimal import Decimal from typing import Any from shared.exceptions import AppError from app.models.types import CustomFieldType, LeadStatus EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") PHONE_RE = re.compile(r"^[\d\s\+\-\(\)]{5,30}$") 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_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_owner(owner_user_id: str | None) -> str: return validate_non_empty(owner_user_id, field="owner_user_id") def validate_lead_status(status: LeadStatus | str) -> LeadStatus: if isinstance(status, LeadStatus): return status try: return LeadStatus(status) except ValueError as exc: raise AppError( "وضعیت سرنخ نامعتبر است", status_code=422, error_code="invalid_lead_status", ) from exc def validate_amount(amount: Decimal | None) -> Decimal: value = amount if amount is not None else Decimal("0") if value < 0: raise AppError( "مبلغ نمی‌تواند منفی باشد", status_code=422, error_code="invalid_amount", ) return value 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_win_probability(value: int) -> int: if value < 0 or value > 100: raise AppError( "احتمال برد باید بین ۰ و ۱۰۰ باشد", status_code=422, error_code="invalid_win_probability", ) return value def validate_address_line(line1: str | None) -> str: return validate_non_empty(line1, field="line1") def validate_tag_name(name: str | None) -> str: cleaned = validate_non_empty(name, field="name") if len(cleaned) > 100: raise AppError( "نام تگ بیش از حد طولانی است", status_code=422, error_code="tag_name_too_long", ) return cleaned def validate_custom_field_value( *, field_type: CustomFieldType, is_required: bool, options: list | None, value_text: str | None = None, value_number: Decimal | None = None, value_boolean: bool | None = None, value_date: Any = None, value_json: list | dict | None = None, ) -> None: has_value = any( v is not None for v in (value_text, value_number, value_boolean, value_date, value_json) ) if is_required and not has_value: raise AppError( "مقدار فیلد سفارشی الزامی است", status_code=422, error_code="custom_field_required", ) if field_type == CustomFieldType.TEXT and value_text is None and has_value: raise AppError( "مقدار متنی مورد انتظار است", status_code=422, error_code="custom_field_type_mismatch", ) if field_type == CustomFieldType.NUMBER and value_number is None and has_value: raise AppError( "مقدار عددی مورد انتظار است", status_code=422, error_code="custom_field_type_mismatch", ) if field_type == CustomFieldType.BOOLEAN and value_boolean is None and has_value: raise AppError( "مقدار بولین مورد انتظار است", status_code=422, error_code="custom_field_type_mismatch", ) if field_type == CustomFieldType.DATE and value_date is None and has_value: raise AppError( "مقدار تاریخ مورد انتظار است", status_code=422, error_code="custom_field_type_mismatch", ) if field_type == CustomFieldType.SELECT and value_text is not None and options: if value_text not in options: raise AppError( "گزینه انتخابی نامعتبر است", status_code=422, error_code="custom_field_invalid_option", ) if field_type == CustomFieldType.MULTI_SELECT and value_json is not None and options: if not isinstance(value_json, list): raise AppError( "مقدار چندگزینه‌ای باید لیست باشد", status_code=422, error_code="custom_field_type_mismatch", ) for item in value_json: if item not in options: raise AppError( "گزینه انتخابی نامعتبر است", status_code=422, error_code="custom_field_invalid_option", ) def validate_file_storage_id(file_storage_id: str | None) -> str: return validate_non_empty(file_storage_id, field="file_storage_id") def validate_attachment_file_name(file_name: str | None) -> str: return validate_non_empty(file_name, field="file_name") # Compatibility aliases used by Phase 6.0 opportunity/quote services ensure_quote_number = lambda n: validate_non_empty(n, field="quote_number") # noqa: E731 def ensure_opportunity_open(status) -> None: from app.models.types import OpportunityStatus if status != OpportunityStatus.OPEN: raise AppError( "فقط فرصت‌های باز قابل این عملیات هستند", status_code=409, error_code="opportunity_not_open", ) def ensure_activity_completable(status) -> None: from app.models.types import ActivityStatus if status == ActivityStatus.COMPLETED: raise AppError( "فعالیت قبلاً تکمیل شده است", status_code=409, error_code="activity_already_completed", ) if status == ActivityStatus.CANCELLED: raise AppError( "فعالیت لغوشده قابل تکمیل نیست", status_code=409, error_code="activity_cancelled", ) def ensure_quote_mutable(status) -> None: from app.models.types import QuoteStatus if status in {QuoteStatus.ACCEPTED, QuoteStatus.CANCELLED}: raise AppError( "این پیش‌فاکتور قابل ویرایش نیست", status_code=409, error_code="quote_not_mutable", )