Independent FastAPI backend for Maskan CRM. Owns contacts, organizations, leads, pipelines, activities, products, quotes, users, permissions, audit records and first-party integration credentials, with Alembic migrations against PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
412 lines
11 KiB
Python
412 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Any, Generic, TypeVar
|
|
|
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
class ApiModel(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class Page(ApiModel, Generic[T]):
|
|
items: list[T]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
|
|
|
|
class LoginRequest(ApiModel):
|
|
workspace: str = Field(min_length=2, max_length=80)
|
|
email: EmailStr
|
|
password: str = Field(min_length=8, max_length=200)
|
|
|
|
|
|
class UserOut(ApiModel):
|
|
id: str
|
|
tenant_id: str
|
|
email: EmailStr
|
|
full_name: str
|
|
role: str
|
|
|
|
|
|
class TenantOut(ApiModel):
|
|
id: str
|
|
slug: str
|
|
name: str
|
|
mode: str
|
|
|
|
|
|
class TokenResponse(ApiModel):
|
|
access_token: str
|
|
token_type: str = "bearer"
|
|
expires_in: int
|
|
user: UserOut
|
|
tenant: TenantOut
|
|
|
|
|
|
class SessionResponse(ApiModel):
|
|
user: UserOut
|
|
tenant: TenantOut
|
|
|
|
|
|
class OrganizationBase(ApiModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
legal_name: str | None = Field(default=None, max_length=240)
|
|
website: str | None = Field(default=None, max_length=500)
|
|
primary_email: EmailStr | None = None
|
|
phone: str | None = Field(default=None, max_length=80)
|
|
industry: str | None = Field(default=None, max_length=120)
|
|
address: dict[str, Any] = Field(default_factory=dict)
|
|
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
owner_id: str | None = None
|
|
|
|
|
|
class OrganizationCreate(OrganizationBase):
|
|
pass
|
|
|
|
|
|
class OrganizationUpdate(ApiModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
|
legal_name: str | None = Field(default=None, max_length=240)
|
|
website: str | None = Field(default=None, max_length=500)
|
|
primary_email: EmailStr | None = None
|
|
phone: str | None = Field(default=None, max_length=80)
|
|
industry: str | None = Field(default=None, max_length=120)
|
|
address: dict[str, Any] | None = None
|
|
attributes: dict[str, Any] | None = None
|
|
owner_id: str | None = None
|
|
|
|
|
|
class OrganizationOut(OrganizationBase):
|
|
id: str
|
|
tenant_id: str
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class ContactBase(ApiModel):
|
|
first_name: str = Field(min_length=1, max_length=100)
|
|
last_name: str = Field(default="", max_length=100)
|
|
job_title: str | None = Field(default=None, max_length=160)
|
|
primary_email: EmailStr | None = None
|
|
emails: list[dict[str, Any]] = Field(default_factory=list)
|
|
phones: list[dict[str, Any]] = Field(default_factory=list)
|
|
lifecycle_stage: str = Field(default="lead", max_length=40)
|
|
lead_source: str | None = Field(default=None, max_length=120)
|
|
score: int = Field(default=0, ge=0, le=100)
|
|
organization_id: str | None = None
|
|
owner_id: str | None = None
|
|
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class ContactCreate(ContactBase):
|
|
pass
|
|
|
|
|
|
class ContactUpdate(ApiModel):
|
|
first_name: str | None = Field(default=None, min_length=1, max_length=100)
|
|
last_name: str | None = Field(default=None, max_length=100)
|
|
job_title: str | None = Field(default=None, max_length=160)
|
|
primary_email: EmailStr | None = None
|
|
emails: list[dict[str, Any]] | None = None
|
|
phones: list[dict[str, Any]] | None = None
|
|
lifecycle_stage: str | None = Field(default=None, max_length=40)
|
|
lead_source: str | None = Field(default=None, max_length=120)
|
|
score: int | None = Field(default=None, ge=0, le=100)
|
|
organization_id: str | None = None
|
|
owner_id: str | None = None
|
|
attributes: dict[str, Any] | None = None
|
|
|
|
|
|
class ContactOut(ContactBase):
|
|
id: str
|
|
tenant_id: str
|
|
name: str
|
|
organization_name: str | None = None
|
|
owner_name: str | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class StageOut(ApiModel):
|
|
id: str
|
|
pipeline_id: str
|
|
name: str
|
|
position: int
|
|
probability: int
|
|
color: str
|
|
|
|
|
|
class PipelineOut(ApiModel):
|
|
id: str
|
|
name: str
|
|
is_default: bool
|
|
stages: list[StageOut]
|
|
|
|
|
|
class LeadBase(ApiModel):
|
|
title: str = Field(min_length=1, max_length=240)
|
|
description: str | None = None
|
|
value: Decimal = Field(default=Decimal("0"), ge=0)
|
|
currency: str = Field(default="USD", min_length=3, max_length=3)
|
|
score: int = Field(default=0, ge=0, le=100)
|
|
expected_close_at: datetime | None = None
|
|
contact_id: str | None = None
|
|
organization_id: str | None = None
|
|
owner_id: str | None = None
|
|
pipeline_id: str
|
|
stage_id: str
|
|
source_id: str | None = None
|
|
type_id: str | None = None
|
|
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
@field_validator("currency")
|
|
@classmethod
|
|
def normalize_currency(cls, value: str) -> str:
|
|
return value.upper()
|
|
|
|
|
|
class LeadCreate(LeadBase):
|
|
pass
|
|
|
|
|
|
class LeadUpdate(ApiModel):
|
|
title: str | None = Field(default=None, min_length=1, max_length=240)
|
|
description: str | None = None
|
|
value: Decimal | None = Field(default=None, ge=0)
|
|
currency: str | None = Field(default=None, min_length=3, max_length=3)
|
|
status: str | None = Field(default=None, pattern="^(open|won|lost)$")
|
|
score: int | None = Field(default=None, ge=0, le=100)
|
|
lost_reason: str | None = None
|
|
expected_close_at: datetime | None = None
|
|
contact_id: str | None = None
|
|
organization_id: str | None = None
|
|
owner_id: str | None = None
|
|
pipeline_id: str | None = None
|
|
stage_id: str | None = None
|
|
source_id: str | None = None
|
|
type_id: str | None = None
|
|
attributes: dict[str, Any] | None = None
|
|
|
|
@field_validator("currency")
|
|
@classmethod
|
|
def normalize_currency(cls, value: str | None) -> str | None:
|
|
return value.upper() if value else value
|
|
|
|
|
|
class LeadMove(ApiModel):
|
|
stage_id: str
|
|
position: int = Field(default=0, ge=0)
|
|
|
|
|
|
class LeadOut(LeadBase):
|
|
id: str
|
|
status: str
|
|
position: int
|
|
lost_reason: str | None
|
|
closed_at: datetime | None
|
|
contact_name: str | None = None
|
|
organization_name: str | None = None
|
|
owner_name: str | None = None
|
|
pipeline_name: str
|
|
stage_name: str
|
|
stage_color: str
|
|
source_name: str | None = None
|
|
type_name: str | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class ActivityBase(ApiModel):
|
|
activity_type: str = Field(
|
|
pattern="^(task|call|meeting|note|email|lunch)$",
|
|
)
|
|
title: str = Field(min_length=1, max_length=240)
|
|
details: str | None = None
|
|
starts_at: datetime | None = None
|
|
ends_at: datetime | None = None
|
|
due_at: datetime | None = None
|
|
is_done: bool = False
|
|
owner_id: str | None = None
|
|
contact_id: str | None = None
|
|
organization_id: str | None = None
|
|
lead_id: str | None = None
|
|
additional: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class ActivityCreate(ActivityBase):
|
|
pass
|
|
|
|
|
|
class ActivityUpdate(ApiModel):
|
|
activity_type: str | None = Field(
|
|
default=None,
|
|
pattern="^(task|call|meeting|note|email|lunch)$",
|
|
)
|
|
title: str | None = Field(default=None, min_length=1, max_length=240)
|
|
details: str | None = None
|
|
starts_at: datetime | None = None
|
|
ends_at: datetime | None = None
|
|
due_at: datetime | None = None
|
|
is_done: bool | None = None
|
|
owner_id: str | None = None
|
|
contact_id: str | None = None
|
|
organization_id: str | None = None
|
|
lead_id: str | None = None
|
|
additional: dict[str, Any] | None = None
|
|
|
|
|
|
class ActivityOut(ActivityBase):
|
|
id: str
|
|
completed_at: datetime | None
|
|
owner_name: str | None = None
|
|
contact_name: str | None = None
|
|
organization_name: str | None = None
|
|
lead_title: str | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class ProductBase(ApiModel):
|
|
sku: str = Field(min_length=1, max_length=100)
|
|
name: str = Field(min_length=1, max_length=200)
|
|
description: str | None = None
|
|
quantity: int = Field(default=0, ge=0)
|
|
price: Decimal = Field(default=Decimal("0"), ge=0)
|
|
currency: str = Field(default="USD", min_length=3, max_length=3)
|
|
is_active: bool = True
|
|
|
|
|
|
class ProductCreate(ProductBase):
|
|
pass
|
|
|
|
|
|
class ProductOut(ProductBase):
|
|
id: str
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class QuoteOut(ApiModel):
|
|
id: str
|
|
number: str
|
|
subject: str
|
|
status: str
|
|
currency: str
|
|
subtotal: Decimal
|
|
grand_total: Decimal
|
|
expires_at: datetime | None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class QuoteCreate(ApiModel):
|
|
number: str = Field(min_length=1, max_length=80)
|
|
subject: str = Field(min_length=1, max_length=240)
|
|
description: str | None = None
|
|
status: str = Field(default="draft", pattern="^(draft|sent|accepted|rejected|expired)$")
|
|
currency: str = Field(default="USD", min_length=3, max_length=3)
|
|
contact_id: str | None = None
|
|
organization_id: str | None = None
|
|
lead_id: str | None = None
|
|
owner_id: str | None = None
|
|
expires_at: datetime | None = None
|
|
|
|
@field_validator("currency")
|
|
@classmethod
|
|
def normalize_currency(cls, value: str) -> str:
|
|
return value.upper()
|
|
|
|
|
|
class ReferenceItem(ApiModel):
|
|
id: str
|
|
name: str
|
|
|
|
|
|
class DashboardStageMetric(ApiModel):
|
|
stage_id: str
|
|
stage_name: str
|
|
color: str
|
|
lead_count: int
|
|
total_value: Decimal
|
|
|
|
|
|
class DashboardSourceMetric(ApiModel):
|
|
source: str
|
|
lead_count: int
|
|
|
|
|
|
class DashboardResponse(ApiModel):
|
|
total_revenue: Decimal
|
|
open_pipeline_value: Decimal
|
|
total_leads: int
|
|
open_leads: int
|
|
won_leads: int
|
|
lost_leads: int
|
|
total_contacts: int
|
|
overdue_activities: int
|
|
stages: list[DashboardStageMetric]
|
|
sources: list[DashboardSourceMetric]
|
|
recent_activities: list[ActivityOut]
|
|
|
|
|
|
class IntegrationLeadRequest(ApiModel):
|
|
provider: str = Field(min_length=2, max_length=80)
|
|
external_id: str = Field(min_length=1, max_length=255)
|
|
first_name: str = Field(min_length=1, max_length=100)
|
|
last_name: str = Field(default="", max_length=100)
|
|
email: EmailStr | None = None
|
|
phone: str | None = Field(default=None, max_length=80)
|
|
company_name: str | None = Field(default=None, max_length=200)
|
|
job_title: str | None = Field(default=None, max_length=160)
|
|
lead_title: str | None = Field(default=None, max_length=240)
|
|
source: str = Field(default="MaskanX", max_length=120)
|
|
score: int = Field(default=0, ge=0, le=100)
|
|
campaign: dict[str, Any] = Field(default_factory=dict)
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class IntegrationLeadResponse(ApiModel):
|
|
contact_id: str
|
|
lead_id: str
|
|
created: bool
|
|
|
|
|
|
class IntegrationKeyCreate(ApiModel):
|
|
name: str = Field(min_length=2, max_length=120)
|
|
|
|
|
|
class IntegrationKeyResponse(ApiModel):
|
|
id: str
|
|
name: str
|
|
key: str
|
|
key_prefix: str
|
|
created_at: datetime
|
|
|
|
|
|
class IntegrationCredentialOut(ApiModel):
|
|
id: str
|
|
name: str
|
|
key_prefix: str
|
|
is_active: bool
|
|
last_used_at: datetime | None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class IntegrationStatusResponse(ApiModel):
|
|
status: str
|
|
product: str
|
|
mode: str
|
|
tenant_id: str
|
|
tenant_name: str
|
|
workspace: str
|
|
credential_id: str
|
|
credential_name: str
|
|
key_prefix: str
|