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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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