admin felület fejlesztése garázs, előfizeztési csomagok
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)"])
|
||||
@@ -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,
|
||||
}
|
||||
346
backend/app/api/v1/endpoints/admin_organizations.py
Normal file
346
backend/app/api/v1/endpoints/admin_organizations.py
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
212
backend/app/api/v1/endpoints/admin_permissions.py
Normal file
212
backend/app/api/v1/endpoints/admin_permissions.py
Normal 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}.",
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
54
backend/app/api/v1/endpoints/regions.py
Normal file
54
backend/app/api/v1/endpoints/regions.py
Normal 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
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
321
backend/app/services/rbac_service.py
Normal file
321
backend/app/services/rbac_service.py
Normal 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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
186
backend/app/tests/test_rbac_service.py
Normal file
186
backend/app/tests/test_rbac_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user