felhasználói felületre pü

This commit is contained in:
Roo
2026-07-27 08:39:18 +00:00
parent c9118bf52f
commit 6f28d3e70d
72 changed files with 6311 additions and 749 deletions

View File

@@ -27,6 +27,7 @@ api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expe
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=["finance-admin"])
api_router.include_router(finance_admin.router, prefix="/admin/finance", tags=["admin-finance"])
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])

View File

@@ -42,7 +42,7 @@ from app.models.vehicle.asset import Asset
from app.models.vehicle.asset import AssetEvent
from app.models.vehicle.asset import OdometerReading
from app.models.marketplace.organization import Organization
from app.models.marketplace.organization import OrganizationMember
from app.models.marketplace.organization import OrganizationMember, OrgRole
from app.models.identity.social import ServiceProvider
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
from app.models.system.system import SystemParameter
@@ -54,6 +54,123 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# ── SINGLE EXPENSE READ ENDPOINT ──
# P0 BUGFIX (2026-07-26): Dedicated single-expense endpoint that returns
# enriched AssetCostResponse with resolved category name, parent_category_id,
# provider name, etc. This is used by the frontend edit modal hydration.
#
# NOTE: This MUST be registered BEFORE the {asset_id} wildcard route to avoid
# FastAPI routing the "by-id" path segment as an asset_id UUID.
@router.get("/by-id/{expense_id}")
async def get_expense_by_id(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
"""
Get a single expense by its ID with enriched fields.
Returns a fully populated AssetCostResponse including:
- category_name, category_code, parent_category_id (from CostCategory JOIN)
- service_provider_name (from ServiceProvider JOIN)
- vendor_name (from Organization JOIN via vendor_organization_id)
- description, mileage_at_cost (extracted from data JSONB)
This is the preferred endpoint for frontend edit modal hydration.
"""
from app.models.fleet_finance.models import CostCategory
from app.models.identity.social import ServiceProvider
from app.models.marketplace.organization import Organization
stmt = (
select(AssetCost)
.where(AssetCost.id == expense_id)
)
result = await db.execute(stmt)
cost = result.scalar_one_or_none()
if not cost:
raise HTTPException(status_code=404, detail="Expense not found.")
# Resolve enriched fields via explicit queries (no lazy loading surprises)
# 1. Category details + parent_id
category_name = None
category_code = None
parent_category_id = None
if cost.category_id is not None:
cat_stmt = select(CostCategory).where(CostCategory.id == cost.category_id)
cat_result = await db.execute(cat_stmt)
category = cat_result.scalar_one_or_none()
if category:
category_name = category.name
category_code = category.code
parent_category_id = category.parent_id
# 2. Service provider name
service_provider_name = None
if cost.service_provider_id is not None:
sp_stmt = select(ServiceProvider).where(ServiceProvider.id == cost.service_provider_id)
sp_result = await db.execute(sp_stmt)
provider = sp_result.scalar_one_or_none()
if provider:
service_provider_name = provider.name
# 3. Vendor organization name
vendor_name = None
if cost.vendor_organization_id is not None:
org_stmt = select(Organization).where(Organization.id == cost.vendor_organization_id)
org_result = await db.execute(org_stmt)
vendor_org = org_result.scalar_one_or_none()
if vendor_org:
vendor_name = vendor_org.name
# 4. Extract description and mileage from data JSONB
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
response = AssetCostResponse(
id=cost.id,
asset_id=cost.asset_id,
organization_id=cost.organization_id,
category_id=cost.category_id,
status=cost.status,
linked_asset_event_id=cost.linked_asset_event_id,
# Monetary
amount_gross=cost.amount_gross,
amount_net=cost.amount_net,
vat_rate=cost.vat_rate,
currency=cost.currency,
# Dates
cost_date=cost.date,
invoice_number=cost.invoice_number,
# Invoice dates
invoice_date=cost.invoice_date,
fulfillment_date=cost.fulfillment_date,
# Vendor fields
vendor_organization_id=cost.vendor_organization_id,
service_provider_id=cost.service_provider_id,
external_vendor_name=cost.external_vendor_name,
# Raw data JSONB
data=data,
# Enriched fields
category_name=category_name,
category_code=category_code,
parent_category_id=parent_category_id,
description=description,
mileage_at_cost=mileage_at_cost,
vendor_name=vendor_name,
service_provider_name=service_provider_name,
)
return {
"status": "success",
"data": response,
}
# ── FUEL CATEGORY IDS ──
# These are the category IDs that represent fuel costs.
# Used for auto-approval logic: fuel costs are always auto-approved.
@@ -100,22 +217,34 @@ async def _check_org_capability(
Uses the JSONB permissions field from fleet.org_roles.
Returns True if the user's role has the capability, False otherwise.
"""
from app.models.fleet.org_role import OrgRole
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name)
.where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if row and isinstance(row, dict):
return row.get(capability, False)
NOTE: Two-step query (not JOIN) to avoid PG ENUM vs varchar type mismatch.
OrganizationMember.role is PG ENUM 'fleet.orguserrole', OrgRole.name_key is varchar.
A direct JOIN would cause: operator does not exist: fleet.orguserrole = character varying
See assets.py:_get_user_permissions() for the same pattern.
"""
# Step 1: Get the user's role name (Python string — asyncpg auto-converts ENUM)
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
return False
# Step 2: Look up permissions from fleet.org_roles using the role name
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True,
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if permissions and isinstance(permissions, dict):
return permissions.get(capability, False)
return False

View File

@@ -1,22 +1,29 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py
"""
Finance Admin API endpoints for managing Issuers with strict RBAC protection.
Finance Admin API endpoints for managing Issuers and FinancialLedger with strict RBAC protection.
Protected by DB-driven RequirePermission("finance:view") dependency.
"""
from fastapi import APIRouter, Depends, HTTPException, status
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from sqlalchemy import select, func, text
from typing import List, Optional
from app.api import deps
from app.models.identity import User
from app.models.marketplace.finance import Issuer
from app.schemas.finance import IssuerResponse, IssuerUpdate
from app.models.system.audit import FinancialLedger
from app.schemas.finance import IssuerResponse, IssuerUpdate, FinancialLedgerListResponse, FinancialLedgerResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# ──────────────────────────────────────────────────────────────────────────────
# Issuer Endpoints (existing)
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
async def list_issuers(
db: AsyncSession = Depends(deps.get_db),
@@ -27,6 +34,7 @@ async def list_issuers(
List all Issuers (billing entities).
Protected by RequirePermission("finance:view").
"""
from app.models.marketplace.finance import Issuer
result = await db.execute(select(Issuer).order_by(Issuer.id))
issuers = result.scalars().all()
return issuers
@@ -44,6 +52,7 @@ async def update_issuer(
Update an Issuer's details (activate/deactivate, revenue limit, API config).
Protected by RequirePermission("finance:edit").
"""
from app.models.marketplace.finance import Issuer
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
issuer = result.scalar_one_or_none()
@@ -61,4 +70,134 @@ async def update_issuer(
await db.commit()
await db.refresh(issuer)
return issuer
return issuer
# ──────────────────────────────────────────────────────────────────────────────
# FinancialLedger Admin Endpoints (NEW)
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/ledger", response_model=FinancialLedgerListResponse, tags=["finance-admin"])
async def list_financial_ledger(
page: int = Query(1, ge=1, description="Oldalszám (1-indexelt)"),
page_size: int = Query(50, ge=1, le=200, description="Rekordok száma oldalanként"),
user_id: Optional[int] = Query(None, description="Szűrés felhasználó ID alapján"),
transaction_type: Optional[str] = Query(None, description="Tranzakció típus szűrés"),
entry_type: Optional[str] = Query(None, description="Könyvelési irány (DEBIT / CREDIT)"),
wallet_type: Optional[str] = Query(None, description="Tárcatípus szűrés"),
date_from: Optional[str] = Query(None, description="Dátum szűrés kezdete (ISO, pl. 2026-01-01)"),
date_to: Optional[str] = Query(None, description="Dátum szűrés vége (ISO, pl. 2026-12-31)"),
sort_by: str = Query("created_at", description="Rendezési mező (created_at, amount, id)"),
sort_order: str = Query("desc", description="Rendezési irány (asc / desc)"),
db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("finance:view")),
):
"""
List FinancialLedger entries with pagination, filtering, and user details.
Uses raw SQL with explicit joins to avoid SQLAlchemy relationship issues.
Protected by RequirePermission("finance:view").
Returns paginated results with joined user email and person name.
"""
# ── Build WHERE clauses dynamically ──
where_clauses = []
params = {}
if user_id is not None:
where_clauses.append("fl.user_id = :user_id")
params["user_id"] = user_id
if transaction_type is not None:
where_clauses.append("fl.transaction_type = :transaction_type")
params["transaction_type"] = transaction_type
if entry_type is not None:
where_clauses.append("fl.entry_type = :entry_type")
params["entry_type"] = entry_type
if wallet_type is not None:
where_clauses.append("fl.wallet_type = :wallet_type")
params["wallet_type"] = wallet_type
if date_from is not None:
where_clauses.append("fl.created_at >= :date_from::timestamp")
params["date_from"] = date_from
if date_to is not None:
where_clauses.append("fl.created_at <= :date_to::timestamp")
params["date_to"] = date_to
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
# Validate sort_by to prevent SQL injection
allowed_sort_fields = {"created_at", "amount", "id"}
if sort_by not in allowed_sort_fields:
sort_by = "created_at"
sort_direction = "ASC" if sort_order == "asc" else "DESC"
# ── Count query ──
count_sql = f"""
SELECT COUNT(*) FROM audit.financial_ledger fl
WHERE {where_sql}
"""
count_result = await db.execute(text(count_sql), params)
total = count_result.scalar() or 0
# ── Data query with joins ──
offset = (page - 1) * page_size
data_sql = f"""
SELECT
fl.id,
fl.user_id,
fl.person_id,
fl.amount,
fl.currency,
fl.transaction_type,
fl.entry_type::text,
fl.wallet_type::text,
fl.status::text,
fl.details,
fl.created_at,
u.email AS user_email,
p.first_name AS person_first_name,
p.last_name AS person_last_name
FROM audit.financial_ledger fl
LEFT JOIN identity.users u ON u.id = fl.user_id
LEFT JOIN identity.persons p ON p.id = u.person_id
WHERE {where_sql}
ORDER BY fl.{sort_by} {sort_direction}
LIMIT :limit OFFSET :offset
"""
params["limit"] = page_size
params["offset"] = offset
data_result = await db.execute(text(data_sql), params)
rows = data_result.all()
# ── Build response ──
items = []
for row in rows:
user_name = None
if row.person_first_name or row.person_last_name:
parts = [p for p in [row.person_last_name, row.person_first_name] if p]
user_name = " ".join(parts)
items.append(FinancialLedgerResponse(
id=row.id,
user_id=row.user_id,
person_id=row.person_id,
amount=float(row.amount) if row.amount else 0.0,
currency=row.currency,
transaction_type=row.transaction_type,
entry_type=row.entry_type,
wallet_type=row.wallet_type,
status=row.status,
details=row.details,
created_at=row.created_at,
user_email=row.user_email,
user_name=user_name,
))
return FinancialLedgerListResponse(
total=total,
page=page,
page_size=page_size,
items=items,
)

View File

@@ -163,11 +163,7 @@ async def list_expertise_categories(
ExpertiseCategoryOut(
id=t.id,
key=t.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=t.name_i18n if t.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=t.name_hu,
name_en=t.name_en,
category=t.category,
level=t.level,
parent_id=t.parent_id,

View File

@@ -120,6 +120,7 @@ class AssetCostResponse(AssetCostBase):
# Derived / enriched fields (not in DB model directly)
category_name: Optional[str] = None # Resolved from CostCategory relationship
category_code: Optional[str] = None # Resolved from CostCategory relationship
parent_category_id: Optional[int] = None # Resolved from CostCategory.parent_id — needed for two-level category dropdown on frontend
description: Optional[str] = None # Extracted from data['description']
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
@@ -129,4 +130,7 @@ class AssetCostResponse(AssetCostBase):
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
service_provider_id: Optional[int] = None
# P0 BUGFIX: Enriched provider name (resolved from ServiceProvider.name)
service_provider_name: Optional[str] = None # Resolved from ServiceProvider.name via service_provider_id
model_config = ConfigDict(from_attributes=True)

View File

@@ -34,6 +34,34 @@ class IssuerResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class FinancialLedgerResponse(BaseModel):
"""Response schema for FinancialLedger admin listing."""
id: int
user_id: Optional[int] = None
person_id: Optional[int] = None
amount: float
currency: Optional[str] = None
transaction_type: Optional[str] = None
entry_type: Optional[str] = None
wallet_type: Optional[str] = None
status: Optional[str] = None
details: Optional[Dict[str, Any]] = None
created_at: Optional[datetime] = None
# Joined user info
user_email: Optional[str] = None
user_name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class FinancialLedgerListResponse(BaseModel):
"""Paginated response for FinancialLedger admin listing."""
total: int
page: int
page_size: int
items: List[FinancialLedgerResponse]
class IssuerUpdate(BaseModel):
"""Update schema for Issuer entities (PATCH)."""
is_active: Optional[bool] = None

View File

@@ -1,12 +1,12 @@
# /opt/docker/dev/service_finder/backend/app/schemas/system.py
from pydantic import BaseModel, ConfigDict
from typing import Dict, Any, Optional
from typing import Any, Optional
from datetime import datetime
class SystemParameterBase(BaseModel):
description: Optional[str] = None
value: Dict[str, Any] # JSONB mező
value: Any # JSONB mező — bármilyen JSON-szeriális érték lehet (int, str, dict, list)
scope_level: str = 'global'
scope_id: Optional[str] = None
is_active: bool = True
@@ -18,7 +18,7 @@ class SystemParameterCreate(SystemParameterBase):
class SystemParameterUpdate(BaseModel):
description: Optional[str] = None
value: Optional[Dict[str, Any]] = None
value: Optional[Any] = None
is_active: Optional[bool] = None

View File

@@ -320,6 +320,15 @@ async def seed_params():
"scope_level": "global"
},
# --- 11. KÜLSŐ API-K (DVLA, UK) ---
# --- 11. INACTIVITY MONITOR (Robot-21) ---
{
"key": "inactivity_threshold_days",
"value": 180,
"category": "system",
"description": "Felhasználói inaktivitás küszöbértéke napokban. Ha a user utolsó aktivitása régebbi, a fiók felfüggesztésre kerül és a MLM jutalékrendszer megkerüli.",
"scope_level": "global"
},
# --- 12. KÜLSŐ API-K (DVLA, UK) ---
{
"key": "dvla_api_en

View File

@@ -573,16 +573,22 @@ class AtomicTransactionManager:
# Convert to dictionary format
transactions = []
for entry in ledger_entries:
# Extract description from JSONB details field (legacy compatibility)
desc = None
if entry.details and isinstance(entry.details, dict):
desc = entry.details.get("description") or entry.details.get("desc") or None
elif entry.details and isinstance(entry.details, str):
desc = entry.details
transactions.append({
"id": entry.id,
"user_id": entry.user_id,
"amount": float(entry.amount),
"entry_type": entry.entry_type.value,
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
"description": entry.description,
"description": desc,
"transaction_id": str(entry.transaction_id),
"reference_type": entry.reference_type,
"reference_id": entry.reference_id,
"reference_type": None,
"reference_id": None,
"balance_after": float(entry.balance_after) if entry.balance_after else None,
"created_at": entry.created_at.isoformat() if entry.created_at else None
})
@@ -622,15 +628,15 @@ class AtomicTransactionManager:
return {
"wallet_id": wallet.id,
"balances": {
"earned": float(wallet.earned_credits),
"purchased": float(wallet.purchased_credits),
"service_coins": float(wallet.service_coins),
"voucher": float(voucher_balance),
"earned": float(wallet.earned_credits or 0),
"purchased": float(wallet.purchased_credits or 0),
"service_coins": float(wallet.service_coins or 0),
"voucher": float(voucher_balance or 0),
"total": float(
wallet.earned_credits +
wallet.purchased_credits +
wallet.service_coins +
voucher_balance
(wallet.earned_credits or 0) +
(wallet.purchased_credits or 0) +
(wallet.service_coins or 0) +
(voucher_balance or 0)
)
},
"recent_transactions": recent_transactions,

View File

@@ -33,6 +33,7 @@ from app.models.marketplace.commission import (
CommissionTier,
)
from app.models.identity.identity import User, Wallet
from app.models.system import SystemParameter
from app.models.system.audit import (
FinancialLedger,
LedgerEntryType,
@@ -589,26 +590,59 @@ async def _credit_commission_to_wallet(
)
async def _get_inactivity_threshold(db: AsyncSession, default: int = 180) -> int:
"""
Read the inactivity_threshold_days from system.system_parameters.
Falls back to `default` (180) if not found.
"""
try:
stmt = select(SystemParameter).where(
SystemParameter.key == "inactivity_threshold_days",
SystemParameter.scope_level == "global",
SystemParameter.is_active == True,
)
result = await db.execute(stmt)
param = result.scalar_one_or_none()
if param is not None:
val = param.value
if isinstance(val, dict):
return int(val.get("value", val.get("days", default)))
return int(val)
except Exception as e:
logger.warning(
f"Failed to read inactivity_threshold_days from DB: {e}. "
f"Falling back to {default}."
)
return default
async def _is_user_recently_active(
db: AsyncSession,
user_id: int,
inactivity_days: int = 180,
inactivity_days: Optional[int] = None,
) -> bool:
"""
Check if a user has been active within the last `inactivity_days` days.
Check if a user has been active within the inactivity threshold.
A user is considered active if:
1. Their subscription is active (subscription_expires_at > now), OR
2. They have a FinancialLedger entry within the inactivity window.
The inactivity threshold is read from system.system_parameters
(key: inactivity_threshold_days), falling back to 180 days.
Args:
db: Database session.
user_id: The user to check.
inactivity_days: Number of days of inactivity before considered inactive.
inactivity_days: Optional override. If None, reads from DB settings.
Returns:
True if the user is recently active, False otherwise.
"""
# Resolve threshold: parameter override or DB setting
if inactivity_days is None:
inactivity_days = await _get_inactivity_threshold(db)
# Check subscription status first
user = await _lookup_user(db, user_id)
if not user:

View File

@@ -393,22 +393,42 @@ class FinancialOrchestrator:
await db.flush()
# 3. Pénztárca egyenleg visszaállítása
# JAVÍTÁS: A Wallet modellben NINCS "wallet_type" és "balance" mező.
# A usernek egyetlen wallet-je van (user_id UNIQUE constraint),
# a credit típusok külön oszlopokban: earned_credits, purchased_credits, service_coins.
# A wallet_type-t az eredeti FinancialLedger bejegyzésből olvassuk ki,
# és a megfelelő oszlopot frissítjük (pont mint process_payment()-ben).
wallet_query = select(Wallet).where(
and_(
Wallet.user_id == original_entry.user_id,
Wallet.wallet_type == original_entry.wallet_type
)
Wallet.user_id == original_entry.user_id
).with_for_update()
wallet_result = await db.execute(wallet_query)
wallet = wallet_result.scalar_one_or_none()
if wallet:
new_balance = wallet.balance - original_entry.amount
wallet_type = original_entry.wallet_type
update_values = {}
if wallet_type == WalletType.EARNED:
new_balance = Decimal(str(wallet.earned_credits)) + original_entry.amount
update_values['earned_credits'] = float(new_balance)
elif wallet_type == WalletType.PURCHASED:
new_balance = Decimal(str(wallet.purchased_credits)) + original_entry.amount
update_values['purchased_credits'] = float(new_balance)
elif wallet_type == WalletType.SERVICE_COINS:
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
update_values['service_coins'] = float(new_balance)
elif wallet_type == WalletType.VOUCHER:
# VOUCHER típusnál nincs dedikált mező, SERVICE_COINS-ba tesszük
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
update_values['service_coins'] = float(new_balance)
else:
raise ValueError(f"Ismeretlen wallet_type: {wallet_type}")
await db.execute(
update(Wallet)
.where(Wallet.id == wallet.id)
.values(balance=new_balance)
.values(**update_values)
)
# 4. Számlakiállító bevételének csökkentése

View File

@@ -1,17 +1,18 @@
#!/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.
Detects users who have been inactive for N+ days (configured via system_parameters)
and flags them so the MLM Commission Engine can bypass them for Gen1/Gen2 rewards.
Process:
1. Find users where last_activity_at < NOW() - INTERVAL '180 days'
1. Read inactivity_threshold_days from system.system_parameters (fallback: 180)
2. Find users where last_activity_at < NOW() - INTERVAL 'N days'
AND is_active = True AND is_deleted = False
2. Set is_active = False
3. Set custom_permissions['inactivity_suspended'] = True
3. Set is_active = False
4. Set custom_permissions['inactivity_suspended'] = True
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
4. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
5. Send notification
5. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
6. Send notification
MLM Integration:
- The custom_permissions['inactivity_suspended'] flag is checked by the
@@ -38,30 +39,61 @@ 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.models.system import SystemParameter
from app.services.notification_service import NotificationService
logger = logging.getLogger("inactivity-monitor-worker")
# ── Constants ──────────────────────────────────────────────────────────────────
PROCESS_NAME = "Inactivity-Monitor"
INACTIVITY_DAYS = 180
DEFAULT_INACTIVITY_DAYS = 180
async def _get_inactivity_threshold(db: AsyncSession) -> int:
"""
Read the inactivity_threshold_days from system.system_parameters.
Falls back to DEFAULT_INACTIVITY_DAYS (180) if not found.
"""
try:
stmt = select(SystemParameter).where(
SystemParameter.key == "inactivity_threshold_days",
SystemParameter.scope_level == "global",
SystemParameter.is_active == True,
)
result = await db.execute(stmt)
param = result.scalar_one_or_none()
if param is not None:
val = param.value
if isinstance(val, dict):
# Could be stored as {"value": 180} or just 180
return int(val.get("value", val.get("days", DEFAULT_INACTIVITY_DAYS)))
return int(val)
except Exception as e:
logger.warning(
f"Failed to read inactivity_threshold_days from DB: {e}. "
f"Falling back to {DEFAULT_INACTIVITY_DAYS}."
)
return DEFAULT_INACTIVITY_DAYS
async def process_inactive_users(db: AsyncSession) -> dict:
"""
Finds and flags users inactive for 180+ days.
Finds and flags users inactive for N+ days (configurable via system_parameters).
Two-phase detection:
A. Users with last_activity_at: last_activity_at < NOW() - 180 days
A. Users with last_activity_at: last_activity_at < NOW() - N days
B. Users without last_activity_at (never logged in):
created_at < NOW() - 180 days AND last_activity_at IS NULL
created_at < NOW() - N days AND last_activity_at IS NULL
Returns:
dict: Statistics (processed_count, suspended_users, errors)
"""
inactivity_days = await _get_inactivity_threshold(db)
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=INACTIVITY_DAYS)
stats = {"processed": 0, "suspended": [], "errors": []}
cutoff = now - timedelta(days=inactivity_days)
stats = {"processed": 0, "suspended": [], "errors": [], "threshold_days": inactivity_days}
logger.info(f"Inactivity threshold: {inactivity_days} days (cutoff={cutoff.isoformat()})")
# ── Phase A: Users with last_activity_at ──
# NOTE: We use a subquery approach to avoid PostgreSQL's
@@ -114,7 +146,7 @@ async def process_inactive_users(db: AsyncSession) -> dict:
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
days_since = (now - ref_ts).days if ref_ts else inactivity_days
# 1. Deactivate the user
user.is_active = False
@@ -141,7 +173,7 @@ async def process_inactive_users(db: AsyncSession) -> dict:
),
"inactivity_days": days_since,
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
"cutoff_days": INACTIVITY_DAYS,
"cutoff_days": inactivity_days,
},
)
db.add(ledger_entry)

View File

@@ -72,7 +72,11 @@ async def process_expired_subscriptions(db: AsyncSession) -> dict:
user_id=user.id,
amount=0.0,
entry_type=LedgerEntryType.DEBIT,
wallet_type=WalletType.SYSTEM,
# JAVÍTÁS: WalletType.SYSTEM nem létező enum érték.
# A WalletType csak EARNED, PURCHASED, SERVICE_COINS, VOUCHER értékeket támogat.
# Mivel ez csak egy subscription lejárati naplóbejegyzés (amount=0),
# a SERVICE_COINS a legmegfelelőbb alapértelmezett választás.
wallet_type=WalletType.SERVICE_COINS,
transaction_type="SUBSCRIPTION_EXPIRED",
description=f"Előfizetés lejárt: {old_plan} → FREE",
reference_type="subscription",