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

@@ -210,28 +210,63 @@ async def get_current_staff(
return current_user
def RequireRole(allowed_roles: List[UserRole]):
def RequireRole(allowed_roles: List[UserRole], target_org_id: Optional[int] = None):
"""
🎯 Flexible role-checking dependency factory.
🎯 Flexible role-checking dependency factory with scope-based access.
Accepts a list of UserRole values. If the current user's role is
in the list, access is granted. Otherwise, 403 Forbidden.
When target_org_id is provided, the RBACService is automatically
invoked to check if the staff member has scope-based access to
that specific organization.
Usage:
# Simple role check
@router.get("/admin/sales")
async def sales_dashboard(
current_user: User = Depends(get_current_user),
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
):
# Scope-based access check
@router.get("/admin/organizations/{org_id}")
async def get_organization(
org_id: int,
current_user: User = Depends(get_current_user),
_ = Depends(RequireRole(
[UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.SALES_REP],
target_org_id=org_id # ← triggers scope check
))
):
"""
async def role_checker(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> bool:
# 1. Check if the user's role is in the allowed list
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="AUTH.INSUFFICIENT_PERMISSIONS"
)
# 2. If target_org_id is provided, perform scope-based access check
if target_org_id is not None:
from app.services.rbac_service import rbac_service
try:
await rbac_service.check_admin_access(
db=db,
user=current_user,
action="VIEW_DATA", # Generic read access for the dependency
target_org_id=target_org_id,
)
except PermissionError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e)
)
return True
return role_checker

View File

