feat: add subscription status to referral network graph nodes

Add subscription_status field (trial_active, paid_active, trial_expired,
paid_expired) to NetworkUserNode and NetworkUserDetail schemas. Backend
computes status from Subscription.is_trial and end_date using window
function to pick latest subscription per user.
This commit is contained in:
Fringg
2026-03-22 09:07:29 +03:00
parent e0bedc8e78
commit de91d3282f
+71 -8
View File
@@ -2,6 +2,7 @@
import re
from collections import defaultdict
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -78,6 +79,7 @@ class NetworkUserNode(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -134,6 +136,7 @@ class NetworkUserDetail(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -215,6 +218,7 @@ def _build_user_node(
campaign_id: int | None,
subscription_name: str | None,
subscription_end_str: str | None,
subscription_status: str | None,
) -> NetworkUserNode:
return NetworkUserNode(
id=user.id,
@@ -232,6 +236,7 @@ def _build_user_node(
personal_spent_kopeks=personal_spent,
subscription_name=subscription_name,
subscription_end=subscription_end_str,
subscription_status=subscription_status,
registered_at=_format_datetime(user.created_at),
)
@@ -376,18 +381,59 @@ async def _fetch_campaign_registrations(db: AsyncSession, user_ids: set[int] | N
return {row[0]: row[1] for row in result}
async def _fetch_subscription_info(db: AsyncSession, user_ids: set[int]) -> dict[int, tuple[str | None, str | None]]:
"""Return {user_id: (tariff_name, end_date_iso)} for given users."""
async def _fetch_subscription_info(
db: AsyncSession, user_ids: set[int],
) -> dict[int, tuple[str | None, str | None, str | None]]:
"""Return {user_id: (tariff_name, end_date_iso, subscription_status)} for given users."""
if not user_ids:
return {}
stmt = (
select(Subscription.user_id, Tariff.name, Subscription.end_date)
row_num = (
func.row_number()
.over(
partition_by=Subscription.user_id,
order_by=Subscription.end_date.desc().nullslast(),
)
.label('rn')
)
inner = (
select(
Subscription.user_id,
Tariff.name,
Subscription.end_date,
Subscription.is_trial,
row_num,
)
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
.where(Subscription.user_id.in_(user_ids))
)
subq = inner.subquery()
stmt = select(
subq.c.user_id,
subq.c.name,
subq.c.end_date,
subq.c.is_trial,
).where(subq.c.rn == 1)
result = await db.execute(stmt)
return {row[0]: (row[1], _format_datetime(row[2]) if row[2] else None) for row in result}
now = datetime.now(UTC)
out: dict[int, tuple[str | None, str | None, str | None]] = {}
for row in result:
user_id, tariff_name, end_date, is_trial = row
end_date_iso = _format_datetime(end_date) if end_date else None
if is_trial is None:
sub_status = None
elif is_trial:
sub_status = 'trial_active' if (end_date and end_date > now) else 'trial_expired'
else:
sub_status = 'paid_active' if (end_date and end_date > now) else 'paid_expired'
out[user_id] = (tariff_name, end_date_iso, sub_status)
return out
async def _fetch_campaign_stats(
@@ -567,7 +613,7 @@ async def get_referral_network(
# Build user nodes
user_nodes: list[NetworkUserNode] = []
for user in users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -578,6 +624,7 @@ async def get_referral_network(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -816,7 +863,7 @@ async def _build_scoped_graph(
user_nodes: list[NetworkUserNode] = []
for user in users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -827,6 +874,7 @@ async def _build_scoped_graph(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -1124,10 +1172,23 @@ async def get_network_user_detail(
# Subscription info
subscription_name: str | None = None
subscription_end: str | None = None
subscription_status: str | None = None
if user.subscription is not None:
if user.subscription.tariff is not None:
subscription_name = user.subscription.tariff.name
subscription_end = _format_datetime(user.subscription.end_date)
# Compute subscription status
now = datetime.now(UTC)
if user.subscription.is_trial is None:
subscription_status = None
elif user.subscription.is_trial:
subscription_status = (
'trial_active' if (user.subscription.end_date and user.subscription.end_date > now) else 'trial_expired'
)
else:
subscription_status = (
'paid_active' if (user.subscription.end_date and user.subscription.end_date > now) else 'paid_expired'
)
return NetworkUserDetail(
id=user.id,
@@ -1147,6 +1208,7 @@ async def get_network_user_detail(
personal_spent_kopeks=personal_spent,
subscription_name=subscription_name,
subscription_end=subscription_end,
subscription_status=subscription_status,
registered_at=_format_datetime(user.created_at),
)
@@ -1336,7 +1398,7 @@ async def search_referral_network(
sub_info = await _fetch_subscription_info(db, matched_ids)
for user in matched_users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -1347,6 +1409,7 @@ async def search_referral_network(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)