felhasználói felületre pü

This commit is contained in:
Roo
2026-07-27 08:39:18 +00:00
parent c9118bf52f
commit 6f28d3e70d
72 changed files with 6311 additions and 749 deletions

View File

@@ -42,7 +42,7 @@ from app.models.vehicle.asset import Asset
from app.models.vehicle.asset import AssetEvent
from app.models.vehicle.asset import OdometerReading
from app.models.marketplace.organization import Organization
from app.models.marketplace.organization import OrganizationMember
from app.models.marketplace.organization import OrganizationMember, OrgRole
from app.models.identity.social import ServiceProvider
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
from app.models.system.system import SystemParameter
@@ -54,6 +54,123 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# ── SINGLE EXPENSE READ ENDPOINT ──
# P0 BUGFIX (2026-07-26): Dedicated single-expense endpoint that returns
# enriched AssetCostResponse with resolved category name, parent_category_id,
# provider name, etc. This is used by the frontend edit modal hydration.
#
# NOTE: This MUST be registered BEFORE the {asset_id} wildcard route to avoid
# FastAPI routing the "by-id" path segment as an asset_id UUID.
@router.get("/by-id/{expense_id}")
async def get_expense_by_id(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
"""
Get a single expense by its ID with enriched fields.
Returns a fully populated AssetCostResponse including:
- category_name, category_code, parent_category_id (from CostCategory JOIN)
- service_provider_name (from ServiceProvider JOIN)
- vendor_name (from Organization JOIN via vendor_organization_id)
- description, mileage_at_cost (extracted from data JSONB)
This is the preferred endpoint for frontend edit modal hydration.
"""
from app.models.fleet_finance.models import CostCategory
from app.models.identity.social import ServiceProvider
from app.models.marketplace.organization import Organization
stmt = (
select(AssetCost)
.where(AssetCost.id == expense_id)
)
result = await db.execute(stmt)
cost = result.scalar_one_or_none()
if not cost:
raise HTTPException(status_code=404, detail="Expense not found.")
# Resolve enriched fields via explicit queries (no lazy loading surprises)
# 1. Category details + parent_id
category_name = None
category_code = None
parent_category_id = None
if cost.category_id is not None:
cat_stmt = select(CostCategory).where(CostCategory.id == cost.category_id)
cat_result = await db.execute(cat_stmt)
category = cat_result.scalar_one_or_none()
if category:
category_name = category.name
category_code = category.code
parent_category_id = category.parent_id
# 2. Service provider name
service_provider_name = None
if cost.service_provider_id is not None:
sp_stmt = select(ServiceProvider).where(ServiceProvider.id == cost.service_provider_id)
sp_result = await db.execute(sp_stmt)
provider = sp_result.scalar_one_or_none()
if provider:
service_provider_name = provider.name
# 3. Vendor organization name
vendor_name = None
if cost.vendor_organization_id is not None:
org_stmt = select(Organization).where(Organization.id == cost.vendor_organization_id)
org_result = await db.execute(org_stmt)
vendor_org = org_result.scalar_one_or_none()
if vendor_org:
vendor_name = vendor_org.name
# 4. Extract description and mileage from data JSONB
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
response = AssetCostResponse(
id=cost.id,
asset_id=cost.asset_id,
organization_id=cost.organization_id,
category_id=cost.category_id,
status=cost.status,
linked_asset_event_id=cost.linked_asset_event_id,
# Monetary
amount_gross=cost.amount_gross,
amount_net=cost.amount_net,
vat_rate=cost.vat_rate,
currency=cost.currency,
# Dates
cost_date=cost.date,
invoice_number=cost.invoice_number,
# Invoice dates
invoice_date=cost.invoice_date,
fulfillment_date=cost.fulfillment_date,
# Vendor fields
vendor_organization_id=cost.vendor_organization_id,
service_provider_id=cost.service_provider_id,
external_vendor_name=cost.external_vendor_name,
# Raw data JSONB
data=data,
# Enriched fields
category_name=category_name,
category_code=category_code,
parent_category_id=parent_category_id,
description=description,
mileage_at_cost=mileage_at_cost,
vendor_name=vendor_name,
service_provider_name=service_provider_name,
)
return {
"status": "success",
"data": response,
}
# ── FUEL CATEGORY IDS ──
# These are the category IDs that represent fuel costs.
# Used for auto-approval logic: fuel costs are always auto-approved.
@@ -100,22 +217,34 @@ async def _check_org_capability(
Uses the JSONB permissions field from fleet.org_roles.
Returns True if the user's role has the capability, False otherwise.
"""
from app.models.fleet.org_role import OrgRole
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name)
.where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if row and isinstance(row, dict):
return row.get(capability, False)
NOTE: Two-step query (not JOIN) to avoid PG ENUM vs varchar type mismatch.
OrganizationMember.role is PG ENUM 'fleet.orguserrole', OrgRole.name_key is varchar.
A direct JOIN would cause: operator does not exist: fleet.orguserrole = character varying
See assets.py:_get_user_permissions() for the same pattern.
"""
# Step 1: Get the user's role name (Python string — asyncpg auto-converts ENUM)
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
# Step 2: Look up permissions from fleet.org_roles using the role name
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 permissions and isinstance(permissions, dict):
return permissions.get(capability, False)
return False