@@ -5,8 +5,8 @@ from app.api.v1.endpoints import (
services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters,
gamification, translations, users, reports, dictionaries,
admin_packages, admin_services, constants, providers,
subscriptions, marketing,
admin_packages, admin_services, admin_organizations, constants, providers,
subscriptions, marketing, admin_permissions, regions,
)
api_router = APIRouter()
@@ -27,6 +27,7 @@ api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=
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"])
api_router.include_router(regions.router, prefix="/system", tags=["Global Region Registry"])
api_router.include_router(gamification.router, prefix="/gamification", tags=["Gamification"])
api_router.include_router(translations.router, prefix="/translations", tags=["i18n"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
@@ -34,8 +35,10 @@ api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"])
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"])

View File

@@ -734,4 +734,86 @@ async def bulk_user_action(
"total_requested": len(request.user_ids),
"missing_ids": missing_ids if missing_ids else None,
"message": f"{request.action} művelet végrehajtva {affected_count} felhasználón."
}
# ==================== ORGANIZATION SEARCH (EPIC 10: Admin Frontend) ====================
@router.get("/organizations/search", tags=["Organization Management"])
async def search_organizations(
name: str = Query(..., min_length=2, description="Cégnév vagy garázsnév keresése (ILIKE)"),
limit: int = Query(20, ge=1, le=100, description="Maximum találatok száma"),
db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_admin),
):
"""
🔍 Szervezetek / Garázsok keresése név alapján.
Csak SUPERADMIN, ADMIN és MODERATOR szerepkörű felhasználók számára elérhető.
ILIKE keresést használ a full_name, name és display_name mezőkben.
Args:
name: A keresett cégnév vagy garázsnév (minimum 2 karakter).
limit: Maximum visszaadott találatok száma (1-100).
Returns:
Lista a megtalált szervezetek alapadataival.
"""
from app.models.marketplace.organization import Organization
stmt = (
select(
Organization.id,
Organization.name,
Organization.full_name,
Organization.display_name,
Organization.tax_number,
Organization.org_type,
Organization.status,
Organization.is_verified,
Organization.is_active,
Organization.is_deleted,
Organization.address_city,
Organization.region,
Organization.segment,
Organization.created_at,
)
.where(
or_(
Organization.full_name.ilike(f"%{name}%"),
Organization.name.ilike(f"%{name}%"),
Organization.display_name.ilike(f"%{name}%"),
)
)
.order_by(Organization.full_name.asc())
.limit(limit)
)
result = await db.execute(stmt)
rows = result.all()
organizations = []
for row in rows:
organizations.append({
"id": row.id,
"name": row.name,
"full_name": row.full_name,
"display_name": row.display_name,
"tax_number": row.tax_number,
"org_type": row.org_type.value if hasattr(row.org_type, 'value') else str(row.org_type),
"status": row.status,
"is_verified": row.is_verified,
"is_active": row.is_active,
"is_deleted": row.is_deleted,
"city": row.address_city,
"region": row.region,
"segment": row.segment,
"created_at": row.created_at.isoformat() if row.created_at else None,
})
return {
"total": len(organizations),
"query": name,
"organizations": organizations,
}

View File

@@ -0,0 +1,346 @@
"""
🏢 Admin Garages (Organizations) CRM API
Végpontok:
GET /admin/organizations — Garázsok listázása előfizetési adatokkal
PATCH /admin/organizations/{org_id}/subscription — Előfizetés módosítása
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload, contains_eager
from pydantic import BaseModel, Field
from app.api import deps
from app.db.session import get_db
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.identity import User
logger = logging.getLogger("admin-organizations")
router = APIRouter()
# =============================================================================
# Pydantic Schemas
# =============================================================================
class OrgSubscriptionUpdate(BaseModel):
"""Előfizetés módosításának kérése."""
tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja")
expires_at: Optional[datetime] = Field(
default=None,
description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). "
"Ha None, a csomag duration.days alapján számolódik."
)
class OrgSubscriptionResponse(BaseModel):
"""Előfizetés adatainak válasza."""
id: int
org_id: int
tier_id: int
tier_name: str = ""
valid_from: Optional[str] = None
valid_until: Optional[str] = None
is_active: bool = True
extra_allowances: dict = {}
class GarageListItem(BaseModel):
"""Egy garázs adatai a listában."""
id: int
name: str
full_name: str
display_name: Optional[str] = None
email: Optional[str] = None
status: str
is_active: bool
is_verified: bool
is_deleted: bool
org_type: str
city: Optional[str] = None
region: Optional[str] = None
segment: Optional[str] = None
member_count: int = 0
created_at: Optional[str] = None
# Előfizetési adatok
subscription: Optional[OrgSubscriptionResponse] = None
subscription_tier_name: str = "Free/Fallback"
subscription_expires_at: Optional[str] = None
class GarageListResponse(BaseModel):
"""Garázsok listájának válasza."""
total: int
skip: int
limit: int
garages: List[GarageListItem]
# =============================================================================
# GET / — Garázsok listázása
# =============================================================================
@router.get(
"",
response_model=GarageListResponse,
summary="Garázsok listázása",
description="Visszaadja az összes szervezetet (garázst) előfizetési adatokkal és taglétszámmal.",
)
async def list_organizations(
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
search_term: Optional[str] = Query(None, description="Keresés név vagy e-mail alapján"),
status_filter: Optional[str] = Query(None, description="Szűrés státusz szerint (active/inactive/suspended/pending_verification)"),
org_type_filter: Optional[str] = Query(None, description="Szűrés szervezet típus szerint"),
db: AsyncSession = Depends(get_db),
admin: User = Depends(deps.get_current_admin),
) -> GarageListResponse:
"""
Garázsok / Szervezetek listázása lapozással és kereséssel.
Minden garázshoz visszaadja:
- Alapadatokat (név, státusz, típus, város)
- Taglétszámot (OrganizationMember-ek száma)
- Aktív előfizetés adatait (tier név, lejárati dátum)
"""
# Alap lekérdezés
query = select(Organization).options(
selectinload(Organization.subscription_tier),
)
# Szűrés
if search_term:
like_pattern = f"%{search_term}%"
query = query.where(
Organization.full_name.ilike(like_pattern) |
Organization.name.ilike(like_pattern) |
Organization.display_name.ilike(like_pattern)
)
if status_filter:
query = query.where(Organization.status == status_filter)
if org_type_filter:
query = query.where(Organization.org_type == org_type_filter)
# Teljes számolás
count_stmt = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# Lapozás
query = query.order_by(Organization.id.desc()).offset(skip).limit(limit)
result = await db.execute(query)
organizations = result.scalars().all()
# Összes org_id begyűjtése a tömeges subscription lekérdezéshez
org_ids = [org.id for org in organizations]
# Tömeges subscription lekérdezés
subscriptions_map: Dict[int, OrganizationSubscription] = {}
if org_ids:
sub_stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id.in_(org_ids),
OrganizationSubscription.is_active == True,
)
)
sub_result = await db.execute(sub_stmt)
for sub in sub_result.scalars().all():
# Ha több subscription is van, a legutolsót vegyük
if sub.org_id not in subscriptions_map:
subscriptions_map[sub.org_id] = sub
# Tömeges taglétszám lekérdezés
member_counts: Dict[int, int] = {}
if org_ids:
from sqlalchemy import func as sa_func
member_stmt = (
select(
OrganizationMember.organization_id,
sa_func.count(OrganizationMember.id)
)
.where(
OrganizationMember.organization_id.in_(org_ids),
OrganizationMember.status == "active",
)
.group_by(OrganizationMember.organization_id)
)
member_result = await db.execute(member_stmt)
for row in member_result.all():
member_counts[row[0]] = row[1]
# Válasz összeállítása
garage_list = []
for org in organizations:
sub = subscriptions_map.get(org.id)
sub_response = None
tier_name = "Free/Fallback"
expires_at = None
if sub and sub.tier:
tier_name = sub.tier.name
expires_at = sub.valid_until.isoformat() if sub.valid_until else None
sub_response = OrgSubscriptionResponse(
id=sub.id,
org_id=sub.org_id,
tier_id=sub.tier_id,
tier_name=sub.tier.name,
valid_from=sub.valid_from.isoformat() if sub.valid_from else None,
valid_until=expires_at,
is_active=sub.is_active,
extra_allowances=sub.extra_allowances or {},
)
elif org.subscription_tier:
# Fallback: Organization.subscription_tier_id kapcsolat
tier_name = org.subscription_tier.name
expires_at = org.subscription_expires_at.isoformat() if org.subscription_expires_at else None
garage_list.append(GarageListItem(
id=org.id,
name=org.name,
full_name=org.full_name,
display_name=org.display_name,
email=None, # Organization-nak nincs közvetlen email mezője
status=org.status,
is_active=org.is_active,
is_verified=org.is_verified,
is_deleted=org.is_deleted,
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
city=org.address_city,
region=org.region,
segment=org.segment,
member_count=member_counts.get(org.id, 0),
created_at=org.created_at.isoformat() if org.created_at else None,
subscription=sub_response,
subscription_tier_name=tier_name,
subscription_expires_at=expires_at,
))
return GarageListResponse(
total=total,
skip=skip,
limit=limit,
garages=garage_list,
)
# =============================================================================
# PATCH /{org_id}/subscription — Előfizetés módosítása
# =============================================================================
@router.patch(
"/{org_id}/subscription",
summary="Garázs előfizetésének módosítása",
description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. "
"Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal).",
)
async def update_org_subscription(
org_id: int,
payload: OrgSubscriptionUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(deps.get_current_admin),
) -> Dict[str, Any]:
"""
Garázs előfizetésének módosítása.
- Ellenőrzi, hogy a szervezet létezik-e.
- Ellenőrzi, hogy a SubscriptionTier létezik-e.
- Ha van aktív OrganizationSubscription, inaktiválja (is_active=False).
- Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val
és opcionális expires_at dátummal.
- Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol.
"""
# 1. Ellenőrizzük a szervezetet
org_stmt = select(Organization).where(Organization.id == org_id)
org_result = await db.execute(org_stmt)
org = org_result.scalar_one_or_none()
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Szervezet nem található ID-vel: {org_id}",
)
# 2. Ellenőrizzük a SubscriptionTier-t
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id)
tier_result = await db.execute(tier_stmt)
tier = tier_result.scalar_one_or_none()
if not tier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Előfizetési csomag nem található ID-vel: {payload.tier_id}",
)
# 3. Inaktiváljuk a meglévő aktív subscription-öket
await db.execute(
sa_update(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
)
.values(is_active=False)
)
# 4. Számoljuk a lejárati dátumot
now = datetime.utcnow()
if payload.expires_at:
valid_until = payload.expires_at
else:
# Ha nincs megadva, a csomag duration.days alapján számolunk
rules = tier.rules or {}
duration = rules.get("duration", {})
days = duration.get("days", 30) if isinstance(duration, dict) else 30
from datetime import timedelta
valid_until = now + timedelta(days=days)
# 5. Létrehozzuk az új subscription rekordot
new_sub = OrganizationSubscription(
org_id=org_id,
tier_id=payload.tier_id,
valid_from=now,
valid_until=valid_until,
is_active=True,
)
db.add(new_sub)
# 6. Frissítjük a Organization denormalizált mezőit is
org.subscription_tier_id = payload.tier_id
org.subscription_expires_at = valid_until
org.subscription_plan = tier.name
await db.commit()
await db.refresh(new_sub)
logger.info(
f"Admin {admin.id} ({admin.email}) updated subscription for org {org_id}: "
f"tier_id={payload.tier_id}, tier_name={tier.name}, "
f"valid_until={valid_until.isoformat()}"
)
return {
"status": "success",
"message": f"Előfizetés frissítve a '{org.full_name}' garázs számára.",
"subscription": {
"id": new_sub.id,
"org_id": new_sub.org_id,
"tier_id": new_sub.tier_id,
"tier_name": tier.name,
"valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None,
"valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None,
"is_active": new_sub.is_active,
},
}

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import json
import logging
from copy import deepcopy
from datetime import date
from typing import Any, Dict, List, Optional
@@ -38,6 +39,47 @@ logger = logging.getLogger("admin-packages")
router = APIRouter()
# =============================================================================
# P0 CRITICAL FIX: Recursive Deep Merge for JSONB fields
# =============================================================================
def deep_merge_dict(base: dict, override: dict) -> dict:
"""
Recursive deep merge of two dictionaries.
P0 CRITICAL FIX: Prevents data loss when updating nested JSONB fields.
Unlike a simple dict.update(), this function recursively merges nested
dictionaries instead of replacing them.
Rules:
- If both values are dicts, recurse into them.
- If the override value is None, the base value is preserved.
- Otherwise, the override value replaces the base value (scalars, lists, etc.).
Example:
base = {"pricing_zones": {"HU": {...}, "DEFAULT": {...}}}
override = {"pricing_zones": {"DEFAULT": {"monthly_price": 5.0}}}
Result: {"pricing_zones": {"HU": {...}, "DEFAULT": {"monthly_price": 5.0}}}
The HU zone is PRESERVED because it was not in the override.
"""
result = deepcopy(base)
for key, override_val in override.items():
if override_val is None:
# Preserve existing value when override is explicitly None
continue
if key in result and isinstance(result[key], dict) and isinstance(override_val, dict):
# Recursive merge for nested dicts
result[key] = deep_merge_dict(result[key], override_val)
else:
# Scalar, list, or new key — replace/add
result[key] = deepcopy(override_val)
return result
# =============================================================================
# GET / — Listázza az összes SubscriptionTier rekordot
# =============================================================================
@@ -149,6 +191,8 @@ async def create_package(
- A `name` mező egyedi kell legyen (unique constraint).
- A `rules` mezőben a teljes SubscriptionRulesModel struktúrát kell megadni.
- Alapértelmezetten a lifecycle.is_public = True lesz beállítva.
- Singleton szabály: ha is_default_fallback=True, minden más csomag False lesz.
- Singleton szabály: ha trial_days_on_signup>0, minden más csomag 0-ra áll.
"""
# Ellenőrizzük, hogy létezik-e már ilyen nevű csomag
existing = await db.execute(
@@ -160,6 +204,22 @@ async def create_package(
detail=f"Már létezik csomag ezzel a névvel: '{payload.name}'",
)
# ── P0 SINGLETON RULE: is_default_fallback ──
if payload.is_default_fallback:
await db.execute(
update(SubscriptionTier)
.where(SubscriptionTier.is_default_fallback == True)
.values(is_default_fallback=False)
)
# ── P0 SINGLETON RULE: trial_days_on_signup ──
if payload.trial_days_on_signup > 0:
await db.execute(
update(SubscriptionTier)
.where(SubscriptionTier.trial_days_on_signup > 0)
.values(trial_days_on_signup=0)
)
# Alapértelmezett lifecycle beállítás, ha nincs megadva
rules_dict = payload.rules.model_dump()
if rules_dict.get("lifecycle") is None:
@@ -169,6 +229,10 @@ async def create_package(
name=payload.name,
rules=rules_dict,
is_custom=payload.is_custom,
tier_level=payload.tier_level,
feature_capabilities=payload.feature_capabilities,
is_default_fallback=payload.is_default_fallback,
trial_days_on_signup=payload.trial_days_on_signup,
)
db.add(tier)
await db.commit()
@@ -176,7 +240,9 @@ async def create_package(
logger.info(
f"Admin {admin.id} ({admin.email}) created subscription tier: "
f"name={tier.name}, id={tier.id}"
f"name={tier.name}, id={tier.id}, "
f"is_default_fallback={tier.is_default_fallback}, "
f"trial_days_on_signup={tier.trial_days_on_signup}"
)
return SubscriptionTierResponse.model_validate(tier)
@@ -203,9 +269,12 @@ async def update_package(
Meglévő előfizetési csomag részleges frissítése.
- Csak a megadott mezők frissülnek (PATCH szemantika).
- A `rules` mezőben a teljes SubscriptionRulesModel-t át kell adni,
ha módosítani szeretnéd (a JSONB oszlop teljes egészében lecserélődik).
- A `rules` JSONB oszlop **deep merge** szemantikával frissül:
a meglévő kulcsok (pl. pricing_zones.HU, lifecycle.available_until)
NEM vesznek el, ha a payload nem tartalmazza őket.
- A lifecycle.is_public false-ra állításával a csomag elrejthető.
- Singleton szabály: ha is_default_fallback=True, minden más csomag False lesz.
- Singleton szabály: ha trial_days_on_signup>0, minden más csomag 0-ra áll.
"""
# Betöltjük a meglévő rekordot
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
@@ -238,22 +307,66 @@ async def update_package(
tier.name = update_data["name"]
if "rules" in update_data and update_data["rules"] is not None:
# A meglévő rules-ból megtartjuk a lifecycle-t, ha nem adtak újat
# ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ──
# Previously: tier.rules = new_rules (wholesale replacement)
# This caused data loss: pricing_zones.HU, lifecycle.available_until, etc.
# were silently destroyed when the frontend sent incomplete payloads.
#
# Now: deep_merge_dict() recursively merges the incoming rules into
# the existing tier.rules, preserving all keys not present in the payload.
new_rules = update_data["rules"]
# Biztosítjuk a JSON kompatibilis szerializációt (model_dump(mode='json') már megtörtént)
if isinstance(new_rules, dict) and new_rules.get("lifecycle") is None and tier.rules.get("lifecycle"):
new_rules["lifecycle"] = tier.rules["lifecycle"]
tier.rules = new_rules
if isinstance(new_rules, dict):
merged_rules = deep_merge_dict(tier.rules or {}, new_rules)
tier.rules = merged_rules
else:
tier.rules = new_rules
if "is_custom" in update_data and update_data["is_custom"] is not None:
tier.is_custom = update_data["is_custom"]
if "tier_level" in update_data and update_data["tier_level"] is not None:
tier.tier_level = update_data["tier_level"]
if "feature_capabilities" in update_data and update_data["feature_capabilities"] is not None:
tier.feature_capabilities = update_data["feature_capabilities"]
# ── P0 SINGLETON RULE: is_default_fallback ──
if "is_default_fallback" in update_data and update_data["is_default_fallback"] is True:
# Clear fallback on all other tiers first
await db.execute(
update(SubscriptionTier)
.where(
SubscriptionTier.is_default_fallback == True,
SubscriptionTier.id != tier_id,
)
.values(is_default_fallback=False)
)
tier.is_default_fallback = True
elif "is_default_fallback" in update_data and update_data["is_default_fallback"] is False:
tier.is_default_fallback = False
# ── P0 SINGLETON RULE: trial_days_on_signup ──
if "trial_days_on_signup" in update_data and update_data["trial_days_on_signup"] is not None:
if update_data["trial_days_on_signup"] > 0:
# Clear trial on all other tiers first
await db.execute(
update(SubscriptionTier)
.where(
SubscriptionTier.trial_days_on_signup > 0,
SubscriptionTier.id != tier_id,
)
.values(trial_days_on_signup=0)
)
tier.trial_days_on_signup = update_data["trial_days_on_signup"]
await db.commit()
await db.refresh(tier)
logger.info(
f"Admin {admin.id} ({admin.email}) updated subscription tier: "
f"id={tier.id}, name={tier.name}"
f"id={tier.id}, name={tier.name}, "
f"is_default_fallback={tier.is_default_fallback}, "
f"trial_days_on_signup={tier.trial_days_on_signup}"
)
return SubscriptionTierResponse.model_validate(tier)

View File

@@ -0,0 +1,212 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_permissions.py
"""
🎯 RBAC Permission Matrix & Override Endpoints (P0)
Provides admin-facing endpoints for:
- GET /admin/permissions/matrix — Returns the full permission matrix
showing which roles can perform which actions.
- PATCH /admin/permissions/override/{org_id} — Updates custom permissions
for a specific organization (scope-based override).
All endpoints are protected by Depends(RequireRole(...)) so only
authorized staff (SUPERADMIN, ADMIN) can access them.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.api import deps
from app.api.deps import RequireRole, get_db, get_current_user
from app.models.identity import User, UserRole
from app.models.marketplace.organization import Organization
from app.services.rbac_service import (
rbac_service,
AdminAction,
ScopeType,
ADMIN_SCOPE_ACTIONS,
MODERATOR_SCOPE_ACTIONS,
SALES_REP_SCOPE_ACTIONS,
SERVICE_MGR_SCOPE_ACTIONS,
)
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter()
# ──────────────────────────────────────────────
# Pydantic Schemas
# ──────────────────────────────────────────────
class PermissionMatrixEntry(BaseModel):
"""A single entry in the permission matrix."""
role: str
actions: List[str]
class PermissionMatrixResponse(BaseModel):
"""Full permission matrix response."""
matrix: List[PermissionMatrixEntry]
all_actions: List[str]
class PermissionOverrideRequest(BaseModel):
"""Request body for overriding organization permissions."""
action: str = Field(..., description="The action to override (e.g., EDIT_DATA)")
granted: bool = Field(..., description="Whether to grant or revoke this action")
class PermissionOverrideResponse(BaseModel):
"""Response after updating custom permissions."""
status: str
org_id: int
action: str
granted: bool
message: str
# ──────────────────────────────────────────────
# Endpoints
# ──────────────────────────────────────────────
@router.get(
"/admin/permissions/matrix",
response_model=PermissionMatrixResponse,
tags=["Admin Permissions"],
summary="Get the full RBAC permission matrix",
)
async def get_permission_matrix(
current_user: User = Depends(get_current_user),
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.ADMIN])),
):
"""
🔐 Returns the complete RBAC permission matrix.
Shows which roles are permitted to perform which admin actions.
Only SUPERADMIN and ADMIN roles can access this endpoint.
Returns:
- matrix: List of {role, actions[]} entries
- all_actions: Complete list of all possible admin actions
"""
matrix = []
for role in [UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.MODERATOR,
UserRole.SALES_REP, UserRole.SERVICE_MGR]:
actions = rbac_service.get_permitted_actions(role)
matrix.append(PermissionMatrixEntry(
role=role.value,
actions=sorted(actions),
))
all_actions = [a.value for a in AdminAction]
return PermissionMatrixResponse(
matrix=matrix,
all_actions=sorted(all_actions),
)
@router.patch(
"/admin/permissions/override/{org_id}",
response_model=PermissionOverrideResponse,
tags=["Admin Permissions"],
summary="Override custom permissions for an organization",
)
async def override_org_permission(
org_id: int,
payload: PermissionOverrideRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.ADMIN])),
):
"""
🔐 Updates custom permissions for a specific organization.
This endpoint allows SUPERADMIN and ADMIN to grant or revoke
specific actions for an organization, overriding the default
role-based permissions.
The RBACService.check_admin_access() is invoked to verify that
the requesting admin has scope-based access to the target
organization before applying the override.
Args:
org_id: The target organization ID.
payload.action: The action to override (e.g., "EDIT_DATA").
payload.granted: True to grant, False to revoke.
Returns:
Confirmation of the override operation.
"""
# 1. Validate that the action exists
valid_actions = {a.value for a in AdminAction}
if payload.action not in valid_actions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid action '{payload.action}'. Valid actions: {sorted(valid_actions)}",
)
# 2. Verify scope-based access to the target organization
try:
await rbac_service.check_admin_access(
db=db,
user=current_user,
action=AdminAction.EDIT_DATA,
target_org_id=org_id,
)
except PermissionError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
# 3. Fetch the organization
stmt = select(Organization).where(Organization.id == org_id)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization {org_id} not found.",
)
# 4. Update custom permissions via the settings JSONB field
# The Organization model has a `settings` JSONB column that stores
# dynamic org-level configuration. We store permission overrides
# under a "permission_overrides" key within settings.
if not isinstance(org.settings, dict):
org.settings = {}
# Ensure the permission_overrides sub-key exists
if "permission_overrides" not in org.settings:
org.settings["permission_overrides"] = {}
# Apply the override
org.settings["permission_overrides"][payload.action] = payload.granted
# 5. Persist
await db.commit()
await db.refresh(org)
logger.info(
f"Permission override applied: org={org_id}, "
f"action={payload.action}, granted={payload.granted}, "
f"by_user={current_user.id}"
)
return PermissionOverrideResponse(
status="success",
org_id=org_id,
action=payload.action,
granted=payload.granted,
message=f"Permission '{payload.action}' {'granted' if payload.granted else 'revoked'} for organization {org_id}.",
)

