Files
maskanx_crm_backend/app/api/leads.py
T

310 lines
9.1 KiB
Python
Raw Normal View History

2026-08-01 10:28:41 +05:30
from fastapi import APIRouter, HTTPException, Query, Response, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
from app.core.security import Admin, CurrentUser, Database, Writer
from app.models import (
Lead,
LeadSource,
LeadType,
Pipeline,
Stage,
User,
)
from app.schemas import (
LeadCreate,
LeadMove,
LeadOut,
LeadUpdate,
Page,
PipelineOut,
ReferenceItem,
StageOut,
UserOut,
)
from app.services import (
add_audit,
add_event,
apply_updates,
lead_load_options,
lead_to_out,
model_or_404,
next_lead_position,
set_lead_status,
validate_lead_references,
verify_optional_reference,
)
router = APIRouter(tags=["Leads"])
@router.get("/pipelines", response_model=list[PipelineOut])
def list_pipelines(user: CurrentUser, db: Database) -> list[PipelineOut]:
pipelines = db.scalars(
select(Pipeline)
.where(Pipeline.tenant_id == user.tenant_id)
.options(selectinload(Pipeline.stages))
.order_by(Pipeline.name),
).all()
return [
PipelineOut(
id=pipeline.id,
name=pipeline.name,
is_default=pipeline.is_default,
stages=[StageOut.model_validate(stage) for stage in pipeline.stages],
)
for pipeline in pipelines
]
@router.get("/lead-sources", response_model=list[ReferenceItem])
def list_sources(user: CurrentUser, db: Database) -> list[ReferenceItem]:
records = db.scalars(
select(LeadSource)
.where(LeadSource.tenant_id == user.tenant_id)
.order_by(LeadSource.name),
).all()
return [ReferenceItem.model_validate(item) for item in records]
@router.get("/lead-types", response_model=list[ReferenceItem])
def list_types(user: CurrentUser, db: Database) -> list[ReferenceItem]:
records = db.scalars(
select(LeadType)
.where(LeadType.tenant_id == user.tenant_id)
.order_by(LeadType.name),
).all()
return [ReferenceItem.model_validate(item) for item in records]
@router.get("/users", response_model=list[UserOut])
def list_users(user: CurrentUser, db: Database) -> list[UserOut]:
records = db.scalars(
select(User)
.where(User.tenant_id == user.tenant_id, User.is_active.is_(True))
.order_by(User.full_name),
).all()
return [UserOut.model_validate(item) for item in records]
@router.get("/leads", response_model=Page[LeadOut])
def list_leads(
user: CurrentUser,
db: Database,
search: str | None = Query(default=None, max_length=200),
pipeline_id: str | None = None,
stage_id: str | None = None,
contact_id: str | None = None,
organization_id: str | None = None,
status_value: str | None = Query(default=None, alias="status"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=100, ge=1, le=250),
) -> Page[LeadOut]:
filters = [Lead.tenant_id == user.tenant_id]
if pipeline_id:
filters.append(Lead.pipeline_id == pipeline_id)
if stage_id:
filters.append(Lead.stage_id == stage_id)
if contact_id:
filters.append(Lead.contact_id == contact_id)
if organization_id:
filters.append(Lead.organization_id == organization_id)
if status_value:
filters.append(Lead.status == status_value)
if search:
pattern = f"%{search.strip().lower()}%"
filters.append(
or_(
func.lower(Lead.title).like(pattern),
func.lower(Lead.description).like(pattern),
),
)
total = db.scalar(select(func.count(Lead.id)).where(*filters)) or 0
leads = db.scalars(
select(Lead)
.where(*filters)
.options(*lead_load_options())
.order_by(Lead.stage_id, Lead.position, Lead.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size),
).all()
return Page(
items=[lead_to_out(lead) for lead in leads],
total=total,
page=page,
page_size=page_size,
)
@router.post("/leads", response_model=LeadOut, status_code=status.HTTP_201_CREATED)
def create_lead(payload: LeadCreate, user: Writer, db: Database) -> LeadOut:
validate_lead_references(
db,
tenant_id=user.tenant_id,
pipeline_id=payload.pipeline_id,
stage_id=payload.stage_id,
contact_id=payload.contact_id,
organization_id=payload.organization_id,
owner_id=payload.owner_id,
)
verify_optional_reference(db, LeadSource, payload.source_id, user.tenant_id)
verify_optional_reference(db, LeadType, payload.type_id, user.tenant_id)
values = payload.model_dump()
values["position"] = next_lead_position(db, user.tenant_id, payload.stage_id)
lead = Lead(tenant_id=user.tenant_id, **values)
db.add(lead)
db.flush()
add_audit(
db,
actor=user,
tenant_id=user.tenant_id,
action="lead.created",
entity_type="lead",
entity_id=lead.id,
)
add_event(
db,
tenant_id=user.tenant_id,
topic="crm.lead.created",
payload={"lead_id": lead.id},
)
db.commit()
lead = db.scalar(
select(Lead).where(Lead.id == lead.id).options(*lead_load_options()),
)
return lead_to_out(lead)
@router.get("/leads/{lead_id}", response_model=LeadOut)
def get_lead(lead_id: str, user: CurrentUser, db: Database) -> LeadOut:
lead = db.scalar(
select(Lead)
.where(Lead.id == lead_id, Lead.tenant_id == user.tenant_id)
.options(*lead_load_options()),
)
if lead is None:
raise HTTPException(status_code=404, detail="The requested record was not found.")
return lead_to_out(lead)
@router.patch("/leads/{lead_id}", response_model=LeadOut)
def update_lead(
lead_id: str,
payload: LeadUpdate,
user: Writer,
db: Database,
) -> LeadOut:
lead = model_or_404(db, Lead, lead_id, user.tenant_id)
values = payload.model_dump(exclude_unset=True)
target_pipeline = values.get("pipeline_id", lead.pipeline_id)
target_stage = values.get("stage_id", lead.stage_id)
validate_lead_references(
db,
tenant_id=user.tenant_id,
pipeline_id=target_pipeline,
stage_id=target_stage,
contact_id=values.get("contact_id", lead.contact_id),
organization_id=values.get("organization_id", lead.organization_id),
owner_id=values.get("owner_id", lead.owner_id),
)
verify_optional_reference(
db,
LeadSource,
values.get("source_id", lead.source_id),
user.tenant_id,
)
verify_optional_reference(
db,
LeadType,
values.get("type_id", lead.type_id),
user.tenant_id,
)
status_value = values.pop("status", None)
lost_reason = values.pop("lost_reason", lead.lost_reason)
if target_stage != lead.stage_id:
values["position"] = next_lead_position(
db,
user.tenant_id,
target_stage,
)
apply_updates(lead, values)
if status_value is not None:
set_lead_status(lead, status_value, lost_reason)
add_audit(
db,
actor=user,
tenant_id=user.tenant_id,
action="lead.updated",
entity_type="lead",
entity_id=lead.id,
payload={"fields": sorted(payload.model_fields_set)},
)
add_event(
db,
tenant_id=user.tenant_id,
topic="crm.lead.updated",
payload={"lead_id": lead.id, "fields": sorted(payload.model_fields_set)},
)
db.commit()
lead = db.scalar(
select(Lead).where(Lead.id == lead.id).options(*lead_load_options()),
)
return lead_to_out(lead)
@router.post("/leads/{lead_id}/move", response_model=LeadOut)
def move_lead(
lead_id: str,
payload: LeadMove,
user: Writer,
db: Database,
) -> LeadOut:
lead = model_or_404(db, Lead, lead_id, user.tenant_id)
stage = model_or_404(db, Stage, payload.stage_id, user.tenant_id)
old_stage_id = lead.stage_id
lead.stage_id = stage.id
lead.pipeline_id = stage.pipeline_id
lead.position = payload.position or next_lead_position(
db,
user.tenant_id,
stage.id,
)
add_audit(
db,
actor=user,
tenant_id=user.tenant_id,
action="lead.moved",
entity_type="lead",
entity_id=lead.id,
payload={"from_stage_id": old_stage_id, "to_stage_id": stage.id},
)
add_event(
db,
tenant_id=user.tenant_id,
topic="crm.lead.stage_changed",
payload={"lead_id": lead.id, "stage_id": stage.id},
)
db.commit()
lead = db.scalar(
select(Lead).where(Lead.id == lead.id).options(*lead_load_options()),
)
return lead_to_out(lead)
@router.delete("/leads/{lead_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_lead(lead_id: str, user: Admin, db: Database) -> Response:
lead = model_or_404(db, Lead, lead_id, user.tenant_id)
add_audit(
db,
actor=user,
tenant_id=user.tenant_id,
action="lead.deleted",
entity_type="lead",
entity_id=lead.id,
)
db.delete(lead)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)