admin felület fejlesztése garázs, előfizeztési csomagok

This commit is contained in:
Roo
2026-06-25 01:58:04 +00:00
parent 80a5d67f79
commit 52011606ff
334 changed files with 46636 additions and 1606 deletions

View File

@@ -478,22 +478,19 @@ async def quick_add_provider(
"""
Gyors szolgáltató felvétel crowdsourcingból.
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
P0 HYBRID VENDOR REFACTOR (2026-06-25):
========================================
A quick_add_provider() MOST MÁR ServiceProvider-t hoz létre, NEM Organization-t.
Ez a P0 Architecture Refactor (Option C - Hybrid) része.
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
- Ha category_id nincs megadva, a primary_category kihagyásra kerül.
- A 4-level kategória rendszerből érkező category_ids és new_tags
feldolgozásra kerülnek a ServiceExpertise kapcsolatok létrehozásához.
Folyamat:
Új folyamat:
1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags)
2. Organization létrehozása (is_verified=False, org_type='service_provider')
3. ServiceProfile létrehozása
2. ServiceProvider létrehozása (marketplace.service_providers)
3. ServiceProfile létrehozása (service_provider_id hivatkozással)
4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags)
5. Branch létrehozása a címmel
6. OrganizationMember létrehozása a felhasználóhoz
5. 🎮 Gamification pontozás
NINCS: Organization, Branch, OrganizationMember létrehozás!
Args:
db: AsyncSession
@@ -501,7 +498,7 @@ async def quick_add_provider(
user_id: A felhasználó ID-ja
Returns:
ProviderQuickAddResponse
ProviderQuickAddResponse (id = ServiceProvider.id)
Raises:
ValueError: Ha a kategória nem létezik
@@ -513,47 +510,39 @@ async def quick_add_provider(
if not category:
raise ValueError(f"Category with id={data.category_id} not found")
primary_category_key = category.key
else:
category = None
# 2. Organization létrehozása
folder_slug = hashlib.md5(
f"{data.name}-{uuid.uuid4()}".encode()
).hexdigest()[:12]
# P1 CRITICAL ALIGN: address_street_name, address_street_type, address_house_number
# használata a régi street helyett.
org = Organization(
name=data.name[:100],
full_name=data.name,
display_name=data.name[:50],
folder_slug=folder_slug,
org_type=OrgType.service_provider,
is_verified=False,
status="pending_verification",
subscription_plan="FREE",
base_asset_limit=1,
purchased_extra_slots=0,
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
external_integration_config={"source": "crowdsourced"},
is_ownership_transferable=True,
address_city=data.city,
# 2. ServiceProvider létrehozása (NEM Organization!)
provider = ServiceProvider(
name=data.name,
address=", ".join(filter(None, [
data.address_street_name or "",
data.address_street_type or "",
data.address_house_number or "",
])) or data.name,
category=primary_category_key,
# Atomizált címmezők
city=data.city,
address_zip=data.address_zip,
address_street_name=data.address_street_name,
address_street_type=data.address_street_type,
address_house_number=data.address_house_number,
plus_code=data.plus_code,
owner_id=None,
first_registered_at=datetime.now(timezone.utc),
current_lifecycle_started_at=datetime.now(timezone.utc),
created_at=datetime.now(timezone.utc),
# Kapcsolatfelvételi adatok
contact_phone=data.contact_phone,
contact_email=data.contact_email,
website=data.website,
# Státusz és forrás
status="pending",
source="manual",
validation_score=50,
added_by_user_id=user_id,
)
db.add(org)
db.add(provider)
await db.flush()
# 3. ServiceProfile létrehozása
# 3. ServiceProfile létrehozása (service_provider_id hivatkozással)
fingerprint = hashlib.sha256(
f"quick-add-{org.id}-{datetime.now().isoformat()}".encode()
f"quick-add-{provider.id}-{datetime.now().isoformat()}".encode()
).hexdigest()[:64]
# specialization_tags összeállítása: primary_category + user-supplied tags
@@ -564,7 +553,7 @@ async def quick_add_provider(
spec_tags["user_tags"] = data.tags
profile = ServiceProfile(
organization_id=org.id,
service_provider_id=provider.id,
fingerprint=fingerprint,
status="ghost",
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
@@ -579,7 +568,6 @@ async def quick_add_provider(
# =====================================================================
# 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
# =====================================================================
# Összegyűjtjük az összes kategória ID-t a ServiceExpertise kapcsolatokhoz.
all_category_ids: List[int] = []
# 4a. Elsődleges kategória (ha meg van adva)
@@ -605,13 +593,12 @@ async def quick_add_provider(
# 4d. ServiceExpertise kapcsolatok létrehozása
if all_category_ids:
# Deduplikáció: csak egyedi ID-k
unique_ids = list(set(all_category_ids))
for expertise_id in unique_ids:
expertise = ServiceExpertise(
service_id=profile.id,
expertise_id=expertise_id,
confidence_level=50, # Közepes bizonyosság (crowdsourced)
confidence_level=50,
)
db.add(expertise)
logger.info(
@@ -620,29 +607,8 @@ async def quick_add_provider(
)
# =====================================================================
# 5. Branch létrehozása atomizált címmezőkkel
branch = Branch(
organization_id=org.id,
name=data.name[:100],
is_main=True,
city=data.city,
postal_code=data.address_zip,
street_name=data.address_street_name,
street_type=data.address_street_type,
house_number=data.address_house_number,
status="active",
)
db.add(branch)
# =====================================================================
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (KIHAGYVA - P0 ARCHITECTURE FIX)
# =====================================================================
# DÖNTÉS: A létrehozó felhasználó NEM lehet tagja a beszállító szervezetnek.
# A szerviz jöjjön létre owner_id=None beállítással, tagok nélkül.
# =====================================================================
# =====================================================================
# 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
# 5. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
# =====================================================================
earned_points = await _award_provider_points(
db=db,
@@ -652,15 +618,15 @@ async def quick_add_provider(
# =====================================================================
await db.commit()
await db.refresh(org)
await db.refresh(provider)
logger.info(
f"Quick-add provider created: org_id={org.id}, name={data.name}, "
f"Quick-add provider created: service_provider_id={provider.id}, name={data.name}, "
f"user_id={user_id}, earned_points={earned_points}"
)
return ProviderQuickAddResponse(
id=org.id,
id=provider.id,
name=data.name,
status="pending_verification",
message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.",

View File

@@ -0,0 +1,321 @@
# /opt/docker/dev/service_finder/backend/app/services/rbac_service.py
"""
🎯 Scope-Based Admin RBAC Service (P0)
Implements scope-based access control for admin/staff roles.
Instead of direct organization membership, admins are assigned
a scope (e.g., REGION → "Pest_County") that determines which
organizations they can manage.
Architecture:
- SUPERADMIN: Unrestricted access (bypasses all scope checks)
- ADMIN/MODERATOR: Full access within their assigned scope
- SALES_REP/SERVICE_MGR: Scoped access with action-level filtering
Scope Types:
- REGION: Geographic region (e.g., "Pest_County", "Budapest")
- SEGMENT: Business segment (e.g., "Fleet", "Dealer", "Service")
- GLOBAL: Unrestricted (equivalent to SUPERADMIN for scope purposes)
Usage:
rbac = RBACService()
await rbac.check_admin_access(db, user, "EDIT_DATA", target_org_id=42)
"""
from __future__ import annotations
import logging
from enum import Enum
from typing import Optional, Dict, Set, List, Union
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.identity import User, UserRole
from app.models.marketplace.organization import Organization
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
logger = logging.getLogger(__name__)
class ScopeType(str, Enum):
"""The type of scope assigned to an admin user."""
GLOBAL = "GLOBAL"
REGION = "REGION"
SEGMENT = "SEGMENT"
class AdminAction(str, Enum):
"""Admin actions that can be checked against scope."""
# ── Data Management ──
EDIT_DATA = "EDIT_DATA"
VIEW_DATA = "VIEW_DATA"
DELETE_DATA = "DELETE_DATA"
EXPORT_DATA = "EXPORT_DATA"
# ── Subscription Management ──
VIEW_SUBSCRIPTION = "VIEW_SUBSCRIPTION"
EDIT_SUBSCRIPTION = "EDIT_SUBSCRIPTION"
CANCEL_SUBSCRIPTION = "CANCEL_SUBSCRIPTION"
# ── User Management ──
MANAGE_USERS = "MANAGE_USERS"
SUSPEND_USERS = "SUSPEND_USERS"
BAN_USERS = "BAN_USERS"
# ── Financial ──
VIEW_FINANCIALS = "VIEW_FINANCIALS"
MANAGE_BILLING = "MANAGE_BILLING"
ISSUE_REFUNDS = "ISSUE_REFUNDS"
# ── Moderation ──
APPROVE_CONTENT = "APPROVE_CONTENT"
MODERATE_REVIEWS = "MODERATE_REVIEWS"
VERIFY_PROVIDERS = "VERIFY_PROVIDERS"
# ──────────────────────────────────────────────
# ACTION-TO-ROLE MAPPING
# Which roles are permitted to perform which actions
# ──────────────────────────────────────────────
# Actions that SUPERADMIN can always do (implicitly all)
# Actions that ADMIN can do within their scope
ADMIN_SCOPE_ACTIONS: Set[str] = {
AdminAction.EDIT_DATA,
AdminAction.VIEW_DATA,
AdminAction.DELETE_DATA,
AdminAction.EXPORT_DATA,
AdminAction.VIEW_SUBSCRIPTION,
AdminAction.EDIT_SUBSCRIPTION,
AdminAction.MANAGE_USERS,
AdminAction.SUSPEND_USERS,
AdminAction.BAN_USERS,
AdminAction.VIEW_FINANCIALS,
AdminAction.MANAGE_BILLING,
AdminAction.APPROVE_CONTENT,
AdminAction.MODERATE_REVIEWS,
AdminAction.VERIFY_PROVIDERS,
}
# MODERATOR can do within their scope
MODERATOR_SCOPE_ACTIONS: Set[str] = {
AdminAction.VIEW_DATA,
AdminAction.VIEW_SUBSCRIPTION,
AdminAction.APPROVE_CONTENT,
AdminAction.MODERATE_REVIEWS,
AdminAction.VERIFY_PROVIDERS,
AdminAction.SUSPEND_USERS,
}
# SALES_REP can do within their scope
SALES_REP_SCOPE_ACTIONS: Set[str] = {
AdminAction.VIEW_DATA,
AdminAction.VIEW_SUBSCRIPTION,
AdminAction.EDIT_SUBSCRIPTION,
AdminAction.VIEW_FINANCIALS,
AdminAction.EXPORT_DATA,
}
# SERVICE_MGR can do within their scope
SERVICE_MGR_SCOPE_ACTIONS: Set[str] = {
AdminAction.VIEW_DATA,
AdminAction.EDIT_DATA,
AdminAction.APPROVE_CONTENT,
AdminAction.VERIFY_PROVIDERS,
AdminAction.MODERATE_REVIEWS,
}
# ──────────────────────────────────────────────
# RBAC SERVICE
# ──────────────────────────────────────────────
class RBACService:
"""
Scope-Based Admin RBAC Service.
Provides methods to check whether a staff user has access
to perform a specific action on a target organization,
based on the user's role and assigned scope.
"""
# Mapping of role → set of permitted actions
ROLE_ACTIONS: Dict[UserRole, Set[str]] = {
UserRole.SUPERADMIN: set(AdminAction), # All actions
UserRole.ADMIN: ADMIN_SCOPE_ACTIONS,
UserRole.MODERATOR: MODERATOR_SCOPE_ACTIONS,
UserRole.SALES_REP: SALES_REP_SCOPE_ACTIONS,
UserRole.SERVICE_MGR: SERVICE_MGR_SCOPE_ACTIONS,
}
async def check_admin_access(
self,
db: AsyncSession,
user: User,
action: Union[str, AdminAction],
target_org_id: Optional[int] = None,
) -> bool:
"""
Check if a user has scope-based access to perform an action.
Args:
db: Database session
user: The staff user to check
action: The action being attempted (AdminAction enum or string)
target_org_id: Optional target organization ID for scope matching
Returns:
True if access is granted
Raises:
PermissionError: If access is denied (with details)
"""
action_str = action.value if isinstance(action, AdminAction) else action
# ── 1. SUPERADMIN bypass ──
if user.role == UserRole.SUPERADMIN:
return True
# ── 2. Check if the role is permitted for this action ──
permitted_actions = self.ROLE_ACTIONS.get(user.role, set())
if action_str not in permitted_actions:
raise PermissionError(
f"Action '{action_str}' is not permitted for role '{user.role.value}'."
)
# ── 3. If no target_org_id, just check action permission ──
if target_org_id is None:
return True
# ── 4. Scope-based organization access check ──
return await self._check_org_scope_access(db, user, target_org_id)
async def _check_org_scope_access(
self,
db: AsyncSession,
user: User,
target_org_id: int,
) -> bool:
"""
Check if the user's scope covers the target organization.
The user's scope is defined by:
- scope_type: "REGION", "SEGMENT", or "GLOBAL"
- scope_value: The specific value (e.g., "Pest_County", "Fleet")
The organization's scope attributes:
- region: Geographic region (e.g., "Pest_County")
- segment: Business segment (e.g., "Fleet")
"""
# Get user scope info
scope_type = getattr(user, 'scope_type', None)
scope_value = getattr(user, 'scope_value', None)
# GLOBAL scope → access to all organizations
if scope_type == ScopeType.GLOBAL.value:
return True
# No scope assigned → deny
if not scope_type or not scope_value:
raise PermissionError(
f"User {user.id} has no scope assigned. "
f"Contact a SUPERADMIN to assign a scope."
)
# Fetch the target organization
stmt = select(Organization).where(Organization.id == target_org_id)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise PermissionError(f"Organization {target_org_id} not found.")
# ── Scope matching logic ──
if scope_type == ScopeType.REGION.value:
# Check if org's region matches user's scope_value
org_region = getattr(org, 'region', None)
if not org_region:
raise PermissionError(
f"Organization {target_org_id} has no region assigned. "
f"Cannot match against REGION scope '{scope_value}'."
)
if org_region != scope_value:
raise PermissionError(
f"REGION scope mismatch: user scope='{scope_value}', "
f"org region='{org_region}'."
)
return True
elif scope_type == ScopeType.SEGMENT.value:
# Check if org's segment matches user's scope_value
org_segment = getattr(org, 'segment', None)
if not org_segment:
raise PermissionError(
f"Organization {target_org_id} has no segment assigned. "
f"Cannot match against SEGMENT scope '{scope_value}'."
)
if org_segment != scope_value:
raise PermissionError(
f"SEGMENT scope mismatch: user scope='{scope_value}', "
f"org segment='{org_segment}'."
)
return True
else:
raise PermissionError(f"Unknown scope type: '{scope_type}'.")
async def get_accessible_org_ids(
self,
db: AsyncSession,
user: User,
) -> Optional[List[int]]:
"""
Get list of organization IDs accessible by this user based on their scope.
Returns None for SUPERADMIN/GLOBAL scope (unrestricted).
Returns a list of matching org IDs for REGION/SEGMENT scope.
"""
# SUPERADMIN and GLOBAL scope → unrestricted
if user.role == UserRole.SUPERADMIN:
return None
scope_type = getattr(user, 'scope_type', None)
scope_value = getattr(user, 'scope_value', None)
if not scope_type or not scope_value:
return [] # No scope → no orgs
if scope_type == ScopeType.GLOBAL.value:
return None
# Build query based on scope type
if scope_type == ScopeType.REGION.value:
stmt = select(Organization.id).where(
Organization.region == scope_value,
Organization.is_deleted == False
)
elif scope_type == ScopeType.SEGMENT.value:
stmt = select(Organization.id).where(
Organization.segment == scope_value,
Organization.is_deleted == False
)
else:
return []
result = await db.execute(stmt)
return [row[0] for row in result.all()]
def get_permitted_actions(self, role: UserRole) -> Set[str]:
"""Get the set of actions permitted for a given role."""
return self.ROLE_ACTIONS.get(role, set())
def is_action_permitted(self, role: UserRole, action: Union[str, AdminAction]) -> bool:
"""Check if a specific action is permitted for a given role."""
action_str = action.value if isinstance(action, AdminAction) else action
return action_str in self.ROLE_ACTIONS.get(role, set())
# ──────────────────────────────────────────────
# Singleton instance
# ──────────────────────────────────────────────
rbac_service = RBACService()

View File

@@ -10,43 +10,64 @@ Hierarchia:
- free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
- premium: free + útdíj/parkolás, ápolás, egyéb
- enterprise: premium + minden (nincs korlátozás)
P0 Feature Flag System:
- A get_user_tier() most már valós adatbázis lekérdezést végez a UserSubscription
és OrganizationSubscription táblákból.
- A get_user_feature_flags() metódus visszaadja az összes feature flag állapotát
a felhasználó tier-je alapján.
- A get_org_subscription_details() metódus visszaadja a szervezet előfizetési
adatait a frontend számára.
"""
from __future__ import annotations
import logging
from typing import Optional, List
from datetime import datetime
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.models.fleet_finance import CostCategory
from app.models.core_logic import SubscriptionTier, UserSubscription, OrganizationSubscription
from app.models.identity import User
from app.models.marketplace.organization import Organization
logger = logging.getLogger(__name__)
# ── Subscription Feature Matrix ──────────────────────────────────────────────
# Minden funkció/kategória minimális tier szintje.
# A tier-ek: free < premium < enterprise
TIER_HIERARCHY = {
"free": 0,
"premium": 1,
"enterprise": 2,
}
# A tier-ek: 0=free, 1=premium, 2=enterprise
# A TIER_HIERARCHY a SubscriptionTier.tier_level mezőből származik.
# A SUBSCRIPTION_FEATURES itt a minimális tier_level értéket tárolja (int).
SUBSCRIPTION_FEATURES: dict[str, str] = {
# Feature -> minimum required tier
"cost_category:fuel": "free",
"cost_category:service": "free",
"cost_category:tires": "free",
"cost_category:insurance": "free",
"cost_category:taxes": "free",
"cost_category:toll_parking": "premium",
"cost_category:cleaning": "premium",
"cost_category:other": "premium",
"cost_category:all": "enterprise",
SUBSCRIPTION_FEATURES: dict[str, int] = {
# Feature -> minimum required tier_level (0=free, 1=premium, 2=enterprise)
"cost_category:fuel": 0,
"cost_category:service": 0,
"cost_category:tires": 0,
"cost_category:insurance": 0,
"cost_category:taxes": 0,
"cost_category:toll_parking": 1,
"cost_category:cleaning": 1,
"cost_category:other": 1,
"cost_category:all": 2,
# Feature flags
"export_csv": "free",
"analytics_tco": "premium",
"analytics_detailed": "enterprise",
"api_access": "free",
"multi_vehicle": "free",
"unlimited_vehicles": "premium",
"export_csv": 0,
"analytics_tco": 1,
"analytics_detailed": 2,
"api_access": 0,
"multi_vehicle": 0,
"unlimited_vehicles": 1,
# P0 Frontend feature flags
"subscription_management": 0,
"vehicle_tracking": 0,
"cost_tracking": 0,
"service_booking": 0,
"advanced_reports": 1,
"team_management": 1,
"priority_support": 1,
"white_label": 2,
"api_webhooks": 2,
"custom_integrations": 2,
}
@@ -56,33 +77,346 @@ class SubscriptionService:
Metódusok:
get_user_tier(db, user_id) -> str
get_user_feature_flags(db, user_id) -> dict[str, bool]
get_org_subscription_details(db, org_id) -> dict | None
can_access_feature(tier, feature_key) -> bool
get_visible_categories(db, tier) -> list[CostCategory]
require_tier(tier, required_feature_or_min_tier) -> bool
P0 AUDIT FIX: A tier feloldás már a SubscriptionTier.tier_level mezőt használja
ahelyett, hogy a tier nevét string-matchinggel próbálná meg kitalálni.
A tier_level egy integer: 0=free, 1=premium, 2=enterprise.
"""
# ── Tier level → display name mapping ──────────────────────────────
# This is the canonical mapping from DB tier_level to the human-readable
# entitlement tier name used in feature flag resolution.
TIER_LEVEL_NAMES: dict[int, str] = {
0: "free",
1: "premium",
2: "enterprise",
}
@staticmethod
def _tier_to_int(tier: str) -> int:
"""Tier string konvertálása numerikus értékre a hierarchiában."""
return TIER_HIERARCHY.get(tier.lower(), 0)
def _tier_level_to_name(level: int) -> str:
"""Convert a numeric tier_level to the canonical tier name."""
return SubscriptionService.TIER_LEVEL_NAMES.get(level, "free")
@staticmethod
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()
@staticmethod
async def get_user_tier(db: AsyncSession, user_id: int) -> str:
"""
Visszaadja a felhasználó aktuális előfizetési szintjét.
Visszaadja a felhasználó aktuális előfizetési szintjét valós adatbázis lekérdezéssel.
Jelenlegi implementáció: minden user 'free' tier-t kap.
A jövőben ez a subscription/org táblákból lesz lekérdezve.
P0 Feature Flag: Valós tier feloldás a UserSubscription és OrganizationSubscription
táblákból, valamint a User.subscription_plan mezőből.
Feloldási sorrend:
1. Aktív UserSubscription lekérdezése (finance.user_subscriptions)
- Ha van aktív és nem járt le, a tier.tier_level alapján
2. Ha nincs user subscription, de a user aktív szervezethez tartozik:
- OrganizationSubscription lekérdezése
3. Ha egyik sincs, visszaesés a User.subscription_plan mezőre
4. Ha az is üres, ellenőrizzük a default fallback csomagot
5. Alapértelmezett 'free'
P0 AUDIT FIX: A tier_level mezőt használjuk a SubscriptionTier modellből,
nem pedig a tier name string-matchingjét.
P0 SINGLETON FALLBACK: Ha a subscription lejárt (expired), dinamikusan
lekérdezzük a is_default_fallback==True csomagot és annak feature-jeit adjuk vissza.
"""
# TODO: Éles implementáció — subscription tábla lekérdezése
# TODO: Szervezeti tier felülírás (pl. enterprise org)
# ── 1. UserSubscription lekérdezése ──
us_stmt = (
select(UserSubscription)
.options(selectinload(UserSubscription.tier))
.where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True,
)
.order_by(UserSubscription.id.desc())
)
us_result = await db.execute(us_stmt)
user_sub = us_result.scalars().first()
if user_sub and user_sub.tier:
# Ellenőrizzük a lejáratot
if user_sub.valid_until is None or user_sub.valid_until > datetime.utcnow():
# P0 FIX: Use tier_level from DB instead of hardcoded string matching
level = user_sub.tier.tier_level
return SubscriptionService._tier_level_to_name(level)
else:
# ── P0 SINGLETON FALLBACK: subscription lejárt → default fallback tier ──
logger.info(
f"UserSubscription {user_sub.id} for user {user_id} has expired "
f"(valid_until={user_sub.valid_until}). Falling back to default fallback tier."
)
fallback = await SubscriptionService._get_default_fallback_tier(db)
if fallback:
level = fallback.tier_level
return SubscriptionService._tier_level_to_name(level)
# ── 2. Aktív szervezet subscription ──
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
# active_organization_id is stored in the User.scope_id column (String)
# and set on current_user by the auth dependency from the JWT token.
# When querying from DB directly, we use scope_id.
active_org_id = None
if user and user.scope_id:
try:
active_org_id = int(user.scope_id)
except (ValueError, TypeError):
pass
if active_org_id:
org_stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.id.desc())
)
org_result = await db.execute(org_stmt)
org_sub = org_result.scalars().first()
if org_sub and org_sub.tier:
if org_sub.valid_until is None or org_sub.valid_until > datetime.utcnow():
# P0 FIX: Use tier_level from DB instead of hardcoded string matching
level = org_sub.tier.tier_level
return SubscriptionService._tier_level_to_name(level)
else:
# ── P0 SINGLETON FALLBACK: org subscription lejárt ──
logger.info(
f"OrganizationSubscription {org_sub.id} for org {active_org_id} has expired "
f"(valid_until={org_sub.valid_until}). Falling back to default fallback tier."
)
fallback = await SubscriptionService._get_default_fallback_tier(db)
if fallback:
level = fallback.tier_level
return SubscriptionService._tier_level_to_name(level)
# ── 3. Fallback: User.subscription_plan ──
if user and user.subscription_plan:
plan = user.subscription_plan.lower()
# For the fallback plan field, we still need a simple heuristic
# since there's no SubscriptionTier object. We use a minimal mapping.
if "enterprise" in plan or "vip" in plan:
return "enterprise"
if "premium" in plan or "pro" in plan:
return "premium"
return "free"
# ── 4. Default fallback tier ──
fallback = await SubscriptionService._get_default_fallback_tier(db)
if fallback:
level = fallback.tier_level
return SubscriptionService._tier_level_to_name(level)
# ── 5. Alapértelmezett ──
return "free"
@staticmethod
async def get_user_feature_flags(
db: AsyncSession,
user_id: int,
) -> Dict[str, Any]:
"""
Visszaadja az összes feature flag állapotát a felhasználó tier-je alapján.
P0 Feature: Ez a metódus a frontend useFeatureFlag() composable számára
biztosítja a backend által validált feature flag-eket.
Returns:
Dict a következő struktúrával:
{
"tier": "premium",
"features": {
"export_csv": True,
"analytics_tco": True,
...
},
"expires_at": "2026-07-24T12:00:00Z" | None
}
"""
user_tier = await SubscriptionService.get_user_tier(db, user_id)
# Lekérdezzük a lejárati időt is
expires_at = None
us_stmt = (
select(UserSubscription)
.where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True,
)
.order_by(UserSubscription.id.desc())
)
us_result = await db.execute(us_stmt)
user_sub = us_result.scalars().first()
if user_sub and user_sub.valid_until:
expires_at = user_sub.valid_until.isoformat()
# Összes feature flag feloldása
features = {}
for feature_key, required_tier in SUBSCRIPTION_FEATURES.items():
features[feature_key] = SubscriptionService.can_access_feature(user_tier, feature_key)
return {
"tier": user_tier,
"features": features,
"expires_at": expires_at,
}
@staticmethod
async def get_org_subscription_details(
db: AsyncSession,
org_id: int,
) -> Optional[Dict[str, Any]]:
"""
Visszaadja a szervezet előfizetési adatait a frontend számára.
P0 Feature: Ez a metódus a GET /subscriptions/my végpont számára
biztosítja a szervezet előfizetési adatait.
Returns:
Dict a következő struktúrával:
{
"tier_id": 16,
"tier_name": "corp_premium_v1",
"display_name": "Céges Prémium",
"valid_from": "2026-06-01T00:00:00Z",
"valid_until": "2026-07-01T00:00:00Z",
"is_active": True,
"allowances": { ... },
"pricing": { ... },
"feature_capabilities": { ... },
}
vagy None, ha nincs aktív előfizetés.
"""
stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.id.desc())
)
result = await db.execute(stmt)
org_sub = result.scalars().first()
if not org_sub or not org_sub.tier:
return None
tier = org_sub.tier
rules = tier.rules or {}
return {
"tier_id": tier.id,
"tier_name": tier.name,
"display_name": rules.get("display_name", tier.name),
"valid_from": org_sub.valid_from.isoformat() if org_sub.valid_from else None,
"valid_until": org_sub.valid_until.isoformat() if org_sub.valid_until else None,
"is_active": org_sub.is_active,
"allowances": rules.get("allowances", {}),
"pricing": rules.get("pricing", {}),
"duration": rules.get("duration", {}),
"ad_policy": rules.get("ad_policy", {}),
"entitlements": rules.get("entitlements", []),
"feature_capabilities": tier.feature_capabilities or {},
"extra_allowances": org_sub.extra_allowances or {},
}
@staticmethod
async def get_user_subscription_details(
db: AsyncSession,
user_id: int,
) -> Optional[Dict[str, Any]]:
"""
Visszaadja a felhasználó személyes előfizetési adatait.
P0 Feature: Ez a metódus a GET /subscriptions/my végpont számára
biztosítja a felhasználói előfizetés adatait (ha nincs org subscription).
Returns:
Dict a következő struktúrával:
{
"tier_id": 5,
"tier_name": "private_pro_v1",
"display_name": "Privát Pro",
"valid_from": "...",
"valid_until": "...",
"is_active": True,
"allowances": { ... },
"pricing": { ... },
}
vagy None, ha nincs aktív előfizetés.
"""
stmt = (
select(UserSubscription)
.options(selectinload(UserSubscription.tier))
.where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True,
)
.order_by(UserSubscription.id.desc())
)
result = await db.execute(stmt)
user_sub = result.scalars().first()
if not user_sub or not user_sub.tier:
return None
tier = user_sub.tier
rules = tier.rules or {}
return {
"tier_id": tier.id,
"tier_name": tier.name,
"display_name": rules.get("display_name", tier.name),
"valid_from": user_sub.valid_from.isoformat() if user_sub.valid_from else None,
"valid_until": user_sub.valid_until.isoformat() if user_sub.valid_until else None,
"is_active": user_sub.is_active,
"allowances": rules.get("allowances", {}),
"pricing": rules.get("pricing", {}),
"duration": rules.get("duration", {}),
"ad_policy": rules.get("ad_policy", {}),
"entitlements": rules.get("entitlements", []),
"feature_capabilities": tier.feature_capabilities or {},
}
@staticmethod
def _get_user_level(user_tier: str) -> int:
"""
Convert a user tier name to its numeric level.
Uses the canonical TIER_LEVEL_NAMES mapping.
"""
reverse_map = {v: k for k, v in SubscriptionService.TIER_LEVEL_NAMES.items()}
return reverse_map.get(user_tier.lower(), 0)
@staticmethod
def can_access_feature(user_tier: str, feature_key: str) -> bool:
"""
Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
P0 AUDIT FIX: A SUBSCRIPTION_FEATURES most már integer tier_level értékeket
tartalmaz (0=free, 1=premium, 2=enterprise), így nincs szükség string konverzióra.
Args:
user_tier: A felhasználó tier szintje (free/premium/enterprise)
feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
@@ -90,14 +424,13 @@ class SubscriptionService:
Returns:
bool: True ha hozzáfér, False ha nem
"""
required_tier = SUBSCRIPTION_FEATURES.get(feature_key)
if required_tier is None:
required_level = SUBSCRIPTION_FEATURES.get(feature_key)
if required_level is None:
# Ismeretlen feature — alapértelmezetten tiltva
logger.warning(f"Unknown feature key: {feature_key}")
return False
user_level = SubscriptionService._tier_to_int(user_tier)
required_level = SubscriptionService._tier_to_int(required_tier)
user_level = SubscriptionService._get_user_level(user_tier)
return user_level >= required_level
@staticmethod
@@ -121,7 +454,7 @@ class SubscriptionService:
Returns:
List[CostCategory]: A látható kategóriák listája
"""
user_level = SubscriptionService._tier_to_int(user_tier)
user_level = SubscriptionService._get_user_level(user_tier)
# Lekérjük az összes kategóriát
stmt = select(CostCategory).order_by(CostCategory.id)
@@ -132,7 +465,7 @@ class SubscriptionService:
visible = []
for cat in all_categories:
cat_tier = getattr(cat, "min_tier", "free") or "free"
cat_level = SubscriptionService._tier_to_int(cat_tier)
cat_level = SubscriptionService._get_user_level(cat_tier)
if user_level >= cat_level:
visible.append(cat)
@@ -157,6 +490,75 @@ class SubscriptionService:
)
# Ha nem feature kulcs, akkor tier névként kezeljük
user_level = SubscriptionService._tier_to_int(user_tier)
required_level = SubscriptionService._tier_to_int(required_feature_or_min_tier)
user_level = SubscriptionService._get_user_level(user_tier)
required_level = SubscriptionService._get_user_level(required_feature_or_min_tier)
return user_level >= required_level
@staticmethod
async def assign_default_trial(
db: AsyncSession,
org_id: int,
user_id: int,
) -> Optional[Dict[str, Any]]:
"""
Új regisztrációnál automatikusan hozzárendeli a próbaidős csomagot.
P0 SINGLETON TRIAL: Lekérdezi a trial_days_on_signup > 0 csomagot.
Ha van ilyen, létrehoz egy OrganizationSubscription rekordot a megadott
org_id-hoz, ami a trial_days_on_signup nap múlva jár le.
Args:
db: Adatbázis session
org_id: A szervezet ID-ja, amelyhez a trial subscription tartozik
user_id: A felhasználó ID-ja (naplózáshoz)
Returns:
Dict a subscription adatokkal, vagy None ha nincs trial csomag beállítva.
"""
# Lekérdezzük a trial csomagot (ahol trial_days_on_signup > 0)
stmt = select(SubscriptionTier).where(
SubscriptionTier.trial_days_on_signup > 0
)
result = await db.execute(stmt)
trial_tier = result.scalar_one_or_none()
if not trial_tier:
logger.info(
f"No trial tier configured (no tier with trial_days_on_signup > 0). "
f"Skipping trial assignment for org {org_id}, user {user_id}."
)
return None
from datetime import timedelta
# Számoljuk a lejárati időt
trial_days = trial_tier.trial_days_on_signup
now = datetime.utcnow()
valid_until = now + timedelta(days=trial_days)
# Létrehozzuk a subscription rekordot
org_sub = OrganizationSubscription(
org_id=org_id,
tier_id=trial_tier.id,
valid_from=now,
valid_until=valid_until,
is_active=True,
)
db.add(org_sub)
await db.commit()
await db.refresh(org_sub)
logger.info(
f"Assigned trial subscription for org {org_id}, user {user_id}: "
f"tier='{trial_tier.name}' (id={trial_tier.id}), "
f"trial_days={trial_days}, valid_until={valid_until}"
)
return {
"tier_id": trial_tier.id,
"tier_name": trial_tier.name,
"valid_from": now.isoformat(),
"valid_until": valid_until.isoformat(),
"is_active": True,
"trial_days": trial_days,
}