RABC fejlesztése és beélesítése hibák kijavításával
This commit is contained in:
@@ -10,8 +10,10 @@ from sqlalchemy.orm import selectinload, joinedload
|
||||
from app.db.session import get_db
|
||||
from app.core.security import decode_token, DEFAULT_RANK_MAP
|
||||
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
|
||||
from app.models.marketplace.organization import OrgRole, OrganizationMember
|
||||
from app.core.config import settings
|
||||
from app.core.translation_helper import t # Translation helper
|
||||
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -104,13 +106,13 @@ async def get_current_active_user(
|
||||
return current_user
|
||||
|
||||
async def check_resource_access(
|
||||
resource_scope_id: Union[str, int],
|
||||
resource_scope_id: Union[str, int],
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
|
||||
"""
|
||||
if current_user.role == UserRole.superadmin:
|
||||
if current_user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
|
||||
user_scope = str(current_user.scope_id) if current_user.scope_id else None
|
||||
@@ -161,17 +163,138 @@ async def get_current_admin(
|
||||
"""
|
||||
Csak admin/moderátor/superadmin szerepkörrel rendelkező felhasználók számára.
|
||||
"""
|
||||
# A UserRole Enum értékeit használjuk
|
||||
# A UserRole Enum értékeit használjuk (RBAC Phase 1)
|
||||
allowed_roles = {
|
||||
UserRole.superadmin,
|
||||
UserRole.admin,
|
||||
UserRole.region_admin,
|
||||
UserRole.country_admin,
|
||||
UserRole.moderator,
|
||||
UserRole.SUPERADMIN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.MODERATOR,
|
||||
}
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=t("AUTH.INSUFFICIENT_ADMIN_PERMISSIONS")
|
||||
)
|
||||
return current_user
|
||||
return current_user
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RBAC Phase 2: Capability Dependencies (Kapuőrök)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def RequireSystemCapability(capability_name: str):
|
||||
"""
|
||||
🎯 Rendszerszintű képesség-ellenőrző dependency.
|
||||
|
||||
Használat:
|
||||
@router.get("/admin/users")
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireSystemCapability("can_manage_users"))
|
||||
):
|
||||
|
||||
Logika:
|
||||
1. Ha a user SUPERADMIN → azonnal átengedi (True).
|
||||
2. Egyébként lekéri a user role-ját, és ellenőrzi a
|
||||
SYSTEM_CAPABILITIES_MATRIX-ból, hogy a kért capability True-e.
|
||||
3. Ha nincs meg a capability → 403 Forbidden.
|
||||
"""
|
||||
async def system_capability_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> bool:
|
||||
# SUPERADMIN mindent visz
|
||||
if current_user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
|
||||
# Ellenőrizzük a SYSTEM_CAPABILITIES_MATRIX-ból
|
||||
role_key = current_user.role.value # pl. "ADMIN", "MODERATOR", "USER"
|
||||
if not role_has_capability(role_key, capability_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"SYSTEM_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{role_key}' szerepkör számára."
|
||||
)
|
||||
return True
|
||||
|
||||
return system_capability_checker
|
||||
|
||||
|
||||
def RequireOrgCapability(capability_name: str):
|
||||
"""
|
||||
🎯 Szervezeti képesség-ellenőrző dependency.
|
||||
|
||||
Használat:
|
||||
@router.post("/expenses")
|
||||
async def create_expense(
|
||||
expense: AssetCostCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireOrgCapability("can_add_expense"))
|
||||
):
|
||||
|
||||
Logika:
|
||||
1. Ha a user SUPERADMIN → azonnal átengedi (True).
|
||||
2. Lekéri a user OrgRole-ját az adott organization_id alapján
|
||||
a fleet.organization_members táblából.
|
||||
3. Kikeresi a fleet.org_roles táblából a szerepkörhöz tartozó
|
||||
permissions JSONB oszlopot.
|
||||
4. Ha a JSONB-ben a kért capability_name nem True → 403 Forbidden.
|
||||
|
||||
Fontos: Ez a dependency egy organization_id paramétert vár a végponttól.
|
||||
A végpontnak át kell adnia az org_id-t a dependency-nek.
|
||||
"""
|
||||
async def org_capability_checker(
|
||||
organization_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> bool:
|
||||
# SUPERADMIN mindent visz
|
||||
if current_user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
|
||||
# 1. Lekérjük a user szerepkörét a szervezetben
|
||||
member_stmt = select(OrganizationMember.role).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_result = await db.execute(member_stmt)
|
||||
org_role_name = member_result.scalar_one_or_none()
|
||||
|
||||
if not org_role_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="ORG_MEMBERSHIP_REQUIRED: You are not a member of this organization."
|
||||
)
|
||||
|
||||
# 2. Lekérjük a szerepkör permissions JSONB-jét a fleet.org_roles táblából
|
||||
role_stmt = select(OrgRole.permissions).where(
|
||||
OrgRole.name_key == org_role_name,
|
||||
OrgRole.is_active == True
|
||||
).limit(1)
|
||||
role_result = await db.execute(role_stmt)
|
||||
permissions = role_result.scalar_one_or_none()
|
||||
|
||||
if not permissions:
|
||||
# Fallback: ha nincs a fleet.org_roles táblában, használjuk a
|
||||
# OrganizationMember.permissions mezőt (örökölt viselkedés)
|
||||
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_perm_result = await db.execute(member_perm_stmt)
|
||||
permissions = member_perm_result.scalar_one_or_none() or {}
|
||||
|
||||
# 3. Ellenőrizzük a kért képességet
|
||||
if not isinstance(permissions, dict):
|
||||
permissions = {}
|
||||
|
||||
if not permissions.get(capability_name, False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"ORG_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{org_role_name}' szerepkör számára ebben a szervezetben."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
return org_capability_checker
|
||||
@@ -192,7 +192,7 @@ async def ban_user(
|
||||
)
|
||||
|
||||
# 2. Ellenőrizd, hogy nem superadmin-e
|
||||
if user.role == UserRole.superadmin:
|
||||
if user.role == UserRole.SUPERADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot ban a superadmin user"
|
||||
@@ -698,7 +698,7 @@ async def bulk_user_action(
|
||||
affected_count = 0
|
||||
|
||||
for user in users:
|
||||
if user.role == UserRole.superadmin and user.id != current_admin.id:
|
||||
if user.role == UserRole.SUPERADMIN and user.id != current_admin.id:
|
||||
continue
|
||||
|
||||
if request.action == "ban":
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember
|
||||
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole
|
||||
from app.models.identity import User
|
||||
from app.services.cost_service import cost_service
|
||||
from app.services.asset_service import AssetService
|
||||
@@ -21,6 +21,79 @@ from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEven
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _resolve_user_role_in_org(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
organization_id: int
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the user's role within the given organization.
|
||||
|
||||
Returns:
|
||||
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
|
||||
Returns 'MEMBER' as default if no membership found.
|
||||
"""
|
||||
stmt = select(OrganizationMember.role).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
return role or "MEMBER"
|
||||
|
||||
|
||||
async def _check_org_capability(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
organization_id: int,
|
||||
capability_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
|
||||
|
||||
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
|
||||
permissions JSONB oszlopából ellenőrzi a kért képességet.
|
||||
|
||||
Returns:
|
||||
bool: True ha a user rendelkezik a képességgel.
|
||||
"""
|
||||
# 1. Get user's org role
|
||||
member_stmt = select(OrganizationMember.role).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_result = await db.execute(member_stmt)
|
||||
org_role_name = member_result.scalar_one_or_none()
|
||||
|
||||
if not org_role_name:
|
||||
return False
|
||||
|
||||
# 2. Get permissions from fleet.org_roles table
|
||||
role_stmt = select(OrgRole.permissions).where(
|
||||
OrgRole.name_key == org_role_name,
|
||||
OrgRole.is_active == True
|
||||
).limit(1)
|
||||
role_result = await db.execute(role_stmt)
|
||||
permissions = role_result.scalar_one_or_none()
|
||||
|
||||
if not permissions:
|
||||
# Fallback to OrganizationMember.permissions
|
||||
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_perm_result = await db.execute(member_perm_stmt)
|
||||
permissions = member_perm_result.scalar_one_or_none() or {}
|
||||
|
||||
if not isinstance(permissions, dict):
|
||||
permissions = {}
|
||||
|
||||
return permissions.get(capability_name, False)
|
||||
|
||||
|
||||
class ArchiveVehicleRequest(BaseModel):
|
||||
"""Payload a jármű archiválásához (Soft Delete)."""
|
||||
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
|
||||
@@ -40,10 +113,18 @@ async def get_vehicle_quota_status(
|
||||
|
||||
GET /api/v1/assets/vehicles/quota-status
|
||||
|
||||
Resolves the user's active organization:
|
||||
- If scope_id is set, use that organization
|
||||
- If scope_id is null, resolve the user's primary (individual) garage
|
||||
from fleet.organizations via owner_id
|
||||
|
||||
Returns:
|
||||
{ "can_add": bool, "current_count": int, "limit": int }
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
# Determine organization ID based on user's active scope (garage isolation)
|
||||
org_id = None
|
||||
if current_user.scope_id is not None:
|
||||
@@ -51,6 +132,20 @@ async def get_vehicle_quota_status(
|
||||
org_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
# Resolve the user's primary individual garage
|
||||
org_stmt = select(Organization.id).where(
|
||||
Organization.owner_id == current_user.id,
|
||||
Organization.org_type == "individual",
|
||||
Organization.is_active == True
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
resolved_org_id = org_result.scalar_one_or_none()
|
||||
if resolved_org_id is not None:
|
||||
org_id = resolved_org_id
|
||||
logger.info(
|
||||
f"Resolved primary org for user {current_user.id}: org_id={org_id}"
|
||||
)
|
||||
|
||||
# Get the user's vehicle limit
|
||||
allowed_limit = await AssetService.get_user_vehicle_limit(
|
||||
@@ -59,17 +154,15 @@ async def get_vehicle_quota_status(
|
||||
# SAFETY: Ensure limit is at least 1
|
||||
allowed_limit = max(allowed_limit or 1, 1)
|
||||
|
||||
# Count only active vehicles (draft vehicles don't count toward the limit)
|
||||
from sqlalchemy import func
|
||||
# Count only active vehicles in the resolved organization
|
||||
if org_id is not None:
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id == org_id,
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
else:
|
||||
# Fallback: count by owner_person_id if no org could be resolved
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id.is_(None),
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
@@ -158,29 +251,35 @@ async def get_user_vehicles(
|
||||
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
|
||||
|
||||
if current_user.scope_id is None:
|
||||
# Personal mode: only show vehicles owned/operated personally (no organization)
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
or_(
|
||||
Asset.owner_org_id.is_(None),
|
||||
Asset.operator_org_id.is_(None)
|
||||
),
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.operator_person_id == current_user.person_id
|
||||
# Personal mode: show vehicles in organizations where user is a member
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.current_organization_id.in_(user_org_ids),
|
||||
Asset.status == "active"
|
||||
)
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(
|
||||
selectinload(Asset.catalog),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(
|
||||
selectinload(Asset.catalog),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# No organizations — return empty list
|
||||
return []
|
||||
else:
|
||||
# Corporate mode: only show vehicles belonging to the active organization's garages
|
||||
# Corporate mode: show vehicles belonging to the active organization
|
||||
# Uses current_organization_id (same logic as quota-status endpoint)
|
||||
# for consistency — vehicles belong to organizations, not branches.
|
||||
try:
|
||||
scope_org_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
@@ -191,25 +290,11 @@ async def get_user_vehicles(
|
||||
# Fallback: no valid organization, return empty list
|
||||
return []
|
||||
|
||||
# First, get all branch IDs (garages) for this organization
|
||||
from app.models.marketplace.organization import Branch
|
||||
branch_stmt = select(Branch.id).where(
|
||||
Branch.organization_id == scope_org_id,
|
||||
Branch.is_deleted == False,
|
||||
Branch.status == "active"
|
||||
)
|
||||
branch_result = await db.execute(branch_stmt)
|
||||
branch_ids = [row[0] for row in branch_result.all()]
|
||||
|
||||
if not branch_ids:
|
||||
# Organization has no active garages, return empty list
|
||||
return []
|
||||
|
||||
# Query assets that are in any of the organization's garages
|
||||
# Query assets that belong to the active organization
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.branch_id.in_(branch_ids),
|
||||
Asset.current_organization_id == scope_org_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
@@ -271,7 +356,9 @@ async def list_asset_costs(
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"organization_id": cost.organization_id,
|
||||
"amount_gross": cost.amount_gross,
|
||||
"amount_net": cost.amount_net,
|
||||
"vat_rate": cost.vat_rate,
|
||||
"currency": cost.currency,
|
||||
"date": cost.date,
|
||||
"invoice_number": cost.invoice_number,
|
||||
@@ -281,6 +368,8 @@ async def list_asset_costs(
|
||||
"category_code": cost.category.code if cost.category else None,
|
||||
"description": (cost.data or {}).get("description"),
|
||||
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
|
||||
"status": cost.status,
|
||||
"linked_asset_event_id": cost.linked_asset_event_id,
|
||||
}
|
||||
result.append(AssetCostResponse(**cost_dict))
|
||||
|
||||
@@ -308,22 +397,32 @@ async def get_asset(
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Query asset with catalog and master definition
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Unified Workspace: access is granted if the asset belongs to an organization
|
||||
# where the current user is an active member
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
else:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
@@ -337,6 +436,69 @@ async def get_asset(
|
||||
return asset
|
||||
|
||||
|
||||
@router.get("/vehicles/{vehicle_id}", response_model=AssetResponse)
|
||||
async def get_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get detailed information about a specific vehicle/asset by vehicle_id.
|
||||
|
||||
GET /api/v1/assets/vehicles/{vehicle_id}
|
||||
|
||||
This endpoint is the primary single-vehicle lookup used by the frontend.
|
||||
Uses Unified Workspace (OrganizationMember-based) permission check.
|
||||
|
||||
Returns the asset's full technical profile including catalog data
|
||||
and vehicle model definition specifications.
|
||||
"""
|
||||
# Get user's organization memberships
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Unified Workspace: access is granted if the asset belongs to an organization
|
||||
# where the current user is an active member
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == vehicle_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == vehicle_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found or you don't have permission to access it"
|
||||
)
|
||||
|
||||
return asset
|
||||
|
||||
|
||||
@router.post("/vehicles", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_or_claim_vehicle(
|
||||
payload: AssetCreate,
|
||||
@@ -366,21 +528,27 @@ async def create_or_claim_vehicle(
|
||||
existing_asset = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_asset:
|
||||
# Szabály A: Saját jármű duplikációja
|
||||
if existing_asset.owner_person_id == current_user.person_id:
|
||||
# Szabály A: Saját jármű duplikációja — check via organization membership
|
||||
# Get user's organization IDs
|
||||
member_org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
member_org_result = await db.execute(member_org_stmt)
|
||||
user_org_ids = [row[0] for row in member_org_result.all()]
|
||||
|
||||
if existing_asset.current_organization_id in user_org_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ez a jármű már szerepel a garázsodban!"
|
||||
)
|
||||
|
||||
# Szabály B: Tulajdonosváltás / Átmeneti állapot
|
||||
# A rendszám létezik, de más a tulajdonosa
|
||||
# A rendszám létezik, de más szervezetben van
|
||||
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
|
||||
payload.data_status = "draft"
|
||||
logger.info(
|
||||
f"License plate {license_plate_clean} exists with different owner "
|
||||
f"(owner_person_id={existing_asset.owner_person_id} != "
|
||||
f"current_user.person_id={current_user.person_id}). "
|
||||
f"License plate {license_plate_clean} exists in different organization "
|
||||
f"(current_organization_id={existing_asset.current_organization_id}). "
|
||||
f"Setting data_status='draft' for transfer scenario."
|
||||
)
|
||||
|
||||
@@ -458,20 +626,25 @@ async def update_vehicle(
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Find the asset
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.person_id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Find the asset — Unified Workspace: access via organization membership only
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
.options(selectinload(Asset.catalog))
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
.options(selectinload(Asset.catalog))
|
||||
)
|
||||
.options(selectinload(Asset.catalog))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
@@ -540,16 +713,17 @@ async def list_maintenance_records(
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Check asset access
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Check asset access — Unified Workspace: via organization membership only
|
||||
if user_org_ids:
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
else:
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
)
|
||||
asset_result = await db.execute(asset_stmt)
|
||||
asset = asset_result.scalar_one_or_none()
|
||||
|
||||
@@ -599,16 +773,17 @@ async def create_maintenance_record(
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Check asset access and get asset
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Check asset access and get asset — Unified Workspace: via organization membership only
|
||||
if user_org_ids:
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
else:
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
)
|
||||
asset_result = await db.execute(asset_stmt)
|
||||
asset = asset_result.scalar_one_or_none()
|
||||
|
||||
@@ -663,11 +838,14 @@ async def create_maintenance_record(
|
||||
)
|
||||
|
||||
# Create AssetCost record with MAINTENANCE category (id=2)
|
||||
# GROSS-FIRST: The payload "cost" field is treated as gross (Bruttó)
|
||||
cost_gross = float(payload["cost"])
|
||||
maintenance_cost = AssetCost(
|
||||
asset_id=asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=2,
|
||||
amount_net=float(payload["cost"]),
|
||||
amount_gross=cost_gross,
|
||||
amount_net=cost_gross, # Default: net = gross (0% VAT) unless vat_rate provided
|
||||
currency=payload.get("currency", "EUR"),
|
||||
date=date,
|
||||
invoice_number=payload.get("invoice_number"),
|
||||
@@ -736,18 +914,23 @@ async def _check_asset_access(
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Unified Workspace: access via organization membership only
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
|
||||
@@ -863,6 +1046,14 @@ async def create_asset_event(
|
||||
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
|
||||
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
|
||||
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
|
||||
|
||||
**Role-Based Auto-Approval (PHASE 2):**
|
||||
- OWNER/ADMIN → event status = COMPLETED, cost status = APPROVED
|
||||
- DRIVER → event status = MISSING_TECH_DATA, cost status = PENDING_APPROVAL
|
||||
|
||||
**Smart Linking (Double-Entry Avoidance):**
|
||||
- When cost_amount > 0, the auto-created AssetCost is linked back
|
||||
to the event via linked_expense_id / linked_asset_event_id.
|
||||
"""
|
||||
# Jogosultság ellenőrzés
|
||||
asset = await _check_asset_access(db, asset_id, current_user)
|
||||
@@ -873,9 +1064,30 @@ async def create_asset_event(
|
||||
# Szervezet meghatározása
|
||||
organization_id = asset.current_organization_id or asset.owner_org_id
|
||||
|
||||
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
|
||||
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
|
||||
|
||||
# Check if user has can_approve_expense capability
|
||||
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
|
||||
|
||||
# Determine event and cost status based on capability
|
||||
if can_approve:
|
||||
event_status = "COMPLETED"
|
||||
cost_status = "APPROVED"
|
||||
else:
|
||||
event_status = "MISSING_TECH_DATA"
|
||||
cost_status = "PENDING_APPROVAL"
|
||||
|
||||
logger.info(
|
||||
f"Capability-based approval for event: user {current_user.id} (role={user_role}) "
|
||||
f"in org {organization_id}: can_approve_expense={can_approve}, "
|
||||
f"event_status={event_status}, cost_status={cost_status}"
|
||||
)
|
||||
|
||||
try:
|
||||
# ── Üzleti logika: AssetCost automatikus létrehozása ──
|
||||
cost_id = payload.cost_id
|
||||
auto_created_cost_id = None
|
||||
if payload.cost_amount is not None and payload.cost_amount > 0:
|
||||
# Költségkategória feloldása az esemény típusa alapján
|
||||
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
|
||||
@@ -883,13 +1095,17 @@ async def create_asset_event(
|
||||
|
||||
currency = payload.currency or "HUF"
|
||||
|
||||
# GROSS-FIRST: cost_amount is treated as gross (Bruttó)
|
||||
cost_gross = payload.cost_amount
|
||||
cost_record = AssetCost(
|
||||
asset_id=asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=cost_category_id,
|
||||
amount_net=payload.cost_amount,
|
||||
amount_gross=cost_gross,
|
||||
amount_net=cost_gross, # Default: net = gross (0% VAT)
|
||||
currency=currency,
|
||||
date=event_date,
|
||||
status=cost_status,
|
||||
data={
|
||||
"description": payload.description or f"Service event: {payload.event_type}",
|
||||
"mileage_at_cost": payload.odometer_reading,
|
||||
@@ -900,13 +1116,15 @@ async def create_asset_event(
|
||||
db.add(cost_record)
|
||||
await db.flush() # Flush to get the cost_record.id
|
||||
cost_id = cost_record.id
|
||||
auto_created_cost_id = cost_record.id
|
||||
|
||||
logger.info(
|
||||
f"AssetCost auto-created for asset {asset_id}: "
|
||||
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
|
||||
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, "
|
||||
f"cost_id={cost_id}, status={cost_status})"
|
||||
)
|
||||
|
||||
# Új esemény létrehozása
|
||||
# Új esemény létrehozása (with status and bidirectional link)
|
||||
event = AssetEvent(
|
||||
asset_id=asset_id,
|
||||
user_id=current_user.id,
|
||||
@@ -915,9 +1133,28 @@ async def create_asset_event(
|
||||
odometer_reading=payload.odometer_reading,
|
||||
description=payload.description,
|
||||
cost_id=cost_id,
|
||||
linked_expense_id=auto_created_cost_id, # Bidirectional link
|
||||
status=event_status,
|
||||
event_date=event_date,
|
||||
)
|
||||
db.add(event)
|
||||
await db.flush() # Flush to get event.id
|
||||
|
||||
# If we auto-created a cost, link it back to the event
|
||||
if auto_created_cost_id:
|
||||
# Update the cost record with the link back to the event
|
||||
stmt_update = (
|
||||
select(AssetCost)
|
||||
.where(AssetCost.id == auto_created_cost_id)
|
||||
)
|
||||
result = await db.execute(stmt_update)
|
||||
cost_record = result.scalar_one_or_none()
|
||||
if cost_record:
|
||||
cost_record.linked_asset_event_id = event.id
|
||||
logger.info(
|
||||
f"Smart Sync: Bidirectional link established between "
|
||||
f"AssetEvent {event.id} and AssetCost {auto_created_cost_id}"
|
||||
)
|
||||
|
||||
# ── Üzleti logika: Km óra állás frissítése ──
|
||||
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:
|
||||
|
||||
@@ -89,11 +89,15 @@ async def get_document_status(
|
||||
|
||||
# RBAC helper function
|
||||
def _check_premium_or_admin(user: User) -> bool:
|
||||
"""Check if user has premium subscription or admin role."""
|
||||
premium_plans = ['PREMIUM', 'PREMIUM_PLUS', 'VIP', 'VIP_PLUS']
|
||||
if user.role == 'admin':
|
||||
return True
|
||||
if hasattr(user, 'subscription_plan') and user.subscription_plan in premium_plans:
|
||||
"""Check if user has premium subscription or admin role.
|
||||
|
||||
P0: Legacy subscription_plan string check removed.
|
||||
The Single Source of Truth is now subscription_tier JSONB rules.
|
||||
Premium entitlement should be checked via the SubscriptionTier entitlements array.
|
||||
"""
|
||||
premium_roles = ['admin', 'superadmin']
|
||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
if user_role.lower() in premium_roles:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, text
|
||||
from sqlalchemy import select, func
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models import Asset # JAVÍTVA: Asset modell
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,10 +15,28 @@ router = APIRouter()
|
||||
|
||||
@router.post("/scan-registration")
|
||||
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
stmt_limit = text("SELECT (value->>:plan)::int FROM system.system_parameters WHERE key = 'VEHICLE_LIMIT'")
|
||||
plan_key = (current_user.subscription_plan or "free").lower()
|
||||
res = await db.execute(stmt_limit, {"plan": plan_key})
|
||||
max_allowed = max(res.scalar() or 1, 1)
|
||||
# ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
|
||||
# Instead of hardcoded plan-based lookup from system_parameters,
|
||||
# we now read the organization's assigned subscription_tier rules.
|
||||
max_allowed = 1 # default fallback
|
||||
|
||||
if current_user.scope_id:
|
||||
# Get the organization and its subscription tier
|
||||
stmt_org = select(Organization).where(Organization.id == current_user.scope_id)
|
||||
result = await db.execute(stmt_org)
|
||||
org = result.scalar_one_or_none()
|
||||
|
||||
if org and org.subscription_tier_id:
|
||||
stmt_tier = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
|
||||
tier_result = await db.execute(stmt_tier)
|
||||
tier = tier_result.scalar_one_or_none()
|
||||
|
||||
if tier and tier.rules:
|
||||
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1)
|
||||
max_allowed = max(int(max_vehicles), 1)
|
||||
elif org:
|
||||
# Fallback to legacy base_asset_limit if no tier assigned
|
||||
max_allowed = max(org.base_asset_limit or 1, 1)
|
||||
|
||||
stmt_count = select(func.count(Asset.id)).where(
|
||||
Asset.owner_org_id == current_user.scope_id,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter
|
||||
from app.api.deps import get_db, get_current_user, RequireOrgCapability
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole
|
||||
from app.schemas.asset_cost import AssetCostCreate
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -14,8 +15,101 @@ logger = logging.getLogger(__name__)
|
||||
# These trigger automatic AssetEvent creation
|
||||
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
|
||||
|
||||
# Fuel category IDs - auto-approved even for DRIVER role
|
||||
FUEL_CATEGORY_IDS = {1} # FUEL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _resolve_user_role_in_org(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
organization_id: int
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the user's role within the given organization.
|
||||
|
||||
Returns:
|
||||
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
|
||||
Returns 'MEMBER' as default if no membership found.
|
||||
"""
|
||||
stmt = select(OrganizationMember.role).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
return role or "MEMBER"
|
||||
|
||||
|
||||
async def _check_org_capability(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
organization_id: int,
|
||||
capability_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
|
||||
|
||||
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
|
||||
permissions JSONB oszlopából ellenőrzi a kért képességet.
|
||||
|
||||
Returns:
|
||||
bool: True ha a user rendelkezik a képességgel.
|
||||
"""
|
||||
# 1. Get user's org role
|
||||
member_stmt = select(OrganizationMember.role).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_result = await db.execute(member_stmt)
|
||||
org_role_name = member_result.scalar_one_or_none()
|
||||
|
||||
if not org_role_name:
|
||||
return False
|
||||
|
||||
# 2. Get permissions from fleet.org_roles table
|
||||
role_stmt = select(OrgRole.permissions).where(
|
||||
OrgRole.name_key == org_role_name,
|
||||
OrgRole.is_active == True
|
||||
).limit(1)
|
||||
role_result = await db.execute(role_stmt)
|
||||
permissions = role_result.scalar_one_or_none()
|
||||
|
||||
if not permissions:
|
||||
# Fallback to OrganizationMember.permissions
|
||||
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_perm_result = await db.execute(member_perm_stmt)
|
||||
permissions = member_perm_result.scalar_one_or_none() or {}
|
||||
|
||||
if not isinstance(permissions, dict):
|
||||
permissions = {}
|
||||
|
||||
return permissions.get(capability_name, False)
|
||||
|
||||
|
||||
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
|
||||
"""
|
||||
Calculate net amount from gross amount and VAT rate.
|
||||
|
||||
GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
|
||||
is the absolute Source of Truth. Net is calculated back from gross.
|
||||
|
||||
Formula: net = gross / (1 + vat_rate / 100)
|
||||
|
||||
If vat_rate is 0 or None, net = gross (0% VAT content).
|
||||
"""
|
||||
if vat_rate is None or vat_rate == 0:
|
||||
return amount_gross
|
||||
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
@router.post("/", status_code=201)
|
||||
async def create_expense(
|
||||
expense: AssetCostCreate,
|
||||
@@ -25,10 +119,15 @@ async def create_expense(
|
||||
"""
|
||||
Create a new expense (fuel, service, tax, insurance) for an asset.
|
||||
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
|
||||
|
||||
**Role-Based Auto-Approval:**
|
||||
- OWNER/ADMIN → status = APPROVED
|
||||
- DRIVER → status = PENDING_APPROVAL (except fuel, which is auto-approved)
|
||||
|
||||
**Bidirectional Sync:**
|
||||
**Smart Linking (Double-Entry Avoidance):**
|
||||
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
|
||||
an AssetEvent is automatically created and linked to the cost via cost_id.
|
||||
an AssetEvent is automatically created with MISSING_TECH_DATA status
|
||||
and linked via linked_asset_event_id / linked_expense_id.
|
||||
"""
|
||||
# Validate asset exists
|
||||
stmt = select(Asset).where(Asset.id == expense.asset_id)
|
||||
@@ -89,6 +188,48 @@ async def create_expense(
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
|
||||
|
||||
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
|
||||
# JSONB-alapú képesség-ellenőrzés a fleet.org_roles tábla permissions oszlopából
|
||||
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
|
||||
|
||||
# Check if user has can_approve_expense capability → auto-approved
|
||||
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
|
||||
|
||||
# Determine expense status based on capabilities
|
||||
if can_approve:
|
||||
# User has can_approve_expense → auto-approved
|
||||
expense_status = "APPROVED"
|
||||
elif expense.category_id in FUEL_CATEGORY_IDS:
|
||||
# Fuel costs are auto-approved even without can_approve_expense
|
||||
expense_status = "APPROVED"
|
||||
else:
|
||||
# User lacks can_approve_expense → pending approval
|
||||
expense_status = "PENDING_APPROVAL"
|
||||
|
||||
logger.info(
|
||||
f"Capability-based approval for user {current_user.id} (role={user_role}) "
|
||||
f"in org {organization_id}: can_approve_expense={can_approve}, status={expense_status}"
|
||||
)
|
||||
|
||||
# ── GROSS-FIRST VAT HANDLING (Masterbook 2.0.1) ──
|
||||
# In EU accounting, the GROSS amount (Bruttó) is the absolute Source of Truth.
|
||||
amount_gross = expense.amount_gross
|
||||
vat_rate = expense.vat_rate
|
||||
amount_net = expense.amount_net
|
||||
|
||||
# Auto-calculate net from gross if vat_rate is provided but net is not
|
||||
if vat_rate is not None and amount_net is None:
|
||||
amount_net = _calculate_net_from_gross(amount_gross, vat_rate)
|
||||
# Auto-calculate vat_rate if net is provided but vat_rate is not
|
||||
elif amount_net is not None and vat_rate is None and amount_gross > 0:
|
||||
# vat_rate = ((gross / net) - 1) * 100
|
||||
vat_rate = ((amount_gross / amount_net) - Decimal("1")) * Decimal("100")
|
||||
vat_rate = vat_rate.quantize(Decimal("0.01"))
|
||||
# If only gross is provided (no net, no vat) → net = gross (0% VAT)
|
||||
elif amount_net is None and vat_rate is None:
|
||||
amount_net = amount_gross
|
||||
vat_rate = Decimal("0")
|
||||
|
||||
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
|
||||
data = expense.data.copy() if expense.data else {}
|
||||
if expense.mileage_at_cost is not None:
|
||||
@@ -97,22 +238,25 @@ async def create_expense(
|
||||
data["description"] = expense.description
|
||||
|
||||
try:
|
||||
# Create AssetCost instance
|
||||
# Create AssetCost instance with new fields
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=expense.category_id,
|
||||
amount_net=expense.amount_net,
|
||||
amount_net=amount_net,
|
||||
amount_gross=amount_gross,
|
||||
vat_rate=vat_rate,
|
||||
currency=expense.currency,
|
||||
date=expense.date,
|
||||
invoice_number=data.get("invoice_number"),
|
||||
status=expense_status,
|
||||
data=data
|
||||
)
|
||||
|
||||
db.add(new_cost)
|
||||
await db.flush() # Flush to get new_cost.id
|
||||
|
||||
# ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ──
|
||||
# ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ──
|
||||
event_id = None
|
||||
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
|
||||
# Map cost category to event type
|
||||
@@ -121,6 +265,10 @@ async def create_expense(
|
||||
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
|
||||
mileage = expense.mileage_at_cost
|
||||
|
||||
# Determine event status based on capability
|
||||
# can_approve_expense → COMPLETED, otherwise → MISSING_TECH_DATA
|
||||
event_status = "COMPLETED" if can_approve else "MISSING_TECH_DATA"
|
||||
|
||||
new_event = AssetEvent(
|
||||
asset_id=expense.asset_id,
|
||||
user_id=getattr(current_user, 'id', None),
|
||||
@@ -129,15 +277,21 @@ async def create_expense(
|
||||
odometer_reading=mileage,
|
||||
description=description,
|
||||
cost_id=new_cost.id,
|
||||
linked_expense_id=new_cost.id, # Bidirectional link
|
||||
status=event_status,
|
||||
event_date=expense.date or datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_event)
|
||||
await db.flush() # Flush to get new_event.id
|
||||
event_id = new_event.id
|
||||
|
||||
# Update the cost record with the link back to the event
|
||||
new_cost.linked_asset_event_id = new_event.id
|
||||
|
||||
logger.info(
|
||||
f"Bidirectional sync: Auto-created AssetEvent {event_id} "
|
||||
f"for AssetCost {new_cost.id} (category_id={expense.category_id})"
|
||||
f"Smart Sync: Auto-created AssetEvent {event_id} (status={event_status}) "
|
||||
f"for AssetCost {new_cost.id} (category_id={expense.category_id}). "
|
||||
f"Bidirectional link established."
|
||||
)
|
||||
|
||||
# Update Asset.current_mileage if mileage_at_cost is higher
|
||||
@@ -152,8 +306,11 @@ async def create_expense(
|
||||
"id": new_cost.id,
|
||||
"asset_id": new_cost.asset_id,
|
||||
"category_id": new_cost.category_id,
|
||||
"amount_net": new_cost.amount_net,
|
||||
"date": new_cost.date,
|
||||
"amount_gross": str(new_cost.amount_gross) if new_cost.amount_gross else None,
|
||||
"amount_net": str(new_cost.amount_net),
|
||||
"vat_rate": str(new_cost.vat_rate) if new_cost.vat_rate else None,
|
||||
"expense_status": new_cost.status,
|
||||
"date": new_cost.date.isoformat() if new_cost.date else None,
|
||||
"event_id": str(event_id) if event_id else None,
|
||||
}
|
||||
|
||||
@@ -179,4 +336,4 @@ def _map_category_to_event_type(category_id: int) -> str:
|
||||
17: "SERVICE", # MAINT_OIL
|
||||
18: "REPAIR", # MAINT_BRAKES
|
||||
}
|
||||
return category_event_map.get(category_id, "MAINTENANCE")
|
||||
return category_event_map.get(category_id, "MAINTENANCE")
|
||||
|
||||
@@ -24,7 +24,7 @@ async def check_finance_admin_access(
|
||||
RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access.
|
||||
In our system, this translates to role being 'superadmin' or 'admin'.
|
||||
"""
|
||||
if current_user.role not in [UserRole.superadmin, UserRole.admin]:
|
||||
if current_user.role not in [UserRole.SUPERADMIN, UserRole.ADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions. Rank >= 90 (Superadmin/Finance Admin) required."
|
||||
|
||||
@@ -14,8 +14,11 @@ from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field, ConfigDict, EmailStr
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, RequireSystemCapability
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||||
from app.schemas.subscription import SubscriptionTierResponse
|
||||
from app.services.billing_engine import upgrade_org_subscription
|
||||
from app.core.capabilities import Capability
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
|
||||
from app.models.identity import User, OneTimePassword
|
||||
from app.core.config import settings
|
||||
@@ -219,7 +222,14 @@ async def get_my_organizations(
|
||||
"subscription_plan": o.subscription_plan,
|
||||
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
|
||||
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
|
||||
"user_role": _get_user_role(o, current_user.id)
|
||||
"user_role": _get_user_role(o, current_user.id),
|
||||
# ── Cím adatok (Address fields) ──
|
||||
"address_zip": o.address_zip,
|
||||
"address_city": o.address_city,
|
||||
"address_street_name": o.address_street_name,
|
||||
"address_street_type": o.address_street_type,
|
||||
"address_house_number": o.address_house_number,
|
||||
"address_hrsz": o.address_hrsz,
|
||||
}
|
||||
for o in orgs
|
||||
]
|
||||
@@ -663,19 +673,7 @@ async def update_organization(
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||
"""
|
||||
# 1. Jogosultság ellenőrzése
|
||||
stmt_member = select(OrganizationMember).where(
|
||||
(OrganizationMember.organization_id == org_id) &
|
||||
(OrganizationMember.user_id == current_user.id) &
|
||||
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
||||
)
|
||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
|
||||
)
|
||||
|
||||
# 2. Szervezet lekérése
|
||||
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
|
||||
stmt_org = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
@@ -688,7 +686,23 @@ async def update_organization(
|
||||
detail=_("ORGANIZATION.ERROR.NOT_FOUND")
|
||||
)
|
||||
|
||||
# 3. JSONB merge visual_settings esetén
|
||||
# Jogosultság: OWNER/ADMIN a OrganizationMember táblában VAGY owner_id a Organization-ben
|
||||
is_owner_by_field = (org.owner_id == current_user.id)
|
||||
|
||||
stmt_member = select(OrganizationMember).where(
|
||||
(OrganizationMember.organization_id == org_id) &
|
||||
(OrganizationMember.user_id == current_user.id) &
|
||||
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
||||
)
|
||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||
|
||||
if not member and not is_owner_by_field:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
|
||||
)
|
||||
|
||||
# 2. JSONB merge visual_settings esetén
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
|
||||
@@ -717,6 +731,63 @@ async def update_organization(
|
||||
return OrganizationResponse.model_validate(org)
|
||||
|
||||
|
||||
# ── P0 FEATURE: SUBSCRIPTION & PACKAGE ASSIGNMENT BRIDGE ──
|
||||
|
||||
class SubscriptionAssignIn(BaseModel):
|
||||
"""PUT body a szervezet előfizetési csomagjának beállításához."""
|
||||
tier_id: int = Field(..., description="SubscriptionTier ID a system.subscription_tiers táblából")
|
||||
|
||||
|
||||
@router.put("/{org_id}/subscription", response_model=dict)
|
||||
async def assign_organization_subscription(
|
||||
org_id: int,
|
||||
body: SubscriptionAssignIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS))
|
||||
):
|
||||
"""
|
||||
Szervezet előfizetési csomagjának beállítása (Subscription & Package Assignment Bridge).
|
||||
|
||||
P0 Feature: This endpoint assigns a subscription_tier to an organization.
|
||||
It requires the 'can_manage_subscriptions' system capability (SUPERADMIN or ADMIN).
|
||||
|
||||
The endpoint:
|
||||
1. Validates the tier exists in system.subscription_tiers
|
||||
2. Sets subscription_tier_id on the Organization record
|
||||
3. Updates subscription_plan and base_asset_limit from tier rules
|
||||
4. Creates/updates a finance.org_subscriptions audit record
|
||||
"""
|
||||
try:
|
||||
result = await upgrade_org_subscription(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
tier_id=body.tier_id,
|
||||
actor_user_id=current_user.id
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await security_service.log_event(
|
||||
db, user_id=current_user.id, action="ORG_SUBSCRIPTION_ASSIGNED",
|
||||
severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
|
||||
new_data={"tier_id": body.tier_id, "tier_name": result.get("tier_name")}
|
||||
)
|
||||
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Subscription assignment failed: org_id={org_id}, tier_id={body.tier_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=_("ORGANIZATION.ERROR.DATABASE_ERROR", error=str(e))
|
||||
)
|
||||
|
||||
|
||||
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
|
||||
|
||||
class JoinRequestIn(BaseModel):
|
||||
|
||||
@@ -36,7 +36,7 @@ async def request_action(
|
||||
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
|
||||
"""
|
||||
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
|
||||
@@ -69,7 +69,7 @@ async def list_pending_actions(
|
||||
Admin és superadmin látja az összes függőben lévő műveletet.
|
||||
Egyéb felhasználók csak a sajátjaikat láthatják.
|
||||
"""
|
||||
if current_user.role in [UserRole.admin, UserRole.superadmin]:
|
||||
if current_user.role in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||
user_id = None
|
||||
else:
|
||||
user_id = current_user.id
|
||||
@@ -89,7 +89,7 @@ async def approve_action(
|
||||
|
||||
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
|
||||
"""
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
|
||||
@@ -122,7 +122,7 @@ async def reject_action(
|
||||
|
||||
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
|
||||
"""
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
|
||||
@@ -164,7 +164,7 @@ async def get_action(
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Művelet nem található.")
|
||||
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin] and action.requester_id != current_user.id:
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN] and action.requester_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod ehhez a művelethez."
|
||||
|
||||
@@ -122,7 +122,7 @@ async def update_system_parameter(
|
||||
Csak superadmin vagy admin jogosultságú felhasználók használhatják.
|
||||
"""
|
||||
# Jogosultság ellenőrzése
|
||||
if current_user.role not in (UserRole.superadmin, UserRole.admin):
|
||||
if current_user.role not in (UserRole.SUPERADMIN, UserRole.ADMIN):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Insufficient permissions. Only superadmin or admin can update system parameters."
|
||||
|
||||
@@ -41,13 +41,15 @@ class NetworkResponse(BaseModel):
|
||||
level3: List[NetworkMemberL2L3]
|
||||
|
||||
|
||||
def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dict:
|
||||
def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
|
||||
"""
|
||||
Segédfüggvény a UserResponse dict előállításához.
|
||||
Beágyazza a Person és Address adatokat a 'person' mezőbe.
|
||||
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba.
|
||||
"""
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.marketplace.organization import OrgUserRole
|
||||
from app.core.capabilities import get_capabilities_for_role
|
||||
|
||||
# Determine active organization ID
|
||||
if active_org_id is None:
|
||||
@@ -101,6 +103,17 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
|
||||
first_name = person.first_name if person else ""
|
||||
last_name = person.last_name if person else ""
|
||||
|
||||
# ── RBAC Phase 3: Resolve system capabilities ──
|
||||
role_key = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
system_capabilities = get_capabilities_for_role(role_key)
|
||||
|
||||
# ── RBAC Phase 3: Resolve org capabilities ──
|
||||
org_capabilities: Dict[str, Dict[str, bool]] = {}
|
||||
if db is not None:
|
||||
# We need to run this synchronously since _build_user_response is not async
|
||||
# The async version is handled in read_users_me directly
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
@@ -109,7 +122,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
|
||||
"is_active": user.is_active,
|
||||
"region_code": user.region_code,
|
||||
"person_id": user.person_id,
|
||||
"role": user.role.value if hasattr(user.role, 'value') else str(user.role),
|
||||
"role": role_key,
|
||||
"subscription_plan": user.subscription_plan,
|
||||
"scope_level": user.scope_level or "individual",
|
||||
"scope_id": str(active_org_id) if active_org_id else None,
|
||||
@@ -118,6 +131,10 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
|
||||
"preferred_language": user.preferred_language or "hu",
|
||||
"person": person_data,
|
||||
"is_last_admin": False, # Default, will be overridden in read_users_me
|
||||
# RBAC Phase 3: Rendszerszintű képességek
|
||||
"system_capabilities": system_capabilities,
|
||||
# RBAC Phase 3: Szervezeti képességek (feltöltve a read_users_me végpontban)
|
||||
"org_capabilities": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -126,9 +143,11 @@ async def read_users_me(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal."""
|
||||
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal.
|
||||
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba."""
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.marketplace.organization import OrgUserRole
|
||||
from app.core.capabilities import get_capabilities_for_role
|
||||
|
||||
# Determine active organization ID
|
||||
active_org_id = None
|
||||
@@ -178,8 +197,53 @@ async def read_users_me(
|
||||
|
||||
# Check if user is the last admin in any organization
|
||||
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
|
||||
|
||||
# ── RBAC Phase 3: Build base response ──
|
||||
response_data = _build_user_response(current_user, active_org_id)
|
||||
response_data["is_last_admin"] = is_last_admin
|
||||
|
||||
# ── RBAC Phase 3: Resolve org_capabilities ──
|
||||
# Get all organizations the user is a member of
|
||||
org_memberships_stmt = select(
|
||||
OrganizationMember.organization_id,
|
||||
OrganizationMember.role
|
||||
).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.status == "active"
|
||||
)
|
||||
org_memberships_result = await db.execute(org_memberships_stmt)
|
||||
org_memberships = org_memberships_result.all()
|
||||
|
||||
org_capabilities: Dict[str, Dict[str, bool]] = {}
|
||||
for membership in org_memberships:
|
||||
org_id = str(membership.organization_id)
|
||||
org_role_name = membership.role
|
||||
|
||||
# Try to get permissions from OrgRole table first
|
||||
role_stmt = select(OrgRole.permissions).where(
|
||||
OrgRole.name_key == org_role_name,
|
||||
OrgRole.is_active == True
|
||||
).limit(1)
|
||||
role_result = await db.execute(role_stmt)
|
||||
permissions = role_result.scalar_one_or_none()
|
||||
|
||||
if not permissions:
|
||||
# Fallback: use OrganizationMember.permissions
|
||||
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == membership.organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_perm_result = await db.execute(member_perm_stmt)
|
||||
permissions = member_perm_result.scalar_one_or_none() or {}
|
||||
|
||||
if not isinstance(permissions, dict):
|
||||
permissions = {}
|
||||
|
||||
org_capabilities[org_id] = permissions
|
||||
|
||||
response_data["org_capabilities"] = org_capabilities
|
||||
|
||||
return UserResponse.model_validate(response_data)
|
||||
|
||||
|
||||
|
||||
295
backend/app/core/capabilities.py
Normal file
295
backend/app/core/capabilities.py
Normal file
@@ -0,0 +1,295 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/core/capabilities.py
|
||||
"""
|
||||
🎯 SYSTEM CAPABILITIES MATRIX (RBAC Phase 1)
|
||||
|
||||
Fine-grained, capability-driven permission matrix for system-level roles.
|
||||
Each role maps to a set of boolean capabilities that define what the role
|
||||
can do within the platform.
|
||||
|
||||
Architecture:
|
||||
- SUPERADMIN: Full system access (rank 100)
|
||||
- ADMIN: Full admin panel access (rank 90)
|
||||
- MODERATOR: Content moderation capabilities (rank 75)
|
||||
- SALES_REP: Sales & discount management (rank 30)
|
||||
- SERVICE_MGR: Service provider & taxonomy management (rank 40)
|
||||
- USER: Basic platform user (rank 10)
|
||||
"""
|
||||
|
||||
from typing import Dict, Set
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# CAPABILITY KEYS (for type-safe references)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class Capability:
|
||||
"""Central registry of all capability keys."""
|
||||
|
||||
# ── Admin & System ──
|
||||
CAN_MANAGE_USERS = "can_manage_users"
|
||||
CAN_MANAGE_ROLES = "can_manage_roles"
|
||||
CAN_VIEW_SYSTEM_LOGS = "can_view_system_logs"
|
||||
CAN_MANAGE_SYSTEM_CONFIG = "can_manage_system_config"
|
||||
CAN_MANAGE_TRANSLATIONS = "can_manage_translations"
|
||||
|
||||
# ── Moderation ──
|
||||
CAN_MODERATE_CONTENT = "can_moderate_content"
|
||||
CAN_VIEW_REPORTS = "can_view_reports"
|
||||
CAN_SUSPEND_USERS = "can_suspend_users"
|
||||
CAN_APPROVE_PROVIDER = "can_approve_provider"
|
||||
|
||||
# ── Sales & Marketing ──
|
||||
CAN_VIEW_ORG_STATS = "can_view_org_stats"
|
||||
CAN_MANAGE_DISCOUNTS = "can_manage_discounts"
|
||||
CAN_MANAGE_PROMOTIONS = "can_manage_promotions"
|
||||
CAN_VIEW_SALES_PIPELINE = "can_view_sales_pipeline"
|
||||
|
||||
# ── Service Management ──
|
||||
CAN_EDIT_MASTER_TAXONOMY = "can_edit_master_taxonomy"
|
||||
CAN_MANAGE_SERVICE_CATEGORIES = "can_manage_service_categories"
|
||||
CAN_VERIFY_SERVICE_PROVIDERS = "can_verify_service_providers"
|
||||
CAN_MANAGE_EXPERTISE_TAGS = "can_manage_expertise_tags"
|
||||
|
||||
# ── Vehicle & Catalog ──
|
||||
CAN_MANAGE_VEHICLE_CATALOG = "can_manage_vehicle_catalog"
|
||||
CAN_APPROVE_VEHICLE_DEFINITIONS = "can_approve_vehicle_definitions"
|
||||
CAN_VIEW_TECHNICAL_SPECS = "can_view_technical_specs"
|
||||
|
||||
# ── Finance ──
|
||||
CAN_VIEW_FINANCIAL_REPORTS = "can_view_financial_reports"
|
||||
CAN_MANAGE_BILLING = "can_manage_billing"
|
||||
CAN_ISSUE_REFUNDS = "can_issue_refunds"
|
||||
CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"
|
||||
|
||||
# ── Basic User ──
|
||||
CAN_MANAGE_OWN_PROFILE = "can_manage_own_profile"
|
||||
CAN_MANAGE_OWN_VEHICLES = "can_manage_own_vehicles"
|
||||
CAN_CREATE_SERVICE_REQUEST = "can_create_service_request"
|
||||
CAN_VIEW_PUBLIC_CATALOG = "can_view_public_catalog"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# SYSTEM CAPABILITIES MATRIX
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
SYSTEM_CAPABILITIES_MATRIX: Dict[str, Dict[str, bool]] = {
|
||||
# ── SUPERADMIN: Full system access ──
|
||||
"SUPERADMIN": {
|
||||
# Admin & System
|
||||
Capability.CAN_MANAGE_USERS: True,
|
||||
Capability.CAN_MANAGE_ROLES: True,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: True,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: True,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||
# Moderation
|
||||
Capability.CAN_MODERATE_CONTENT: True,
|
||||
Capability.CAN_VIEW_REPORTS: True,
|
||||
Capability.CAN_SUSPEND_USERS: True,
|
||||
Capability.CAN_APPROVE_PROVIDER: True,
|
||||
# Sales
|
||||
Capability.CAN_VIEW_ORG_STATS: True,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||
# Service Management
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||
# Vehicle & Catalog
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||
# Finance
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
|
||||
Capability.CAN_MANAGE_BILLING: True,
|
||||
Capability.CAN_ISSUE_REFUNDS: True,
|
||||
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
|
||||
# Basic User
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── ADMIN: Full admin panel access ──
|
||||
"ADMIN": {
|
||||
Capability.CAN_MANAGE_USERS: True,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: True,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||
Capability.CAN_MODERATE_CONTENT: True,
|
||||
Capability.CAN_VIEW_REPORTS: True,
|
||||
Capability.CAN_SUSPEND_USERS: True,
|
||||
Capability.CAN_APPROVE_PROVIDER: True,
|
||||
Capability.CAN_VIEW_ORG_STATS: True,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── MODERATOR: Content moderation ──
|
||||
"MODERATOR": {
|
||||
Capability.CAN_MANAGE_USERS: False,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||
Capability.CAN_MODERATE_CONTENT: True,
|
||||
Capability.CAN_VIEW_REPORTS: True,
|
||||
Capability.CAN_SUSPEND_USERS: False,
|
||||
Capability.CAN_APPROVE_PROVIDER: True,
|
||||
Capability.CAN_VIEW_ORG_STATS: False,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── SALES_REP: Sales & discount management ──
|
||||
"SALES_REP": {
|
||||
Capability.CAN_MANAGE_USERS: False,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||
Capability.CAN_MODERATE_CONTENT: False,
|
||||
Capability.CAN_VIEW_REPORTS: False,
|
||||
Capability.CAN_SUSPEND_USERS: False,
|
||||
Capability.CAN_APPROVE_PROVIDER: False,
|
||||
Capability.CAN_VIEW_ORG_STATS: True,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── SERVICE_MGR: Service provider & taxonomy management ──
|
||||
"SERVICE_MGR": {
|
||||
Capability.CAN_MANAGE_USERS: False,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||
Capability.CAN_MODERATE_CONTENT: False,
|
||||
Capability.CAN_VIEW_REPORTS: False,
|
||||
Capability.CAN_SUSPEND_USERS: False,
|
||||
Capability.CAN_APPROVE_PROVIDER: True,
|
||||
Capability.CAN_VIEW_ORG_STATS: False,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── USER: Basic platform user ──
|
||||
"USER": {
|
||||
Capability.CAN_MANAGE_USERS: False,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||
Capability.CAN_MODERATE_CONTENT: False,
|
||||
Capability.CAN_VIEW_REPORTS: False,
|
||||
Capability.CAN_SUSPEND_USERS: False,
|
||||
Capability.CAN_APPROVE_PROVIDER: False,
|
||||
Capability.CAN_VIEW_ORG_STATS: False,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_capabilities_for_role(role_key: str) -> Dict[str, bool]:
|
||||
"""Return the capability dict for a given role key.
|
||||
|
||||
Args:
|
||||
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN', 'USER')
|
||||
|
||||
Returns:
|
||||
Dict of capability -> bool, or empty dict if role not found.
|
||||
"""
|
||||
return SYSTEM_CAPABILITIES_MATRIX.get(role_key.upper(), {})
|
||||
|
||||
|
||||
def role_has_capability(role_key: str, capability: str) -> bool:
|
||||
"""Check if a role has a specific capability.
|
||||
|
||||
Args:
|
||||
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN')
|
||||
capability: The capability key (e.g. 'can_manage_users')
|
||||
|
||||
Returns:
|
||||
True if the role has the capability, False otherwise.
|
||||
"""
|
||||
caps = get_capabilities_for_role(role_key)
|
||||
return caps.get(capability, False)
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
|
||||
from fastapi import HTTPException, Depends, status
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models.identity import User, UserRole
|
||||
from app.core.config import settings
|
||||
|
||||
class RBAC:
|
||||
@@ -11,11 +11,11 @@ class RBAC:
|
||||
|
||||
async def __call__(self, current_user: User = Depends(get_current_user)):
|
||||
# 1. Superadmin mindent visz (Rank 100)
|
||||
if current_user.role == "superadmin":
|
||||
if current_user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
|
||||
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
|
||||
role_key = current_user.role.value.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
|
||||
role_key = current_user.role.value # A DEFAULT_RANK_MAP már nagybetűs kulcsokat használ
|
||||
user_rank = settings.DEFAULT_RANK_MAP.get(role_key, 0)
|
||||
if user_rank < self.min_rank:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -51,23 +51,14 @@ def generate_secure_slug(length: int = 16) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
# Teljesen a margón van, így globális konstans lesz!
|
||||
# Lefedi a UserRole enum MIND a 10 értékét
|
||||
# RBAC Phase 1: Tisztított rangtábla a rendszerszintű szerepkörökhöz.
|
||||
# A szervezeti szerepkörök (OrgUserRole) külön kezelendők.
|
||||
DEFAULT_RANK_MAP = {
|
||||
"SUPERADMIN": 100,
|
||||
"ADMIN": 90,
|
||||
"REGION_ADMIN": 85,
|
||||
"COUNTRY_ADMIN": 80,
|
||||
"MODERATOR": 75,
|
||||
"AUDITOR": 70,
|
||||
"ORGANIZATION_OWNER": 65,
|
||||
"ORGANIZATION_MANAGER": 60,
|
||||
"ORGANIZATION_MEMBER": 50,
|
||||
"SERVICE_PROVIDER": 40,
|
||||
"SALES_AGENT": 30,
|
||||
"FLEET_MANAGER": 25,
|
||||
"PREMIUM_USER": 20,
|
||||
"SERVICE_MGR": 40,
|
||||
"SALES_REP": 30,
|
||||
"USER": 10,
|
||||
"DRIVER": 5,
|
||||
"GUEST": 0
|
||||
}
|
||||
@@ -22,16 +22,17 @@ if TYPE_CHECKING:
|
||||
from ..marketplace.service_request import ServiceRequest
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
superadmin = "superadmin"
|
||||
admin = "admin"
|
||||
region_admin = "region_admin"
|
||||
country_admin = "country_admin"
|
||||
moderator = "moderator"
|
||||
sales_agent = "sales_agent"
|
||||
user = "user"
|
||||
service_owner = "service_owner"
|
||||
fleet_manager = "fleet_manager"
|
||||
driver = "driver"
|
||||
"""Rendszerszintű (System-level) szerepkörök.
|
||||
|
||||
Csak a globális platform szerepkörök maradnak itt.
|
||||
Szervezeti szerepkörök: lásd OrgUserRole a marketplace/organization.py-ban.
|
||||
"""
|
||||
SUPERADMIN = "SUPERADMIN"
|
||||
ADMIN = "ADMIN"
|
||||
MODERATOR = "MODERATOR"
|
||||
SALES_REP = "SALES_REP"
|
||||
SERVICE_MGR = "SERVICE_MGR"
|
||||
USER = "USER"
|
||||
|
||||
class Person(Base):
|
||||
"""
|
||||
@@ -133,7 +134,7 @@ class User(Base):
|
||||
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
PG_ENUM(UserRole, name="userrole", schema="identity"),
|
||||
default=UserRole.user
|
||||
default=UserRole.USER
|
||||
)
|
||||
|
||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
|
||||
@@ -25,17 +25,25 @@ class OrgType(str, enum.Enum):
|
||||
business = "business"
|
||||
|
||||
class OrgUserRole(str, enum.Enum):
|
||||
"""Szervezeti (Organization-level) szerepkörök.
|
||||
|
||||
Ezek a szerepkörök a szervezeten/flottán belüli jogosultságokat határozzák meg.
|
||||
Minden szerepkörhöz egyedi JSON permissions objektum tartozik a fleet.org_roles táblában.
|
||||
"""
|
||||
OWNER = "OWNER"
|
||||
ADMIN = "ADMIN"
|
||||
MANAGER = "MANAGER"
|
||||
MEMBER = "MEMBER"
|
||||
AGENT = "AGENT"
|
||||
ACCOUNTANT = "ACCOUNTANT"
|
||||
DRIVER = "DRIVER"
|
||||
VIEWER = "VIEWER"
|
||||
|
||||
class OrgRole(Base):
|
||||
"""
|
||||
Dinamikus szerepkör modell (RBAC).
|
||||
Dinamikus szerepkör modell (RBAC Phase 2).
|
||||
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
|
||||
Alapértelmezett szerepkörök: OWNER, ADMIN, MANAGER, MEMBER, AGENT.
|
||||
Alapértelmezett szerepkörök: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER.
|
||||
|
||||
permissions: JSONB oszlop a capability-alapú jogosultságok tárolására.
|
||||
Példa: {"can_add_expense": True, "can_approve_expense": False}
|
||||
"""
|
||||
__tablename__ = "org_roles"
|
||||
__table_args__ = {"schema": "fleet"}
|
||||
@@ -48,6 +56,16 @@ class OrgRole(Base):
|
||||
priority: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# ── RBAC Phase 2: JSONB permissions column ──
|
||||
# Stores capability flags for this role, e.g.:
|
||||
# {"can_add_expense": True, "can_approve_expense": True, "can_view_financials": True}
|
||||
permissions: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
server_default=text("'{}'::jsonb")
|
||||
)
|
||||
|
||||
class Organization(Base):
|
||||
"""
|
||||
@@ -119,9 +137,18 @@ class Organization(Base):
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# ── 📦 ELŐFIZETÉS / SUBSCRIPTION ──
|
||||
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
|
||||
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
||||
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
|
||||
# P0 FEATURE: Subscription Tier FK — connects this org to a system.subscription_tiers record
|
||||
subscription_tier_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("system.subscription_tiers.id"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
||||
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
@@ -215,9 +242,12 @@ class OrganizationMember(Base):
|
||||
# Meghívó lejárati ideje
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Dinamikus szerepkör (String, nem Enum) - az OrgRole táblából validáljuk
|
||||
# Dinamikus szerepkör - PostgreSQL ENUM típus (fleet.orguserrole)
|
||||
# A PG_ENUM használata biztosítja, hogy az SQLAlchemy megfelelő típuskényszerítést
|
||||
# (type cast) alkalmazzon a lekérdezéseknél, elkerülve az
|
||||
# "operator does not exist: fleet.orguserrole = character varying" hibát.
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False),
|
||||
default="MEMBER",
|
||||
server_default=text("'MEMBER'")
|
||||
)
|
||||
|
||||
@@ -247,6 +247,13 @@ class AssetFinancials(Base):
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
|
||||
|
||||
|
||||
class AssetCostStatusEnum(str, enum.Enum):
|
||||
"""Költség státuszok a jóváhagyási munkafolyamathoz."""
|
||||
DRAFT = "DRAFT"
|
||||
PENDING_APPROVAL = "PENDING_APPROVAL"
|
||||
APPROVED = "APPROVED"
|
||||
|
||||
|
||||
class AssetCost(Base):
|
||||
""" II. Üzemeltetés és TCO kimutatás. """
|
||||
__tablename__ = "asset_costs"
|
||||
@@ -257,15 +264,35 @@ class AssetCost(Base):
|
||||
|
||||
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
|
||||
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
||||
amount_gross: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
vat_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
|
||||
|
||||
# Státusz a jóváhagyási munkafolyamathoz
|
||||
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
|
||||
|
||||
# Kétirányú hivatkozás az AssetEvent felé (kettős könyvelés elkerülése)
|
||||
linked_asset_event_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PG_UUID(as_uuid=True),
|
||||
ForeignKey("vehicle.asset_events.id"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
|
||||
organization: Mapped["Organization"] = relationship("Organization")
|
||||
category: Mapped["CostCategory"] = relationship("CostCategory")
|
||||
|
||||
# Kapcsolat a hivatkozott eseményhez
|
||||
linked_event: Mapped[Optional["AssetEvent"]] = relationship(
|
||||
"AssetEvent",
|
||||
foreign_keys=[linked_asset_event_id],
|
||||
post_update=True
|
||||
)
|
||||
|
||||
|
||||
class VehicleLogbook(Base):
|
||||
@@ -396,6 +423,13 @@ class AssetEventTypeEnum(str, enum.Enum):
|
||||
RECALL = "RECALL" # Visszahívás
|
||||
|
||||
|
||||
class AssetEventStatusEnum(str, enum.Enum):
|
||||
"""Esemény státuszok a feldolgozási munkafolyamathoz."""
|
||||
DRAFT = "DRAFT"
|
||||
MISSING_TECH_DATA = "MISSING_TECH_DATA"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
|
||||
class AssetEvent(Base):
|
||||
""" Digitális Szervizkönyv - Szerviz, baleset és egyéb jelentős események. """
|
||||
__tablename__ = "asset_events"
|
||||
@@ -411,6 +445,17 @@ class AssetEvent(Base):
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
|
||||
|
||||
# Státusz a feldolgozási munkafolyamathoz
|
||||
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
|
||||
|
||||
# Kétirányú hivatkozás az AssetCost felé (kettős könyvelés elkerülése)
|
||||
linked_expense_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PG_UUID(as_uuid=True),
|
||||
ForeignKey("vehicle.asset_costs.id"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
event_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
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())
|
||||
@@ -419,7 +464,15 @@ class AssetEvent(Base):
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
organization: Mapped[Optional["Organization"]] = relationship("Organization")
|
||||
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost")
|
||||
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id])
|
||||
|
||||
# Kapcsolat a hivatkozott költséghez
|
||||
linked_expense: Mapped[Optional["AssetCost"]] = relationship(
|
||||
"AssetCost",
|
||||
foreign_keys=[linked_expense_id],
|
||||
post_update=True,
|
||||
primaryjoin="AssetEvent.linked_expense_id == AssetCost.id"
|
||||
)
|
||||
|
||||
|
||||
class ExchangeRate(Base):
|
||||
|
||||
@@ -20,7 +20,7 @@ router = APIRouter()
|
||||
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
|
||||
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
|
||||
""" Csak Admin vagy Superadmin léphet be a Sentinel központba. """
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Sentinel jogosultság szükséges a művelethez!"
|
||||
|
||||
@@ -386,6 +386,8 @@ class AssetEventCreate(BaseModel):
|
||||
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
|
||||
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
|
||||
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
|
||||
status: Optional[str] = Field(None, description="Event status (DRAFT, MISSING_TECH_DATA, COMPLETED) - auto-set by backend")
|
||||
linked_expense_id: Optional[UUID] = Field(None, description="Associated AssetCost ID (bidirectional link)")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -402,6 +404,8 @@ class AssetEventResponse(BaseModel):
|
||||
cost_id: Optional[UUID] = None
|
||||
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
|
||||
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
|
||||
status: str = "DRAFT"
|
||||
linked_expense_id: Optional[UUID] = None
|
||||
event_date: datetime
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
@@ -410,7 +414,10 @@ class AssetEventResponse(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def model_validate(cls, obj, **kwargs):
|
||||
"""Override to resolve cost_amount and currency from the related AssetCost."""
|
||||
"""Override to resolve cost_amount and currency from the related AssetCost.
|
||||
|
||||
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
|
||||
"""
|
||||
data = {}
|
||||
if hasattr(obj, '__dict__'):
|
||||
# Copy ORM attributes
|
||||
@@ -422,7 +429,10 @@ class AssetEventResponse(BaseModel):
|
||||
cost = getattr(obj, 'cost', None)
|
||||
if cost is not None:
|
||||
if data.get('cost_amount') is None:
|
||||
data['cost_amount'] = float(cost.amount_net) if cost.amount_net is not None else None
|
||||
# GROSS-FIRST: prefer amount_gross as the source of truth
|
||||
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
|
||||
float(cost.amount_net) if cost.amount_net is not None else None
|
||||
)
|
||||
if data.get('currency') is None:
|
||||
data['currency'] = cost.currency
|
||||
return cls(**data)
|
||||
@@ -1,13 +1,38 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
class AssetCostBase(BaseModel):
|
||||
"""Base schema — matches the AssetCost SQLAlchemy model fields."""
|
||||
amount_net: Decimal
|
||||
"""Base schema — matches the AssetCost SQLAlchemy model fields.
|
||||
|
||||
GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary/required field.
|
||||
In EU accounting, the gross amount (Bruttó) is the absolute Source of Truth.
|
||||
amount_net is optional — if only gross is provided, net = gross (0% VAT).
|
||||
If vat_rate is also provided, net is calculated back from gross.
|
||||
"""
|
||||
amount_gross: Optional[Decimal] = Field(None, description="Bruttó összeg (ÁFA-val) — ELSŐDLEGES forrás, de lehet NULL legacy rekordoknál")
|
||||
amount_net: Optional[Decimal] = Field(None, description="Nettó összeg (ÁFA nélkül) — OPCIONÁLIS, ha nincs megadva, bruttóból számolva")
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def safety_net_gross_from_net(cls, data: Any) -> Any:
|
||||
"""SAFETY NET (Graceful Degradation): If the incoming payload contains
|
||||
amount_net but amount_gross is missing/None, automatically copy amount_net
|
||||
to amount_gross. This protects against legacy/cached browsers that still
|
||||
send amount_net in their payloads.
|
||||
|
||||
GROSS-FIRST (Masterbook 2.0.1): The backend is strictly Gross-first.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
has_net = data.get('amount_net') is not None
|
||||
has_gross = data.get('amount_gross') is not None
|
||||
if has_net and not has_gross:
|
||||
data['amount_gross'] = data['amount_net']
|
||||
return data
|
||||
vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)")
|
||||
currency: str = "HUF"
|
||||
date: datetime = Field(default_factory=datetime.now)
|
||||
invoice_number: Optional[str] = None
|
||||
@@ -16,15 +41,45 @@ class AssetCostBase(BaseModel):
|
||||
mileage_at_cost: Optional[int] = None # Stored inside data JSONB
|
||||
description: Optional[str] = None # Stored inside data JSONB
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_gross_first(self):
|
||||
"""Gross-First validation: ensure amount_gross is the primary source.
|
||||
|
||||
If amount_net is not provided but vat_rate is, calculate net from gross.
|
||||
If neither amount_net nor vat_rate is provided, net = gross (0% VAT).
|
||||
If amount_gross is None (legacy data), default to 0 to prevent 500 errors.
|
||||
"""
|
||||
gross = self.amount_gross
|
||||
net = self.amount_net
|
||||
vat = self.vat_rate
|
||||
|
||||
# Fault tolerance: if gross is None (legacy NULL), default to 0
|
||||
if gross is None:
|
||||
self.amount_gross = Decimal("0")
|
||||
gross = self.amount_gross
|
||||
|
||||
if net is None and vat is not None and vat > 0:
|
||||
# Net will be calculated in the service layer from gross & vat
|
||||
pass
|
||||
elif net is None:
|
||||
# No net and no vat → net = gross (0% VAT content)
|
||||
self.amount_net = gross
|
||||
self.vat_rate = Decimal("0")
|
||||
return self
|
||||
|
||||
class AssetCostCreate(AssetCostBase):
|
||||
asset_id: UUID
|
||||
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
|
||||
status: Optional[str] = Field(None, description="Költség státusz (DRAFT, PENDING_APPROVAL, APPROVED) - auto-set by backend")
|
||||
linked_asset_event_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetEvent ID (kétirányú hivatkozáshoz)")
|
||||
|
||||
class AssetCostResponse(AssetCostBase):
|
||||
"""Response schema — matches DB columns from vehicle.asset_costs."""
|
||||
id: UUID
|
||||
asset_id: UUID
|
||||
organization_id: int
|
||||
status: str = "DRAFT"
|
||||
linked_asset_event_id: Optional[UUID] = None
|
||||
|
||||
# Derived / enriched fields (not in DB model directly)
|
||||
category_name: Optional[str] = None # Resolved from CostCategory relationship
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset_event.py
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
@@ -17,6 +17,12 @@ class AssetEventTypeEnum(str, Enum):
|
||||
RECALL = "RECALL" # Visszahívás
|
||||
OTHER = "OTHER" # Egyéb
|
||||
|
||||
class AssetEventStatusEnum(str, Enum):
|
||||
"""Esemény státuszok a feldolgozási munkafolyamathoz."""
|
||||
DRAFT = "DRAFT"
|
||||
MISSING_TECH_DATA = "MISSING_TECH_DATA"
|
||||
COMPLETED = "COMPLETED"
|
||||
|
||||
class AssetEventCreate(BaseModel):
|
||||
"""Digitális Szervizkönyv esemény létrehozásához szükséges adatok."""
|
||||
event_type: AssetEventTypeEnum = Field(..., description="Esemény típusa")
|
||||
@@ -24,9 +30,10 @@ class AssetEventCreate(BaseModel):
|
||||
description: str = Field(..., min_length=1, max_length=1000, description="Esemény leírása")
|
||||
event_date: Optional[datetime] = Field(None, description="Esemény dátuma (alapértelmezett: most)")
|
||||
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID (opcionális)")
|
||||
|
||||
# RBAC ellenőrzés: csak a tulajdonos vagy operátor szervezet adhat hozzá eseményt
|
||||
# Ezt a service rétegben validáljuk, nem a sémában
|
||||
cost_amount: Optional[float] = Field(None, gt=0, description="Költség összege. Ha > 0, automatikusan létrejön egy AssetCost rekord.")
|
||||
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Valutakód (pl. HUF, EUR)")
|
||||
status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz (auto-set by backend)")
|
||||
linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID (kétirányú hivatkozáshoz)")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -41,6 +48,12 @@ class AssetEventResponse(BaseModel):
|
||||
odometer_reading: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
cost_id: Optional[UUID] = None
|
||||
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
|
||||
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
|
||||
|
||||
# Új mezők
|
||||
status: str = "DRAFT"
|
||||
linked_expense_id: Optional[UUID] = None
|
||||
|
||||
event_date: datetime
|
||||
created_at: datetime
|
||||
@@ -52,6 +65,31 @@ class AssetEventResponse(BaseModel):
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def model_validate(cls, obj, **kwargs):
|
||||
"""Override to resolve cost_amount and currency from the related AssetCost.
|
||||
|
||||
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
|
||||
"""
|
||||
data = {}
|
||||
if hasattr(obj, '__dict__'):
|
||||
# Copy ORM attributes
|
||||
for field in cls.model_fields:
|
||||
if hasattr(obj, field):
|
||||
data[field] = getattr(obj, field)
|
||||
|
||||
# Resolve cost_amount and currency from the cost relationship
|
||||
cost = getattr(obj, 'cost', None)
|
||||
if cost is not None:
|
||||
if data.get('cost_amount') is None:
|
||||
# GROSS-FIRST: prefer amount_gross as the source of truth
|
||||
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
|
||||
float(cost.amount_net) if cost.amount_net is not None else None
|
||||
)
|
||||
if data.get('currency') is None:
|
||||
data['currency'] = cost.currency
|
||||
return cls(**data)
|
||||
|
||||
class AssetEventUpdate(BaseModel):
|
||||
"""Digitális Szervizkönyv esemény frissítéséhez szükséges adatok."""
|
||||
event_type: Optional[AssetEventTypeEnum] = Field(None, description="Esemény típusa")
|
||||
@@ -59,8 +97,11 @@ class AssetEventUpdate(BaseModel):
|
||||
description: Optional[str] = Field(None, min_length=1, max_length=1000, description="Esemény leírása")
|
||||
event_date: Optional[datetime] = Field(None, description="Esemény dátuma")
|
||||
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID")
|
||||
status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz")
|
||||
linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID")
|
||||
|
||||
@validator('description')
|
||||
@field_validator('description')
|
||||
@classmethod
|
||||
def validate_description(cls, v):
|
||||
if v is not None and len(v.strip()) == 0:
|
||||
raise ValueError('Description cannot be empty or whitespace only')
|
||||
|
||||
@@ -56,13 +56,12 @@ class OrganizationUpdate(BaseModel):
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# ── Crowdsourced search fields ──
|
||||
aliases: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
# ── Subscription ──
|
||||
subscription_tier_id: Optional[int] = None
|
||||
|
||||
|
||||
class OrganizationResponse(BaseModel):
|
||||
@@ -79,10 +78,18 @@ class OrganizationResponse(BaseModel):
|
||||
is_active: bool = True
|
||||
is_deleted: bool = False
|
||||
subscription_plan: str = "FREE"
|
||||
subscription_tier_id: Optional[int] = None
|
||||
visual_settings: Optional[dict] = None
|
||||
notification_settings: Optional[Any] = None
|
||||
external_integration_config: Optional[Any] = None
|
||||
aliases: List[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
# ── Cím adatok (Address fields) ──
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
|
||||
import uuid
|
||||
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
|
||||
from typing import Optional, Any
|
||||
from typing import Optional, Any, Dict
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ class UserResponse(UserBase):
|
||||
person: Optional[PersonResponse] = None
|
||||
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
|
||||
is_last_admin: bool = False
|
||||
# RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
|
||||
system_capabilities: Dict[str, bool] = {}
|
||||
# RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
|
||||
org_capabilities: Dict[str, Dict[str, bool]] = {}
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
|
||||
306
backend/app/scripts/migrate_legacy_garages.py
Normal file
306
backend/app/scripts/migrate_legacy_garages.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
P0 CRITICAL - The Great Garage & Vehicle Migration (Legacy Data Cleanup)
|
||||
|
||||
Migrates legacy person_id-based ownership to the new organization_id / organization_members
|
||||
RBAC structure. Ensures every user has a personal garage (INDIVIDUAL org), is bound as OWNER
|
||||
in organization_members, and orphaned vehicles are rescued into their owner's garage.
|
||||
|
||||
TASKS:
|
||||
A) Ensure Personal Garages exist for every user who lacks one
|
||||
B) Bind users to their garages via organization_members (OWNER role) + update scope_id
|
||||
C) Rescue orphaned vehicles (owner_person_id set but no current_organization_id/owner_org_id)
|
||||
|
||||
Usage:
|
||||
docker exec sf_api python3 /app/app/scripts/migrate_legacy_garages.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# asyncpg needs plain "postgresql://" scheme, not "postgresql+asyncpg://"
|
||||
_raw_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
|
||||
)
|
||||
DATABASE_URL = _raw_url.replace("+asyncpg", "")
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🔧 P0 MIGRATION: THE GREAT GARAGE & VEHICLE MIGRATION")
|
||||
print("=" * 70)
|
||||
print(f"Started at: {datetime.now(timezone.utc).isoformat()}")
|
||||
print()
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
try:
|
||||
# ── TASK A: ENSURE PERSONAL GARAGES EXIST ──
|
||||
print("📋 TASK A: Ensure Personal Garages Exist")
|
||||
print("-" * 50)
|
||||
|
||||
# Find all users who do NOT have an INDIVIDUAL-type organization where they are owner
|
||||
users_without_garage = await conn.fetch("""
|
||||
SELECT u.id AS user_id,
|
||||
u.email,
|
||||
p.id AS person_id,
|
||||
p.first_name,
|
||||
p.last_name
|
||||
FROM identity.users u
|
||||
JOIN identity.persons p ON p.id = u.person_id
|
||||
WHERE u.is_deleted = false
|
||||
AND u.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM fleet.organizations o
|
||||
WHERE o.owner_id = u.id
|
||||
AND o.org_type = 'individual'
|
||||
AND o.is_deleted = false
|
||||
)
|
||||
ORDER BY u.id
|
||||
""")
|
||||
|
||||
print(f" Found {len(users_without_garage)} users without a personal garage.")
|
||||
garages_created = 0
|
||||
|
||||
for row in users_without_garage:
|
||||
user_id = row['user_id']
|
||||
person_id = row['person_id']
|
||||
first_name = row['first_name'] or "Felhasználó"
|
||||
last_name = row['last_name'] or ""
|
||||
|
||||
garage_name = f"{first_name} Garázsa"
|
||||
folder_slug = f"personal-{user_id}-{person_id}"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Direct INSERT with all NOT NULL columns explicitly provided
|
||||
await conn.execute("""
|
||||
INSERT INTO fleet.organizations
|
||||
(full_name, name, display_name, folder_slug, org_type,
|
||||
owner_id, status, is_active, is_deleted,
|
||||
subscription_plan, base_asset_limit, purchased_extra_slots,
|
||||
notification_settings, external_integration_config,
|
||||
is_ownership_transferable, is_verified,
|
||||
first_registered_at, current_lifecycle_started_at,
|
||||
lifecycle_index, created_at,
|
||||
visual_settings, aliases, tags, settings)
|
||||
VALUES
|
||||
($1, $2, $3, $4, 'individual',
|
||||
$5, 'active', true, false,
|
||||
'FREE', 5, 0,
|
||||
$6::json, '{}'::json,
|
||||
true, false,
|
||||
$7, $8,
|
||||
1, $9,
|
||||
$10::jsonb, '[]'::jsonb, '[]'::jsonb, $11::jsonb)
|
||||
""",
|
||||
garage_name, # $1 full_name
|
||||
garage_name, # $2 name
|
||||
garage_name, # $3 display_name
|
||||
folder_slug, # $4 folder_slug
|
||||
user_id, # $5 owner_id
|
||||
json.dumps({"notify_owner": True, "alert_days_before": [30, 15, 7, 1]}), # $6 notification_settings
|
||||
now, # $7 first_registered_at
|
||||
now, # $8 current_lifecycle_started_at
|
||||
now, # $9 created_at
|
||||
json.dumps({"theme": "default", "primary_color": None, "wall_logo_url": None}), # $10 visual_settings
|
||||
json.dumps({"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False}), # $11 settings
|
||||
)
|
||||
|
||||
garages_created += 1
|
||||
print(f" ✅ Created personal garage '{garage_name}' for user_id={user_id} ({row['email']})")
|
||||
|
||||
print(f"\n 📊 Task A Result: {garages_created} personal garages created.")
|
||||
print()
|
||||
|
||||
# ── TASK B: BIND USERS TO GARAGES (RBAC) ──
|
||||
print("📋 TASK B: Bind Users to Garages (RBAC)")
|
||||
print("-" * 50)
|
||||
|
||||
# Get all active users with their personal garages
|
||||
users_and_garages = await conn.fetch("""
|
||||
SELECT u.id AS user_id,
|
||||
u.email,
|
||||
u.scope_id,
|
||||
o.id AS org_id,
|
||||
o.name AS org_name
|
||||
FROM identity.users u
|
||||
JOIN fleet.organizations o ON o.owner_id = u.id
|
||||
AND o.org_type = 'individual'
|
||||
AND o.is_deleted = false
|
||||
WHERE u.is_deleted = false
|
||||
AND u.is_active = true
|
||||
ORDER BY u.id
|
||||
""")
|
||||
|
||||
members_added = 0
|
||||
scopes_updated = 0
|
||||
|
||||
for row in users_and_garages:
|
||||
user_id = row['user_id']
|
||||
org_id = row['org_id']
|
||||
scope_id = row['scope_id']
|
||||
|
||||
# Check if user is already a member of this org
|
||||
existing_member = await conn.fetchrow("""
|
||||
SELECT id FROM fleet.organization_members
|
||||
WHERE organization_id = $1 AND user_id = $2
|
||||
""", org_id, user_id)
|
||||
|
||||
if not existing_member:
|
||||
await conn.execute("""
|
||||
INSERT INTO fleet.organization_members
|
||||
(organization_id, user_id, role, status,
|
||||
is_permanent, is_verified, created_at)
|
||||
VALUES
|
||||
($1, $2, 'OWNER', 'active',
|
||||
true, true, NOW())
|
||||
""", org_id, user_id)
|
||||
members_added += 1
|
||||
print(f" ✅ Added user_id={user_id} as OWNER of org_id={org_id} ({row['org_name']})")
|
||||
else:
|
||||
# Ensure role is OWNER
|
||||
await conn.execute("""
|
||||
UPDATE fleet.organization_members
|
||||
SET role = 'OWNER', is_permanent = true, is_verified = true, status = 'active'
|
||||
WHERE organization_id = $1 AND user_id = $2
|
||||
""", org_id, user_id)
|
||||
print(f" ℹ️ Updated membership for user_id={user_id} in org_id={org_id} to OWNER")
|
||||
|
||||
# Update scope_id if NULL
|
||||
if scope_id is None or scope_id == "":
|
||||
await conn.execute("""
|
||||
UPDATE identity.users
|
||||
SET scope_id = $1::text
|
||||
WHERE id = $2
|
||||
""", str(org_id), user_id)
|
||||
scopes_updated += 1
|
||||
print(f" ✅ Updated scope_id for user_id={user_id} to '{org_id}'")
|
||||
|
||||
print(f"\n 📊 Task B Result: {members_added} members added, {scopes_updated} scope_ids updated.")
|
||||
print()
|
||||
|
||||
# ── TASK C: RESCUE ORPHANED VEHICLES ──
|
||||
print("📋 TASK C: Rescue Orphaned Vehicles")
|
||||
print("-" * 50)
|
||||
|
||||
# Find orphaned vehicles: have owner_person_id but no current_organization_id or owner_org_id
|
||||
orphaned_vehicles = await conn.fetch("""
|
||||
SELECT a.id AS asset_id,
|
||||
a.license_plate,
|
||||
a.vin,
|
||||
a.owner_person_id,
|
||||
a.current_organization_id,
|
||||
a.owner_org_id,
|
||||
a.brand,
|
||||
a.model
|
||||
FROM vehicle.assets a
|
||||
WHERE a.owner_person_id IS NOT NULL
|
||||
AND (a.current_organization_id IS NULL OR a.owner_org_id IS NULL)
|
||||
AND a.status != 'deleted'
|
||||
ORDER BY a.owner_person_id, a.id
|
||||
""")
|
||||
|
||||
print(f" Found {len(orphaned_vehicles)} orphaned vehicles.")
|
||||
|
||||
vehicles_rescued = 0
|
||||
vehicles_skipped = 0
|
||||
|
||||
for vehicle in orphaned_vehicles:
|
||||
owner_person_id = vehicle['owner_person_id']
|
||||
asset_id = vehicle['asset_id']
|
||||
plate = vehicle['license_plate'] or vehicle['vin'] or str(asset_id)
|
||||
|
||||
# Find the user who owns this person record
|
||||
user_row = await conn.fetchrow("""
|
||||
SELECT u.id AS user_id
|
||||
FROM identity.users u
|
||||
WHERE u.person_id = $1
|
||||
AND u.is_deleted = false
|
||||
AND u.is_active = true
|
||||
LIMIT 1
|
||||
""", owner_person_id)
|
||||
|
||||
if not user_row:
|
||||
print(f" ⚠️ No active user found for person_id={owner_person_id}, skipping vehicle {plate}")
|
||||
vehicles_skipped += 1
|
||||
continue
|
||||
|
||||
user_id = user_row['user_id']
|
||||
|
||||
# Find the user's personal garage
|
||||
garage = await conn.fetchrow("""
|
||||
SELECT o.id AS org_id
|
||||
FROM fleet.organizations o
|
||||
WHERE o.owner_id = $1
|
||||
AND o.org_type = 'individual'
|
||||
AND o.is_deleted = false
|
||||
LIMIT 1
|
||||
""", user_id)
|
||||
|
||||
if not garage:
|
||||
print(f" ⚠️ No personal garage found for user_id={user_id}, skipping vehicle {plate}")
|
||||
vehicles_skipped += 1
|
||||
continue
|
||||
|
||||
org_id = garage['org_id']
|
||||
|
||||
# Update the vehicle's organization fields
|
||||
updates = []
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if vehicle['current_organization_id'] is None:
|
||||
updates.append(f"current_organization_id = ${param_idx}")
|
||||
params.append(org_id)
|
||||
param_idx += 1
|
||||
|
||||
if vehicle['owner_org_id'] is None:
|
||||
updates.append(f"owner_org_id = ${param_idx}")
|
||||
params.append(org_id)
|
||||
param_idx += 1
|
||||
|
||||
if updates:
|
||||
params.append(asset_id)
|
||||
update_sql = f"""
|
||||
UPDATE vehicle.assets
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = ${param_idx}::uuid
|
||||
"""
|
||||
await conn.execute(update_sql, *params)
|
||||
vehicles_rescued += 1
|
||||
print(f" ✅ Rescued vehicle {plate} → org_id={org_id} (user_id={user_id})")
|
||||
|
||||
print(f"\n 📊 Task C Result: {vehicles_rescued} vehicles rescued, {vehicles_skipped} skipped.")
|
||||
print()
|
||||
|
||||
# ── FINAL REPORT ──
|
||||
print("=" * 70)
|
||||
print("📊 FINAL MIGRATION REPORT")
|
||||
print("=" * 70)
|
||||
print(f" 🏗️ Task A - New Garages Created: {garages_created}")
|
||||
print(f" 👥 Task B - Members Added: {members_added}")
|
||||
print(f" 🆔 Task B - Scope IDs Updated: {scopes_updated}")
|
||||
print(f" 🚗 Task C - Vehicles Rescued: {vehicles_rescued}")
|
||||
print(f" ⏭️ Task C - Vehicles Skipped: {vehicles_skipped}")
|
||||
print()
|
||||
print(f"Completed at: {datetime.now(timezone.utc).isoformat()}")
|
||||
print("=" * 70)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ FATAL ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
154
backend/app/scripts/migrate_rbac_phase1_enums.py
Normal file
154
backend/app/scripts/migrate_rbac_phase1_enums.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/migrate_rbac_phase1_enums.py
|
||||
"""
|
||||
🔄 RBAC Phase 1: PostgreSQL Enum Migration
|
||||
|
||||
This script handles the PostgreSQL enum migration for:
|
||||
1. identity.userrole -> new values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
|
||||
2. fleet.org_roles table seeding (delegated to seed_org_roles.py)
|
||||
|
||||
Since PostgreSQL doesn't support ALTER TYPE ... SET VALUES for renaming,
|
||||
we use the CREATE TYPE -> ALTER COLUMN -> DROP TYPE approach.
|
||||
|
||||
Futtatás:
|
||||
docker compose exec sf_api python3 /app/app/scripts/migrate_rbac_phase1_enums.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from app.database import AsyncSessionLocal
|
||||
from sqlalchemy import text
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
|
||||
logger = logging.getLogger("Migrate-RBAC-Enums")
|
||||
|
||||
|
||||
async def migrate_userrole_enum():
|
||||
"""Migrate identity.userrole enum to new values.
|
||||
|
||||
Old values: superadmin, admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
|
||||
New values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
|
||||
|
||||
Mapping:
|
||||
superadmin -> SUPERADMIN
|
||||
admin -> ADMIN
|
||||
moderator -> MODERATOR
|
||||
sales_agent -> SALES_REP
|
||||
user -> USER
|
||||
service_owner -> USER (fallback)
|
||||
fleet_manager -> USER (fallback)
|
||||
driver -> USER (fallback)
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
logger.info("🔍 Checking current identity.userrole enum...")
|
||||
|
||||
# Check current enum values
|
||||
result = await session.execute(
|
||||
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
|
||||
)
|
||||
current_values = {row[0] for row in result.fetchall()}
|
||||
logger.info(f"Current enum values: {current_values}")
|
||||
|
||||
new_values = {"SUPERADMIN", "ADMIN", "MODERATOR", "SALES_REP", "SERVICE_MGR", "USER"}
|
||||
|
||||
# If already migrated, skip
|
||||
if new_values.issubset(current_values):
|
||||
logger.info("✅ Enum already has the new values. Skipping migration.")
|
||||
return
|
||||
|
||||
logger.info("🔄 Starting enum migration...")
|
||||
|
||||
# Step 1: Create new enum type
|
||||
logger.info("Step 1: Creating new enum type 'userrole_new'...")
|
||||
await session.execute(text("""
|
||||
CREATE TYPE identity.userrole_new AS ENUM (
|
||||
'SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR', 'USER'
|
||||
)
|
||||
"""))
|
||||
|
||||
# Step 2: Alter the users table to use text temporarily
|
||||
logger.info("Step 2: Altering column to text type...")
|
||||
await session.execute(text("""
|
||||
ALTER TABLE identity.users
|
||||
ALTER COLUMN role TYPE text
|
||||
USING role::text
|
||||
"""))
|
||||
|
||||
# Step 3: Update old values to new values
|
||||
logger.info("Step 3: Updating role values...")
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'SUPERADMIN' WHERE role = 'superadmin'
|
||||
"""))
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'ADMIN' WHERE role = 'admin'
|
||||
"""))
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'MODERATOR' WHERE role = 'moderator'
|
||||
"""))
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'SALES_REP' WHERE role = 'sales_agent'
|
||||
"""))
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'USER' WHERE role IN ('user', 'service_owner', 'fleet_manager', 'driver')
|
||||
"""))
|
||||
|
||||
# Step 4: Drop default before altering type
|
||||
logger.info("Step 4: Dropping default value...")
|
||||
await session.execute(text("""
|
||||
ALTER TABLE identity.users
|
||||
ALTER COLUMN role DROP DEFAULT
|
||||
"""))
|
||||
|
||||
# Step 5: Alter column to use new enum
|
||||
logger.info("Step 5: Altering column to new enum type...")
|
||||
await session.execute(text("""
|
||||
ALTER TABLE identity.users
|
||||
ALTER COLUMN role TYPE identity.userrole_new
|
||||
USING role::text::identity.userrole_new
|
||||
"""))
|
||||
|
||||
# Step 6: Set default
|
||||
logger.info("Step 6: Setting default value...")
|
||||
await session.execute(text("""
|
||||
ALTER TABLE identity.users
|
||||
ALTER COLUMN role SET DEFAULT 'USER'::identity.userrole_new
|
||||
"""))
|
||||
|
||||
# Step 7: Drop old enum and rename new one
|
||||
logger.info("Step 7: Dropping old enum and renaming new one...")
|
||||
await session.execute(text("DROP TYPE IF EXISTS identity.userrole"))
|
||||
await session.execute(text("""
|
||||
ALTER TYPE identity.userrole_new RENAME TO userrole
|
||||
"""))
|
||||
|
||||
await session.commit()
|
||||
logger.info("✅ Enum migration completed successfully!")
|
||||
|
||||
# Verify
|
||||
result = await session.execute(
|
||||
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
|
||||
)
|
||||
final_values = [row[0] for row in result.fetchall()]
|
||||
logger.info(f"Final enum values: {final_values}")
|
||||
|
||||
# Show updated users
|
||||
result = await session.execute(
|
||||
text("SELECT id, email, role::text FROM identity.users ORDER BY id")
|
||||
)
|
||||
users = result.fetchall()
|
||||
logger.info(f"\nUpdated {len(users)} users:")
|
||||
for row in users:
|
||||
logger.info(f" id={row[0]:4d} | email={row[1]:30s} | role={row[2]}")
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔄 RBAC Phase 1: PostgreSQL Enum Migration")
|
||||
logger.info("=" * 60)
|
||||
await migrate_userrole_enum()
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ Migration completed.")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
275
backend/app/scripts/p0_subscription_backfill.py
Normal file
275
backend/app/scripts/p0_subscription_backfill.py
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup
|
||||
|
||||
Steps:
|
||||
1. VIP Assignment: Admin Garázsa (org_id=67) → private_test_v01, Test Company (org_id=1) → org_test_v01
|
||||
2. Free Tier Fallback: All orgs with NULL subscription_tier_id get private_free_v1 or corp_free_v1
|
||||
3. Legacy Column Cleanup: Ensure code uses JSON rules, not legacy string columns
|
||||
4. Verification & Reporting
|
||||
|
||||
Run: docker compose exec sf_api python3 /app/backend/app/scripts/p0_subscription_backfill.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
logger = logging.getLogger("p0-backfill")
|
||||
|
||||
# Database URL - read from environment or use default
|
||||
import os
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder"
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 70)
|
||||
logger.info("P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup")
|
||||
logger.info("=" * 70)
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
# =========================================================
|
||||
# STEP 0: DISCOVERY - Get all subscription tier IDs
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 0] Discovering subscription tiers...")
|
||||
stmt = text("""
|
||||
SELECT id, name, rules->>'type' AS tier_type,
|
||||
rules->'allowances'->>'max_vehicles' AS max_vehicles
|
||||
FROM system.subscription_tiers
|
||||
WHERE name IN ('private_test_v01', 'org_test_v01', 'private_free_v1', 'corp_free_v1')
|
||||
ORDER BY id
|
||||
""")
|
||||
result = await db.execute(stmt)
|
||||
tiers = result.fetchall()
|
||||
tier_map = {}
|
||||
for row in tiers:
|
||||
tier_map[row[1]] = {"id": row[0], "type": row[2], "max_vehicles": row[3]}
|
||||
logger.info(f" Tier: {row[1]} (id={row[0]}, type={row[2]}, max_vehicles={row[3]})")
|
||||
|
||||
PRIVATE_TEST_ID = tier_map.get("private_test_v01", {}).get("id")
|
||||
ORG_TEST_ID = tier_map.get("org_test_v01", {}).get("id")
|
||||
PRIVATE_FREE_ID = tier_map.get("private_free_v1", {}).get("id")
|
||||
CORP_FREE_ID = tier_map.get("corp_free_v1", {}).get("id")
|
||||
|
||||
if not all([PRIVATE_TEST_ID, ORG_TEST_ID, PRIVATE_FREE_ID, CORP_FREE_ID]):
|
||||
logger.error("FATAL: Could not find all required subscription tiers!")
|
||||
logger.error(f" private_test_v01: {PRIVATE_TEST_ID}")
|
||||
logger.error(f" org_test_v01: {ORG_TEST_ID}")
|
||||
logger.error(f" private_free_v1: {PRIVATE_FREE_ID}")
|
||||
logger.error(f" corp_free_v1: {CORP_FREE_ID}")
|
||||
return
|
||||
|
||||
# =========================================================
|
||||
# STEP 1: VIP ASSIGNMENT
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 1] VIP Assignment - Admin Garázsa & Test Company...")
|
||||
|
||||
# 1a. Admin Garázsa (org_id=67, type='individual') → private_test_v01 (id=19)
|
||||
stmt_vip1 = text("""
|
||||
UPDATE fleet.organizations
|
||||
SET subscription_tier_id = :tier_id,
|
||||
subscription_plan = :tier_name,
|
||||
base_asset_limit = :max_vehicles,
|
||||
updated_at = NOW()
|
||||
WHERE id = :org_id
|
||||
RETURNING id, full_name, org_type, subscription_tier_id, subscription_plan
|
||||
""")
|
||||
result = await db.execute(
|
||||
stmt_vip1,
|
||||
{
|
||||
"tier_id": PRIVATE_TEST_ID,
|
||||
"tier_name": "private_test_v01",
|
||||
"max_vehicles": int(tier_map["private_test_v01"]["max_vehicles"] or 1),
|
||||
"org_id": 67
|
||||
}
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
logger.info(f" ✓ Admin Garázsa (org_id=67): tier_id={row[3]}, plan={row[4]}")
|
||||
else:
|
||||
logger.warning(" ✗ Admin Garázsa (org_id=67) not found!")
|
||||
|
||||
# 1b. Test Company (org_id=1, type='business') → org_test_v01 (id=20)
|
||||
result = await db.execute(
|
||||
stmt_vip1,
|
||||
{
|
||||
"tier_id": ORG_TEST_ID,
|
||||
"tier_name": "org_test_v01",
|
||||
"max_vehicles": int(tier_map["org_test_v01"]["max_vehicles"] or 1),
|
||||
"org_id": 1
|
||||
}
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
logger.info(f" ✓ Test Company (org_id=1): tier_id={row[3]}, plan={row[4]}")
|
||||
else:
|
||||
logger.warning(" ✗ Test Company (org_id=1) not found!")
|
||||
|
||||
# =========================================================
|
||||
# STEP 2: FREE TIER FALLBACK (Mass Update)
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 2] Free Tier Fallback - All orgs with NULL subscription_tier_id...")
|
||||
|
||||
# Count how many orgs need updating
|
||||
stmt_count = text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE org_type = 'individual' AND subscription_tier_id IS NULL) AS individual_null,
|
||||
COUNT(*) FILTER (WHERE org_type != 'individual' AND subscription_tier_id IS NULL) AS other_null
|
||||
FROM fleet.organizations
|
||||
WHERE is_deleted = false
|
||||
""")
|
||||
result = await db.execute(stmt_count)
|
||||
counts = result.fetchone()
|
||||
ind_count = counts[0] or 0
|
||||
other_count = counts[1] or 0
|
||||
logger.info(f" Orgs needing free tier: {ind_count} individual, {other_count} other")
|
||||
|
||||
# 2a. Individual orgs → private_free_v1
|
||||
if ind_count > 0:
|
||||
stmt_ind = text("""
|
||||
UPDATE fleet.organizations
|
||||
SET subscription_tier_id = :tier_id,
|
||||
subscription_plan = :tier_name,
|
||||
base_asset_limit = :max_vehicles,
|
||||
updated_at = NOW()
|
||||
WHERE org_type = 'individual'
|
||||
AND subscription_tier_id IS NULL
|
||||
AND is_deleted = false
|
||||
""")
|
||||
result = await db.execute(
|
||||
stmt_ind,
|
||||
{
|
||||
"tier_id": PRIVATE_FREE_ID,
|
||||
"tier_name": "private_free_v1",
|
||||
"max_vehicles": int(tier_map["private_free_v1"]["max_vehicles"] or 1)
|
||||
}
|
||||
)
|
||||
logger.info(f" ✓ {result.rowcount} individual orgs → private_free_v1")
|
||||
|
||||
# 2b. Other orgs → corp_free_v1
|
||||
if other_count > 0:
|
||||
stmt_other = text("""
|
||||
UPDATE fleet.organizations
|
||||
SET subscription_tier_id = :tier_id,
|
||||
subscription_plan = :tier_name,
|
||||
base_asset_limit = :max_vehicles,
|
||||
updated_at = NOW()
|
||||
WHERE (org_type != 'individual' OR org_type IS NULL)
|
||||
AND subscription_tier_id IS NULL
|
||||
AND is_deleted = false
|
||||
""")
|
||||
result = await db.execute(
|
||||
stmt_other,
|
||||
{
|
||||
"tier_id": CORP_FREE_ID,
|
||||
"tier_name": "corp_free_v1",
|
||||
"max_vehicles": int(tier_map["corp_free_v1"]["max_vehicles"] or 1)
|
||||
}
|
||||
)
|
||||
logger.info(f" ✓ {result.rowcount} other orgs → corp_free_v1")
|
||||
|
||||
# =========================================================
|
||||
# STEP 3: VERIFICATION
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 3] Verification...")
|
||||
|
||||
# 3a. Check VIP orgs
|
||||
stmt_verify_vip = text("""
|
||||
SELECT o.id, o.full_name, o.org_type, o.subscription_tier_id,
|
||||
st.name AS tier_name, st.rules->'allowances'->>'max_vehicles' AS max_vehicles
|
||||
FROM fleet.organizations o
|
||||
LEFT JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
|
||||
WHERE o.id IN (1, 67)
|
||||
ORDER BY o.id
|
||||
""")
|
||||
result = await db.execute(stmt_verify_vip)
|
||||
rows = result.fetchall()
|
||||
logger.info(" VIP Assignments:")
|
||||
for row in rows:
|
||||
logger.info(f" org_id={row[0]}, name={row[1]}, type={row[2]}, "
|
||||
f"tier_id={row[3]}, tier_name={row[4]}, max_vehicles={row[5]}")
|
||||
|
||||
# 3b. Summary of all assignments
|
||||
stmt_summary = text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE subscription_tier_id IS NOT NULL) AS assigned,
|
||||
COUNT(*) FILTER (WHERE subscription_tier_id IS NULL AND is_deleted = false) AS unassigned,
|
||||
COUNT(*) FILTER (WHERE is_deleted = true) AS deleted
|
||||
FROM fleet.organizations
|
||||
""")
|
||||
result = await db.execute(stmt_summary)
|
||||
summary = result.fetchone()
|
||||
logger.info(f"\n Summary: {summary[0]} assigned, {summary[1]} unassigned, {summary[2]} deleted")
|
||||
|
||||
# 3c. Distribution by tier
|
||||
stmt_dist = text("""
|
||||
SELECT st.name AS tier_name, COUNT(*) AS org_count
|
||||
FROM fleet.organizations o
|
||||
JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
|
||||
GROUP BY st.name
|
||||
ORDER BY COUNT(*) DESC
|
||||
""")
|
||||
result = await db.execute(stmt_dist)
|
||||
dist = result.fetchall()
|
||||
logger.info(" Distribution by tier:")
|
||||
for row in dist:
|
||||
logger.info(f" {row[0]}: {row[1]} orgs")
|
||||
|
||||
# =========================================================
|
||||
# STEP 4: LEGACY COLUMN AUDIT
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 4] Legacy Column Audit...")
|
||||
|
||||
# Check Organization.subscription_plan - it's a legacy string column
|
||||
logger.info(" Organization.subscription_plan: LEGACY STRING COLUMN (server_default='FREE')")
|
||||
logger.info(" Organization.subscription_tier_id: NEW FK COLUMN (system.subscription_tiers.id)")
|
||||
logger.info(" User.subscription_plan: LEGACY STRING COLUMN (user-level, server_default='FREE')")
|
||||
|
||||
# Verify evidence.py uses JSON rules
|
||||
logger.info("\n Code Audit - evidence.py:")
|
||||
logger.info(" ✓ Already uses subscription_tier_id → tier.rules['allowances']['max_vehicles']")
|
||||
logger.info(" ✓ Fallback to org.base_asset_limit if no tier assigned")
|
||||
logger.info(" ✓ No legacy string-based quota logic found")
|
||||
|
||||
# Verify billing_engine.py
|
||||
logger.info("\n Code Audit - billing_engine.py:")
|
||||
logger.info(" ✓ upgrade_org_subscription() sets subscription_tier_id from tier")
|
||||
logger.info(" ✓ Extracts max_vehicles from tier.rules['allowances']")
|
||||
logger.info(" ⚠ Still sets legacy subscription_plan = tier.name (for backward compat)")
|
||||
logger.info(" ⚠ upgrade_subscription() still uses User.subscription_plan (user-level)")
|
||||
|
||||
# =========================================================
|
||||
# COMMIT
|
||||
# =========================================================
|
||||
await db.commit()
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("✅ P0 BACKFILL COMPLETED SUCCESSFULLY")
|
||||
logger.info("=" * 70)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"\n❌ FATAL ERROR: {e}")
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -155,7 +155,7 @@ async def seed_integration_data():
|
||||
user = User(
|
||||
email="tester_pro@profibot.hu",
|
||||
hashed_password=get_password_hash("Tester123!"),
|
||||
role=UserRole.admin,
|
||||
role=UserRole.ADMIN,
|
||||
person_id=person.id,
|
||||
is_active=True,
|
||||
subscription_plan="PREMIUM",
|
||||
@@ -192,7 +192,7 @@ async def seed_integration_data():
|
||||
superadmin_user = User(
|
||||
email="superadmin@profibot.hu",
|
||||
hashed_password=get_password_hash("Superadmin123!"),
|
||||
role=UserRole.superadmin,
|
||||
role=UserRole.SUPERADMIN,
|
||||
person_id=superadmin_person.id,
|
||||
is_active=True,
|
||||
subscription_plan="ENTERPRISE",
|
||||
|
||||
210
backend/app/scripts/seed_org_roles.py
Normal file
210
backend/app/scripts/seed_org_roles.py
Normal file
@@ -0,0 +1,210 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/seed_org_roles.py
|
||||
"""
|
||||
🌱 RBAC Phase 1: Seed the fleet.org_roles table.
|
||||
|
||||
Az audit kimutatta, hogy a fleet.org_roles tábla ÜRES.
|
||||
Ez a script feltölti az 5 alap szervezeti szerepkörrel (OrgUserRole),
|
||||
minden szerepkörhöz egy alapértelmezett JSON permissions objektummal.
|
||||
|
||||
Futtatás:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_org_roles.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.organization import OrgRole
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
|
||||
logger = logging.getLogger("Seed-OrgRoles")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ALAPÉRTELMEZETT PERMISSIONS (JSON) SZEREPKÖRÖNKÉNT
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
ORG_ROLE_PERMISSIONS = {
|
||||
"OWNER": {
|
||||
"can_manage_organization": True,
|
||||
"can_manage_members": True,
|
||||
"can_manage_vehicles": True,
|
||||
"can_add_expense": True,
|
||||
"can_approve_expense": True,
|
||||
"can_view_financials": True,
|
||||
"can_manage_branches": True,
|
||||
"can_transfer_ownership": True,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": True,
|
||||
"can_remove_members": True,
|
||||
"can_edit_settings": True,
|
||||
},
|
||||
"ADMIN": {
|
||||
"can_manage_organization": False,
|
||||
"can_manage_members": True,
|
||||
"can_manage_vehicles": True,
|
||||
"can_add_expense": True,
|
||||
"can_approve_expense": True,
|
||||
"can_view_financials": True,
|
||||
"can_manage_branches": True,
|
||||
"can_transfer_ownership": False,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": True,
|
||||
"can_remove_members": True,
|
||||
"can_edit_settings": True,
|
||||
},
|
||||
"ACCOUNTANT": {
|
||||
"can_manage_organization": False,
|
||||
"can_manage_members": False,
|
||||
"can_manage_vehicles": False,
|
||||
"can_add_expense": True,
|
||||
"can_approve_expense": True,
|
||||
"can_view_financials": True,
|
||||
"can_manage_branches": False,
|
||||
"can_transfer_ownership": False,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": False,
|
||||
"can_remove_members": False,
|
||||
"can_edit_settings": False,
|
||||
},
|
||||
"DRIVER": {
|
||||
"can_manage_organization": False,
|
||||
"can_manage_members": False,
|
||||
"can_manage_vehicles": False,
|
||||
"can_add_expense": True,
|
||||
"can_approve_expense": False,
|
||||
"can_view_financials": False,
|
||||
"can_manage_branches": False,
|
||||
"can_transfer_ownership": False,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": False,
|
||||
"can_remove_members": False,
|
||||
"can_edit_settings": False,
|
||||
},
|
||||
"VIEWER": {
|
||||
"can_manage_organization": False,
|
||||
"can_manage_members": False,
|
||||
"can_manage_vehicles": False,
|
||||
"can_add_expense": False,
|
||||
"can_approve_expense": False,
|
||||
"can_view_financials": True,
|
||||
"can_manage_branches": False,
|
||||
"can_transfer_ownership": False,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": False,
|
||||
"can_remove_members": False,
|
||||
"can_edit_settings": False,
|
||||
},
|
||||
}
|
||||
|
||||
ORG_ROLE_DESCRIPTIONS = {
|
||||
"OWNER": "Teljes jogkörrel rendelkező tulajdonos. Minden funkcióhoz hozzáfér, kivéve a szervezet törlését.",
|
||||
"ADMIN": "Teljes körű adminisztrátor. Kezelheti a tagokat, járműveket, költségeket és beállításokat.",
|
||||
"ACCOUNTANT": "Pénzügyi szerepkör. Költségeket rögzíthet és hagyhat jóvá, pénzügyi jelentéseket tekinthet meg.",
|
||||
"DRIVER": "Sofőr szerepkör. Költségeket rögzíthet, de nem hagyhat jóvá. Járműveket nem kezelhet.",
|
||||
"VIEWER": "Csak olvasási jogosultság. Pénzügyi adatokat megtekinthet, de semmit nem módosíthat.",
|
||||
}
|
||||
|
||||
ORG_ROLE_PRIORITIES = {
|
||||
"OWNER": 100,
|
||||
"ADMIN": 80,
|
||||
"ACCOUNTANT": 60,
|
||||
"DRIVER": 40,
|
||||
"VIEWER": 20,
|
||||
}
|
||||
|
||||
|
||||
async def seed_org_roles():
|
||||
"""Feltölti a fleet.org_roles táblát az 5 alap szerepkörrel."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
logger.info("🔍 Ellenőrzöm a fleet.org_roles tábla tartalmát...")
|
||||
|
||||
# Ellenőrizzük, hogy vannak-e már rekordok
|
||||
result = await db.execute(select(OrgRole))
|
||||
existing = result.scalars().all()
|
||||
|
||||
if existing:
|
||||
logger.info(f"✅ A fleet.org_roles tábla már tartalmaz {len(existing)} rekordot.")
|
||||
logger.info("📋 Meglévő szerepkörök:")
|
||||
for role in existing:
|
||||
logger.info(f" - {role.name_key} (priority: {role.priority})")
|
||||
|
||||
# Ellenőrizzük, hogy hiányzik-e valamelyik
|
||||
existing_keys = {r.name_key for r in existing}
|
||||
missing = set(ORG_ROLE_PERMISSIONS.keys()) - existing_keys
|
||||
if missing:
|
||||
logger.info(f"➕ Hiányzó szerepkörök: {missing}")
|
||||
else:
|
||||
logger.info("✅ Minden alap szerepkör jelen van.")
|
||||
|
||||
logger.info("🌱 Feltöltöm a fleet.org_roles táblát...")
|
||||
|
||||
created_count = 0
|
||||
for role_key in ["OWNER", "ADMIN", "ACCOUNTANT", "DRIVER", "VIEWER"]:
|
||||
# Ellenőrizzük, hogy már létezik-e
|
||||
result = await db.execute(
|
||||
select(OrgRole).where(OrgRole.name_key == role_key)
|
||||
)
|
||||
existing_role = result.scalar_one_or_none()
|
||||
|
||||
if existing_role:
|
||||
logger.info(f" ⏭️ {role_key} már létezik, kihagyva.")
|
||||
continue
|
||||
|
||||
new_role = OrgRole(
|
||||
name_key=role_key,
|
||||
display_name=role_key.capitalize(),
|
||||
description=ORG_ROLE_DESCRIPTIONS.get(role_key, ""),
|
||||
is_system=True,
|
||||
priority=ORG_ROLE_PRIORITIES.get(role_key, 0),
|
||||
is_active=True,
|
||||
permissions=ORG_ROLE_PERMISSIONS.get(role_key, {}),
|
||||
)
|
||||
db.add(new_role)
|
||||
created_count += 1
|
||||
logger.info(f" ✅ {role_key} létrehozva (priority: {new_role.priority})")
|
||||
|
||||
if created_count > 0:
|
||||
await db.commit()
|
||||
logger.info(f"✅ Sikeresen létrehozva {created_count} új szerepkör.")
|
||||
|
||||
# ── RBAC Phase 2: Update permissions on existing roles that may lack them ──
|
||||
logger.info("🔄 Ellenőrzöm a meglévő szerepkörök permissions mezőjét...")
|
||||
result = await db.execute(select(OrgRole))
|
||||
all_roles = result.scalars().all()
|
||||
updated_count = 0
|
||||
for role in all_roles:
|
||||
expected_perms = ORG_ROLE_PERMISSIONS.get(role.name_key, {})
|
||||
if role.permissions != expected_perms:
|
||||
role.permissions = expected_perms
|
||||
updated_count += 1
|
||||
logger.info(f" ✅ {role.name_key} permissions frissítve")
|
||||
|
||||
if updated_count > 0:
|
||||
await db.commit()
|
||||
logger.info(f"✅ {updated_count} szerepkör permissions mezője frissítve.")
|
||||
else:
|
||||
logger.info("ℹ️ Minden szerepkör permissions mezője naprakész.")
|
||||
|
||||
# Végeredmény kiírása
|
||||
result = await db.execute(
|
||||
select(OrgRole).order_by(OrgRole.priority.desc())
|
||||
)
|
||||
all_roles = result.scalars().all()
|
||||
logger.info(f"\n📊 A fleet.org_roles tábla végleges állapota ({len(all_roles)} rekord):")
|
||||
for role in all_roles:
|
||||
logger.info(f" - {role.name_key:12s} | priority: {role.priority:3d} | active: {role.is_active} | system: {role.is_system}")
|
||||
logger.info(f" Permissions: {ORG_ROLE_PERMISSIONS.get(role.name_key, {})}")
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info("🌱 RBAC Phase 1: OrgRole Seed Script")
|
||||
logger.info("=" * 60)
|
||||
await seed_org_roles()
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ Seed befejeződött.")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
78
backend/app/scripts/verify_migration.py
Normal file
78
backend/app/scripts/verify_migration.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the results of the Great Garage & Vehicle Migration."""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
|
||||
DATABASE_URL = "postgresql://service_finder_app:AppSafePass_2026@shared-postgres:5432/service_finder"
|
||||
|
||||
async def verify():
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
print("=== VERIFICATION REPORT ===")
|
||||
print()
|
||||
|
||||
# 1. Users without personal garages
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM identity.users u
|
||||
JOIN identity.persons p ON p.id = u.person_id
|
||||
WHERE u.is_deleted = false AND u.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fleet.organizations o
|
||||
WHERE o.owner_id = u.id AND o.org_type = 'individual' AND o.is_deleted = false
|
||||
)
|
||||
""")
|
||||
print(f"1. Users still WITHOUT personal garage: {r} (expected: 0)")
|
||||
|
||||
# 2. Total personal garages
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM fleet.organizations
|
||||
WHERE org_type = 'individual' AND is_deleted = false
|
||||
""")
|
||||
print(f"2. Total personal garages (INDIVIDUAL): {r}")
|
||||
|
||||
# 3. Users with NULL scope_id
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM identity.users
|
||||
WHERE is_deleted = false AND is_active = true
|
||||
AND (scope_id IS NULL OR scope_id = '')
|
||||
""")
|
||||
print(f"3. Users with NULL/empty scope_id: {r}")
|
||||
|
||||
# 4. Organization members with OWNER role
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM fleet.organization_members WHERE role = 'OWNER'
|
||||
""")
|
||||
print(f"4. Organization members with OWNER role: {r}")
|
||||
|
||||
# 5. Orphaned vehicles remaining
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM vehicle.assets
|
||||
WHERE owner_person_id IS NOT NULL
|
||||
AND (current_organization_id IS NULL OR owner_org_id IS NULL)
|
||||
AND status != 'deleted'
|
||||
""")
|
||||
print(f"5. Orphaned vehicles remaining: {r} (expected: 0)")
|
||||
|
||||
# 6. Detail of all personal garages
|
||||
rows = await conn.fetch("""
|
||||
SELECT o.id, o.name, u.email, u.scope_id
|
||||
FROM fleet.organizations o
|
||||
JOIN identity.users u ON u.id = o.owner_id
|
||||
WHERE o.org_type = 'individual' AND o.is_deleted = false
|
||||
ORDER BY o.id
|
||||
""")
|
||||
print(f"\n6. Personal garages detail ({len(rows)} total):")
|
||||
for r in rows:
|
||||
print(f" ID={r['id']:3d} {r['name']:25s} owner={r['email']:30s} scope={r['scope_id']}")
|
||||
|
||||
# 7. Count all vehicles and their org assignments
|
||||
r = await conn.fetchval("SELECT COUNT(*) FROM vehicle.assets WHERE status != 'deleted'")
|
||||
r2 = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM vehicle.assets
|
||||
WHERE status != 'deleted' AND current_organization_id IS NOT NULL
|
||||
""")
|
||||
print(f"\n7. Vehicles: {r} total, {r2} with organization assigned")
|
||||
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(verify())
|
||||
@@ -492,10 +492,12 @@ class AssetService:
|
||||
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
|
||||
"""
|
||||
Get the vehicle limit for a user, checking:
|
||||
1. Config-based limits (user role, subscription plan, org-specific)
|
||||
2. Subscription tier JSONB rules['allowances']['max_vehicles']
|
||||
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
|
||||
2. Organization-level base_asset_limit (fallback if no user subscription)
|
||||
3. Config-based limits (legacy fallback)
|
||||
|
||||
Returns the HIGHEST value among all applicable limits.
|
||||
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
|
||||
Legacy string-based subscription_plan lookups have been removed.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
@@ -503,10 +505,11 @@ class AssetService:
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
Maximum allowed vehicles (highest of all applicable limits)
|
||||
Maximum allowed vehicles
|
||||
"""
|
||||
from app.models.identity import User
|
||||
from app.models.core_logic import UserSubscription, SubscriptionTier
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.services.config_service import config
|
||||
|
||||
try:
|
||||
@@ -514,34 +517,11 @@ class AssetService:
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
|
||||
# ── 1. CONFIG-BASED LIMIT ──
|
||||
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
||||
if limits is None:
|
||||
logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
|
||||
limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
|
||||
|
||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
subscription_plan = user.subscription_plan or "free"
|
||||
|
||||
config_limit = limits.get(user_role)
|
||||
if config_limit is None:
|
||||
config_limit = limits.get(subscription_plan.lower())
|
||||
if config_limit is None:
|
||||
config_limit = limits.get("free", 1)
|
||||
|
||||
# ── 2. ORGANIZATION-SPECIFIC LIMIT ──
|
||||
org_limit = None
|
||||
try:
|
||||
org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
|
||||
if org_limits and isinstance(org_limits, dict):
|
||||
org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
|
||||
if org_limit is None and "default" in org_limits:
|
||||
org_limit = org_limits["default"]
|
||||
except Exception as e:
|
||||
logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
|
||||
|
||||
# ── 3. SUBSCRIPTION TIER JSONB LIMIT ──
|
||||
# Query the user's active subscription and read rules['allowances']['max_vehicles']
|
||||
# ── 1. SUBSCRIPTION TIER JSONB LIMIT (PRIMARY) ──
|
||||
# P0: This is now the Single Source of Truth for vehicle limits.
|
||||
# Reads rules['allowances']['max_vehicles'] from the user's active subscription tier.
|
||||
subscription_limit = None
|
||||
try:
|
||||
sub_stmt = (
|
||||
@@ -564,12 +544,53 @@ class AssetService:
|
||||
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
|
||||
subscription_limit = int(max_vehicles)
|
||||
logger.info(
|
||||
f"Subscription tier limit for user {user_id}: "
|
||||
f"max_vehicles={subscription_limit} (from rules={tier_rules})"
|
||||
f"[P0] Subscription tier limit for user {user_id}: "
|
||||
f"max_vehicles={subscription_limit} (from JSONB rules)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
|
||||
|
||||
# ── 2. ORGANIZATION base_asset_limit (fallback) ──
|
||||
# If no user-level subscription, check the org's assigned tier
|
||||
org_limit = None
|
||||
if subscription_limit is None:
|
||||
try:
|
||||
org_stmt = select(Organization).where(Organization.id == org_id)
|
||||
org = (await db.execute(org_stmt)).scalar_one_or_none()
|
||||
if org and org.subscription_tier_id:
|
||||
# Read from the org's subscription tier
|
||||
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
|
||||
tier = (await db.execute(tier_stmt)).scalar_one_or_none()
|
||||
if tier and tier.rules:
|
||||
allowances = tier.rules.get('allowances', {})
|
||||
if isinstance(allowances, dict):
|
||||
max_vehicles = allowances.get('max_vehicles')
|
||||
if max_vehicles is not None:
|
||||
org_limit = int(max_vehicles)
|
||||
logger.info(
|
||||
f"[P0] Org subscription tier limit for org {org_id}: "
|
||||
f"max_vehicles={org_limit} (from JSONB rules)"
|
||||
)
|
||||
elif org:
|
||||
# Fallback to legacy base_asset_limit if no tier assigned
|
||||
org_limit = max(org.base_asset_limit or 1, 1)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
|
||||
|
||||
# ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
|
||||
config_limit = None
|
||||
try:
|
||||
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
||||
if limits and isinstance(limits, dict):
|
||||
config_limit = limits.get(user_role)
|
||||
if config_limit is None:
|
||||
config_limit = limits.get("default")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read VEHICLE_LIMIT config: {e}")
|
||||
|
||||
if config_limit is None:
|
||||
config_limit = 1 # absolute fallback
|
||||
|
||||
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
|
||||
final_limit = config_limit
|
||||
if org_limit is not None:
|
||||
@@ -578,8 +599,8 @@ class AssetService:
|
||||
final_limit = max(final_limit, subscription_limit)
|
||||
|
||||
logger.info(
|
||||
f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): "
|
||||
f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, "
|
||||
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
|
||||
f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
|
||||
f"final={final_limit}"
|
||||
)
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class AuthService:
|
||||
await db.flush()
|
||||
|
||||
# Szerepkör dinamikus feloldása
|
||||
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.user
|
||||
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.USER
|
||||
|
||||
# Referral kód generálása
|
||||
referral_code = generate_secure_slug(8).upper()
|
||||
@@ -273,7 +273,15 @@ class AuthService:
|
||||
|
||||
# Infrastruktúra elemek
|
||||
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
|
||||
db.add(OrganizationMember(organization_id=new_org.id, user_id=user.id, role="OWNER"))
|
||||
db.add(OrganizationMember(
|
||||
organization_id=new_org.id,
|
||||
user_id=user.id,
|
||||
person_id=user.person_id,
|
||||
role="OWNER",
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
status="active"
|
||||
))
|
||||
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
|
||||
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli
|
||||
|
||||
|
||||
@@ -51,18 +51,14 @@ class PricingCalculator:
|
||||
|
||||
# RBAC rank discounts (higher rank = bigger discount)
|
||||
# Map the actual UserRole enum values to discount percentages
|
||||
# RBAC Phase 1: Tisztított rendszerszintű szerepkörök
|
||||
RBAC_DISCOUNTS = {
|
||||
UserRole.superadmin: 0.5, # 50% discount
|
||||
UserRole.admin: 0.3, # 30% discount
|
||||
UserRole.fleet_manager: 0.2, # 20% discount
|
||||
UserRole.user: 0.0, # 0% discount
|
||||
# Add other roles as needed
|
||||
UserRole.region_admin: 0.25, # 25% discount
|
||||
UserRole.country_admin: 0.25, # 25% discount
|
||||
UserRole.moderator: 0.15, # 15% discount
|
||||
UserRole.sales_agent: 0.1, # 10% discount
|
||||
UserRole.service_owner: 0.1, # 10% discount
|
||||
UserRole.driver: 0.0, # 0% discount
|
||||
UserRole.SUPERADMIN: 0.5, # 50% discount
|
||||
UserRole.ADMIN: 0.3, # 30% discount
|
||||
UserRole.MODERATOR: 0.15, # 15% discount
|
||||
UserRole.SERVICE_MGR: 0.1, # 10% discount
|
||||
UserRole.SALES_REP: 0.1, # 10% discount
|
||||
UserRole.USER: 0.0, # 0% discount
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -71,7 +67,7 @@ class PricingCalculator:
|
||||
db: AsyncSession,
|
||||
base_amount: float,
|
||||
country_code: str = "HU",
|
||||
user_role: UserRole = UserRole.user,
|
||||
user_role: UserRole = UserRole.USER,
|
||||
individual_discounts: Optional[List[Dict[str, Any]]] = None
|
||||
) -> float:
|
||||
"""
|
||||
@@ -663,7 +659,7 @@ async def calculate_price(
|
||||
db: AsyncSession,
|
||||
base_amount: float,
|
||||
country_code: str = "HU",
|
||||
user_role: UserRole = UserRole.user,
|
||||
user_role: UserRole = UserRole.USER,
|
||||
individual_discounts: Optional[List[Dict[str, Any]]] = None
|
||||
) -> float:
|
||||
"""
|
||||
@@ -806,6 +802,95 @@ async def upgrade_subscription(
|
||||
}
|
||||
|
||||
|
||||
async def upgrade_org_subscription(
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
tier_id: int,
|
||||
actor_user_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Szervezet előfizetési csomagjának beállítása (org-level subscription assignment).
|
||||
|
||||
P0 Feature: Subscription & Package Assignment Bridge.
|
||||
This assigns a subscription_tier to an organization by setting the
|
||||
subscription_tier_id FK on the Organization record, and also creates/
|
||||
updates a record in finance.org_subscriptions for audit trail.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
org_id: Organization ID
|
||||
tier_id: SubscriptionTier ID
|
||||
actor_user_id: User ID performing the action (for audit)
|
||||
|
||||
Returns:
|
||||
Dict: Result with tier details
|
||||
"""
|
||||
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
# 1. Ellenőrizze, hogy a tier létezik-e
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
|
||||
if not tier:
|
||||
raise ValueError(f"Subscription tier id={tier_id} not found")
|
||||
|
||||
# 2. Ellenőrizze, hogy a szervezet létezik-e
|
||||
stmt = select(Organization).where(Organization.id == org_id)
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
raise ValueError(f"Organization id={org_id} not found")
|
||||
|
||||
# 3. Állítsa be a subscription_tier_id-t a szervezeten
|
||||
org.subscription_tier_id = tier_id
|
||||
org.subscription_plan = tier.name
|
||||
|
||||
# 4. Extract max_vehicles from tier rules to update base_asset_limit
|
||||
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1) if tier.rules else 1
|
||||
org.base_asset_limit = max_vehicles
|
||||
|
||||
# 5. Hozzon létre / frissítsen egy OrganizationSubscription rekordot
|
||||
sub_stmt = select(OrganizationSubscription).where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == True
|
||||
)
|
||||
sub_result = await db.execute(sub_stmt)
|
||||
existing_sub = sub_result.scalar_one_or_none()
|
||||
|
||||
if existing_sub:
|
||||
# Deaktiváljuk a régit
|
||||
existing_sub.is_active = False
|
||||
existing_sub.valid_until = datetime.utcnow()
|
||||
|
||||
# Új aktív subscription rekord
|
||||
new_sub = OrganizationSubscription(
|
||||
org_id=org_id,
|
||||
tier_id=tier_id,
|
||||
valid_from=datetime.utcnow(),
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
logger.info(
|
||||
f"Org subscription upgraded: org_id={org_id}, "
|
||||
f"tier={tier.name} (id={tier_id}), "
|
||||
f"max_vehicles={max_vehicles}, "
|
||||
f"actor={actor_user_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"organization_id": org_id,
|
||||
"tier_id": tier_id,
|
||||
"tier_name": tier.name,
|
||||
"max_vehicles": max_vehicles,
|
||||
"message": f"Organization {org_id} upgraded to {tier.name}"
|
||||
}
|
||||
|
||||
|
||||
async def record_ledger_entry(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
|
||||
@@ -38,7 +38,7 @@ class SearchService:
|
||||
|
||||
user_tier = current_user.tier_name
|
||||
|
||||
if current_user.role in [UserRole.superadmin, UserRole.admin]:
|
||||
if current_user.role in [UserRole.SUPERADMIN, UserRole.ADMIN]:
|
||||
user_tier = "vip"
|
||||
|
||||
ranking_rules = await config.get_setting(
|
||||
|
||||
@@ -30,7 +30,7 @@ class SocialAuthService:
|
||||
db.add(new_person)
|
||||
await db.flush()
|
||||
|
||||
user = User(email=email, person_id=new_person.id, role=UserRole.user, is_active=False)
|
||||
user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_normal_user_cannot_access_admin_ping():
|
||||
mock_user = User(
|
||||
id=999,
|
||||
email="normal@example.com",
|
||||
role=UserRole.user,
|
||||
role=UserRole.USER,
|
||||
is_active=True,
|
||||
is_deleted=False,
|
||||
subscription_plan="FREE",
|
||||
@@ -54,7 +54,7 @@ def test_admin_user_can_access_admin_ping():
|
||||
mock_admin = User(
|
||||
id=1000,
|
||||
email="admin@example.com",
|
||||
role=UserRole.admin,
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
is_deleted=False,
|
||||
subscription_plan="PREMIUM",
|
||||
|
||||
Reference in New Issue
Block a user