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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user