Files

258 lines
10 KiB
Python

from __future__ import annotations
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import (
DiscountOffer,
Subscription,
SubscriptionTemporaryAccess,
User,
)
from app.services.subscription_service import SubscriptionService
logger = structlog.get_logger(__name__)
class PromoOfferService:
def __init__(self) -> None:
self.subscription_service = SubscriptionService()
async def grant_test_access(
self,
db: AsyncSession,
user: User,
offer: DiscountOffer,
) -> tuple[bool, list[str] | None, datetime | None, str]:
# Collect target subscriptions: all active in multi-tariff, single otherwise
if settings.is_multi_tariff_enabled():
subs = getattr(user, 'subscriptions', None) or []
target_subs = [s for s in subs if s.is_active and not getattr(s, 'is_daily_tariff', False)]
if not target_subs:
target_subs = [s for s in subs if s.is_active]
else:
single = getattr(user, 'subscription', None)
target_subs = [single] if single else []
if not target_subs:
return False, None, None, 'subscription_missing'
payload = offer.extra_data or {}
raw_squads = payload.get('test_squad_uuids') or payload.get('squads') or []
if isinstance(raw_squads, str):
candidates = [raw_squads]
else:
try:
candidates = list(raw_squads)
except TypeError:
candidates = []
squad_uuids: Sequence[str] = [str(item) for item in candidates if item]
if not squad_uuids:
return False, None, None, 'squads_missing'
squad_uuids = list(dict.fromkeys(squad_uuids))
# Check if ALL subscriptions already have all squads
all_already = all(
set(squad_uuids).issubset({str(s) for s in (sub.connected_squads or [])}) for sub in target_subs
)
if all_already:
return False, None, None, 'already_connected'
try:
duration_hours = int(payload.get('test_duration_hours') or payload.get('duration_hours') or 24)
except (TypeError, ValueError):
duration_hours = 24
if duration_hours <= 0:
duration_hours = 24
now = datetime.now(UTC)
expires_at = now + timedelta(hours=duration_hours)
all_newly_added: list[str] = []
any_sync_failed = False
for subscription in target_subs:
connected = {str(item) for item in subscription.connected_squads or []}
original_connected = set(connected)
sub_newly_added: list[str] = []
for squad_uuid in squad_uuids:
normalized_uuid = str(squad_uuid)
# Check existing temp access for this subscription + offer + squad
existing_result = await db.execute(
select(SubscriptionTemporaryAccess)
.where(
SubscriptionTemporaryAccess.offer_id == offer.id,
SubscriptionTemporaryAccess.subscription_id == subscription.id,
SubscriptionTemporaryAccess.squad_uuid == normalized_uuid,
)
.order_by(SubscriptionTemporaryAccess.id.desc())
)
existing_access = existing_result.scalars().first()
if existing_access and existing_access.is_active:
existing_access.expires_at = max(existing_access.expires_at, expires_at)
continue
was_already_connected = normalized_uuid in connected
if not was_already_connected:
connected.add(normalized_uuid)
sub_newly_added.append(normalized_uuid)
access_entry = SubscriptionTemporaryAccess(
subscription_id=subscription.id,
offer_id=offer.id,
squad_uuid=normalized_uuid,
expires_at=expires_at,
is_active=True,
was_already_connected=was_already_connected,
)
db.add(access_entry)
if sub_newly_added:
subscription.connected_squads = list(connected)
subscription.updated_at = now
for s in sub_newly_added:
if s not in all_newly_added:
all_newly_added.append(s)
if connected != original_connected:
remnawave_user = await self.subscription_service.update_remnawave_user(
db,
subscription,
sync_squads=True,
)
if remnawave_user is None:
logger.error(
'Не удалось синхронизировать тестовый доступ с RemnaWave',
subscription_id=subscription.id,
)
any_sync_failed = True
if any_sync_failed and not all_newly_added:
await db.rollback()
return False, None, None, 'remnawave_sync_failed'
await db.commit()
for sub in target_subs:
try:
await db.refresh(sub)
except Exception:
pass
return True, all_newly_added, expires_at, 'ok'
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
now = datetime.now(UTC)
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(
selectinload(SubscriptionTemporaryAccess.subscription),
selectinload(SubscriptionTemporaryAccess.offer),
)
.where(
SubscriptionTemporaryAccess.is_active == True,
SubscriptionTemporaryAccess.expires_at <= now,
)
)
entries = result.scalars().all()
if not entries:
return 0
subscriptions_updates: dict[int, tuple[Subscription, set[str]]] = {}
log_payloads: list[dict[str, object]] = []
for entry in entries:
entry.is_active = False
entry.deactivated_at = now
subscription = entry.subscription
if not subscription:
continue
bucket = subscriptions_updates.setdefault(subscription.id, (subscription, set()))
if not entry.was_already_connected:
bucket[1].add(entry.squad_uuid)
user_id = subscription.user_id
if user_id:
offer = entry.offer
log_payloads.append(
{
'user_id': user_id,
'offer_id': entry.offer_id,
'source': getattr(offer, 'notification_type', None),
'percent': getattr(offer, 'discount_percent', None),
'effect_type': getattr(offer, 'effect_type', 'test_access'),
'details': {
'reason': 'test_access_expired',
'squad_uuid': entry.squad_uuid,
},
}
)
for subscription, squads_to_remove in subscriptions_updates.values():
if not squads_to_remove:
continue
current = set(subscription.connected_squads or [])
updated = current.difference(squads_to_remove)
if updated != current:
subscription.connected_squads = list(updated)
subscription.updated_at = now
try:
await self.subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
'Ошибка обновления Remnawave при отзыве тестового доступа подписки',
subscription_id=subscription.id,
exc=exc,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
await db.commit()
for payload in log_payloads:
try:
await log_promo_offer_action(
db,
user_id=payload['user_id'],
offer_id=payload.get('offer_id'),
action='disabled',
source=payload.get('source'),
percent=payload.get('percent'),
effect_type=payload.get('effect_type'),
details=payload.get('details'),
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer test access disable log for user',
payload=payload.get('user_id'),
exc=exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer test access log failure',
rollback_error=rollback_error,
)
return len(entries)
promo_offer_service = PromoOfferService()