admin_nyelvi_javítas
This commit is contained in:
250
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
250
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Inactivity Monitor Worker (Robot-21)
|
||||
Detects users who have been inactive for 180+ days and flags them so the
|
||||
MLM Commission Engine can bypass them for Gen1/Gen2 reward calculations.
|
||||
|
||||
Process:
|
||||
1. Find users where last_activity_at < NOW() - INTERVAL '180 days'
|
||||
AND is_active = True AND is_deleted = False
|
||||
2. Set is_active = False
|
||||
3. Set custom_permissions['inactivity_suspended'] = True
|
||||
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
|
||||
4. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
||||
5. Send notification
|
||||
|
||||
MLM Integration:
|
||||
- The custom_permissions['inactivity_suspended'] flag is checked by the
|
||||
Commission Engine when calculating Gen1/Gen2 rewards. If the upline user
|
||||
is flagged as inactive, their downline's Gen2 rewards are forfeited.
|
||||
|
||||
Design:
|
||||
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||
- Only processes users who have a last_activity_at value (never-logged-in
|
||||
users are handled separately by their created_at timestamp)
|
||||
|
||||
Run:
|
||||
docker exec sf_api python -m app.workers.system.inactivity_monitor_worker
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("inactivity-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Inactivity-Monitor"
|
||||
INACTIVITY_DAYS = 180
|
||||
|
||||
|
||||
async def process_inactive_users(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Finds and flags users inactive for 180+ days.
|
||||
|
||||
Two-phase detection:
|
||||
A. Users with last_activity_at: last_activity_at < NOW() - 180 days
|
||||
B. Users without last_activity_at (never logged in):
|
||||
created_at < NOW() - 180 days AND last_activity_at IS NULL
|
||||
|
||||
Returns:
|
||||
dict: Statistics (processed_count, suspended_users, errors)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(days=INACTIVITY_DAYS)
|
||||
stats = {"processed": 0, "suspended": [], "errors": []}
|
||||
|
||||
# ── Phase A: Users with last_activity_at ──
|
||||
# NOTE: We use a subquery approach to avoid PostgreSQL's
|
||||
# "FOR UPDATE cannot be applied to the nullable side of an outer join" error.
|
||||
# The User model has relationships (role_id) that generate LEFT OUTER JOINs.
|
||||
subquery_a = (
|
||||
select(User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity_at.isnot(None),
|
||||
User.last_activity_at < cutoff,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.subquery()
|
||||
)
|
||||
stmt_a = select(User).where(User.id.in_(select(subquery_a.c.id)))
|
||||
result_a = await db.execute(stmt_a)
|
||||
inactive_users_a: List[User] = result_a.scalars().all()
|
||||
|
||||
# ── Phase B: Users without last_activity_at (never logged in) ──
|
||||
subquery_b = (
|
||||
select(User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity_at.is_(None),
|
||||
User.created_at < cutoff,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.subquery()
|
||||
)
|
||||
stmt_b = select(User).where(User.id.in_(select(subquery_b.c.id)))
|
||||
result_b = await db.execute(stmt_b)
|
||||
inactive_users_b: List[User] = result_b.scalars().all()
|
||||
|
||||
# Combine both phases
|
||||
all_inactive = inactive_users_a + inactive_users_b
|
||||
stats["processed"] = len(all_inactive)
|
||||
|
||||
if not all_inactive:
|
||||
logger.info("No inactive users found.")
|
||||
return stats
|
||||
|
||||
for user in all_inactive:
|
||||
try:
|
||||
# Determine the reference timestamp for this user
|
||||
ref_ts = user.last_activity_at or user.created_at
|
||||
days_since = (now - ref_ts).days if ref_ts else INACTIVITY_DAYS
|
||||
|
||||
# 1. Deactivate the user
|
||||
user.is_active = False
|
||||
|
||||
# 2. Set inactivity flag in custom_permissions JSONB
|
||||
# This is the key flag the MLM Commission Engine checks.
|
||||
perms = dict(user.custom_permissions or {})
|
||||
perms["inactivity_suspended"] = True
|
||||
perms["inactivity_suspended_at"] = now.isoformat()
|
||||
perms["inactivity_days_since_last_activity"] = days_since
|
||||
user.custom_permissions = perms
|
||||
|
||||
# 3. Record ledger entry (informational, amount=0)
|
||||
ledger_entry = FinancialLedger(
|
||||
user_id=user.id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type="ACCOUNT_SUSPENDED_INACTIVITY",
|
||||
details={
|
||||
"description": (
|
||||
f"Account suspended after {days_since} days of inactivity. "
|
||||
f"Last activity: {ref_ts.isoformat() if ref_ts else 'never'}"
|
||||
),
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"cutoff_days": INACTIVITY_DAYS,
|
||||
},
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
|
||||
# 4. Send notification
|
||||
try:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title="Fiók felfüggesztve inaktivitás miatt",
|
||||
message=(
|
||||
f"Fiókod {days_since} napos inaktivitás miatt "
|
||||
f"felfüggesztésre került. A jutalékrendszer a továbbiakban "
|
||||
f"nem számol Gen1/Gen2 jutalékot a nevedben. "
|
||||
f"Lépj be újra a fiókod aktiválásához."
|
||||
),
|
||||
category="system",
|
||||
priority="high",
|
||||
data={
|
||||
"user_id": user.id,
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"suspended_at": now.isoformat(),
|
||||
},
|
||||
send_email=True,
|
||||
email_template="account_suspended_inactivity",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name
|
||||
if user.person
|
||||
else "Partnerünk",
|
||||
"inactivity_days": str(days_since),
|
||||
"lang": user.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for user {user.id}: {notify_err}"
|
||||
)
|
||||
|
||||
stats["suspended"].append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {user.id} ({user.email}) suspended after "
|
||||
f"{days_since} days of inactivity"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing user {user.id}: {e}", exc_info=True
|
||||
)
|
||||
stats["errors"].append({"user_id": user.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Inactive users processed: {stats['processed']} total, "
|
||||
f"{len(stats['suspended'])} suspended, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def run_full_monitor() -> dict:
|
||||
"""Runs the complete inactivity monitor cycle."""
|
||||
logger.info("=== Inactivity Monitor Worker started ===")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await process_inactive_users(db)
|
||||
logger.info(
|
||||
f"=== Inactivity Monitor Worker completed: "
|
||||
f"{stats['processed']} processed =="
|
||||
)
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Inactivity Monitor Worker failed: {e}", exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point for standalone execution (cron / manual)."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Starting Inactivity Monitor Worker (standalone)...")
|
||||
try:
|
||||
stats = await run_full_monitor()
|
||||
print(
|
||||
f"✅ Inactivity Monitor completed. "
|
||||
f"Total processed: {stats['processed']}, "
|
||||
f"Suspended: {len(stats['suspended'])}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Inactivity Monitor failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Subscription Monitor Worker (Robot-20 Enhanced)
|
||||
Comprehensive subscription lifecycle management for both UserSubscription
|
||||
and OrganizationSubscription tables.
|
||||
|
||||
Process:
|
||||
1. Scan finance.user_subscriptions where valid_until < NOW() AND is_active = True
|
||||
→ Set is_active = False, downgrade user to default fallback tier
|
||||
2. Scan finance.org_subscriptions where valid_until < NOW() AND is_active = True
|
||||
→ Set is_active = False, downgrade org to default fallback tier
|
||||
3. Sync denormalized fields on identity.users and fleet.organizations
|
||||
4. Record ledger entries (SUBSCRIPTION_EXPIRED)
|
||||
5. Send notifications via NotificationService
|
||||
|
||||
Design:
|
||||
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||
- Falls back to SubscriptionTier.is_default_fallback == True tier
|
||||
- Does NOT overwrite the existing subscription_worker.py (backward compatible)
|
||||
|
||||
Run:
|
||||
docker exec sf_api python -m app.workers.system.subscription_monitor_worker
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.core_logic import (
|
||||
SubscriptionTier,
|
||||
UserSubscription,
|
||||
OrganizationSubscription,
|
||||
)
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("subscription-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Subscription-Monitor"
|
||||
|
||||
|
||||
async def _get_default_fallback_tier(db: AsyncSession) -> Optional[SubscriptionTier]:
|
||||
"""
|
||||
Lekérdezi az egyetlen csomagot, ahol is_default_fallback == True.
|
||||
Ha nincs ilyen, None-t ad vissza.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(
|
||||
SubscriptionTier.is_default_fallback == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _record_expired_ledger(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
old_plan: str,
|
||||
subscription_type: str,
|
||||
reference_id: int,
|
||||
) -> None:
|
||||
"""Record a SUBSCRIPTION_EXPIRED ledger entry (amount=0, informational only)."""
|
||||
entry = FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type="SUBSCRIPTION_EXPIRED",
|
||||
details={
|
||||
"description": f"Subscription expired: {old_plan} → FREE ({subscription_type})",
|
||||
"reference_type": subscription_type,
|
||||
"reference_id": reference_id,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": "FREE",
|
||||
},
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
|
||||
async def process_expired_user_subscriptions(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Processes expired UserSubscription records.
|
||||
- Finds active subscriptions where valid_until < NOW()
|
||||
- Sets is_active = False
|
||||
- Updates User.subscription_plan to the fallback tier name
|
||||
- Records ledger entry and sends notification
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||
|
||||
# 1. Find expired UserSubscriptions with atomic lock
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.options(selectinload(UserSubscription.tier))
|
||||
.where(
|
||||
and_(
|
||||
UserSubscription.valid_until < now,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
expired_subs: List[UserSubscription] = result.scalars().all()
|
||||
|
||||
if not expired_subs:
|
||||
logger.info("No expired user subscriptions found.")
|
||||
return stats
|
||||
|
||||
# 2. Get the default fallback tier
|
||||
fallback_tier = await _get_default_fallback_tier(db)
|
||||
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||
|
||||
for sub in expired_subs:
|
||||
try:
|
||||
# Mark subscription as inactive
|
||||
sub.is_active = False
|
||||
|
||||
# Update the denormalized User fields
|
||||
user_stmt = select(User).where(User.id == sub.user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
old_plan = user.subscription_plan
|
||||
user.subscription_plan = fallback_plan.upper()
|
||||
user.subscription_expires_at = None
|
||||
user.is_vip = False
|
||||
|
||||
# Record ledger entry
|
||||
await _record_expired_ledger(
|
||||
db,
|
||||
user_id=user.id,
|
||||
old_plan=old_plan,
|
||||
subscription_type="user_subscription",
|
||||
reference_id=sub.id,
|
||||
)
|
||||
|
||||
# Send notification
|
||||
try:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title="Előfizetésed lejárt",
|
||||
message=(
|
||||
f"A(z) {old_plan} előfizetésed lejárt. "
|
||||
f"Fiókod a(z) {fallback_plan.upper()} csomagra váltott. "
|
||||
"További előnyökért frissíts előfizetést!"
|
||||
),
|
||||
category="billing",
|
||||
priority="medium",
|
||||
data={
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"user_id": user.id,
|
||||
"subscription_id": sub.id,
|
||||
"expired_at": sub.valid_until.isoformat()
|
||||
if sub.valid_until
|
||||
else None,
|
||||
},
|
||||
send_email=True,
|
||||
email_template="subscription_expired",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name
|
||||
if user.person
|
||||
else "Partnerünk",
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"lang": user.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for user {user.id}: {notify_err}"
|
||||
)
|
||||
|
||||
stats["downgraded"].append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"User {user.id} ({user.email}) subscription expired: "
|
||||
f"{old_plan} → {fallback_plan.upper()}"
|
||||
)
|
||||
|
||||
stats["processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing UserSubscription {sub.id}: {e}", exc_info=True
|
||||
)
|
||||
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Expired user subscriptions processed: {stats['processed']} total, "
|
||||
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def process_expired_org_subscriptions(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Processes expired OrganizationSubscription records.
|
||||
- Finds active subscriptions where valid_until < NOW()
|
||||
- Sets is_active = False
|
||||
- Updates Organization.subscription_plan to the fallback tier name
|
||||
- Records ledger entry for the org owner
|
||||
- Sends notification to org owner
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||
|
||||
# 1. Find expired OrganizationSubscriptions with atomic lock
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.options(selectinload(OrganizationSubscription.tier))
|
||||
.where(
|
||||
and_(
|
||||
OrganizationSubscription.valid_until < now,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
expired_subs: List[OrganizationSubscription] = result.scalars().all()
|
||||
|
||||
if not expired_subs:
|
||||
logger.info("No expired organization subscriptions found.")
|
||||
return stats
|
||||
|
||||
# 2. Get the default fallback tier
|
||||
fallback_tier = await _get_default_fallback_tier(db)
|
||||
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||
|
||||
for sub in expired_subs:
|
||||
try:
|
||||
# Mark subscription as inactive
|
||||
sub.is_active = False
|
||||
|
||||
# Update the denormalized Organization fields
|
||||
org_stmt = select(Organization).where(Organization.id == sub.org_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if org:
|
||||
old_plan = org.subscription_plan
|
||||
org.subscription_plan = fallback_plan.upper()
|
||||
org.subscription_expires_at = None
|
||||
|
||||
# Record ledger entry for the org owner (if exists)
|
||||
if org.owner_id:
|
||||
await _record_expired_ledger(
|
||||
db,
|
||||
user_id=org.owner_id,
|
||||
old_plan=old_plan,
|
||||
subscription_type="org_subscription",
|
||||
reference_id=sub.id,
|
||||
)
|
||||
|
||||
# Send notification to org owner
|
||||
try:
|
||||
owner_stmt = select(User).where(User.id == org.owner_id)
|
||||
owner_result = await db.execute(owner_stmt)
|
||||
owner = owner_result.scalar_one_or_none()
|
||||
|
||||
if owner:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=owner.id,
|
||||
title="Szervezeti előfizetésed lejárt",
|
||||
message=(
|
||||
f"A(z) '{org.name}' szervezet {old_plan} "
|
||||
f"előfizetése lejárt. A szervezet a(z) "
|
||||
f"{fallback_plan.upper()} csomagra váltott."
|
||||
),
|
||||
category="billing",
|
||||
priority="medium",
|
||||
data={
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"subscription_id": sub.id,
|
||||
"expired_at": sub.valid_until.isoformat()
|
||||
if sub.valid_until
|
||||
else None,
|
||||
},
|
||||
send_email=True,
|
||||
email_template="subscription_expired",
|
||||
email_vars={
|
||||
"recipient": owner.email,
|
||||
"first_name": owner.person.first_name
|
||||
if owner.person
|
||||
else "Partnerünk",
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"lang": owner.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for org owner {org.owner_id}: "
|
||||
f"{notify_err}"
|
||||
)
|
||||
|
||||
stats["downgraded"].append(
|
||||
{
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"Organization {org.id} ({org.name}) subscription expired: "
|
||||
f"{old_plan} → {fallback_plan.upper()}"
|
||||
)
|
||||
|
||||
stats["processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing OrganizationSubscription {sub.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Expired org subscriptions processed: {stats['processed']} total, "
|
||||
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def run_full_monitor() -> dict:
|
||||
"""
|
||||
Runs the complete subscription monitor cycle.
|
||||
Returns aggregated statistics.
|
||||
"""
|
||||
logger.info("=== Subscription Monitor Worker started ===")
|
||||
aggregated = {
|
||||
"user_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||
"org_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||
}
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Phase 1: Expired UserSubscriptions
|
||||
user_stats = await process_expired_user_subscriptions(db)
|
||||
aggregated["user_subscriptions"] = user_stats
|
||||
|
||||
# Phase 2: Expired OrganizationSubscriptions
|
||||
org_stats = await process_expired_org_subscriptions(db)
|
||||
aggregated["org_subscriptions"] = org_stats
|
||||
|
||||
logger.info(
|
||||
f"=== Subscription Monitor Worker completed: "
|
||||
f"{user_stats['processed'] + org_stats['processed']} total =="
|
||||
)
|
||||
return aggregated
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Subscription Monitor Worker failed: {e}", exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point for standalone execution (cron / manual)."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Starting Subscription Monitor Worker (standalone)...")
|
||||
try:
|
||||
stats = await run_full_monitor()
|
||||
total = (
|
||||
stats["user_subscriptions"]["processed"]
|
||||
+ stats["org_subscriptions"]["processed"]
|
||||
)
|
||||
print(
|
||||
f"✅ Subscription Monitor completed. "
|
||||
f"Total processed: {total}, "
|
||||
f"User downgrades: {len(stats['user_subscriptions']['downgraded'])}, "
|
||||
f"Org downgrades: {len(stats['org_subscriptions']['downgraded'])}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Subscription Monitor failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user