refactor: project architecture refactor, container splitting

This commit is contained in:
3252a8
2026-05-17 00:01:28 +03:00
parent e0b5218037
commit 30fb774d93
367 changed files with 2609 additions and 864 deletions
+25
View File
@@ -0,0 +1,25 @@
from . import (
ad_dal,
app_settings_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
promo_code_dal,
security_dal,
subscription_dal,
user_billing_dal,
user_dal,
)
__all__ = (
"user_dal",
"payment_dal",
"subscription_dal",
"promo_code_dal",
"panel_sync_dal",
"message_log_dal",
"user_billing_dal",
"ad_dal",
"security_dal",
"app_settings_dal",
)
+210
View File
@@ -0,0 +1,210 @@
import logging
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..models import AdAttribution, AdCampaign, Payment
async def create_campaign(
session: AsyncSession, *, source: str, start_param: str, cost: float
) -> AdCampaign:
existing = await get_campaign_by_start_param(session, start_param)
if existing:
raise ValueError("ad_campaign_start_param_exists")
campaign = AdCampaign(source=source, start_param=start_param, cost=float(cost))
session.add(campaign)
await session.flush()
await session.refresh(campaign)
logging.info(
f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}" # noqa: E501
)
return campaign
async def get_campaign_by_id(session: AsyncSession, campaign_id: int) -> Optional[AdCampaign]:
stmt = select(AdCampaign).where(AdCampaign.ad_campaign_id == campaign_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_campaign_by_start_param(
session: AsyncSession, start_param: str
) -> Optional[AdCampaign]:
clean = start_param.strip()
stmt = select(AdCampaign).where(AdCampaign.start_param == clean)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def list_campaigns(session: AsyncSession, *, only_active: bool = False) -> List[AdCampaign]:
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc())
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
result = await session.execute(stmt)
return result.scalars().all()
async def toggle_campaign_active(session: AsyncSession, campaign_id: int, is_active: bool) -> bool:
stmt = (
update(AdCampaign)
.where(AdCampaign.ad_campaign_id == campaign_id)
.values(is_active=is_active)
)
result = await session.execute(stmt)
return result.rowcount > 0
async def ensure_attribution(
session: AsyncSession, *, user_id: int, campaign_id: int
) -> AdAttribution:
existing = await get_attribution_for_user(session, user_id)
if existing:
return existing
attrib = AdAttribution(user_id=user_id, ad_campaign_id=campaign_id)
session.add(attrib)
await session.flush()
await session.refresh(attrib)
logging.info(f"AdAttribution created for user {user_id} -> campaign {campaign_id}")
return attrib
async def get_attribution_for_user(session: AsyncSession, user_id: int) -> Optional[AdAttribution]:
stmt = select(AdAttribution).where(AdAttribution.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def mark_trial_activated(session: AsyncSession, user_id: int) -> bool:
stmt = (
update(AdAttribution)
.where(and_(AdAttribution.user_id == user_id, AdAttribution.trial_activated_at.is_(None)))
.values(trial_activated_at=func.now())
)
result = await session.execute(stmt)
return result.rowcount > 0
async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[str, Any]:
# Starts (attributed users)
starts_stmt = select(func.count(AdAttribution.user_id)).where(
AdAttribution.ad_campaign_id == campaign_id
)
starts = (await session.execute(starts_stmt)).scalar() or 0
# Trials
trials_stmt = select(func.count(AdAttribution.user_id)).where(
and_(
AdAttribution.ad_campaign_id == campaign_id,
AdAttribution.trial_activated_at.is_not(None),
)
)
trials = (await session.execute(trials_stmt)).scalar() or 0
# Payers (unique users with succeeded payments)
attrib_subq = (
select(
AdAttribution.user_id.label("user_id"),
AdAttribution.first_start_at.label("first_start_at"),
)
.where(AdAttribution.ad_campaign_id == campaign_id)
.subquery()
)
payers_stmt = (
select(func.count(func.distinct(Payment.user_id)))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
payers = (await session.execute(payers_stmt)).scalar() or 0
# Revenue sum
revenue_stmt = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
return {
"starts": int(starts),
"trials": int(trials),
"payers": int(payers),
"revenue": revenue,
}
async def count_campaigns(session: AsyncSession, *, only_active: bool = False) -> int:
stmt = select(func.count(AdCampaign.ad_campaign_id))
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
return int((await session.execute(stmt)).scalar() or 0)
async def list_campaigns_paged(
session: AsyncSession, *, page: int, page_size: int, only_active: bool = False
) -> List[AdCampaign]:
offset = max(0, page) * max(1, page_size)
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc()).offset(offset).limit(page_size)
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
result = await session.execute(stmt)
return result.scalars().all()
async def get_totals(session: AsyncSession) -> Dict[str, float]:
# Total cost across all campaigns
total_cost_stmt = select(func.coalesce(func.sum(AdCampaign.cost), 0.0))
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
# Total revenue from all attributed users (unique users counted across all campaigns)
attrib_subq = select(
AdAttribution.user_id.label("user_id"),
AdAttribution.first_start_at.label("first_start_at"),
).subquery()
revenue_stmt = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
return {"cost": total_cost, "revenue": total_revenue}
async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
"""Delete ad campaign by id along with related attributions.
Returns True if campaign existed and was deleted, False otherwise.
"""
try:
campaign = await session.get(AdCampaign, campaign_id)
if not campaign:
return False
await session.delete(campaign)
await session.flush()
logging.info(f"AdCampaign deleted id={campaign_id}")
return True
except Exception as e:
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
raise
+100
View File
@@ -0,0 +1,100 @@
"""Persistent overrides for application settings.
Overrides take priority over `.env` values for keys exposed via the admin
manifest. Values are stored as JSON-encoded text to preserve typing across
strings, booleans, integers and floats.
"""
import json
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AppSettingOverride
logger = logging.getLogger(__name__)
def _encode(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _decode(raw: Optional[str]) -> Any:
if raw is None:
return None
try:
return json.loads(raw)
except (TypeError, ValueError):
return raw
async def get_all_overrides(session: AsyncSession) -> Dict[str, Any]:
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
return {row.key: _decode(row.value) for row in rows}
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, Any]]:
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
items: List[Dict[str, Any]] = []
for row in rows:
items.append(
{
"key": row.key,
"value": _decode(row.value),
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"updated_by": row.updated_by,
}
)
return items
async def upsert_override(
session: AsyncSession,
*,
key: str,
value: Any,
updated_by: Optional[int],
) -> None:
encoded = _encode(value)
now = datetime.now(timezone.utc)
stmt = (
pg_insert(AppSettingOverride)
.values(key=key, value=encoded, updated_at=now, updated_by=updated_by)
.on_conflict_do_update(
index_elements=[AppSettingOverride.key],
set_={
"value": encoded,
"updated_at": now,
"updated_by": updated_by,
},
)
)
await session.execute(stmt)
async def delete_override(session: AsyncSession, key: str) -> bool:
stmt = delete(AppSettingOverride).where(AppSettingOverride.key == key)
result = await session.execute(stmt)
return bool(result.rowcount or 0)
async def bulk_apply(
session: AsyncSession,
*,
updates: Dict[str, Tuple[bool, Any]],
updated_by: Optional[int],
) -> None:
"""Apply a batch of changes. Each entry maps key -> (set_flag, value).
When set_flag is False the override is deleted (revert to env). Otherwise
the value is upserted.
"""
for key, (set_flag, value) in updates.items():
if set_flag:
await upsert_override(session, key=key, value=value, updated_by=updated_by)
else:
await delete_override(session, key)
+88
View File
@@ -0,0 +1,88 @@
import logging
from typing import List, Optional
from sqlalchemy import func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..models import MessageLog
async def create_message_log(session: AsyncSession, log_data: dict) -> Optional[MessageLog]:
try:
log_entry = await create_message_log_no_commit(session, log_data)
await session.commit()
await session.refresh(log_entry)
return log_entry
except Exception as e:
await session.rollback()
logging.error(f"Failed to create and commit message log: {e}", exc_info=True)
return None
async def get_all_message_logs(session: AsyncSession, limit: int, offset: int) -> List[MessageLog]:
stmt = select(MessageLog).order_by(MessageLog.timestamp.desc()).limit(limit).offset(offset)
result = await session.execute(stmt)
return result.scalars().all()
async def count_all_message_logs(session: AsyncSession) -> int:
stmt = select(func.count()).select_from(MessageLog)
result = await session.execute(stmt)
return result.scalar_one()
async def get_user_message_logs(
session: AsyncSession, user_id_to_search: int, limit: int, offset: int
) -> List[MessageLog]:
stmt = (
select(MessageLog)
.where(
or_(
MessageLog.user_id == user_id_to_search,
MessageLog.target_user_id == user_id_to_search,
)
)
.order_by(MessageLog.timestamp.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt)
return result.scalars().all()
async def count_user_message_logs(session: AsyncSession, user_id_to_search: int) -> int:
stmt = (
select(func.count())
.select_from(MessageLog)
.where(
or_(
MessageLog.user_id == user_id_to_search,
MessageLog.target_user_id == user_id_to_search,
)
)
)
result = await session.execute(stmt)
return result.scalar_one()
async def create_message_log_no_commit(session: AsyncSession, log_data: dict) -> MessageLog:
if log_data.get("target_user_id"):
from .user_dal import get_user_by_id
target_user = await get_user_by_id(session, log_data["target_user_id"])
if not target_user:
logging.warning(
f"Target user {log_data['target_user_id']} not found for message log. Setting to NULL." # noqa: E501
)
log_data["target_user_id"] = None
new_log = MessageLog(**log_data)
session.add(new_log)
logging.debug(
f"Message log added to session: user {log_data.get('user_id')}, event {log_data.get('event_type')}" # noqa: E501
)
return new_log
+50
View File
@@ -0,0 +1,50 @@
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import PanelSyncStatus
SINGLETON_ID = 1
async def get_panel_sync_status(session: AsyncSession) -> Optional[PanelSyncStatus]:
return await session.get(PanelSyncStatus, SINGLETON_ID)
async def update_panel_sync_status(
session: AsyncSession,
status: str,
details: str,
users_processed: int = 0,
subs_synced: int = 0,
last_sync_time: Optional[datetime] = None,
) -> PanelSyncStatus:
if last_sync_time is None:
last_sync_time = datetime.now(timezone.utc)
sync_record = await get_panel_sync_status(session)
if sync_record:
sync_record.last_sync_time = last_sync_time
sync_record.status = status
sync_record.details = details
sync_record.users_processed_from_panel = users_processed
sync_record.subscriptions_synced = subs_synced
else:
sync_record = PanelSyncStatus(
id=SINGLETON_ID,
last_sync_time=last_sync_time,
status=status,
details=details,
users_processed_from_panel=users_processed,
subscriptions_synced=subs_synced,
)
session.add(sync_record)
await session.flush()
await session.refresh(sync_record)
logging.info(
f"Panel sync status updated: {status}, Users: {users_processed}, Subs: {subs_synced}"
)
return sync_record
+291
View File
@@ -0,0 +1,291 @@
import logging
from typing import Any, Dict, List, Optional
from sqlalchemy import Date, and_, cast, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from db.models import Payment, User
async def create_payment_record(session: AsyncSession, payment_data: Dict[str, Any]) -> Payment:
from .user_dal import get_user_by_id
user = await get_user_by_id(session, payment_data["user_id"])
if not user:
raise ValueError(f"User with id {payment_data['user_id']} not found for creating payment.")
if payment_data.get("promo_code_id"):
from .promo_code_dal import get_promo_code_by_id
promo = await get_promo_code_by_id(session, payment_data["promo_code_id"])
if not promo:
raise ValueError(f"Promo code with id {payment_data['promo_code_id']} not found.")
new_payment = Payment(**payment_data)
session.add(new_payment)
await session.flush()
await session.refresh(new_payment)
logging.info(f"Payment record {new_payment.payment_id} created for user {new_payment.user_id}")
return new_payment
async def get_payment_by_provider_payment_id(
session: AsyncSession, provider_payment_id: str
) -> Optional[Payment]:
"""Fetch a payment by provider-specific identifier."""
stmt = select(Payment).where(Payment.provider_payment_id == provider_payment_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def ensure_payment_with_provider_id(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
months: int,
description: str,
provider: str,
provider_payment_id: str,
) -> Payment:
"""Idempotently create a payment record for a provider event.
If a payment with the same provider_payment_id already exists, returns it.
Otherwise creates a new pending payment with provided data.
"""
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
if existing:
return existing
pending_status = f"pending_{provider}" if provider else "pending"
payment_payload: Dict[str, Any] = {
"user_id": user_id,
"amount": float(amount),
"currency": currency,
"status": pending_status,
"description": description,
"subscription_duration_months": months,
"provider_payment_id": provider_payment_id,
"provider": provider,
}
return await create_payment_record(session, payment_payload)
async def get_payment_by_db_id(session: AsyncSession, payment_db_id: int) -> Optional[Payment]:
stmt = (
select(Payment)
.where(Payment.payment_id == payment_db_id)
.options(selectinload(Payment.user), selectinload(Payment.promo_code_used))
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_payment_status_by_db_id(
session: AsyncSession, payment_db_id: int, new_status: str, yk_payment_id: Optional[str] = None
) -> Optional[Payment]:
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.status = new_status
payment.updated_at = func.now()
if yk_payment_id and payment.yookassa_payment_id is None:
payment.yookassa_payment_id = yk_payment_id
await session.flush()
await session.refresh(payment)
logging.info(f"Payment record {payment.payment_id} status updated to {new_status}.")
else:
logging.warning(f"Payment record with DB ID {payment_db_id} not found for status update.")
return payment
async def get_recent_payment_logs_with_user(
session: AsyncSession, limit: int = 20, offset: int = 0
) -> List[Payment]:
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.where(Payment.status == "succeeded")
.order_by(Payment.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt)
return result.scalars().all()
async def get_payments_count(session: AsyncSession) -> int:
"""Get total count of successful payments."""
stmt = select(func.count(Payment.payment_id)).where(Payment.status == "succeeded")
result = await session.execute(stmt)
return result.scalar() or 0
async def get_all_succeeded_payments_with_user(session: AsyncSession) -> List[Payment]:
"""Get all successful payments with user data for export."""
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.where(Payment.status == "succeeded")
.order_by(Payment.created_at.desc())
)
result = await session.execute(stmt)
return result.scalars().all()
async def count_user_succeeded_payments(
session: AsyncSession, user_id: int, exclude_payment_id: Optional[int] = None
) -> int:
"""Count succeeded payments for a specific user.
If exclude_payment_id is provided, that specific payment will be excluded
from the count. Useful to check "prior" payments while processing the
current payment in the same transaction.
"""
conditions = [Payment.user_id == user_id, Payment.status == "succeeded"]
if exclude_payment_id is not None:
conditions.append(Payment.payment_id != exclude_payment_id)
stmt = select(func.count(Payment.payment_id)).where(and_(*conditions))
result = await session.execute(stmt)
return result.scalar() or 0
async def update_provider_payment_and_status(
session: AsyncSession, payment_db_id: int, provider_payment_id: str, new_status: str
) -> Optional[Payment]:
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.status = new_status
payment.provider_payment_id = provider_payment_id
payment.updated_at = func.now()
await session.flush()
await session.refresh(payment)
logging.info(
f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}." # noqa: E501
)
else:
logging.warning(f"Payment record with DB ID {payment_db_id} not found for provider update.")
return payment
async def _daily_revenue_series_utc(session: AsyncSession, days: int = 14) -> List[Dict[str, Any]]:
"""Succeeded payment totals per calendar day (UTC) for the last `days` days."""
from datetime import date, datetime, timedelta, timezone
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
range_start = today_start - timedelta(days=days - 1)
day_col = cast(func.date_trunc("day", Payment.created_at), Date).label("d")
stmt = (
select(day_col, func.coalesce(func.sum(Payment.amount), 0.0))
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= range_start,
)
)
.group_by(day_col)
.order_by(day_col)
)
result = await session.execute(stmt)
by_day: Dict[date, float] = {}
for row in result.all():
d_key = row[0]
if isinstance(d_key, datetime):
d_key = d_key.date()
by_day[d_key] = float(row[1] or 0)
out: List[Dict[str, Any]] = []
for i in range(days):
d = (range_start + timedelta(days=i)).date()
out.append({"date": d.isoformat(), "amount": float(by_day.get(d, 0.0) or 0.0)})
return out
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive financial statistics."""
from datetime import datetime, timedelta
from sqlalchemy import and_
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=7)
month_start = today_start - timedelta(days=30)
# Today's revenue
stmt_today = select(func.sum(Payment.amount)).where(
and_(Payment.status == "succeeded", Payment.created_at >= today_start)
)
today_revenue = await session.execute(stmt_today)
today_amount = today_revenue.scalar() or 0
# Week revenue
stmt_week = select(func.sum(Payment.amount)).where(
and_(Payment.status == "succeeded", Payment.created_at >= week_start)
)
week_revenue = await session.execute(stmt_week)
week_amount = week_revenue.scalar() or 0
# Month revenue
stmt_month = select(func.sum(Payment.amount)).where(
and_(Payment.status == "succeeded", Payment.created_at >= month_start)
)
month_revenue = await session.execute(stmt_month)
month_amount = month_revenue.scalar() or 0
# All time revenue
stmt_all = select(func.sum(Payment.amount)).where(Payment.status == "succeeded")
all_revenue = await session.execute(stmt_all)
all_amount = all_revenue.scalar() or 0
# Count of successful payments today
stmt_count_today = select(func.count(Payment.payment_id)).where(
and_(Payment.status == "succeeded", Payment.created_at >= today_start)
)
today_count = await session.execute(stmt_count_today)
today_payments_count = today_count.scalar() or 0
# Longer tail for admin dashboard charts (presets up to 1y + custom range on the client).
daily_series = await _daily_revenue_series_utc(session, days=730)
return {
"today_revenue": float(today_amount),
"week_revenue": float(week_amount),
"month_revenue": float(month_amount),
"all_time_revenue": float(all_amount),
"today_payments_count": today_payments_count,
"daily_series": daily_series,
}
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
stmt = select(func.sum(Payment.amount)).where(
and_(Payment.user_id == user_id, Payment.status == "succeeded")
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
"""Get total revenue generated from referred users' payments.
This calculates the sum of all succeeded payments made by users
where referred_by_id equals the referrer_id.
"""
stmt = (
select(func.sum(Payment.amount))
.join(User, Payment.user_id == User.user_id)
.where(and_(User.referred_by_id == referrer_id, Payment.status == "succeeded"))
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
+208
View File
@@ -0,0 +1,208 @@
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from db.models import PromoCode, PromoCodeActivation
async def create_promo_code(session: AsyncSession, promo_data: Dict[str, Any]) -> PromoCode:
new_promo = PromoCode(**promo_data)
session.add(new_promo)
await session.flush()
await session.refresh(new_promo)
logging.info(f"Promo code '{new_promo.code}' created with ID {new_promo.promo_code_id}")
return new_promo
async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
return await session.get(PromoCode, promo_code_id)
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get promo code by code string (regardless of active status)"""
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_active_promo_code_by_code_str(
session: AsyncSession, code_str: str
) -> Optional[PromoCode]:
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_all_active_promo_codes(
session: AsyncSession, limit: int = 20, offset: int = 0
) -> List[PromoCode]:
stmt = (
select(PromoCode)
.where(
PromoCode.is_active == True,
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
)
.order_by(PromoCode.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt)
return result.scalars().all()
async def get_all_promo_codes_with_details(
session: AsyncSession, limit: int = 50, offset: int = 0
) -> List[PromoCode]:
"""Get all promo codes (active and inactive) with pagination for management"""
stmt = select(PromoCode).order_by(PromoCode.created_at.desc()).limit(limit).offset(offset)
result = await session.execute(stmt)
return result.scalars().all()
async def get_promo_codes_count(session: AsyncSession) -> int:
"""Get total count of all promo codes"""
from sqlalchemy import func
stmt = select(func.count(PromoCode.promo_code_id))
result = await session.execute(stmt)
return result.scalar_one()
async def get_promo_activations_by_code_id(
session: AsyncSession, promo_code_id: int, limit: Optional[int] = None, offset: int = 0
) -> List[PromoCodeActivation]:
"""Get activation history for a specific promo code with optional pagination."""
stmt = (
select(PromoCodeActivation)
.where(PromoCodeActivation.promo_code_id == promo_code_id)
.order_by(PromoCodeActivation.activated_at.desc())
.offset(offset)
)
if limit is not None:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
async def count_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> int:
"""Count total activations for a specific promo code."""
stmt = (
select(func.count())
.select_from(PromoCodeActivation)
.where(PromoCodeActivation.promo_code_id == promo_code_id)
)
result = await session.execute(stmt)
return result.scalar_one()
async def update_promo_code(
session: AsyncSession, promo_id: int, update_data: Dict[str, Any]
) -> Optional[PromoCode]:
promo = await get_promo_code_by_id(session, promo_id)
if not promo:
return None
for key, value in update_data.items():
setattr(promo, key, value)
await session.flush()
await session.refresh(promo)
return promo
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
promo = await get_promo_code_by_id(session, promo_id)
if not promo:
return None
# First, delete related activations due to foreign key constraint
activations = await get_promo_activations_by_code_id(session, promo_id)
for activation in activations:
await session.delete(activation)
await session.delete(promo)
await session.flush()
return promo
async def increment_promo_code_usage(
session: AsyncSession, promo_code_id: int
) -> Optional[PromoCode]:
promo = await get_promo_code_by_id(session, promo_code_id)
if promo:
if promo.current_activations < promo.max_activations:
promo.current_activations += 1
await session.flush()
await session.refresh(promo)
return promo
else:
logging.warning(
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
)
return None
return None
async def get_user_activation_for_promo(
session: AsyncSession, promo_code_id: int, user_id: int
) -> Optional[PromoCodeActivation]:
stmt = (
select(PromoCodeActivation)
.where(
PromoCodeActivation.promo_code_id == promo_code_id,
PromoCodeActivation.user_id == user_id,
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def record_promo_activation(
session: AsyncSession, promo_code_id: int, user_id: int, payment_id: Optional[int] = None
) -> Optional[PromoCodeActivation]:
existing_activation = await get_user_activation_for_promo(session, promo_code_id, user_id)
if existing_activation:
logging.info(
f"User {user_id} has already activated promo code {promo_code_id}. Activation ID: {existing_activation.activation_id}" # noqa: E501
)
return existing_activation
from .user_dal import get_user_by_id
user = await get_user_by_id(session, user_id)
promo = await get_promo_code_by_id(session, promo_code_id)
if not user or not promo:
logging.error(
f"Cannot record promo activation: User {user_id} or Promo {promo_code_id} not found."
)
return None
if payment_id:
from .payment_dal import get_payment_by_db_id
payment = await get_payment_by_db_id(session, payment_id)
if not payment:
logging.error(f"Cannot record promo activation: Payment {payment_id} not found.")
return None
activation_data = {
"promo_code_id": promo_code_id,
"user_id": user_id,
"payment_id": payment_id,
"activated_at": datetime.now(timezone.utc),
}
new_activation = PromoCodeActivation(**activation_data)
session.add(new_activation)
await session.flush()
await session.refresh(new_activation)
logging.info(
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}" # noqa: E501
)
return new_activation
+160
View File
@@ -0,0 +1,160 @@
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import case, delete, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import SecurityThrottle
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
PROMO_CODE_APPLY_SCOPE = "promo_code_apply"
@dataclass(frozen=True)
class ThrottleDecision:
locked: bool
retry_after: Optional[int] = None
def _utc_now(value: Optional[datetime] = None) -> datetime:
if value is None:
value = datetime.now(timezone.utc)
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _retry_after_seconds(locked_until: Optional[datetime], now: datetime) -> Optional[int]:
if not locked_until:
return None
locked_until = _utc_now(locked_until)
remaining = int((locked_until - now).total_seconds())
return max(1, remaining) if remaining > 0 else None
async def get_throttle_state(
session: AsyncSession,
*,
scope: str,
identifier: str,
) -> Optional[SecurityThrottle]:
stmt = (
select(SecurityThrottle)
.where(
SecurityThrottle.scope == scope,
SecurityThrottle.identifier == identifier,
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def check_throttle(
session: AsyncSession,
*,
scope: str,
identifier: str,
now: Optional[datetime] = None,
) -> ThrottleDecision:
now = _utc_now(now)
row = await get_throttle_state(session, scope=scope, identifier=identifier)
if not row or not row.locked_until:
return ThrottleDecision(locked=False)
locked_until = _utc_now(row.locked_until)
if locked_until <= now:
return ThrottleDecision(locked=False)
return ThrottleDecision(
locked=True,
retry_after=_retry_after_seconds(locked_until, now),
)
async def record_throttle_failure(
session: AsyncSession,
*,
scope: str,
identifier: str,
max_failures: int,
window_seconds: int,
lock_seconds: int,
now: Optional[datetime] = None,
) -> ThrottleDecision:
now = _utc_now(now)
max_failures = max(1, int(max_failures))
window_seconds = max(1, int(window_seconds))
lock_seconds = max(1, int(lock_seconds))
window_cutoff = now - timedelta(seconds=window_seconds)
lock_until = now + timedelta(seconds=lock_seconds)
failure_count_expr = case(
(
or_(
SecurityThrottle.window_started_at.is_(None),
SecurityThrottle.window_started_at <= window_cutoff,
),
1,
),
else_=SecurityThrottle.failures + 1,
)
stmt = (
pg_insert(SecurityThrottle)
.values(
scope=scope,
identifier=identifier,
failures=1,
window_started_at=now,
last_attempt_at=now,
locked_until=lock_until if max_failures <= 1 else None,
)
.on_conflict_do_update(
index_elements=[SecurityThrottle.scope, SecurityThrottle.identifier],
set_={
"failures": failure_count_expr,
"window_started_at": case(
(
or_(
SecurityThrottle.window_started_at.is_(None),
SecurityThrottle.window_started_at <= window_cutoff,
),
now,
),
else_=SecurityThrottle.window_started_at,
),
"last_attempt_at": now,
"locked_until": case(
(failure_count_expr >= max_failures, lock_until),
else_=None,
),
},
)
.returning(SecurityThrottle.locked_until)
)
result = await session.execute(stmt)
locked_until = result.scalar_one_or_none()
locked_until = _utc_now(locked_until) if locked_until else None
if locked_until and locked_until > now:
return ThrottleDecision(
locked=True,
retry_after=_retry_after_seconds(locked_until, now),
)
return ThrottleDecision(locked=False)
async def clear_throttle_state(
session: AsyncSession,
*,
scope: str,
identifier: str,
) -> None:
stmt = delete(SecurityThrottle).where(
SecurityThrottle.scope == scope,
SecurityThrottle.identifier == identifier,
)
await session.execute(stmt)
+261
View File
@@ -0,0 +1,261 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from db.models import Subscription
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
model_columns = Subscription.__mapper__.columns.keys()
filtered_payload = {key: value for key, value in sub_payload.items() if key in model_columns}
ignored_keys = sorted(set(sub_payload) - set(filtered_payload))
if ignored_keys:
logging.warning("Ignoring unsupported subscription payload keys: %s", ignored_keys)
return filtered_payload
async def get_active_subscription_by_user_id(
session: AsyncSession, user_id: int, panel_user_uuid: Optional[str] = None
) -> Optional[Subscription]:
stmt = select(Subscription).where(
Subscription.user_id == user_id,
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
if panel_user_uuid:
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
stmt = stmt.order_by(Subscription.end_date.desc()).limit(1)
result = await session.execute(stmt)
return result.scalars().first()
async def get_subscription_by_panel_subscription_uuid(
session: AsyncSession, panel_sub_uuid: str
) -> Optional[Subscription]:
stmt = select(Subscription).where(Subscription.panel_subscription_uuid == panel_sub_uuid)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_active_subscriptions_for_user(
session: AsyncSession, user_id: int
) -> List[Subscription]:
"""Get all active subscriptions for a user."""
stmt = (
select(Subscription)
.where(Subscription.user_id == user_id, Subscription.is_active == True)
.order_by(Subscription.end_date.desc())
)
result = await session.execute(stmt)
return result.scalars().all()
async def update_subscription(
session: AsyncSession, subscription_id: int, update_data: Dict[str, Any]
) -> Optional[Subscription]:
sub = await session.get(Subscription, subscription_id)
if sub:
for key, value in update_data.items():
setattr(sub, key, value)
await session.flush()
await session.refresh(sub)
return sub
async def set_auto_renew(
session: AsyncSession, subscription_id: int, enabled: bool
) -> Optional[Subscription]:
"""Toggle auto_renew_enabled for a subscription."""
return await update_subscription(session, subscription_id, {"auto_renew_enabled": enabled})
async def set_user_subscriptions_cancelled_with_grace(
session: AsyncSession, user_id: int, grace_days: int = 1
) -> int:
"""Mark all active user subscriptions as cancelled with a short grace period.
Sets end_date to now + grace_days, status_from_panel to 'CANCELLED', and
skip future notifications to reduce noise after cancellation.
Returns number of updated rows.
"""
from datetime import datetime, timedelta, timezone
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
stmt = (
update(Subscription)
.where(Subscription.user_id == user_id, Subscription.is_active == True)
.values(
end_date=grace_end,
status_from_panel="CANCELLED",
skip_notifications=True,
)
)
result = await session.execute(stmt)
return result.rowcount or 0
async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any]) -> Subscription:
panel_sub_uuid = sub_payload.get("panel_subscription_uuid")
if not panel_sub_uuid:
raise ValueError("panel_subscription_uuid is required for upsert.")
existing_sub = await get_subscription_by_panel_subscription_uuid(session, panel_sub_uuid)
if existing_sub:
logging.info(
f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}" # noqa: E501
)
for key, value in _subscription_model_payload(sub_payload).items():
setattr(existing_sub, key, value)
await session.flush()
await session.refresh(existing_sub)
return existing_sub
else:
logging.info(f"Creating new subscription with panel_sub_uuid {panel_sub_uuid}")
if sub_payload.get("user_id") is None and "panel_user_uuid" not in sub_payload:
raise ValueError("For a new subscription without user_id, panel_user_uuid is required.")
if "end_date" not in sub_payload:
raise ValueError("Missing 'end_date' for new subscription.")
if sub_payload.get("user_id") is not None:
from .user_dal import get_user_by_id
user = await get_user_by_id(session, sub_payload["user_id"])
if not user:
raise ValueError(
f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}." # noqa: E501
)
new_sub = Subscription(**_subscription_model_payload(sub_payload))
session.add(new_sub)
await session.flush()
await session.refresh(new_sub)
return new_sub
async def deactivate_other_active_subscriptions(
session: AsyncSession, panel_user_uuid: str, current_panel_subscription_uuid: Optional[str]
) -> None:
stmt = (
update(Subscription)
.where(
Subscription.panel_user_uuid == panel_user_uuid,
Subscription.is_active == True,
)
.values(is_active=False, status_from_panel="INACTIVE_BY_BOT_SYNC")
)
if current_panel_subscription_uuid:
stmt = stmt.where(Subscription.panel_subscription_uuid != current_panel_subscription_uuid)
result = await session.execute(stmt)
if result.rowcount > 0:
logging.info(
f"Deactivated {result.rowcount} other active subscriptions for panel_user_uuid {panel_user_uuid}." # noqa: E501
)
async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int) -> int:
stmt = (
update(Subscription)
.where(Subscription.user_id == user_id, Subscription.is_active == True)
.values(is_active=False, status_from_panel="INACTIVE_USER_NOT_FOUND")
)
result = await session.execute(stmt)
if result.rowcount > 0:
logging.info(
f"Deactivated {result.rowcount} subscriptions for user {user_id} due to missing panel user." # noqa: E501
)
return result.rowcount
async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int:
"""Completely delete all user subscriptions (for trial reset)"""
stmt = delete(Subscription).where(Subscription.user_id == user_id)
result = await session.execute(stmt)
if result.rowcount > 0:
logging.info(
f"Deleted {result.rowcount} subscription records for user {user_id} for trial reset."
)
return result.rowcount
async def update_subscription_end_date(
session: AsyncSession, subscription_id: int, new_end_date: datetime
) -> Optional[Subscription]:
return await update_subscription(
session,
subscription_id,
{
"end_date": new_end_date,
"last_notification_sent": None,
"is_active": True,
"status_from_panel": "ACTIVE_EXTENDED_BY_BOT",
},
)
async def has_any_subscription_for_user(session: AsyncSession, user_id: int) -> bool:
stmt = select(Subscription.subscription_id).where(Subscription.user_id == user_id).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def get_subscriptions_near_expiration(
session: AsyncSession, days_threshold: int
) -> List[Subscription]:
now_utc = datetime.now(timezone.utc)
threshold_date = now_utc + timedelta(days=days_threshold)
stmt = (
select(Subscription)
.join(Subscription.user)
.where(
Subscription.is_active == True,
Subscription.skip_notifications == False,
Subscription.end_date > now_utc,
Subscription.end_date <= threshold_date,
or_(
Subscription.last_notification_sent == None,
func.date(Subscription.last_notification_sent) < func.date(now_utc),
),
)
.order_by(Subscription.end_date.asc())
.options(selectinload(Subscription.user))
)
result = await session.execute(stmt)
return result.scalars().all()
async def update_subscription_notification_time(
session: AsyncSession, subscription_id: int, notification_time: datetime
) -> Optional[Subscription]:
return await update_subscription(
session, subscription_id, {"last_notification_sent": notification_time}
)
async def find_subscription_for_notification_update(
session: AsyncSession, user_id: int, subscription_end_date_to_match: datetime
) -> Optional[Subscription]:
if subscription_end_date_to_match.tzinfo is None:
subscription_end_date_to_match = subscription_end_date_to_match.replace(tzinfo=timezone.utc)
stmt = (
select(Subscription)
.where(
Subscription.user_id == user_id,
Subscription.is_active == True,
Subscription.end_date >= subscription_end_date_to_match - timedelta(seconds=1),
Subscription.end_date <= subscription_end_date_to_match + timedelta(seconds=1),
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
+122
View File
@@ -0,0 +1,122 @@
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import HwidDevicePurchase, TariffChange, TrafficTopup, TrafficWarning
async def create_traffic_topup(
session: AsyncSession,
*,
subscription_id: int,
payment_id: Optional[int],
purchased_bytes: int,
kind: str,
) -> TrafficTopup:
record = TrafficTopup(
subscription_id=subscription_id,
payment_id=payment_id,
purchased_bytes=purchased_bytes,
kind=kind,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def create_hwid_device_purchase(
session: AsyncSession,
*,
subscription_id: int,
payment_id: Optional[int],
purchased_devices: int,
) -> HwidDevicePurchase:
record = HwidDevicePurchase(
subscription_id=subscription_id,
payment_id=payment_id,
purchased_devices=purchased_devices,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def create_tariff_change(
session: AsyncSession,
change_data: Dict[str, Any],
) -> TariffChange:
record = TariffChange(**change_data)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def get_warning(
session: AsyncSession,
*,
subscription_id: int,
period_start_at,
level: int,
traffic_limit_bytes: Optional[int] = None,
) -> Optional[TrafficWarning]:
"""Return an existing traffic warning row if one was already recorded.
For traffic-style billing ``period_start_at`` is NULL. Do **not** match on
``traffic_limit_bytes`` in that case: the effective limit can change between
worker ticks (panel sync, top-ups, admin adjustments). Matching on the exact
bytes caused duplicate Telegram alerts after restarts or the next poll.
The ``traffic_limit_bytes`` argument is kept for call-site compatibility
but is ignored when ``period_start_at`` is None.
"""
conditions = [
TrafficWarning.subscription_id == subscription_id,
TrafficWarning.level == level,
]
if period_start_at is None:
conditions.append(TrafficWarning.period_start_at.is_(None))
else:
conditions.append(TrafficWarning.period_start_at == period_start_at)
result = await session.execute(select(TrafficWarning).where(and_(*conditions)).limit(1))
return result.scalar_one_or_none()
async def create_warning(
session: AsyncSession,
*,
subscription_id: int,
period_start_at,
level: int,
traffic_limit_bytes: Optional[int],
) -> TrafficWarning:
record = TrafficWarning(
subscription_id=subscription_id,
period_start_at=period_start_at,
level=level,
traffic_limit_bytes=traffic_limit_bytes,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def clear_period_warnings(session: AsyncSession, subscription_id: int) -> int:
result = await session.execute(
delete(TrafficWarning).where(TrafficWarning.subscription_id == subscription_id)
)
return result.rowcount or 0
async def get_tariff_changes_for_subscription(
session: AsyncSession, subscription_id: int
) -> List[TariffChange]:
result = await session.execute(
select(TariffChange)
.where(TariffChange.subscription_id == subscription_id)
.order_by(TariffChange.created_at.desc())
)
return list(result.scalars().all())
+199
View File
@@ -0,0 +1,199 @@
from typing import List, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import func
from db.models import UserBilling, UserPaymentMethod
async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]:
stmt = select(UserBilling).where(UserBilling.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def upsert_yk_payment_method(
session: AsyncSession,
*,
user_id: int,
payment_method_id: str,
card_last4: Optional[str] = None,
card_network: Optional[str] = None,
) -> UserBilling:
existing = await get_user_billing(session, user_id)
if existing:
existing.yookassa_payment_method_id = payment_method_id
existing.card_last4 = card_last4
existing.card_network = card_network
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return existing
record = UserBilling(
user_id=user_id,
yookassa_payment_method_id=payment_method_id,
card_last4=card_last4,
card_network=card_network,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool:
existing = await get_user_billing(session, user_id)
if not existing:
return False
existing.yookassa_payment_method_id = None
existing.card_last4 = None
existing.card_network = None
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return True
# Multi-card support API
async def upsert_user_payment_method(
session: AsyncSession,
*,
user_id: int,
provider_payment_method_id: str,
provider: str = "yookassa",
card_last4: Optional[str] = None,
card_network: Optional[str] = None,
set_default: bool = False,
) -> UserPaymentMethod:
existing_stmt = select(UserPaymentMethod).where(
UserPaymentMethod.provider_payment_method_id == provider_payment_method_id
)
result = await session.execute(existing_stmt)
existing: Optional[UserPaymentMethod] = result.scalar_one_or_none()
if existing:
existing.card_last4 = card_last4
existing.card_network = card_network
if set_default:
# unset previous defaults
await session.execute(
update(UserPaymentMethod)
.where(UserPaymentMethod.user_id == user_id)
.values(is_default=False)
)
existing.is_default = True
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return existing
if set_default:
await session.execute(
update(UserPaymentMethod)
.where(UserPaymentMethod.user_id == user_id)
.values(is_default=False)
)
record = UserPaymentMethod(
user_id=user_id,
provider=provider,
provider_payment_method_id=provider_payment_method_id,
card_last4=card_last4,
card_network=card_network,
is_default=set_default,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def list_user_payment_methods(
session: AsyncSession, user_id: int, provider: Optional[str] = None
) -> List[UserPaymentMethod]:
stmt = select(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
if provider:
stmt = stmt.where(UserPaymentMethod.provider == provider)
stmt = stmt.order_by(UserPaymentMethod.is_default.desc(), UserPaymentMethod.created_at.desc())
result = await session.execute(stmt)
return result.scalars().all()
async def get_user_default_payment_method(
session: AsyncSession, user_id: int, provider: str = "yookassa"
) -> Optional[UserPaymentMethod]:
stmt = select(UserPaymentMethod).where(
UserPaymentMethod.user_id == user_id,
UserPaymentMethod.provider == provider,
UserPaymentMethod.is_default == True,
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def set_user_default_payment_method(
session: AsyncSession, user_id: int, method_id: int
) -> bool:
methods = await list_user_payment_methods(session, user_id)
if not any(m.method_id == method_id for m in methods):
return False
await session.execute(
update(UserPaymentMethod)
.where(UserPaymentMethod.user_id == user_id)
.values(is_default=False)
)
await session.execute(
update(UserPaymentMethod)
.where(UserPaymentMethod.method_id == method_id)
.values(is_default=True)
)
return True
async def delete_user_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
stmt = select(UserPaymentMethod).where(
UserPaymentMethod.method_id == method_id, UserPaymentMethod.user_id == user_id
)
result = await session.execute(stmt)
method = result.scalar_one_or_none()
if not method:
return False
await session.delete(method)
await session.flush()
return True
async def delete_user_payment_method_by_provider_id(
session: AsyncSession,
user_id: int,
provider_payment_method_id: str,
) -> bool:
"""Delete a saved payment method by its provider payment_method.id for a specific user.
Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id.
""" # noqa: E501
stmt = select(UserPaymentMethod).where(
UserPaymentMethod.user_id == user_id,
UserPaymentMethod.provider_payment_method_id == provider_payment_method_id,
)
result = await session.execute(stmt)
method: Optional[UserPaymentMethod] = result.scalar_one_or_none()
if not method:
return False
await session.delete(method)
await session.flush()
return True
async def user_has_saved_payment_method(
session: AsyncSession,
user_id: int,
provider: str = "yookassa",
) -> bool:
"""Return True if the user has at least one saved payment method."""
try:
methods = await list_user_payment_methods(session, user_id, provider)
if methods:
return True
billing = await get_user_billing(session, user_id)
return bool(billing and billing.yookassa_payment_method_id)
except Exception:
return False
+849
View File
@@ -0,0 +1,849 @@
import logging
import secrets
import string
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, delete, desc, func, or_, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import aliased
from ..models import (
AdAttribution,
MessageLog,
Payment,
PromoCodeActivation,
Subscription,
User,
UserBilling,
UserPaymentMethod,
UserTelegramAvatar,
)
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
REFERRAL_CODE_LENGTH = 9
MAX_REFERRAL_CODE_ATTEMPTS = 25
MAX_EMAIL_USER_ID_ATTEMPTS = 25
class UserMergeConflictError(ValueError):
pass
def _generate_referral_code_candidate() -> str:
return "".join(secrets.choice(REFERRAL_CODE_ALPHABET) for _ in range(REFERRAL_CODE_LENGTH))
async def _referral_code_exists(session: AsyncSession, code: str) -> bool:
stmt = select(User.user_id).where(User.referral_code == code)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def generate_unique_referral_code(session: AsyncSession) -> str:
"""
Generate a unique referral code consisting of uppercase alphanumeric characters.
Retries until a free code is found or raises RuntimeError after exceeding attempts.
"""
for _ in range(MAX_REFERRAL_CODE_ATTEMPTS):
candidate = _generate_referral_code_candidate()
if not await _referral_code_exists(session, candidate):
return candidate
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
async def generate_unique_email_user_id(session: AsyncSession) -> int:
for _ in range(MAX_EMAIL_USER_ID_ATTEMPTS):
candidate = -(secrets.randbelow(9_000_000_000_000_000) + 1)
if not await get_user_by_id(session, candidate):
return candidate
raise RuntimeError("Failed to generate a unique email user id after several attempts.")
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
"""
Ensure the provided user has a referral code, generating and persisting it if missing.
Returns the existing or newly generated code.
"""
if user.referral_code:
normalized = user.referral_code.strip().upper()
if normalized != user.referral_code:
user.referral_code = normalized
await session.flush()
await session.refresh(user)
return user.referral_code
user.referral_code = await generate_unique_referral_code(session)
await session.flush()
await session.refresh(user)
return user.referral_code
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
stmt = select(User).where(User.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
clean_username = username.lstrip("@").lower()
stmt = select(User).where(func.lower(User.username) == clean_username)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_email(session: AsyncSession, email: str) -> Optional[User]:
clean_email = (email or "").strip().lower()
if not clean_email:
return None
stmt = select(User).where(func.lower(User.email) == clean_email)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
stmt = select(User).where(User.telegram_id == telegram_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_telegram_avatar(
session: AsyncSession,
user_id: int,
) -> Optional[UserTelegramAvatar]:
stmt = select(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def upsert_user_telegram_avatar(
session: AsyncSession,
*,
user_id: int,
file_unique_id: Optional[str],
content_type: str,
image_bytes: bytes,
) -> UserTelegramAvatar:
avatar = await get_user_telegram_avatar(session, user_id)
if avatar is None:
avatar = UserTelegramAvatar(
user_id=user_id,
file_unique_id=file_unique_id,
content_type=content_type,
image_bytes=image_bytes,
size_bytes=len(image_bytes),
updated_at=datetime.now(timezone.utc),
)
session.add(avatar)
else:
avatar.file_unique_id = file_unique_id
avatar.content_type = content_type
avatar.image_bytes = image_bytes
avatar.size_bytes = len(image_bytes)
avatar.updated_at = datetime.now(timezone.utc)
await session.flush()
await session.refresh(avatar)
return avatar
async def get_user_by_panel_uuid(session: AsyncSession, panel_uuid: str) -> Optional[User]:
stmt = select(User).where(User.panel_user_uuid == panel_uuid)
result = await session.execute(stmt)
return result.scalar_one_or_none()
## Removed unused generic get_user helper to keep DAL explicit and simple
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple[User, bool]:
"""Create a user if not exists in a race-safe way.
Returns a tuple of (user, created_flag).
"""
if "registration_date" not in user_data:
user_data["registration_date"] = datetime.now(timezone.utc)
if not user_data.get("referral_code"):
user_data["referral_code"] = await generate_unique_referral_code(session)
else:
user_data["referral_code"] = user_data["referral_code"].strip().upper()
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
stmt = (
pg_insert(User)
.values(**user_data)
.on_conflict_do_nothing(index_elements=[User.user_id])
.returning(User.user_id)
)
result = await session.execute(stmt)
inserted_row = result.first()
created = inserted_row is not None
# Fetch the user (inserted just now or pre-existing)
user_id: int = user_data["user_id"]
user = await get_user_by_id(session, user_id)
if created and user is not None:
logging.info(
f"New user {user.user_id} created in DAL. Referred by: {user.referred_by_id or 'N/A'}."
)
elif user is not None:
logging.info(f"User {user.user_id} already exists in DAL. Proceeding without creation.")
return user, created
async def create_email_user(
session: AsyncSession,
*,
email: str,
language_code: str,
email_verified_at: Optional[datetime] = None,
referred_by_id: Optional[int] = None,
) -> Tuple[User, bool]:
normalized_email = (email or "").strip().lower()
user_id = await generate_unique_email_user_id(session)
return await create_user(
session,
{
"user_id": user_id,
"email": normalized_email,
"email_verified_at": email_verified_at or datetime.now(timezone.utc),
"language_code": language_code,
"referred_by_id": referred_by_id,
"registration_date": datetime.now(timezone.utc),
},
)
async def _has_active_panel_subscription(
session: AsyncSession, user_id: int, panel_user_uuid: str
) -> bool:
stmt = (
select(Subscription.subscription_id)
.where(
Subscription.user_id == user_id,
Subscription.panel_user_uuid == panel_user_uuid,
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def _get_latest_subscription_for_user(
session: AsyncSession,
user_id: int,
panel_user_uuid: Optional[str] = None,
*,
active_only: bool = False,
) -> Optional[Subscription]:
stmt = select(Subscription).where(Subscription.user_id == user_id)
if panel_user_uuid is not None:
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
if active_only:
stmt = stmt.where(
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
stmt = stmt.order_by(Subscription.end_date.desc(), Subscription.subscription_id.desc()).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def _get_active_subscription_for_user(
session: AsyncSession,
user_id: int,
panel_user_uuid: Optional[str] = None,
) -> Optional[Subscription]:
return await _get_latest_subscription_for_user(
session,
user_id,
panel_user_uuid,
active_only=True,
)
async def merge_users(
session: AsyncSession,
*,
source_user_id: int,
target_user_id: int,
) -> User:
"""Merge source user data into target user and remove the source row."""
if source_user_id == target_user_id:
target = await get_user_by_id(session, target_user_id)
if not target:
raise ValueError("Target user not found.")
return target
source = await get_user_by_id(session, source_user_id)
target = await get_user_by_id(session, target_user_id)
if not source or not target:
raise ValueError("Both source and target users are required for merge.")
if source.email and target.email and source.email != target.email:
raise UserMergeConflictError("Both accounts already have different emails.")
if (
source.telegram_id
and target.telegram_id
and int(source.telegram_id) != int(target.telegram_id)
):
raise UserMergeConflictError("Both accounts already have different Telegram IDs.")
source_panel_uuid = source.panel_user_uuid
target_panel_uuid = target.panel_user_uuid
panel_uuid_to_keep = target_panel_uuid or source_panel_uuid
now = datetime.now(timezone.utc)
source_active_sub = await _get_active_subscription_for_user(
session, source_user_id, source_panel_uuid
)
target_active_sub = await _get_active_subscription_for_user(
session, target_user_id, target_panel_uuid
)
target_anchor_sub = target_active_sub
if not target_anchor_sub and target_panel_uuid:
target_anchor_sub = await _get_latest_subscription_for_user(
session, target_user_id, target_panel_uuid
)
if not target_anchor_sub and not target_panel_uuid:
target_anchor_sub = await _get_latest_subscription_for_user(session, target_user_id)
if (
source_active_sub
and target_anchor_sub
and source_panel_uuid
and target_panel_uuid
and source_panel_uuid != target_panel_uuid
):
source_end = source_active_sub.end_date
if source_end.tzinfo is None:
source_end = source_end.replace(tzinfo=timezone.utc)
target_end = target_anchor_sub.end_date
if target_end.tzinfo is None:
target_end = target_end.replace(tzinfo=timezone.utc)
source_remaining = max(timedelta(0), source_end - now)
if source_remaining > timedelta(0):
base_end = target_end if target_end > now else now
target_anchor_sub.end_date = base_end + source_remaining
target_anchor_sub.last_notification_sent = None
target_anchor_sub.is_active = True
target_anchor_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
source_active_sub.is_active = False
source_active_sub.skip_notifications = True
source_active_sub.last_notification_sent = None
source_active_sub.status_from_panel = "MERGED_INTO_ACCOUNT"
elif (
source_active_sub
and target_panel_uuid
and source_panel_uuid
and source_panel_uuid != target_panel_uuid
and not target_anchor_sub
):
source_active_sub.panel_user_uuid = target_panel_uuid
source_active_sub.last_notification_sent = None
source_active_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
email_to_move = source.email if source.email and not target.email else None
email_verified_at_to_move = (
source.email_verified_at
if source.email and (not target.email_verified_at or email_to_move)
else None
)
telegram_id_to_move = (
source.telegram_id if source.telegram_id and not target.telegram_id else None
)
referral_code_to_move = (
source.referral_code if source.referral_code and not target.referral_code else None
)
if email_to_move:
source.email = None
if telegram_id_to_move:
source.telegram_id = None
if referral_code_to_move:
source.referral_code = None
if email_to_move or source_panel_uuid or telegram_id_to_move or referral_code_to_move:
await session.flush()
if email_to_move:
target.email = email_to_move
if email_verified_at_to_move and not target.email_verified_at:
target.email_verified_at = email_verified_at_to_move
if telegram_id_to_move:
target.telegram_id = telegram_id_to_move
if panel_uuid_to_keep and not target.panel_user_uuid:
target.panel_user_uuid = panel_uuid_to_keep
if referral_code_to_move:
target.referral_code = referral_code_to_move
for attr in ("username", "first_name", "last_name", "language_code", "telegram_photo_url"):
if not getattr(target, attr) and getattr(source, attr):
setattr(target, attr, getattr(source, attr))
if (
not target.channel_subscription_verified
and source.channel_subscription_verified is not None
):
target.channel_subscription_verified = source.channel_subscription_verified
if not target.channel_subscription_checked_at and source.channel_subscription_checked_at:
target.channel_subscription_checked_at = source.channel_subscription_checked_at
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
target.channel_subscription_verified_for = source.channel_subscription_verified_for
if source.lifetime_used_traffic_bytes is not None:
target.lifetime_used_traffic_bytes = (
target.lifetime_used_traffic_bytes or 0
) + source.lifetime_used_traffic_bytes
if not target.referred_by_id and source.referred_by_id != target_user_id:
target.referred_by_id = source.referred_by_id
if target.referred_by_id == source_user_id:
target.referred_by_id = source.referred_by_id
if target.referred_by_id == target_user_id:
target.referred_by_id = None
target_method_ids = select(UserPaymentMethod.provider_payment_method_id).where(
UserPaymentMethod.user_id == target_user_id
)
await session.execute(
delete(UserPaymentMethod).where(
UserPaymentMethod.user_id == source_user_id,
UserPaymentMethod.provider_payment_method_id.in_(target_method_ids),
)
)
target_promo_ids = select(PromoCodeActivation.promo_code_id).where(
PromoCodeActivation.user_id == target_user_id
)
await session.execute(
delete(PromoCodeActivation).where(
PromoCodeActivation.user_id == source_user_id,
PromoCodeActivation.promo_code_id.in_(target_promo_ids),
)
)
target_has_billing = (
await session.execute(
select(UserBilling.user_id).where(UserBilling.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_billing:
await session.execute(delete(UserBilling).where(UserBilling.user_id == source_user_id))
else:
await session.execute(
update(UserBilling)
.where(UserBilling.user_id == source_user_id)
.values(user_id=target_user_id)
)
target_has_attribution = (
await session.execute(
select(AdAttribution.user_id).where(AdAttribution.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_attribution:
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == source_user_id))
else:
await session.execute(
update(AdAttribution)
.where(AdAttribution.user_id == source_user_id)
.values(user_id=target_user_id)
)
target_has_avatar = (
await session.execute(
select(UserTelegramAvatar.user_id).where(UserTelegramAvatar.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_avatar:
await session.execute(
delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == source_user_id)
)
else:
await session.execute(
update(UserTelegramAvatar)
.where(UserTelegramAvatar.user_id == source_user_id)
.values(user_id=target_user_id)
)
subscription_update_values: Dict[str, Any] = {"user_id": target_user_id}
if panel_uuid_to_keep:
subscription_update_values["panel_user_uuid"] = panel_uuid_to_keep
await session.execute(
update(Subscription)
.where(Subscription.user_id == source_user_id)
.values(**subscription_update_values)
)
for model in (Payment, PromoCodeActivation, UserPaymentMethod):
await session.execute(
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
)
await session.execute(
update(MessageLog)
.where(MessageLog.user_id == source_user_id)
.values(user_id=target_user_id)
)
await session.execute(
update(MessageLog)
.where(MessageLog.target_user_id == source_user_id)
.values(target_user_id=target_user_id)
)
await session.execute(
update(User)
.where(User.referred_by_id == source_user_id)
.values(referred_by_id=target_user_id)
)
await session.delete(source)
await session.flush()
await session.refresh(target)
return target
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
normalized = referral_code.strip().upper()
if not normalized:
return None
stmt = select(User).where(User.referral_code == normalized)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_user(
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
) -> Optional[User]:
user = await get_user_by_id(session, user_id)
if user:
for key, value in update_data.items():
setattr(user, key, value)
await session.flush()
await session.refresh(user)
return user
async def update_user_language(session: AsyncSession, user_id: int, lang_code: str) -> bool:
stmt = update(User).where(User.user_id == user_id).values(language_code=lang_code)
result = await session.execute(stmt)
return result.rowcount > 0
async def get_banned_users(session: AsyncSession) -> List[User]:
"""Get all banned users"""
stmt = select(User).where(User.is_banned == True).order_by(User.registration_date.desc())
result = await session.execute(stmt)
return result.scalars().all()
async def get_all_users_paginated(
session: AsyncSession, *, page: int = 0, page_size: int = 15
) -> List[User]:
"""Return a slice of users ordered by newest registration first."""
safe_page = max(page, 0)
safe_page_size = max(page_size, 1)
stmt = (
select(User)
.order_by(User.registration_date.desc())
.offset(safe_page * safe_page_size)
.limit(safe_page_size)
)
result = await session.execute(stmt)
return result.scalars().all()
async def count_all_users(session: AsyncSession) -> int:
"""Count total number of users."""
result = await session.execute(select(func.count(User.user_id)))
return result.scalar_one()
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
stmt = select(User.user_id).where(User.is_banned == False)
result = await session.execute(stmt)
return result.scalars().all()
async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
stmt = select(User).where(User.panel_user_uuid.is_not(None))
result = await session.execute(stmt)
return result.scalars().all()
async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive user statistics including active users, trial users, etc."""
from datetime import datetime, timezone
# Use timezone-aware UTC to avoid naive/aware comparison issues in SQL queries
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Total users
total_users_stmt = select(func.count(User.user_id))
total_users = (await session.execute(total_users_stmt)).scalar() or 0
# Banned users
banned_users_stmt = select(func.count(User.user_id)).where(User.is_banned == True)
banned_users = (await session.execute(banned_users_stmt)).scalar() or 0
# Active users today (proxy: registered today)
active_today_stmt = select(func.count(User.user_id)).where(
User.registration_date >= today_start
)
active_today = (await session.execute(active_today_stmt)).scalar() or 0
# Users with active paid subscriptions (non-trial providers only)
paid_subs_stmt = (
select(func.count(func.distinct(Subscription.user_id)))
.join(User, Subscription.user_id == User.user_id)
.where(
and_(
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.provider.is_not(None), # Not trial
)
)
)
paid_subs_users = (await session.execute(paid_subs_stmt)).scalar() or 0
# Users on trial period
trial_subs_stmt = (
select(func.count(func.distinct(Subscription.user_id)))
.join(User, Subscription.user_id == User.user_id)
.where(
and_(
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.provider.is_(None), # Trial subscriptions
)
)
)
trial_users = (await session.execute(trial_subs_stmt)).scalar() or 0
# Inactive users (no active subscription)
inactive_users = total_users - paid_subs_users - trial_users - banned_users
# Users attracted via referral
referral_users_stmt = select(func.count(User.user_id)).where(User.referred_by_id.is_not(None))
referral_users = (await session.execute(referral_users_stmt)).scalar() or 0
return {
"total_users": total_users,
"banned_users": banned_users,
"active_today": active_today,
"paid_subscriptions": paid_subs_users,
"trial_users": trial_users,
"inactive_users": max(0, inactive_users),
"referral_users": referral_users,
}
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs who have an active subscription (paid or trial)."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
stmt = (
select(func.distinct(Subscription.user_id))
.join(User, Subscription.user_id == User.user_id)
.where(
and_(
User.is_banned == False,
Subscription.is_active == True,
Subscription.end_date > now,
)
)
)
result = await session.execute(stmt)
return result.scalars().all()
async def get_user_ids_without_active_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs who do NOT have any active subscription."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
active_subs = aliased(Subscription)
stmt = (
select(User.user_id)
.outerjoin(
active_subs,
and_(
active_subs.user_id == User.user_id,
active_subs.is_active == True,
active_subs.end_date > now,
),
)
.where(
and_(
User.is_banned == False,
active_subs.user_id.is_(None),
)
)
)
result = await session.execute(stmt)
return result.scalars().all()
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
"""Completely remove a user and all dependent records from the database.
This helper ensures we do not leave dangling foreign keys or orphaned data.
"""
user = await get_user_by_id(session, user_id)
if not user:
return False
# Ensure referral pointers do not block deletion
await session.execute(
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
)
# Clean up dependent tables that do not cascade automatically
await session.execute(
delete(MessageLog).where(
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
)
)
await session.execute(delete(Payment).where(Payment.user_id == user_id))
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
await session.execute(delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id))
await session.execute(delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id))
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
await session.delete(user)
await session.flush()
return True
async def get_top_users_by_traffic_used(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by total used traffic across all subscriptions."""
safe_limit = max(1, limit)
total_traffic_used = func.coalesce(func.sum(Subscription.traffic_used_bytes), 0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
total_traffic_used.label("traffic_used_bytes"),
)
.join(Subscription, Subscription.user_id == User.user_id, isouter=True)
.group_by(User.user_id, User.username, User.first_name)
.having(total_traffic_used > 0)
.order_by(desc("traffic_used_bytes"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_lifetime_traffic_used(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by lifetime used traffic from panel data."""
safe_limit = max(1, limit)
lifetime_used = func.coalesce(User.lifetime_used_traffic_bytes, 0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
lifetime_used.label("lifetime_used_traffic_bytes"),
)
.where(lifetime_used > 0)
.order_by(desc("lifetime_used_traffic_bytes"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_referrals_count(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by number of invited users."""
safe_limit = max(1, limit)
referred_user = aliased(User)
invited_count = func.count(referred_user.user_id)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
invited_count.label("invited_count"),
)
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
.group_by(User.user_id, User.username, User.first_name)
.having(invited_count > 0)
.order_by(desc("invited_count"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_referral_revenue(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by total revenue brought by all invited users."""
safe_limit = max(1, limit)
referred_user = aliased(User)
referral_revenue = func.coalesce(func.sum(Payment.amount), 0.0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
referral_revenue.label("referral_revenue"),
)
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
.join(
Payment,
and_(
Payment.user_id == referred_user.user_id,
Payment.status == "succeeded",
),
isouter=True,
)
.group_by(User.user_id, User.username, User.first_name)
.having(referral_revenue > 0)
.order_by(desc("referral_revenue"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
+163
View File
@@ -0,0 +1,163 @@
import logging
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from db.models import Base
from .migrator import run_database_migrations
async_engine = None
def redacted_database_url(database_url: str) -> str:
try:
return make_url(database_url).render_as_string(hide_password=True)
except Exception:
return "<invalid database url>"
def init_db_connection(settings: Settings) -> sessionmaker:
global async_engine
if async_engine is None:
logging.info(
"Attempting to create SQLAlchemy engine with URL: %s",
redacted_database_url(settings.DATABASE_URL),
)
async_engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
pool_timeout=settings.DB_POOL_TIMEOUT_SECONDS,
pool_recycle=settings.DB_POOL_RECYCLE_SECONDS,
)
local_async_session_factory = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
logging.info("SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL.")
return local_async_session_factory
async def get_async_session(session_factory: sessionmaker) -> AsyncSession:
if session_factory is None:
raise RuntimeError("AsyncSessionFactory is not provided or initialized.")
async_session = session_factory()
try:
yield async_session
finally:
await async_session.close()
async def init_db(settings: Settings, session_factory: sessionmaker):
global async_engine
if async_engine is None:
logging.warning("init_db: async_engine was None, re-initializing via init_db_connection.")
raise RuntimeError(
"async_engine is not initialized. Call init_db_connection and get session_factory first." # noqa: E501
)
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(run_database_migrations)
logging.info("PostgreSQL database initialized/checked successfully using SQLAlchemy.")
try:
from bot.services.settings_override_service import load_overrides_from_db
await load_overrides_from_db(settings, session_factory)
except Exception as e_overrides:
logging.warning(f"Failed to load setting overrides on startup: {e_overrides}")
async with session_factory() as session:
from sqlalchemy import text
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
try:
current_status = await get_panel_sync_status(session)
if current_status is None:
logging.info("Initializing panel_sync_status record.")
await update_panel_sync_status(
session,
status="never_run",
details="System initialized",
users_processed=0,
subs_synced=0,
)
await session.commit()
except Exception as e_sync_init:
await session.rollback()
logging.error(f"Failed to initialize PanelSyncStatus: {e_sync_init}", exc_info=True)
if settings.tariffs_config:
try:
default_tariff = settings.tariffs_config.default
default_price = (
default_tariff.period_price(1, "rub") or default_tariff.min_period_price_rub()
)
await session.execute(
text(
"""
UPDATE subscriptions AS s
SET
tariff_key = COALESCE(s.tariff_key, :tariff_key),
tier_baseline_bytes = COALESCE(s.tier_baseline_bytes, s.traffic_limit_bytes, :baseline),
topup_balance_bytes = COALESCE(s.topup_balance_bytes, 0),
premium_baseline_bytes = COALESCE(s.premium_baseline_bytes, :premium_baseline),
premium_topup_balance_bytes = COALESCE(s.premium_topup_balance_bytes, 0),
premium_topup_used_bytes = COALESCE(s.premium_topup_used_bytes, 0),
premium_used_bytes = COALESCE(s.premium_used_bytes, 0),
premium_is_limited = COALESCE(s.premium_is_limited, FALSE),
period_start_at = NULL,
effective_monthly_price_rub = COALESCE(
s.effective_monthly_price_rub,
(
SELECT p.amount / GREATEST(COALESCE(p.subscription_duration_months, 1), 1)
FROM payments p
WHERE p.user_id = s.user_id
AND p.status = 'succeeded'
AND COALESCE(p.subscription_duration_months, 0) > 0
ORDER BY p.created_at DESC
LIMIT 1
),
:default_price
)
WHERE s.is_active = TRUE
AND s.tariff_key IS NULL
""" # noqa: E501
),
{
"tariff_key": default_tariff.key,
"baseline": default_tariff.monthly_bytes,
"premium_baseline": default_tariff.premium_monthly_bytes,
"default_price": default_price,
},
)
await session.execute(
text(
"""
UPDATE subscriptions
SET period_start_at = NULL
WHERE is_active = TRUE
AND tariff_key IS NOT NULL
"""
)
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to backfill existing subscriptions for tariffs config.")
+903
View File
@@ -0,0 +1,903 @@
import logging
from dataclasses import dataclass
from typing import Callable, List, Set
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
@dataclass(frozen=True)
class Migration:
id: str
description: str
upgrade: Callable[[Connection], None]
def _ensure_migrations_table(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
id VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
statements: List[str] = []
if "channel_subscription_verified" not in columns:
statements.append("ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN")
if "channel_subscription_checked_at" not in columns:
statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
)
if "channel_subscription_verified_for" not in columns:
statements.append("ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT")
for stmt in statements:
connection.execute(text(stmt))
def _migration_0002_add_referral_code(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "referral_code" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)"))
connection.execute(
text(
"""
WITH generated_codes AS (
SELECT
user_id,
UPPER(
SUBSTRING(
md5(
user_id::text
|| clock_timestamp()::text
|| random()::text
)
FROM 1 FOR 9
)
) AS referral_code
FROM users
WHERE referral_code IS NULL OR referral_code = ''
)
UPDATE users AS u
SET referral_code = g.referral_code
FROM generated_codes AS g
WHERE u.user_id = g.user_id
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_referral_code
ON users (referral_code)
WHERE referral_code IS NOT NULL
"""
)
)
def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "referral_code" not in columns:
return
connection.execute(
text(
"""
UPDATE users
SET referral_code = UPPER(referral_code)
WHERE referral_code IS NOT NULL
AND referral_code <> UPPER(referral_code)
"""
)
)
def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "lifetime_used_traffic_bytes" in columns:
return
connection.execute(text("ALTER TABLE users ADD COLUMN lifetime_used_traffic_bytes BIGINT"))
def _migration_0005_add_email_auth_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "email" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR"))
if "email_verified_at" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ"))
if "telegram_id" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN telegram_id BIGINT"))
connection.execute(
text(
"""
UPDATE users
SET telegram_id = user_id
WHERE telegram_id IS NULL
AND user_id > 0
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email
ON users (email)
WHERE email IS NOT NULL
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_telegram_id
ON users (telegram_id)
WHERE telegram_id IS NOT NULL
"""
)
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS email_verification_codes (
code_id SERIAL PRIMARY KEY,
email VARCHAR NOT NULL,
code_hash VARCHAR NOT NULL,
purpose VARCHAR NOT NULL,
target_user_id BIGINT NULL REFERENCES users(user_id),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_lookup
ON email_verification_codes (email, purpose, target_user_id, created_at DESC)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_expires_at
ON email_verification_codes (expires_at)
"""
)
)
def _migration_0006_add_security_throttles(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS security_throttles (
throttle_id SERIAL PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
identifier VARCHAR(512) NOT NULL,
failures INTEGER NOT NULL DEFAULT 0,
window_started_at TIMESTAMPTZ NULL,
locked_until TIMESTAMPTZ NULL,
last_attempt_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NULL,
CONSTRAINT uq_security_throttles_scope_identifier UNIQUE (scope, identifier)
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_security_throttles_scope
ON security_throttles (scope)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_security_throttles_locked_until
ON security_throttles (locked_until)
"""
)
)
def _migration_0007_add_telegram_photo_url(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "telegram_photo_url" in columns:
return
connection.execute(text("ALTER TABLE users ADD COLUMN telegram_photo_url TEXT"))
def _migration_0008_add_email_verification_code_status(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
if "status" not in columns:
connection.execute(
text(
"ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'" # noqa: E501
)
)
else:
connection.execute(
text(
"""
UPDATE email_verification_codes
SET status = 'active'
WHERE status IS NULL OR status = ''
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_status
ON email_verification_codes (status)
"""
)
)
def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
if "magic_token_hash" not in columns:
connection.execute(
text("ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR")
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash
ON email_verification_codes (magic_token_hash)
"""
)
)
def _migration_0011_add_user_telegram_avatars(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS user_telegram_avatars (
user_id BIGINT PRIMARY KEY REFERENCES users(user_id),
file_unique_id VARCHAR,
content_type VARCHAR(64) NOT NULL DEFAULT 'image/jpeg',
image_bytes BYTEA NOT NULL,
size_bytes INTEGER NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_user_telegram_avatars_file_unique_id
ON user_telegram_avatars (file_unique_id)
"""
)
)
def _migration_0012_add_tariffs_schema(connection: Connection) -> None:
inspector = inspect(connection)
sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
sub_statements: List[str] = []
if "tariff_key" not in sub_columns:
sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tariff_key VARCHAR")
if "tier_baseline_bytes" not in sub_columns:
sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tier_baseline_bytes BIGINT")
if "topup_balance_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN topup_balance_bytes BIGINT NOT NULL DEFAULT 0"
)
if "premium_baseline_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0"
)
if "premium_topup_balance_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
)
if "premium_topup_used_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
)
if "premium_used_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_used_bytes BIGINT NOT NULL DEFAULT 0"
)
if "premium_is_limited" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE"
)
if "premium_period_start_at" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ"
)
if "period_start_at" not in sub_columns:
sub_statements.append("ALTER TABLE subscriptions ADD COLUMN period_start_at TIMESTAMPTZ")
if "is_throttled" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN is_throttled BOOLEAN NOT NULL DEFAULT FALSE"
)
if "effective_monthly_price_rub" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN effective_monthly_price_rub NUMERIC"
)
if "hwid_device_limit" not in sub_columns:
sub_statements.append("ALTER TABLE subscriptions ADD COLUMN hwid_device_limit INTEGER")
if "extra_hwid_devices" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN extra_hwid_devices INTEGER NOT NULL DEFAULT 0"
)
for stmt in sub_statements:
connection.execute(text(stmt))
payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
payment_statements: List[str] = []
if "sale_mode" not in payment_columns:
payment_statements.append("ALTER TABLE payments ADD COLUMN sale_mode VARCHAR")
if "tariff_key" not in payment_columns:
payment_statements.append("ALTER TABLE payments ADD COLUMN tariff_key VARCHAR")
if "purchased_gb" not in payment_columns:
payment_statements.append("ALTER TABLE payments ADD COLUMN purchased_gb DOUBLE PRECISION")
if "purchased_hwid_devices" not in payment_columns:
payment_statements.append("ALTER TABLE payments ADD COLUMN purchased_hwid_devices INTEGER")
for stmt in payment_statements:
connection.execute(text(stmt))
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS traffic_topups (
topup_id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id),
payment_id INTEGER NULL REFERENCES payments(payment_id),
purchased_bytes BIGINT NOT NULL,
kind VARCHAR NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS traffic_warnings (
warning_id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id),
period_start_at TIMESTAMPTZ NULL,
level INTEGER NOT NULL,
traffic_limit_bytes BIGINT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_traffic_warning_period_level UNIQUE (subscription_id, period_start_at, level)
)
""" # noqa: E501
)
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS hwid_device_purchases (
purchase_id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id),
payment_id INTEGER NULL REFERENCES payments(payment_id),
purchased_devices INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS tariff_changes (
change_id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id),
from_tariff_key VARCHAR NULL,
to_tariff_key VARCHAR NOT NULL,
mode VARCHAR NOT NULL,
payment_id INTEGER NULL REFERENCES payments(payment_id),
days_before INTEGER NULL,
days_after INTEGER NULL,
converted_bytes BIGINT NULL,
eff_price_before NUMERIC NULL,
eff_price_after NUMERIC NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
for stmt in [
"CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_key ON subscriptions (tariff_key)",
"CREATE INDEX IF NOT EXISTS ix_subscriptions_is_throttled ON subscriptions (is_throttled)",
"CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_payments_sale_mode ON payments (sale_mode)",
"CREATE INDEX IF NOT EXISTS ix_payments_tariff_key ON payments (tariff_key)",
"CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_traffic_topups_payment_id ON traffic_topups (payment_id)",
"CREATE INDEX IF NOT EXISTS ix_traffic_topups_kind ON traffic_topups (kind)",
"CREATE INDEX IF NOT EXISTS ix_traffic_warnings_subscription_id ON traffic_warnings (subscription_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_tariff_changes_subscription_id ON tariff_changes (subscription_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_id ON hwid_device_purchases (subscription_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_payment_id ON hwid_device_purchases (payment_id)", # noqa: E501
]:
connection.execute(text(stmt))
def _migration_0009_add_composite_indexes(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscriptions_is_active_end_date
ON subscriptions (is_active, end_date)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscriptions_user_id_is_active
ON subscriptions (user_id, is_active)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_payments_user_id_status
ON payments (user_id, status)
"""
)
)
def _migration_0014_add_premium_squad_traffic_fields(connection: Connection) -> None:
inspector = inspect(connection)
sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
statements: List[str] = []
if "premium_baseline_bytes" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0"
)
if "premium_topup_balance_bytes" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
)
if "premium_topup_used_bytes" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
)
if "premium_used_bytes" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_used_bytes BIGINT NOT NULL DEFAULT 0"
)
if "premium_is_limited" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE"
)
if "premium_period_start_at" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ"
)
for stmt in statements:
connection.execute(text(stmt))
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)" # noqa: E501
)
)
def _migration_0015_add_premium_topup_carryover_fields(connection: Connection) -> None:
inspector = inspect(connection)
sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
statements: List[str] = []
if "premium_topup_used_bytes" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
)
if "premium_period_start_at" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ"
)
for stmt in statements:
connection.execute(text(stmt))
def _migration_0016_add_message_logs_admin_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("message_logs")}
statements: List[str] = []
if "is_admin_event" not in columns:
statements.append(
"ALTER TABLE message_logs ADD COLUMN is_admin_event BOOLEAN NOT NULL DEFAULT FALSE"
)
if "target_user_id" not in columns:
statements.append(
"ALTER TABLE message_logs ADD COLUMN target_user_id BIGINT REFERENCES users(user_id)"
)
for stmt in statements:
connection.execute(text(stmt))
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" # noqa: E501
)
)
def _migration_0018_add_premium_admin_overrides(connection: Connection) -> None:
"""Per-subscription overrides letting admins gift extra premium traffic or unlimited access."""
inspector = inspect(connection)
sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
statements: List[str] = []
if "premium_unlimited_override" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501
)
if "premium_bonus_bytes" not in sub_columns:
statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_bonus_bytes BIGINT NOT NULL DEFAULT 0"
)
for stmt in statements:
connection.execute(text(stmt))
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_unlimited_override "
"ON subscriptions (premium_unlimited_override)"
)
)
def _migration_0021_add_regular_unlimited_override(connection: Connection) -> None:
inspector = inspect(connection)
sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
if "regular_unlimited_override" not in sub_columns:
connection.execute(
text(
"ALTER TABLE subscriptions ADD COLUMN regular_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_subscriptions_regular_unlimited_override "
"ON subscriptions (regular_unlimited_override)"
)
)
def _migration_0020_add_regular_bonus_bytes(connection: Connection) -> None:
"""Admin-granted extra bytes on main (non-premium) traffic limit, like premium_bonus_bytes."""
inspector = inspect(connection)
sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
if "regular_bonus_bytes" not in sub_columns:
connection.execute(
text(
"ALTER TABLE subscriptions ADD COLUMN regular_bonus_bytes BIGINT NOT NULL DEFAULT 0"
)
)
def _migration_0019_clear_subscription_months_for_non_subscription_payments(
connection: Connection,
) -> None:
"""Null out subscription_duration_months for legacy non-subscription payments.
Older builds stored the raw `months` callback value into
`subscription_duration_months` for every sale_mode, including traffic
top-ups and HWID device packs. This polluted CSV exports and made admin
log messages render top-ups as "N мес.". The new code only sets the
column for subscription sales; this migration aligns historical rows.
"""
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "payments" not in table_names:
return
pay_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
if "subscription_duration_months" not in pay_columns or "sale_mode" not in pay_columns:
return
connection.execute(
text(
"""
UPDATE payments
SET subscription_duration_months = NULL
WHERE subscription_duration_months IS NOT NULL
AND sale_mode IS NOT NULL
AND split_part(split_part(sale_mode, '@', 1), '|', 1) <> 'subscription'
"""
)
)
def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> None:
"""Backfill columns required by admin user detail API on legacy databases.
Some self-hosted instances were upgraded from older builds where parts of
the tariffs/admin schema were missing. This migration is intentionally
idempotent and only adds absent columns/indexes.
"""
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "subscriptions" in table_names:
sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
sub_statements: List[str] = []
if "tariff_key" not in sub_columns:
sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tariff_key VARCHAR")
if "tier_baseline_bytes" not in sub_columns:
sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tier_baseline_bytes BIGINT")
if "topup_balance_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN topup_balance_bytes BIGINT NOT NULL DEFAULT 0"
)
if "premium_baseline_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
)
if "premium_topup_balance_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
)
if "premium_topup_used_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
)
if "premium_used_bytes" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_used_bytes BIGINT NOT NULL DEFAULT 0"
)
if "premium_is_limited" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501
)
if "is_throttled" not in sub_columns:
sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN is_throttled BOOLEAN NOT NULL DEFAULT FALSE"
)
for stmt in sub_statements:
connection.execute(text(stmt))
if "payments" in table_names:
pay_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
pay_statements: List[str] = []
if "sale_mode" not in pay_columns:
pay_statements.append("ALTER TABLE payments ADD COLUMN sale_mode VARCHAR")
if "tariff_key" not in pay_columns:
pay_statements.append("ALTER TABLE payments ADD COLUMN tariff_key VARCHAR")
if "purchased_gb" not in pay_columns:
pay_statements.append("ALTER TABLE payments ADD COLUMN purchased_gb DOUBLE PRECISION")
if "purchased_hwid_devices" not in pay_columns:
pay_statements.append("ALTER TABLE payments ADD COLUMN purchased_hwid_devices INTEGER")
for stmt in pay_statements:
connection.execute(text(stmt))
if "message_logs" in table_names:
msg_columns: Set[str] = {col["name"] for col in inspector.get_columns("message_logs")}
msg_statements: List[str] = []
if "is_admin_event" not in msg_columns:
msg_statements.append(
"ALTER TABLE message_logs ADD COLUMN is_admin_event BOOLEAN NOT NULL DEFAULT FALSE"
)
if "target_user_id" not in msg_columns:
msg_statements.append(
"ALTER TABLE message_logs ADD COLUMN target_user_id BIGINT REFERENCES users(user_id)" # noqa: E501
)
for stmt in msg_statements:
connection.execute(text(stmt))
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" # noqa: E501
)
)
def _migration_0022_add_indexes_for_admin_reports(connection: Connection) -> None:
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_payments_status_created_at "
"ON payments (status, created_at)"
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_message_logs_timestamp "
"ON message_logs (timestamp DESC)"
)
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
description="Add columns to track required channel subscription verification",
upgrade=_migration_0001_add_channel_subscription_fields,
),
Migration(
id="0002_add_referral_code",
description="Store short referral codes for users and backfill existing rows",
upgrade=_migration_0002_add_referral_code,
),
Migration(
id="0003_normalize_referral_codes",
description="Normalize referral codes to uppercase for consistent lookups",
upgrade=_migration_0003_normalize_referral_codes,
),
Migration(
id="0004_add_lifetime_used_traffic",
description="Store lifetime traffic usage for users",
upgrade=_migration_0004_add_lifetime_used_traffic,
),
Migration(
id="0005_add_email_auth_fields",
description="Add email login identities and verification codes",
upgrade=_migration_0005_add_email_auth_fields,
),
Migration(
id="0006_add_security_throttles",
description="Add generic lockout tracking for brute-force protection",
upgrade=_migration_0006_add_security_throttles,
),
Migration(
id="0007_add_telegram_photo_url",
description="Store Telegram profile photo URLs for linked users",
upgrade=_migration_0007_add_telegram_photo_url,
),
Migration(
id="0008_add_email_verification_code_status",
description="Track superseded email verification codes explicitly",
upgrade=_migration_0008_add_email_verification_code_status,
),
Migration(
id="0009_add_composite_indexes",
description="Add composite indexes for subscription and payment lookups",
upgrade=_migration_0009_add_composite_indexes,
),
Migration(
id="0010_add_email_magic_token_hash",
description="Store hashed magic-link tokens for email login deeplinks",
upgrade=_migration_0010_add_email_magic_token_hash,
),
Migration(
id="0011_add_user_telegram_avatars",
description="Cache compact Telegram profile avatars for WebApp profiles",
upgrade=_migration_0011_add_user_telegram_avatars,
),
Migration(
id="0012_add_tariffs_schema",
description="Add tariff catalog columns and traffic accounting tables",
upgrade=_migration_0012_add_tariffs_schema,
),
Migration(
id="0013_add_app_setting_overrides",
description="Persisted runtime overrides for application settings managed via admin webapp",
upgrade=lambda connection: connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS app_setting_overrides (
key VARCHAR(128) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by BIGINT
)
"""
)
),
),
Migration(
id="0014_add_premium_squad_traffic_fields",
description="Track premium squad traffic limits and top-ups per subscription",
upgrade=_migration_0014_add_premium_squad_traffic_fields,
),
Migration(
id="0015_add_premium_topup_carryover_fields",
description="Track premium top-up usage within the current monthly period",
upgrade=_migration_0015_add_premium_topup_carryover_fields,
),
Migration(
id="0016_add_message_logs_admin_fields",
description="Add admin-related message log fields used by admin user detail APIs",
upgrade=_migration_0016_add_message_logs_admin_fields,
),
Migration(
id="0017_reconcile_legacy_admin_api_schema",
description="Reconcile legacy DB schema for admin user detail endpoint compatibility",
upgrade=_migration_0017_reconcile_legacy_admin_api_schema,
),
Migration(
id="0018_add_premium_admin_overrides",
description="Per-subscription admin overrides for premium traffic (unlimited toggle + bonus bytes)", # noqa: E501
upgrade=_migration_0018_add_premium_admin_overrides,
),
Migration(
id="0019_clear_subscription_months_for_non_subscription_payments",
description="Backfill: null out subscription_duration_months for legacy traffic/topup/hwid payments", # noqa: E501
upgrade=_migration_0019_clear_subscription_months_for_non_subscription_payments,
),
Migration(
id="0020_add_regular_bonus_bytes",
description="Per-subscription admin bonus bytes on regular (main) traffic limit",
upgrade=_migration_0020_add_regular_bonus_bytes,
),
Migration(
id="0021_add_regular_unlimited_override",
description="Admin toggle for effectively unlimited main traffic limit",
upgrade=_migration_0021_add_regular_unlimited_override,
),
Migration(
id="0022_add_indexes_for_admin_reports",
description="Indexes to speed up financial stats and admin log queries",
upgrade=_migration_0022_add_indexes_for_admin_reports,
),
]
def run_database_migrations(connection: Connection) -> None:
"""
Apply pending migrations sequentially. Already applied revisions are skipped.
"""
_ensure_migrations_table(connection)
applied_revisions: Set[str] = {
row[0] for row in connection.execute(text("SELECT id FROM schema_migrations"))
}
for migration in MIGRATIONS:
if migration.id in applied_revisions:
continue
logging.info("Migrator: applying %s %s", migration.id, migration.description)
try:
with connection.begin_nested():
migration.upgrade(connection)
connection.execute(
text("INSERT INTO schema_migrations (id) VALUES (:revision)"),
{"revision": migration.id},
)
except Exception as exc:
logging.error(
"Migrator: failed to apply %s (%s)",
migration.id,
migration.description,
exc_info=True,
)
raise exc
else:
logging.info("Migrator: migration %s applied successfully", migration.id)
+433
View File
@@ -0,0 +1,433 @@
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Index,
Integer,
LargeBinary,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, relationship
from sqlalchemy.sql import func
class Base(AsyncAttrs, DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
user_id = Column(BigInteger, primary_key=True, index=True)
username = Column(String, nullable=True, index=True)
email = Column(String, nullable=True, unique=True, index=True)
email_verified_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
telegram_photo_url = Column(Text, nullable=True)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
language_code = Column(String, default="ru")
registration_date = Column(DateTime(timezone=True), server_default=func.now())
is_banned = Column(Boolean, default=False)
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
referral_code = Column(String(16), nullable=True, unique=True, index=True)
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
channel_subscription_verified = Column(Boolean, nullable=True)
channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True)
channel_subscription_verified_for = Column(BigInteger, nullable=True)
referrer = relationship("User", remote_side=[user_id], backref="referrals")
subscriptions = relationship(
"Subscription", back_populates="user", cascade="all, delete-orphan"
)
payments = relationship("Payment", back_populates="user", cascade="all, delete-orphan")
promo_code_activations = relationship(
"PromoCodeActivation", back_populates="user", cascade="all, delete-orphan"
)
message_logs_authored = relationship(
"MessageLog",
foreign_keys="MessageLog.user_id",
back_populates="author_user",
cascade="all, delete-orphan",
)
message_logs_targeted = relationship(
"MessageLog",
foreign_keys="MessageLog.target_user_id",
back_populates="target_user",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<User(user_id={self.user_id}, username='{self.username}')>"
class UserTelegramAvatar(Base):
__tablename__ = "user_telegram_avatars"
user_id = Column(
BigInteger,
ForeignKey("users.user_id"),
primary_key=True,
index=True,
)
file_unique_id = Column(String, nullable=True, index=True)
content_type = Column(String(64), nullable=False, default="image/jpeg")
image_bytes = Column(LargeBinary, nullable=False)
size_bytes = Column(Integer, nullable=False)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
user = relationship("User")
class Subscription(Base):
__tablename__ = "subscriptions"
__table_args__ = (
Index("ix_subscriptions_is_active_end_date", "is_active", "end_date"),
Index("ix_subscriptions_user_id_is_active", "user_id", "is_active"),
)
subscription_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
panel_user_uuid = Column(String, nullable=False, index=True)
panel_subscription_uuid = Column(String, unique=True, index=True, nullable=True)
start_date = Column(DateTime(timezone=True), nullable=True)
end_date = Column(DateTime(timezone=True), nullable=False, index=True)
duration_months = Column(Integer, nullable=True)
is_active = Column(Boolean, default=True, index=True)
status_from_panel = Column(String, nullable=True)
traffic_limit_bytes = Column(BigInteger, nullable=True)
traffic_used_bytes = Column(BigInteger, nullable=True)
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
provider = Column(String, nullable=True)
skip_notifications = Column(Boolean, default=False)
auto_renew_enabled = Column(Boolean, default=True, index=True)
tariff_key = Column(String, nullable=True, index=True)
tier_baseline_bytes = Column(BigInteger, nullable=True)
topup_balance_bytes = Column(BigInteger, nullable=False, default=0)
premium_baseline_bytes = Column(BigInteger, nullable=False, default=0)
premium_topup_balance_bytes = Column(BigInteger, nullable=False, default=0)
premium_topup_used_bytes = Column(BigInteger, nullable=False, default=0)
premium_used_bytes = Column(BigInteger, nullable=False, default=0)
premium_is_limited = Column(Boolean, nullable=False, default=False, index=True)
premium_period_start_at = Column(DateTime(timezone=True), nullable=True)
premium_unlimited_override = Column(Boolean, nullable=False, default=False, index=True)
premium_bonus_bytes = Column(BigInteger, nullable=False, default=0)
regular_bonus_bytes = Column(BigInteger, nullable=False, default=0)
regular_unlimited_override = Column(Boolean, nullable=False, default=False, index=True)
period_start_at = Column(DateTime(timezone=True), nullable=True)
is_throttled = Column(Boolean, nullable=False, default=False, index=True)
effective_monthly_price_rub = Column(Numeric, nullable=True)
hwid_device_limit = Column(Integer, nullable=True)
extra_hwid_devices = Column(Integer, nullable=False, default=0)
user = relationship("User", back_populates="subscriptions")
def __repr__(self):
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>" # noqa: E501
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
code_id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String, nullable=False, index=True)
code_hash = Column(String, nullable=False)
magic_token_hash = Column(String, nullable=True, index=True)
purpose = Column(String, nullable=False, index=True)
target_user_id = Column(
BigInteger,
ForeignKey("users.user_id"),
nullable=True,
index=True,
)
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
consumed_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String, nullable=False, default="active", index=True)
attempts = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
target_user = relationship("User")
class SecurityThrottle(Base):
__tablename__ = "security_throttles"
throttle_id = Column(Integer, primary_key=True, autoincrement=True)
scope = Column(String(64), nullable=False, index=True)
identifier = Column(String(512), nullable=False, index=True)
failures = Column(Integer, nullable=False, default=0)
window_started_at = Column(DateTime(timezone=True), nullable=True)
locked_until = Column(DateTime(timezone=True), nullable=True, index=True)
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
__table_args__ = (
UniqueConstraint("scope", "identifier", name="uq_security_throttles_scope_identifier"),
)
class Payment(Base):
__tablename__ = "payments"
__table_args__ = (Index("ix_payments_user_id_status", "user_id", "status"),)
payment_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
yookassa_payment_id = Column(String, unique=True, index=True, nullable=True)
provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False)
currency = Column(String, nullable=False)
status = Column(String, nullable=False, index=True)
description = Column(String, nullable=True)
subscription_duration_months = Column(Integer, nullable=True)
sale_mode = Column(String, nullable=True, index=True)
tariff_key = Column(String, nullable=True, index=True)
purchased_gb = Column(Float, nullable=True)
purchased_hwid_devices = Column(Integer, nullable=True)
promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
user = relationship("User", back_populates="payments")
promo_code_used = relationship("PromoCode", back_populates="payments_where_used")
class TrafficTopup(Base):
__tablename__ = "traffic_topups"
topup_id = Column(Integer, primary_key=True, autoincrement=True)
subscription_id = Column(
Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True
)
payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True)
purchased_bytes = Column(BigInteger, nullable=False)
kind = Column(String, nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
subscription = relationship("Subscription")
payment = relationship("Payment")
class HwidDevicePurchase(Base):
__tablename__ = "hwid_device_purchases"
purchase_id = Column(Integer, primary_key=True, autoincrement=True)
subscription_id = Column(
Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True
)
payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True)
purchased_devices = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
subscription = relationship("Subscription")
payment = relationship("Payment")
class TrafficWarning(Base):
__tablename__ = "traffic_warnings"
__table_args__ = (
UniqueConstraint(
"subscription_id", "period_start_at", "level", name="uq_traffic_warning_period_level"
),
)
warning_id = Column(Integer, primary_key=True, autoincrement=True)
subscription_id = Column(
Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True
)
period_start_at = Column(DateTime(timezone=True), nullable=True)
level = Column(Integer, nullable=False)
traffic_limit_bytes = Column(BigInteger, nullable=True)
sent_at = Column(DateTime(timezone=True), server_default=func.now())
subscription = relationship("Subscription")
class TariffChange(Base):
__tablename__ = "tariff_changes"
change_id = Column(Integer, primary_key=True, autoincrement=True)
subscription_id = Column(
Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True
)
from_tariff_key = Column(String, nullable=True)
to_tariff_key = Column(String, nullable=False)
mode = Column(String, nullable=False, index=True)
payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True)
days_before = Column(Integer, nullable=True)
days_after = Column(Integer, nullable=True)
converted_bytes = Column(BigInteger, nullable=True)
eff_price_before = Column(Numeric, nullable=True)
eff_price_after = Column(Numeric, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
subscription = relationship("Subscription")
payment = relationship("Payment")
class UserBilling(Base):
__tablename__ = "user_billing"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
# Saved payment method for off-session recurring charges (YooKassa)
yookassa_payment_method_id = Column(String, nullable=True, unique=True)
card_last4 = Column(String, nullable=True)
card_network = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
user = relationship("User")
class UserPaymentMethod(Base):
__tablename__ = "user_payment_methods"
method_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
provider_payment_method_id = Column(String, nullable=False, unique=True, index=True)
card_last4 = Column(String, nullable=True)
card_network = Column(String, nullable=True)
is_default = Column(Boolean, default=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
user = relationship("User")
__table_args__ = (
UniqueConstraint("user_id", "provider_payment_method_id", name="uq_user_provider_method"),
)
class PromoCode(Base):
__tablename__ = "promo_codes"
promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String, unique=True, nullable=False, index=True)
bonus_days = Column(Integer, nullable=False)
max_activations = Column(Integer, nullable=False)
current_activations = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_by_admin_id = Column(BigInteger, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
valid_until = Column(DateTime(timezone=True), nullable=True)
activations = relationship(
"PromoCodeActivation", back_populates="promo_code", cascade="all, delete-orphan"
)
payments_where_used = relationship("Payment", back_populates="promo_code_used")
class PromoCodeActivation(Base):
__tablename__ = "promo_code_activations"
activation_id = Column(Integer, primary_key=True, autoincrement=True)
promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=False)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False)
activated_at = Column(DateTime(timezone=True), server_default=func.now())
payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True)
promo_code = relationship("PromoCode", back_populates="activations")
user = relationship("User", back_populates="promo_code_activations")
payment = relationship("Payment")
__table_args__ = (
UniqueConstraint("promo_code_id", "user_id", name="uq_promo_user_activation"),
)
class MessageLog(Base):
__tablename__ = "message_logs"
log_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True, index=True)
telegram_username = Column(String, nullable=True)
telegram_first_name = Column(String, nullable=True)
event_type = Column(String, nullable=False, index=True)
content = Column(Text, nullable=True)
raw_update_preview = Column(Text, nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), index=True)
is_admin_event = Column(Boolean, default=False)
target_user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True, index=True)
author_user = relationship(
"User", foreign_keys=[user_id], back_populates="message_logs_authored"
)
target_user = relationship(
"User", foreign_keys=[target_user_id], back_populates="message_logs_targeted"
)
class PanelSyncStatus(Base):
__tablename__ = "panel_sync_status"
id = Column(Integer, primary_key=True, default=1, autoincrement=False)
last_sync_time = Column(DateTime(timezone=True), nullable=True)
status = Column(String, nullable=True)
details = Column(Text, nullable=True)
users_processed_from_panel = Column(Integer, default=0)
subscriptions_synced = Column(Integer, default=0)
__table_args__ = (UniqueConstraint("id"),)
class AdCampaign(Base):
__tablename__ = "ad_campaigns"
ad_campaign_id = Column(Integer, primary_key=True, autoincrement=True)
source = Column(String, nullable=False, index=True)
start_param = Column(String, nullable=False, unique=True, index=True)
cost = Column(Float, nullable=False, default=0.0)
is_active = Column(Boolean, default=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
attributions = relationship(
"AdAttribution",
back_populates="campaign",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>" # noqa: E501
class AdAttribution(Base):
__tablename__ = "ad_attributions"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True, index=True)
ad_campaign_id = Column(
Integer, ForeignKey("ad_campaigns.ad_campaign_id"), nullable=False, index=True
)
first_start_at = Column(DateTime(timezone=True), server_default=func.now())
trial_activated_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User")
campaign = relationship("AdCampaign", back_populates="attributions")
class AppSettingOverride(Base):
__tablename__ = "app_setting_overrides"
key = Column(String(128), primary_key=True)
value = Column(Text, nullable=True)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
updated_by = Column(BigInteger, nullable=True)