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>
250 lines
7.1 KiB
Python
250 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
from app.models import (
|
|
Activity,
|
|
AuditEvent,
|
|
Contact,
|
|
IntegrationEvent,
|
|
Lead,
|
|
Organization,
|
|
Pipeline,
|
|
Stage,
|
|
User,
|
|
)
|
|
from app.schemas import ActivityOut, ContactOut, LeadOut
|
|
|
|
|
|
def model_or_404(
|
|
db: Session,
|
|
model: type[Any],
|
|
entity_id: str,
|
|
tenant_id: str,
|
|
) -> Any:
|
|
entity = db.scalar(
|
|
select(model).where(
|
|
model.id == entity_id,
|
|
model.tenant_id == tenant_id,
|
|
),
|
|
)
|
|
if entity is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="The requested record was not found.",
|
|
)
|
|
return entity
|
|
|
|
|
|
def verify_optional_reference(
|
|
db: Session,
|
|
model: type[Any],
|
|
entity_id: str | None,
|
|
tenant_id: str,
|
|
) -> None:
|
|
if entity_id is not None:
|
|
model_or_404(db, model, entity_id, tenant_id)
|
|
|
|
|
|
def add_audit(
|
|
db: Session,
|
|
*,
|
|
actor: User | None,
|
|
tenant_id: str,
|
|
action: str,
|
|
entity_type: str,
|
|
entity_id: str,
|
|
payload: dict[str, Any] | None = None,
|
|
) -> None:
|
|
db.add(
|
|
AuditEvent(
|
|
tenant_id=tenant_id,
|
|
actor_id=actor.id if actor else None,
|
|
action=action,
|
|
entity_type=entity_type,
|
|
entity_id=entity_id,
|
|
payload=payload or {},
|
|
),
|
|
)
|
|
|
|
|
|
def add_event(
|
|
db: Session,
|
|
*,
|
|
tenant_id: str,
|
|
topic: str,
|
|
payload: dict[str, Any],
|
|
) -> None:
|
|
db.add(
|
|
IntegrationEvent(
|
|
tenant_id=tenant_id,
|
|
topic=topic,
|
|
payload=payload,
|
|
),
|
|
)
|
|
|
|
|
|
def apply_updates(entity: Any, values: dict[str, Any]) -> None:
|
|
for field, value in values.items():
|
|
setattr(entity, field, value)
|
|
|
|
|
|
def contact_to_out(contact: Contact) -> ContactOut:
|
|
return ContactOut(
|
|
id=contact.id,
|
|
tenant_id=contact.tenant_id,
|
|
first_name=contact.first_name,
|
|
last_name=contact.last_name,
|
|
name=contact.name,
|
|
job_title=contact.job_title,
|
|
primary_email=contact.primary_email,
|
|
emails=contact.emails,
|
|
phones=contact.phones,
|
|
lifecycle_stage=contact.lifecycle_stage,
|
|
lead_source=contact.lead_source,
|
|
score=contact.score,
|
|
organization_id=contact.organization_id,
|
|
organization_name=contact.organization.name if contact.organization else None,
|
|
owner_id=contact.owner_id,
|
|
owner_name=contact.owner.full_name if contact.owner else None,
|
|
attributes=contact.attributes,
|
|
created_at=contact.created_at,
|
|
updated_at=contact.updated_at,
|
|
)
|
|
|
|
|
|
def lead_to_out(lead: Lead) -> LeadOut:
|
|
return LeadOut(
|
|
id=lead.id,
|
|
title=lead.title,
|
|
description=lead.description,
|
|
value=lead.value,
|
|
currency=lead.currency,
|
|
status=lead.status,
|
|
score=lead.score,
|
|
position=lead.position,
|
|
lost_reason=lead.lost_reason,
|
|
expected_close_at=lead.expected_close_at,
|
|
closed_at=lead.closed_at,
|
|
contact_id=lead.contact_id,
|
|
contact_name=lead.contact.name if lead.contact else None,
|
|
organization_id=lead.organization_id,
|
|
organization_name=lead.organization.name if lead.organization else None,
|
|
owner_id=lead.owner_id,
|
|
owner_name=lead.owner.full_name if lead.owner else None,
|
|
pipeline_id=lead.pipeline_id,
|
|
pipeline_name=lead.pipeline.name,
|
|
stage_id=lead.stage_id,
|
|
stage_name=lead.stage.name,
|
|
stage_color=lead.stage.color,
|
|
source_id=lead.source_id,
|
|
source_name=lead.source.name if lead.source else None,
|
|
type_id=lead.type_id,
|
|
type_name=lead.lead_type.name if lead.lead_type else None,
|
|
attributes=lead.attributes,
|
|
created_at=lead.created_at,
|
|
updated_at=lead.updated_at,
|
|
)
|
|
|
|
|
|
def activity_to_out(activity: Activity) -> ActivityOut:
|
|
return ActivityOut(
|
|
id=activity.id,
|
|
activity_type=activity.activity_type,
|
|
title=activity.title,
|
|
details=activity.details,
|
|
starts_at=activity.starts_at,
|
|
ends_at=activity.ends_at,
|
|
due_at=activity.due_at,
|
|
is_done=activity.is_done,
|
|
completed_at=activity.completed_at,
|
|
owner_id=activity.owner_id,
|
|
owner_name=activity.owner.full_name if activity.owner else None,
|
|
contact_id=activity.contact_id,
|
|
contact_name=activity.contact.name if activity.contact else None,
|
|
organization_id=activity.organization_id,
|
|
organization_name=(
|
|
activity.organization.name if activity.organization else None
|
|
),
|
|
lead_id=activity.lead_id,
|
|
lead_title=activity.lead.title if activity.lead else None,
|
|
additional=activity.additional,
|
|
created_at=activity.created_at,
|
|
updated_at=activity.updated_at,
|
|
)
|
|
|
|
|
|
def lead_load_options() -> tuple[Any, ...]:
|
|
return (
|
|
selectinload(Lead.contact),
|
|
selectinload(Lead.organization),
|
|
selectinload(Lead.owner),
|
|
selectinload(Lead.pipeline),
|
|
selectinload(Lead.stage),
|
|
selectinload(Lead.source),
|
|
selectinload(Lead.lead_type),
|
|
)
|
|
|
|
|
|
def activity_load_options() -> tuple[Any, ...]:
|
|
return (
|
|
selectinload(Activity.owner),
|
|
selectinload(Activity.contact),
|
|
selectinload(Activity.organization),
|
|
selectinload(Activity.lead),
|
|
)
|
|
|
|
|
|
def validate_lead_references(
|
|
db: Session,
|
|
*,
|
|
tenant_id: str,
|
|
pipeline_id: str,
|
|
stage_id: str,
|
|
contact_id: str | None = None,
|
|
organization_id: str | None = None,
|
|
owner_id: str | None = None,
|
|
) -> None:
|
|
pipeline = model_or_404(db, Pipeline, pipeline_id, tenant_id)
|
|
stage = model_or_404(db, Stage, stage_id, tenant_id)
|
|
if stage.pipeline_id != pipeline.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="The selected stage does not belong to the selected pipeline.",
|
|
)
|
|
verify_optional_reference(db, Contact, contact_id, tenant_id)
|
|
verify_optional_reference(db, Organization, organization_id, tenant_id)
|
|
verify_optional_reference(db, User, owner_id, tenant_id)
|
|
|
|
|
|
def set_lead_status(lead: Lead, status_value: str, lost_reason: str | None) -> None:
|
|
if status_value == "lost" and not lost_reason:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="A lost reason is required when a lead is marked lost.",
|
|
)
|
|
|
|
lead.status = status_value
|
|
lead.lost_reason = lost_reason if status_value == "lost" else None
|
|
lead.closed_at = datetime.now(UTC) if status_value in {"won", "lost"} else None
|
|
|
|
|
|
def next_lead_position(db: Session, tenant_id: str, stage_id: str) -> int:
|
|
maximum = db.scalar(
|
|
select(func.max(Lead.position)).where(
|
|
Lead.tenant_id == tenant_id,
|
|
Lead.stage_id == stage_id,
|
|
),
|
|
)
|
|
return int(maximum or 0) + 1
|
|
|
|
|
|
def decimal_or_zero(value: Decimal | None) -> Decimal:
|
|
return value or Decimal("0")
|