View File

@@ -310,6 +310,37 @@ async def get_current_user_profile(
return UserResponse.model_validate(response_data)
class LanguageUpdateRequest(BaseModel):
preferred_language: str = Field(
..., min_length=2, max_length=10,
description="BCP 47 language code, e.g. 'hu', 'en'"
)
@router.patch("/me/language")
async def update_user_language(
request: LanguageUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
PATCH /auth/me/language
Update the authenticated user's preferred language.
Saves the language preference to the user's profile so it persists
across sessions and devices.
"""
current_user.preferred_language = request.preferred_language
db.add(current_user)
await db.commit()
await db.refresh(current_user)
return {
"status": "success",
"preferred_language": current_user.preferred_language,
}
# ── 30 NAPOS FIÓKVISSZAÁLLÍTÁS (RESTORE) ──
class RestoreRequestIn(BaseModel):

View File

@@ -579,6 +579,8 @@ async def create_expense(
data=data,
# === B2B VENDOR FIELDS ===
vendor_organization_id=expense.vendor_organization_id,
# P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben
service_provider_id=expense.service_provider_id,
external_vendor_name=expense.external_vendor_name,
# === INVOICE DATES ===
invoice_date=expense.invoice_date,

View File

@@ -0,0 +1,54 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/regions.py
"""
🌍 Global Region Registry API Endpoint.
Public endpoint that returns the list of active region configurations.
Used by the frontend to drive locale-aware formatting (dates, numbers, currencies)
and financial calculations (VAT, pricing).
Schema: system.region_config
"""
import logging
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import get_db
from app.models.core_logic import RegionConfig
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/regions", response_model=List[dict])
async def list_active_regions(
db: AsyncSession = Depends(get_db),
):
"""
GET /api/v1/system/regions
Returns all active region configurations from the system.region_config table.
This is a public endpoint (no auth required) used by the frontend to:
- Format dates, numbers, and currencies according to locale
- Calculate VAT rates for pricing
- Determine timezone for date displays
"""
stmt = select(RegionConfig).where(RegionConfig.is_active == True).order_by(RegionConfig.country_code)
result = await db.execute(stmt)
regions = result.scalars().all()
return [
{
"country_code": r.country_code,
"name": r.name,
"currency": r.currency,
"default_vat_rate": r.default_vat_rate,
"locale_code": r.locale_code,
"timezone": r.timezone,
"is_active": r.is_active,
}
for r in regions
]

View File

@@ -5,17 +5,20 @@ Subscription Public API Endpoints.
Provides:
- GET /public — Public package catalog with dynamic pricing resolution
based on the user's organization country (or fallback to DEFAULT zone).
- GET /my — Current user's/org's subscription details with feature flags
- GET /feature-flags — All feature flags resolved for the current user
"""
from typing import List, Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api import deps
from app.db.session import get_db
from app.models.core_logic import SubscriptionTier
from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription
from app.models.identity import User
from app.models.marketplace.organization import Organization
from app.schemas.subscription import (
@@ -23,6 +26,7 @@ from app.schemas.subscription import (
SubscriptionTierResponse,
PricingZoneModel,
)
from app.services.subscription_service import SubscriptionService, SUBSCRIPTION_FEATURES
router = APIRouter()
@@ -174,3 +178,179 @@ async def get_public_subscriptions(
)
return response
# ── P0 Feature Flag Endpoints ─────────────────────────────────────────────────
@router.get(
"/my",
summary="Saját előfizetés részletei",
description=(
"Visszaadja a bejelentkezett felhasználó aktuális előfizetési adatait. "
"Először a szervezeti előfizetést (OrganizationSubscription) ellenőrzi, "
"ha nincs, akkor a felhasználói előfizetést (UserSubscription). "
"Tartalmazza a tier adatokat, allowances, pricing, feature_capabilities mezőket."
),
)
async def get_my_subscription(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
):
"""
Visszaadja a felhasználó aktuális előfizetési adatait.
**Feloldási sorrend:**
1. Aktív szervezet OrganizationSubscription (ha van active_organization_id)
2. UserSubscription (ha nincs org subscription)
3. Fallback: alapértelmezett 'free' adatok
**Response struktúra:**
```json
{
"subscription": {
"tier_id": 16,
"tier_name": "corp_premium_v1",
"display_name": "Céges Prémium",
"valid_from": "...",
"valid_until": "...",
"is_active": true,
"allowances": {"max_vehicles": 20, ...},
"pricing": {"monthly_price": 29.99, ...},
"feature_capabilities": {"can_export_data": true, ...}
},
"tier": "premium",
"features": {
"export_csv": true,
"analytics_tco": true,
...
},
"source": "organization" | "user" | "default"
}
```
"""
user_id = current_user.id
active_org_id = getattr(current_user, "active_organization_id", None)
subscription_data = None
source = "default"
# 1. Try org subscription first
if active_org_id:
org_sub = await SubscriptionService.get_org_subscription_details(db, active_org_id)
if org_sub:
subscription_data = org_sub
source = "organization"
# 2. Fallback to user subscription
if not subscription_data:
user_sub = await SubscriptionService.get_user_subscription_details(db, user_id)
if user_sub:
subscription_data = user_sub
source = "user"
# 3. Resolve tier and feature flags
user_tier = await SubscriptionService.get_user_tier(db, user_id)
feature_flags = await SubscriptionService.get_user_feature_flags(db, user_id)
return {
"subscription": subscription_data,
"tier": user_tier,
"features": feature_flags.get("features", {}),
"expires_at": feature_flags.get("expires_at"),
"source": source,
}
@router.get(
"/feature-flags",
summary="Feature flag-ek listázása",
description=(
"Visszaadja az összes feature flag állapotát a felhasználó "
"előfizetési szintje alapján. A frontend useFeatureFlag() composable "
"ezt a végpontot használja a funkciók engedélyezéséhez/letiltásához."
),
)
async def get_feature_flags(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
):
"""
Visszaadja az összes feature flag állapotát.
**Response:**
```json
{
"tier": "premium",
"features": {
"export_csv": true,
"analytics_tco": true,
"analytics_detailed": false,
"multi_vehicle": true,
"unlimited_vehicles": true,
"subscription_management": true,
"vehicle_tracking": true,
"cost_tracking": true,
"service_booking": true,
"advanced_reports": true,
"team_management": true,
"priority_support": true,
"white_label": false,
"api_webhooks": false,
"custom_integrations": false
},
"expires_at": "2026-07-24T12:00:00"
}
```
"""
feature_flags = await SubscriptionService.get_user_feature_flags(
db, current_user.id
)
return feature_flags
@router.get(
"/check/{feature_key}",
summary="Egy adott feature flag ellenőrzése",
description=(
"Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz. "
"Használható a frontend által komponens szintű guard-okhoz."
),
)
async def check_feature_access(
feature_key: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
):
"""
Ellenőrzi egy adott feature elérhetőségét.
**Response:**
```json
{
"feature_key": "analytics_tco",
"granted": true,
"tier": "premium",
"required_tier": "premium"
}
```
Hibák:
- 404: Ismeretlen feature_key
"""
if feature_key not in SUBSCRIPTION_FEATURES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown feature key: {feature_key}",
)
user_tier = await SubscriptionService.get_user_tier(db, current_user.id)
granted = SubscriptionService.can_access_feature(user_tier, feature_key)
required_tier = SUBSCRIPTION_FEATURES[feature_key]
return {
"feature_key": feature_key,
"granted": granted,
"tier": user_tier,
"required_tier": required_tier,
}

View File

@@ -28,7 +28,7 @@ from .vehicle.asset import Asset, AssetCatalog, AssetEvent, AssetAssignment, Ass
from .fleet_finance import AssetCost, CostCategory, AssetFinancials, InsuranceProvider, VehicleInsurancePolicy, VehicleTaxObligation
# 6. Üzleti logika és előfizetések
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty, RegionConfig
from .marketplace.payment import PaymentIntent, PaymentIntentStatus
from .marketplace.finance import Issuer, IssuerType
@@ -81,7 +81,7 @@ __all__ = [
"ServiceProvider", "Vote", "Competition", "UserScore", "ServiceReview", "ModerationStatus", "SourceType",
"Document", "Translation", "PendingAction", "ActionStatus",
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty",
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty", "RegionConfig",
"PaymentIntent", "PaymentIntentStatus",
"AuditLog", "VehicleOwnership", "LogSeverity",
"SecurityAuditLog", "OperationalLog", "ProcessLog",

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
from typing import Optional, List, Any
from datetime import datetime # Python saját típusa a típusjelöléshez
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
@@ -9,9 +9,65 @@ from sqlalchemy.sql import func
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
# ═══════════════════════════════════════════════════════════════════════════════
# P0 SYSTEM ARCHITECTURE: Global Region Registry
# ═══════════════════════════════════════════════════════════════════════════════
class RegionConfig(Base):
"""
🌍 Global Region Configuration Registry.
Central table for L10n/i18n configuration. Drives all UI formatting
(dates, numbers, currencies) and financial calculations (VAT, pricing).
Each row represents a country/region with its own locale, currency,
VAT rate, and timezone settings.
Schema: system.region_config
"""
__tablename__ = "region_config"
__table_args__ = {"schema": "system"}
country_code: Mapped[str] = mapped_column(
String(10), primary_key=True, index=True,
comment="ISO 3166-1 alpha-2 country code, e.g. 'HU', 'GB', or 'DEFAULT' for fallback"
)
name: Mapped[str] = mapped_column(
String, nullable=False,
comment="Human-readable region name, e.g. 'Hungary', 'United Kingdom'"
)
currency: Mapped[str] = mapped_column(
String(3), nullable=False,
comment="ISO 4217 currency code, e.g. 'HUF', 'GBP', 'EUR'"
)
default_vat_rate: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0,
comment="Default VAT rate as decimal, e.g. 27.0 for 27%"
)
locale_code: Mapped[str] = mapped_column(
String(10), nullable=False,
comment="BCP 47 locale code, e.g. 'hu-HU', 'en-GB', 'de-DE'"
)
timezone: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="IANA timezone, e.g. 'Europe/Budapest', 'Europe/London'"
)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=text("true"),
comment="Soft-toggle for region availability"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), onupdate=func.now()
)
class SubscriptionTier(Base):
"""
Előfizetési csomagok definíciója (pl. Free, Premium, VIP).
"""
Előfizetési csomagok definíciója (pl. Free, Premium, VIP).
A csomagok határozzák meg a korlátokat (pl. max járműszám).
"""
__tablename__ = "subscription_tiers"
@@ -32,6 +88,42 @@ class SubscriptionTier(Base):
server_default=text("'{}'::jsonb")
)
# ── P0 AUDIT FIX: Database-driven tier level instead of hardcoded string mapping ──
# Numeric level for the entitlement hierarchy: 0=free, 1=premium, 2=enterprise.
# This replaces the hardcoded _map_tier_name() string-matching logic.
# New tiers can be added with any name; the tier_level determines their position.
tier_level: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default=text("0"),
comment="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise"
)
# ── P0 SINGLETON PACKAGE RULES: is_default_fallback ──
# Ha True, ez a csomag az alapértelmezett fallback csomag, amit a
# subscription_service használ, ha a user előfizetése lejárt.
# MAXIMUM EGY csomag lehet True értékkel (singleton).
is_default_fallback: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default=text("false"),
comment="Singleton: maximum one tier can be the default fallback package"
)
# ── P0 SINGLETON PACKAGE RULES: trial_days_on_signup ──
# Ha > 0, ez a csomag a próbaidős csomag, amit új regisztrációnál
# automatikusan hozzárendelünk a felhasználóhoz.
# MAXIMUM EGY csomag lehet > 0 értékkel (singleton).
trial_days_on_signup: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default=text("0"),
comment="Singleton: maximum one tier can have trial_days_on_signup > 0"
)
class OrganizationSubscription(Base):
"""
Szervezetek aktuális előfizetései és azok érvényessége.
@@ -56,6 +148,13 @@ class OrganizationSubscription(Base):
# Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100}
extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)
# ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ──
tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[tier_id],
lazy="selectin",
)
class UserSubscription(Base):
"""
Felhasználók aktuális előfizetései és azok érvényessége.
@@ -78,6 +177,13 @@ class UserSubscription(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ──
tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[tier_id],
lazy="selectin",
)
class CreditTransaction(Base):
"""

