310 lines
9.1 KiB
Python
310 lines
9.1 KiB
Python
|
|||
|
|
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)
|