feat: add landing page statistics endpoint with charts data

Add GET /admin/landings/{id}/stats endpoint returning:
- Summary stats (purchases, revenue, gifts, conversion rate)
- Daily breakdown for last 30 days (purchases, revenue, gifts per day)
- Tariff distribution (purchases and revenue per tariff)

Uses case() expressions for SQLite compatibility, timezone-safe
date grouping via func.timezone('UTC', ...), and composite index
(landing_id, status, paid_at) for query performance.
This commit is contained in:
Fringg
2026-03-07 09:35:21 +03:00
parent 26b486cdd9
commit 25478ced20
2 changed files with 199 additions and 2 deletions
+167 -2
View File
@@ -1,11 +1,12 @@
"""Admin routes for landing page management in cabinet."""
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from urllib.parse import urlparse
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import and_, case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.utils.locale import (
@@ -22,7 +23,7 @@ from app.database.crud.landing import (
update_landing,
update_landing_order,
)
from app.database.models import LandingPage, User
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff, User
from ..dependencies import get_cabinet_db, require_permission
@@ -436,6 +437,37 @@ class OrderRequest(BaseModel):
landing_ids: list[int]
class LandingDailyStat(BaseModel):
date: str # YYYY-MM-DD
purchases: int
revenue_kopeks: int
gifts: int
class LandingTariffStat(BaseModel):
tariff_id: int | None
tariff_name: str
purchases: int
revenue_kopeks: int
class LandingStatsResponse(BaseModel):
# Summary
total_purchases: int
total_revenue_kopeks: int
total_gifts: int
total_regular: int
avg_purchase_kopeks: int
# Conversion: created -> paid/delivered
total_created: int
total_successful: int # paid + delivered + pending_activation
conversion_rate: float # percent
# Daily chart data (last 30 days)
daily_stats: list[LandingDailyStat]
# Tariff breakdown
tariff_stats: list[LandingTariffStat]
# ============ Routes ============
# IMPORTANT: /order MUST come before /{landing_id} to avoid "order" being
@@ -677,6 +709,139 @@ async def toggle_landing_active(
return _landing_to_detail(landing)
_SUCCESSFUL_STATUSES = (
GuestPurchaseStatus.PAID.value,
GuestPurchaseStatus.DELIVERED.value,
GuestPurchaseStatus.PENDING_ACTIVATION.value,
)
_STATS_PERIOD_DAYS = 30
@router.get('/{landing_id}/stats', response_model=LandingStatsResponse)
async def get_landing_stats(
landing_id: int,
admin: User = Depends(require_permission('landings:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> LandingStatsResponse:
"""Get daily statistics and tariff breakdown for a landing page."""
landing = await get_landing_by_id(db, landing_id)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
# -- Summary stats (single query) --
is_successful = GuestPurchase.status.in_(_SUCCESSFUL_STATUSES)
summary_result = await db.execute(
select(
func.count(GuestPurchase.id).label('total_created'),
func.count(case((is_successful, GuestPurchase.id))).label('total_successful'),
func.coalesce(func.sum(case((is_successful, GuestPurchase.amount_kopeks))), 0).label(
'total_revenue_kopeks'
),
func.count(
case((and_(is_successful, GuestPurchase.is_gift.is_(True)), GuestPurchase.id))
).label('total_gifts'),
).where(GuestPurchase.landing_id == landing_id)
)
row = summary_result.one()
total_created: int = row.total_created
total_successful: int = row.total_successful
total_revenue_kopeks: int = row.total_revenue_kopeks
total_gifts: int = row.total_gifts
total_regular = total_successful - total_gifts
avg_purchase_kopeks = total_revenue_kopeks // total_successful if total_successful > 0 else 0
conversion_rate = round(total_successful / total_created * 100, 1) if total_created > 0 else 0.0
# -- Daily stats for last N days --
now = datetime.now(UTC)
cutoff = now - timedelta(days=_STATS_PERIOD_DAYS)
day_at_utc = func.date(func.timezone('UTC', GuestPurchase.paid_at))
daily_result = await db.execute(
select(
day_at_utc.label('day'),
func.count(GuestPurchase.id).label('purchases'),
func.coalesce(func.sum(GuestPurchase.amount_kopeks), 0).label('revenue_kopeks'),
func.count(case((GuestPurchase.is_gift.is_(True), GuestPurchase.id))).label('gifts'),
)
.where(
GuestPurchase.landing_id == landing_id,
is_successful,
GuestPurchase.paid_at >= cutoff,
)
.group_by(day_at_utc)
.order_by(day_at_utc)
)
daily_rows = {str(r.day): r for r in daily_result.all()}
# Fill missing days with zeros
today = now.date()
daily_stats: list[LandingDailyStat] = []
for i in range(_STATS_PERIOD_DAYS, -1, -1):
day = today - timedelta(days=i)
day_str = day.isoformat()
if day_str in daily_rows:
r = daily_rows[day_str]
daily_stats.append(
LandingDailyStat(
date=day_str,
purchases=r.purchases,
revenue_kopeks=r.revenue_kopeks,
gifts=r.gifts,
)
)
else:
daily_stats.append(
LandingDailyStat(
date=day_str,
purchases=0,
revenue_kopeks=0,
gifts=0,
)
)
# -- Tariff breakdown --
tariff_result = await db.execute(
select(
GuestPurchase.tariff_id,
func.coalesce(Tariff.name, 'Unknown').label('tariff_name'),
func.count(GuestPurchase.id).label('purchases'),
func.coalesce(func.sum(GuestPurchase.amount_kopeks), 0).label('revenue_kopeks'),
)
.outerjoin(Tariff, GuestPurchase.tariff_id == Tariff.id)
.where(
GuestPurchase.landing_id == landing_id,
is_successful,
)
.group_by(GuestPurchase.tariff_id, Tariff.name)
.order_by(func.coalesce(func.sum(GuestPurchase.amount_kopeks), 0).desc())
)
tariff_stats = [
LandingTariffStat(
tariff_id=r.tariff_id,
tariff_name=r.tariff_name,
purchases=r.purchases,
revenue_kopeks=r.revenue_kopeks,
)
for r in tariff_result.all()
]
return LandingStatsResponse(
total_purchases=total_successful,
total_revenue_kopeks=total_revenue_kopeks,
total_gifts=total_gifts,
total_regular=total_regular,
avg_purchase_kopeks=avg_purchase_kopeks,
total_created=total_created,
total_successful=total_successful,
conversion_rate=conversion_rate,
daily_stats=daily_stats,
tariff_stats=tariff_stats,
)
# ============ Helpers ============
@@ -0,0 +1,32 @@
"""add composite index on guest_purchases for stats queries
Replaces the single-column ix_guest_purchases_landing_id with a composite
index (landing_id, status, paid_at) that covers the summary, daily, and
tariff breakdown queries in the landing stats endpoint.
Revision ID: 0029
Revises: 0028
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0029'
down_revision: Union[str, None] = '0028'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
'ix_guest_purchases_landing_status_paid',
'guest_purchases',
['landing_id', 'status', 'paid_at'],
)
op.drop_index('ix_guest_purchases_landing_id', 'guest_purchases')
def downgrade() -> None:
op.create_index('ix_guest_purchases_landing_id', 'guest_purchases', ['landing_id'])
op.drop_index('ix_guest_purchases_landing_status_paid', 'guest_purchases')