View File

@@ -27,6 +27,7 @@ if TYPE_CHECKING:
from app.models.vehicle.asset import Asset
from app.models.marketplace.organization import Organization
from app.models.system.document import Document
from app.models.identity.social import ServiceProvider
# =============================================================================
@@ -138,6 +139,10 @@ class AssetCost(Base):
vendor_organization_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("fleet.organizations.id"), nullable=True, index=True
)
# P0 HYBRID VENDOR REFACTOR: Új elsődleges hivatkozás a marketplace.service_providers táblára
service_provider_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("marketplace.service_providers.id"), nullable=True, index=True
)
# Szabadon gépelhető beszállító név (ha nincs a rendszerben)
external_vendor_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
@@ -162,6 +167,11 @@ class AssetCost(Base):
"Organization", foreign_keys=[vendor_organization_id]
)
# P0 HYBRID VENDOR REFACTOR: ServiceProvider kapcsolat
service_provider: Mapped[Optional["ServiceProvider"]] = relationship(
"ServiceProvider", foreign_keys=[service_provider_id]
)
# =============================================================================
# AssetFinancials (áthelyezve vehicle -> fleet_finance, kibővítve)

View File

@@ -170,6 +170,14 @@ class User(Base):
scope_level: Mapped[str] = mapped_column(String(30), server_default="individual")
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
# ── P0: Scope-Based Admin RBAC ──
# Defines the admin's scope type and value for organization-level access control.
# scope_type: "GLOBAL", "REGION", or "SEGMENT"
# scope_value: The specific value (e.g., "Pest_County", "Fleet", "Budapest")
scope_type: Mapped[Optional[str]] = mapped_column(String(30), nullable=True, server_default=None)
scope_value: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, server_default=None)
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
# E-mail történetiség és alternatív e-mailek (MB 2.0.1)

