From 92bd3b6c00661cbd700ca0009586329ffb071c3c Mon Sep 17 00:00:00 2001 From: k1nq Date: Sat, 29 Nov 2025 09:14:35 +0500 Subject: [PATCH] feat: implement deal summary and funnel endpoints with response models --- app/api/v1/analytics.py | 93 ++++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/app/api/v1/analytics.py b/app/api/v1/analytics.py index 08d5383..26c9cd2 100644 --- a/app/api/v1/analytics.py +++ b/app/api/v1/analytics.py @@ -1,32 +1,95 @@ -"""Analytics API stubs (deal summary and funnel).""" +"""Analytics API endpoints for summaries and funnels.""" from __future__ import annotations -from fastapi import APIRouter, Depends, Query, status +from decimal import Decimal -from app.api.deps import get_organization_context +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, field_serializer + +from app.api.deps import get_analytics_service, get_organization_context +from app.models.deal import DealStage, DealStatus +from app.services.analytics_service import AnalyticsService, DealSummary, StageBreakdown from app.services.organization_service import OrganizationContext + +def _decimal_to_str(value: Decimal) -> str: + normalized = value.normalize() + return format(normalized, "f") + router = APIRouter(prefix="/analytics", tags=["analytics"]) -def _stub(endpoint: str) -> dict[str, str]: - return {"detail": f"{endpoint} is not implemented yet"} +class StatusSummaryModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + status: DealStatus + count: int + amount_sum: Decimal + + @field_serializer("amount_sum") + def serialize_amount_sum(self, value: Decimal) -> str: + return _decimal_to_str(value) -@router.get("/deals/summary", status_code=status.HTTP_501_NOT_IMPLEMENTED) +class WonStatisticsModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + count: int + amount_sum: Decimal + average_amount: Decimal + + @field_serializer("amount_sum", "average_amount") + def serialize_decimal_fields(self, value: Decimal) -> str: + return _decimal_to_str(value) + + +class NewDealsWindowModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + days: int + count: int + + +class DealSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + by_status: list[StatusSummaryModel] + won: WonStatisticsModel + new_deals: NewDealsWindowModel + total_deals: int + + +class StageBreakdownModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + stage: DealStage + total: int + by_status: dict[DealStatus, int] + conversion_to_next: float | None + + +class DealFunnelResponse(BaseModel): + stages: list[StageBreakdownModel] + + +@router.get("/deals/summary", response_model=DealSummaryResponse) async def deals_summary( days: int = Query(30, ge=1, le=180), context: OrganizationContext = Depends(get_organization_context), -) -> dict[str, str]: - """Placeholder for aggregated deal statistics.""" - _ = (days, context) - return _stub("GET /analytics/deals/summary") + service: AnalyticsService = Depends(get_analytics_service), +) -> DealSummaryResponse: + """Return aggregated deal statistics for the current organization.""" + + summary: DealSummary = await service.get_deal_summary(context.organization_id, days=days) + return DealSummaryResponse.model_validate(summary) -@router.get("/deals/funnel", status_code=status.HTTP_501_NOT_IMPLEMENTED) +@router.get("/deals/funnel", response_model=DealFunnelResponse) async def deals_funnel( context: OrganizationContext = Depends(get_organization_context), -) -> dict[str, str]: - """Placeholder for funnel analytics.""" - _ = context - return _stub("GET /analytics/deals/funnel") + service: AnalyticsService = Depends(get_analytics_service), +) -> DealFunnelResponse: + """Return funnel breakdown by stages and statuses.""" + + breakdowns: list[StageBreakdown] = await service.get_deal_funnel(context.organization_id) + return DealFunnelResponse(stages=[StageBreakdownModel.model_validate(item) for item in breakdowns])