admin_nyelvi_javítas

This commit is contained in:
Roo
2026-07-25 10:22:03 +00:00
parent 33c4d793db
commit c9118bf52f
29 changed files with 4474 additions and 943 deletions

View 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())

View 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())