View File

@@ -20,7 +20,11 @@ class SourceType(str, enum.Enum):
api_import = "import"
class ServiceProvider(Base):
""" Közösség által beküldött szolgáltatók (v1.3.1). """
""" Közösség által beküldött szolgáltatók (v1.3.1).
P0 HYBRID VENDOR REFACTOR: Kibővítve atomizált címmezőkkel és
kapcsolatfelvételi adatokkal a quick_add_provider() refactorhoz.
"""
__tablename__ = "service_providers"
__table_args__ = {"schema": "marketplace"}
@@ -29,6 +33,19 @@ class ServiceProvider(Base):
address: Mapped[str] = mapped_column(String, nullable=False)
category: Mapped[Optional[str]] = mapped_column(String)
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők ===
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
address_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
address_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
plus_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
# === P0 HYBRID VENDOR REFACTOR: Kapcsolatfelvételi adatok ===
contact_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
website: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
status: Mapped[ModerationStatus] = mapped_column(
PG_ENUM(ModerationStatus, name="moderation_status", inherit_schema=True),
default=ModerationStatus.pending

View File

@@ -113,6 +113,12 @@ class Organization(Base):
country_code: Mapped[str] = mapped_column(String(2), default="HU")
language: Mapped[str] = mapped_column(String(5), default="hu")
# ── P0: Scope-Based Admin RBAC ──
# Geographic region for scope-based admin access (e.g., "Pest_County", "Budapest")
region: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
# Business segment for scope-based admin access (e.g., "Fleet", "Dealer", "Service")
segment: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
address_city: Mapped[Optional[str]] = mapped_column(String(100))
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))

