admin felület különválasztva

This commit is contained in:
Roo
2026-06-24 11:29:45 +00:00
parent 71ef33bb85
commit 80a5d67f79
462 changed files with 87873 additions and 312 deletions

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
)