refactor: project architecture refactor, container splitting
This commit is contained in:
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user