View File

@@ -28,6 +28,10 @@ class ServiceProfile(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), unique=True)
# P0 HYBRID VENDOR REFACTOR: Új elsődleges hivatkozás a marketplace.service_providers táblára
service_provider_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("marketplace.service_providers.id"), nullable=True, index=True
)
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)

View File

@@ -43,6 +43,8 @@ class AssetCostBase(BaseModel):
# === BESZÁLLÍTÓI ADATOK (B2B expansion) ===
vendor_organization_id: Optional[int] = Field(None, description="Beszállító szervezet ID (FK fleet.organizations)")
# P0 HYBRID VENDOR REFACTOR: Új elsődleges hivatkozás a marketplace.service_providers táblára
service_provider_id: Optional[int] = Field(None, description="Szolgáltató ID (FK marketplace.service_providers) - P0 elsődleges")
external_vendor_name: Optional[str] = Field(None, max_length=200, description="Szabadon gépelhető beszállító név (ha nincs a rendszerben)")
# === KÖNYVELÉSI DÁTUMOK ===
@@ -99,6 +101,8 @@ class AssetCostUpdate(BaseModel):
description: Optional[str] = None
invoice_number: Optional[str] = None
vendor_organization_id: Optional[int] = None
# P0 HYBRID VENDOR REFACTOR: service_provider_id az update-hez
service_provider_id: Optional[int] = None
external_vendor_name: Optional[str] = None
invoice_date: Optional[date] = None
fulfillment_date: Optional[date] = None
@@ -121,5 +125,8 @@ class AssetCostResponse(AssetCostBase):
# Enriched vendor name (resolved from vendor_organization_id relationship)
vendor_name: Optional[str] = None # Resolved from Organization.name via vendor_organization_id
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
service_provider_id: Optional[int] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -156,6 +156,10 @@ class SubscriptionTierResponse(BaseModel):
name: str
rules: SubscriptionRulesModel
is_custom: bool
tier_level: int = Field(default=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
feature_capabilities: dict = Field(default_factory=dict, description="Feature capability flags JSONB")
is_default_fallback: bool = Field(default=False, description="Singleton: maximum one tier can be the default fallback")
trial_days_on_signup: int = Field(default=0, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0")
model_config = ConfigDict(from_attributes=True)
@@ -182,6 +186,10 @@ class SubscriptionTierCreate(BaseModel):
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
rules: SubscriptionRulesModel
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
feature_capabilities: dict = Field(default_factory=dict, description="Feature capability flags JSONB")
is_default_fallback: bool = Field(default=False, description="Singleton: maximum one tier can be the default fallback")
trial_days_on_signup: int = Field(default=0, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0")
@field_validator("name")
@classmethod
@@ -196,6 +204,10 @@ class SubscriptionTierUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level")
feature_capabilities: Optional[dict] = Field(default=None, description="Feature capability flags JSONB")
is_default_fallback: Optional[bool] = Field(default=None, description="Singleton: maximum one tier can be the default fallback")
trial_days_on_signup: Optional[int] = Field(default=None, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0")
@field_validator("name")
@classmethod

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,
}

View File

@@ -0,0 +1,186 @@
"""
Test script for Scope-Based Admin RBAC Service.
Verifies that the RBACService correctly handles scope-based access control.
"""
import asyncio
import sys
from sqlalchemy import select, text
from app.database import AsyncSessionLocal
from app.models.identity.identity import User, UserRole
from app.models.marketplace.organization import Organization
from app.services.rbac_service import rbac_service, AdminAction
async def test_rbac_service():
print("=" * 60)
print("RBAC SERVICE TEST SUITE")
print("=" * 60)
async with AsyncSessionLocal() as db:
# 1. Check that scope_type and scope_value columns exist on User
print("\n[TEST 1] Checking User model has scope_type/scope_value columns...")
result = await db.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_schema = 'identity' AND table_name = 'users' AND column_name IN ('scope_type', 'scope_value')")
)
cols = result.scalars().all()
assert "scope_type" in cols, "scope_type column missing!"
assert "scope_value" in cols, "scope_value column missing!"
print(f" ✅ Columns found: {cols}")
# 2. Check that region and segment columns exist on Organization
print("\n[TEST 2] Checking Organization model has region/segment columns...")
result = await db.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_schema = 'fleet' AND table_name = 'organizations' AND column_name IN ('region', 'segment')")
)
cols = result.scalars().all()
assert "region" in cols, "region column missing!"
assert "segment" in cols, "segment column missing!"
print(f" ✅ Columns found: {cols}")
# 3. Test RBACService.get_permitted_actions for each role
print("\n[TEST 3] Testing get_permitted_actions for each role...")
for role in UserRole:
actions = rbac_service.get_permitted_actions(role)
print(f" {role.value}: {len(actions)} permitted actions")
assert isinstance(actions, (set, list)), f"Actions for {role} should be a set or list"
print(" ✅ All roles return valid action lists")
# 4. Test is_action_permitted
print("\n[TEST 4] Testing is_action_permitted...")
assert rbac_service.is_action_permitted(UserRole.SUPERADMIN, AdminAction.EDIT_DATA) is True
assert rbac_service.is_action_permitted(UserRole.ADMIN, AdminAction.EDIT_DATA) is True
assert rbac_service.is_action_permitted(UserRole.MODERATOR, AdminAction.EDIT_DATA) is False
assert rbac_service.is_action_permitted(UserRole.SALES_REP, AdminAction.VIEW_DATA) is True
assert rbac_service.is_action_permitted(UserRole.SERVICE_MGR, AdminAction.VIEW_DATA) is True
print(" ✅ All action permission checks pass")
# 5. Test SUPERADMIN bypasses all scope checks
print("\n[TEST 5] Testing SUPERADMIN scope bypass...")
# Create a mock user object
class MockUser:
role = UserRole.SUPERADMIN
scope_type = None
scope_value = None
id = 999999
try:
await rbac_service.check_admin_access(db, MockUser(), AdminAction.VIEW_DATA, target_org_id=1)
print(" ✅ SUPERADMIN bypasses scope check - no PermissionError raised")
except PermissionError as e:
print(f" ❌ SUPERADMIN should bypass scope check but got: {e}")
# 6. Test that a regular user without scope gets PermissionError
print("\n[TEST 6] Testing regular user without scope...")
class MockUserNoScope:
role = UserRole.ADMIN
scope_type = None
scope_value = None
id = 999998
try:
await rbac_service.check_admin_access(db, MockUserNoScope(), AdminAction.VIEW_DATA, target_org_id=1)
print(" ❌ Should have raised PermissionError for user without scope")
except PermissionError as e:
print(f" ✅ Correctly raised PermissionError: {e}")
# 7. Test GLOBAL scope
print("\n[TEST 7] Testing GLOBAL scope...")
class MockUserGlobal:
role = UserRole.ADMIN
scope_type = "GLOBAL"
scope_value = None
id = 999997
try:
await rbac_service.check_admin_access(db, MockUserGlobal(), AdminAction.VIEW_DATA, target_org_id=1)
print(" ✅ GLOBAL scope allows access to any org")
except PermissionError as e:
print(f" ❌ GLOBAL scope should allow access but got: {e}")
# 8. Test get_accessible_org_ids for GLOBAL scope
print("\n[TEST 8] Testing get_accessible_org_ids for GLOBAL scope...")
org_ids = await rbac_service.get_accessible_org_ids(db, MockUserGlobal())
assert isinstance(org_ids, list), "Should return a list"
print(f" ✅ GLOBAL scope returns {len(org_ids)} accessible orgs")
# 9. Test REGION scope matching
print("\n[TEST 9] Testing REGION scope matching...")
# First, set an org's region to a known value for testing
await db.execute(
text("UPDATE fleet.organizations SET region = 'Test_Region' WHERE id = 1")
)
await db.commit()
class MockUserRegion:
role = UserRole.ADMIN
scope_type = "REGION"
scope_value = "Test_Region"
id = 999996
try:
await rbac_service.check_admin_access(db, MockUserRegion(), AdminAction.VIEW_DATA, target_org_id=1)
print(" ✅ REGION scope matches org with same region")
except PermissionError as e:
print(f" ❌ REGION scope should match but got: {e}")
# Test non-matching region
class MockUserRegionWrong:
role = UserRole.ADMIN
scope_type = "REGION"
scope_value = "Other_Region"
id = 999995
try:
await rbac_service.check_admin_access(db, MockUserRegionWrong(), AdminAction.VIEW_DATA, target_org_id=1)
print(" ❌ Should have raised PermissionError for wrong region")
except PermissionError as e:
print(f" ✅ Correctly denied access for wrong region: {e}")
# 10. Test SEGMENT scope matching
print("\n[TEST 10] Testing SEGMENT scope matching...")
await db.execute(
text("UPDATE fleet.organizations SET segment = 'Test_Segment' WHERE id = 1")
)
await db.commit()
class MockUserSegment:
role = UserRole.ADMIN
scope_type = "SEGMENT"
scope_value = "Test_Segment"
id = 999994
try:
await rbac_service.check_admin_access(db, MockUserSegment(), AdminAction.VIEW_DATA, target_org_id=1)
print(" ✅ SEGMENT scope matches org with same segment")
except PermissionError as e:
print(f" ❌ SEGMENT scope should match but got: {e}")
# 11. Test action not permitted for role
print("\n[TEST 11] Testing action not permitted for role...")
class MockUserModerator:
role = UserRole.MODERATOR
scope_type = "GLOBAL"
scope_value = None
id = 999993
try:
await rbac_service.check_admin_access(db, MockUserModerator(), AdminAction.DELETE_DATA, target_org_id=1)
print(" ❌ Should have raised PermissionError - MODERATOR cannot DELETE_DATA")
except PermissionError as e:
print(f" ✅ Correctly denied DELETE_DATA for MODERATOR: {e}")
# Cleanup test data
await db.execute(
text("UPDATE fleet.organizations SET region = NULL, segment = NULL WHERE id = 1")
)
await db.commit()
print("\n" + "=" * 60)
print("ALL TESTS PASSED ✅")
print("=" * 60)
return True
if __name__ == "__main__":
success = asyncio.run(test_rbac_service())
sys.exit(0 if success else 1)