admin felület különválasztva
This commit is contained in:
@@ -130,6 +130,7 @@ async def login(
|
||||
httponly=True,
|
||||
secure=True, # Use Secure
|
||||
samesite="lax",
|
||||
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
|
||||
max_age=max_age_sec
|
||||
)
|
||||
|
||||
@@ -224,6 +225,7 @@ async def verify_email(
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
|
||||
max_age=max_age_sec
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
Szótárak és katalógusok végpontjai.
|
||||
- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve
|
||||
(P0 3D Capability Matrix: filtered by resolved org capabilities)
|
||||
- CRUD /dictionaries/insurance-providers: Biztosítók katalógusa
|
||||
"""
|
||||
import logging
|
||||
@@ -17,6 +18,7 @@ from app.schemas.fleet_finance import (
|
||||
InsuranceProviderCreate,
|
||||
InsuranceProviderUpdate,
|
||||
)
|
||||
from app.services.capability_service import resolve_org_capabilities
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,7 +33,21 @@ async def list_cost_categories(
|
||||
"""
|
||||
Költségkategóriák lekérése hierarchikus struktúrában.
|
||||
Opcionálisan szűrhető visibility alapján (pl. ha a frontend csak a 'b2c' és 'both' kategóriákat akarja).
|
||||
|
||||
P0 3D Capability Matrix:
|
||||
- Resolves the active organization's capability matrix.
|
||||
- If the user is SUPERADMIN (Fast-Pass), all categories are returned.
|
||||
- Otherwise, categories with a required_feature are only included
|
||||
if the resolved capabilities have that feature set to True.
|
||||
"""
|
||||
# ── Determine active organization ──
|
||||
active_org_id: Optional[int] = None
|
||||
if hasattr(current_user, "active_organization_id") and current_user.active_organization_id:
|
||||
active_org_id = current_user.active_organization_id
|
||||
|
||||
# ── Resolve capabilities ──
|
||||
resolved_caps = await resolve_org_capabilities(current_user, active_org_id, db)
|
||||
|
||||
stmt = select(CostCategory).order_by(CostCategory.name)
|
||||
|
||||
if visibility:
|
||||
@@ -46,6 +62,14 @@ async def list_cost_categories(
|
||||
result = await db.execute(stmt)
|
||||
categories = result.scalars().all()
|
||||
|
||||
# ── P0 3D Capability Matrix: filter by required_feature ──
|
||||
is_superadmin = resolved_caps.get("_is_superadmin", False)
|
||||
if not is_superadmin:
|
||||
categories = [
|
||||
cat for cat in categories
|
||||
if not cat.required_feature or resolved_caps.get(cat.required_feature) is True
|
||||
]
|
||||
|
||||
# Hierarchikus struktúra építése
|
||||
category_map = {}
|
||||
root_categories = []
|
||||
@@ -60,6 +84,8 @@ async def list_cost_categories(
|
||||
"visibility": cat.visibility,
|
||||
"accounting_code": cat.accounting_code,
|
||||
"is_system": cat.is_system,
|
||||
"min_tier": cat.min_tier,
|
||||
"required_feature": cat.required_feature,
|
||||
"children": [],
|
||||
}
|
||||
category_map[cat.id] = cat_dict
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from app.api.deps import get_db, get_current_user, RequireOrgCapability
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
|
||||
from app.schemas.asset_cost import AssetCostCreate
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -257,6 +257,100 @@ async def list_all_expenses(
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/{expense_id}", status_code=200)
|
||||
async def update_expense(
|
||||
expense_id: UUID,
|
||||
update: AssetCostUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update an existing expense record.
|
||||
|
||||
Only the fields provided in the request body will be updated.
|
||||
The expense_id is the UUID of the existing AssetCost record.
|
||||
The user must be a member of the organization that owns the expense.
|
||||
"""
|
||||
# Fetch the existing expense
|
||||
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.")
|
||||
|
||||
# Verify user has access to the expense's organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == cost.organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not an active member of the organization that owns this expense."
|
||||
)
|
||||
|
||||
# Build update dict from non-None fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields provided for update.")
|
||||
|
||||
# Map 'date' field to 'cost_date' column if present
|
||||
if 'date' in update_data:
|
||||
update_data['cost_date'] = update_data.pop('date')
|
||||
|
||||
# Handle mileage_at_cost and description → store in data JSONB
|
||||
data_update = {}
|
||||
if 'mileage_at_cost' in update_data:
|
||||
data_update['mileage_at_cost'] = update_data.pop('mileage_at_cost')
|
||||
if 'description' in update_data:
|
||||
data_update['description'] = update_data.pop('description')
|
||||
|
||||
# Merge data_update into existing data JSONB
|
||||
if data_update:
|
||||
existing_data = dict(cost.data or {})
|
||||
existing_data.update(data_update)
|
||||
update_data['data'] = existing_data
|
||||
|
||||
# Apply updates
|
||||
for field, value in update_data.items():
|
||||
setattr(cost, field, value)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(cost)
|
||||
|
||||
logger.info(f"Expense {expense_id} updated by user {current_user.id}: fields={list(update_data.keys())}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"category_id": cost.category_id,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"expense_status": cost.status,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Expense update error for {expense_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a költség módosításakor"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{asset_id}")
|
||||
async def list_asset_expenses(
|
||||
asset_id: UUID,
|
||||
@@ -395,30 +489,30 @@ async def create_expense(
|
||||
detail=f"DRAFT_LIMIT_REACHED: Draft vehicles are limited to {limit} expenses. This asset already has {expense_count} expenses."
|
||||
)
|
||||
|
||||
# Determine organization_id: prefer explicit from payload, fallback to asset fields
|
||||
organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id
|
||||
if not organization_id:
|
||||
# B2C fallback: if the asset is owned by a person (not an org),
|
||||
# try to find the current user's default organization
|
||||
# P0 ARCHITECTURE FIX: organization_id MUST follow the Asset, not the user.
|
||||
# The cost belongs to the asset's current organization (e.g., Aprilia garage = org 21).
|
||||
# The vendor is stored separately via vendor_organization_id.
|
||||
#
|
||||
# Resolution priority:
|
||||
# 1. asset.current_organization_id (the asset's current fleet/garage)
|
||||
# 2. asset.owner_org_id (the asset's owning organization)
|
||||
# 3. Fallback: user's active organization from membership
|
||||
if asset.current_organization_id:
|
||||
organization_id = asset.current_organization_id
|
||||
elif asset.owner_org_id:
|
||||
organization_id = asset.owner_org_id
|
||||
else:
|
||||
# Fallback: user's active organization from membership
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.is_verified == True
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id
|
||||
else:
|
||||
# Last resort: any membership (even unverified)
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
|
||||
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
|
||||
|
||||
@@ -778,13 +778,13 @@ async def assign_organization_subscription(
|
||||
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).
|
||||
It requires org-level ADMIN or OWNER access (checked via _check_org_admin_access).
|
||||
System-level SUPERADMIN capability is also accepted via the org admin check.
|
||||
|
||||
The endpoint:
|
||||
1. Validates the tier exists in system.subscription_tiers
|
||||
@@ -792,6 +792,8 @@ async def assign_organization_subscription(
|
||||
3. Updates subscription_plan and base_asset_limit from tier rules
|
||||
4. Creates/updates a finance.org_subscriptions audit record
|
||||
"""
|
||||
# Allow org ADMIN/OWNER to manage their own subscription
|
||||
await _check_org_admin_access(org_id, current_user.id, db)
|
||||
try:
|
||||
result = await upgrade_org_subscription(
|
||||
db=db,
|
||||
|
||||
@@ -187,6 +187,7 @@ async def search_service_providers(
|
||||
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
||||
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
||||
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),
|
||||
org_type: Optional[str] = Query(None, max_length=50, description="Szervezet típus szűrő (pl. 'service_provider', 'insurance')"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"),
|
||||
offset: int = Query(0, ge=0, description="Lapozási offset"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -224,6 +225,7 @@ async def search_service_providers(
|
||||
category=category,
|
||||
city=city,
|
||||
category_ids=parsed_category_ids,
|
||||
org_type=org_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user