Files
maskanx_crm_frontend/app/services.py
T
AFFAANhandClaude Opus 5 f6a54d6f93 Initial commit: Maskan CRM backend
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>
2026-08-01 10:28:41 +05:30

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