TorbatYar/backend/services/crm/app/validators/foundation.py
Mortezakoohjani 064d67f099 Ship CRM Core Platform (phases 6.0–6.3) with docs and prod deploy.
Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 20:32:00 +03:30

241 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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