379 lines
13 KiB
Python
379 lines
13 KiB
Python
import logging
|
|
import re
|
|
import secrets
|
|
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, SubscriptionNotification
|
|
|
|
INSTALL_SHARE_TOKEN_BYTES = 16
|
|
|
|
|
|
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()
|
|
|
|
|
|
def normalize_install_share_token(value: Any) -> str:
|
|
token = str(value or "").strip().lower()
|
|
if not re.fullmatch(r"[a-f0-9]{32}", token):
|
|
return ""
|
|
return token
|
|
|
|
|
|
async def get_subscription_by_install_share_token(
|
|
session: AsyncSession,
|
|
token: str,
|
|
) -> Optional[Subscription]:
|
|
normalized = normalize_install_share_token(token)
|
|
if not normalized:
|
|
return None
|
|
stmt = select(Subscription).where(Subscription.install_share_token == normalized)
|
|
result = await session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def ensure_install_share_token(
|
|
session: AsyncSession,
|
|
subscription: Subscription,
|
|
) -> str:
|
|
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
|
|
existing = normalize_install_share_token(raw_existing)
|
|
if existing:
|
|
if existing != getattr(subscription, "install_share_token", None):
|
|
subscription.install_share_token = existing
|
|
await session.flush()
|
|
return existing
|
|
|
|
subscription_id = getattr(subscription, "subscription_id", None)
|
|
for _attempt in range(10):
|
|
token = secrets.token_hex(INSTALL_SHARE_TOKEN_BYTES)
|
|
if await get_subscription_by_install_share_token(session, token):
|
|
continue
|
|
if subscription_id:
|
|
result = await session.execute(
|
|
update(Subscription)
|
|
.where(
|
|
Subscription.subscription_id == subscription_id,
|
|
or_(
|
|
Subscription.install_share_token.is_(None),
|
|
Subscription.install_share_token == "",
|
|
Subscription.install_share_token == raw_existing,
|
|
),
|
|
)
|
|
.values(install_share_token=token)
|
|
)
|
|
await session.flush()
|
|
if result.rowcount:
|
|
await session.refresh(subscription)
|
|
return (
|
|
normalize_install_share_token(
|
|
getattr(subscription, "install_share_token", None)
|
|
)
|
|
or token
|
|
)
|
|
|
|
await session.refresh(subscription)
|
|
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
|
|
existing = normalize_install_share_token(raw_existing)
|
|
if existing:
|
|
return existing
|
|
continue
|
|
|
|
subscription.install_share_token = token
|
|
await session.flush()
|
|
await session.refresh(subscription)
|
|
return token
|
|
|
|
raise RuntimeError("Failed to generate a unique install share token")
|
|
|
|
|
|
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 has_subscription_notification(
|
|
session: AsyncSession,
|
|
subscription_id: int,
|
|
notification_key: str,
|
|
) -> bool:
|
|
stmt = (
|
|
select(SubscriptionNotification.notification_id)
|
|
.where(
|
|
SubscriptionNotification.subscription_id == subscription_id,
|
|
SubscriptionNotification.notification_key == notification_key,
|
|
)
|
|
.limit(1)
|
|
)
|
|
result = await session.execute(stmt)
|
|
return result.scalar_one_or_none() is not None
|
|
|
|
|
|
async def record_subscription_notification(
|
|
session: AsyncSession,
|
|
subscription_id: int,
|
|
notification_key: str,
|
|
*,
|
|
sent_at: Optional[datetime] = None,
|
|
) -> None:
|
|
if sent_at is None:
|
|
sent_at = datetime.now(timezone.utc)
|
|
existing = await has_subscription_notification(session, subscription_id, notification_key)
|
|
if existing:
|
|
return
|
|
session.add(
|
|
SubscriptionNotification(
|
|
subscription_id=subscription_id,
|
|
notification_key=notification_key,
|
|
sent_at=sent_at,
|
|
)
|
|
)
|
|
await update_subscription_notification_time(session, subscription_id, sent_at)
|
|
|
|
|
|
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()
|