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

@@ -1,5 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/api/deps.py
from typing import Optional, Dict, Any, Union
from typing import Optional, Dict, Any, Union, List
import logging
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
@@ -177,6 +177,65 @@ async def get_current_admin(
return current_user
# ═══════════════════════════════════════════════════════════════════════════════
# RBAC Phase 1.5: Staff & Flexible Role Dependencies
# ═══════════════════════════════════════════════════════════════════════════════
# All internal staff roles that can access the Backoffice (admin.servicefinder.hu)
STAFF_ROLES = {
UserRole.SUPERADMIN,
UserRole.ADMIN,
UserRole.MODERATOR,
UserRole.SALES_REP,
UserRole.SERVICE_MGR,
}
async def get_current_staff(
current_user: User = Depends(get_current_user)
) -> User:
"""
🎯 Staff-level access dependency.
Admits all internal staff roles: SUPERADMIN, ADMIN, MODERATOR,
SALES_REP, and SERVICE_MGR.
Use this for Backoffice (admin.servicefinder.hu) endpoints that
should be accessible to all staff members, not just superadmins.
"""
if current_user.role not in STAFF_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="AUTH.STAFF_ONLY"
)
return current_user
def RequireRole(allowed_roles: List[UserRole]):
"""
🎯 Flexible role-checking dependency factory.
Accepts a list of UserRole values. If the current user's role is
in the list, access is granted. Otherwise, 403 Forbidden.
Usage:
@router.get("/admin/sales")
async def sales_dashboard(
current_user: User = Depends(get_current_user),
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
):
"""
async def role_checker(
current_user: User = Depends(get_current_user),
) -> bool:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="AUTH.INSUFFICIENT_PERMISSIONS"
)
return True
return role_checker
# ═══════════════════════════════════════════════════════════════════════════════
# RBAC Phase 2: Capability Dependencies (Kapuőrök)
# ═══════════════════════════════════════════════════════════════════════════════

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

View File

@@ -86,6 +86,7 @@ class Settings(BaseSettings):
# --- External URLs ---
FRONTEND_BASE_URL: str = "https://app.servicefinder.hu"
COOKIE_DOMAIN: str = ".servicefinder.hu" # Cross-subdomain SSO cookie domain
BACKEND_CORS_ORIGINS: List[str] = Field(
default=[
# Production domains

View File

@@ -22,6 +22,16 @@ class SubscriptionTier(Base):
rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5}
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
# ── P0 3D CAPABILITY MATRIX: feature_capabilities JSONB ──
# Defines what features/capabilities this subscription tier unlocks.
# Example: {"can_export_data": True, "can_use_analytics": True, "max_vehicles": 50}
feature_capabilities: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=dict,
server_default=text("'{}'::jsonb")
)
class OrganizationSubscription(Base):
"""
Szervezetek aktuális előfizetései és azok érvényessége.

View File

@@ -56,6 +56,17 @@ class CostCategory(Base):
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'")
accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
# ── P0 3D CAPABILITY MATRIX: required_feature ──
# If set, this category is ONLY visible to organizations whose resolved
# capability matrix has this feature key set to True.
# Example: "can_use_analytics" → only orgs with that capability see this category.
required_feature: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
comment="Feature key required to access this cost category"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())

View File

@@ -151,6 +151,17 @@ class Organization(Base):
index=True
)
# ── P0 3D CAPABILITY MATRIX: custom_features JSONB ──
# Organization-specific feature overrides on top of the subscription tier defaults.
# These values MERGE with (and override) tier.feature_capabilities at runtime.
# Example: {"can_export_data": True, "can_use_analytics": False}
custom_features: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=dict,
server_default=text("'{}'::jsonb")
)
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"))
@@ -208,6 +219,14 @@ class Organization(Base):
# Kapcsolat az örök személy rekordhoz
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[subscription_tier_id],
backref="organizations",
lazy="selectin"
)
class OrganizationFinancials(Base):

View File

@@ -81,6 +81,30 @@ class AssetCostCreate(AssetCostBase):
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 AssetCostUpdate(BaseModel):
"""Schema for updating an existing expense via PUT /expenses/{expense_id}.
All fields are optional — only provided fields will be updated.
The expense_id is passed via path parameter, asset_id and organization_id
are immutable after creation.
"""
category_id: Optional[int] = None
amount_gross: Optional[Decimal] = None
amount_net: Optional[Decimal] = None
vat_rate: Optional[Decimal] = None
currency: Optional[str] = None
date: Optional[datetime] = None
mileage_at_cost: Optional[int] = None
description: Optional[str] = None
invoice_number: Optional[str] = None
vendor_organization_id: Optional[int] = None
external_vendor_name: Optional[str] = None
invoice_date: Optional[date] = None
fulfillment_date: Optional[date] = None
data: Optional[Dict[str, Any]] = None
class AssetCostResponse(AssetCostBase):
"""Response schema — matches DB columns from vehicle.asset_costs."""
id: UUID

View File

@@ -0,0 +1,109 @@
"""
P0 3D Capability Matrix — Capability Resolver Service
Resolves the effective feature capabilities for a given organization by merging:
1. The SubscriptionTier's feature_capabilities (tier defaults)
2. The Organization's custom_features (org-specific overrides)
Superadmin "Fast-Pass": If the user's role is SUPERADMIN, all capabilities
are bypassed and a special {"_is_superadmin": True} marker is returned.
"""
import logging
from typing import Dict, Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.models.identity import User, UserRole
from app.models.marketplace.organization import Organization
from app.models.core_logic import SubscriptionTier
logger = logging.getLogger(__name__)
async def resolve_org_capabilities(
user: User,
org_id: Optional[int],
db: AsyncSession,
) -> Dict[str, Any]:
"""
Resolve the effective 3D capability matrix for a user within an organization.
Business Logic:
1. Fast-Pass: If user.role is SUPERADMIN, return {"_is_superadmin": True}.
2. If no org_id is provided, return an empty dict (no capabilities).
3. Fetch the Organization by org_id, joined with its SubscriptionTier.
4. tier_features = tier.feature_capabilities (or {} if None/missing).
5. org_features = org.custom_features (or {} if None/missing).
6. Merge: {**tier_features, **org_features} (org overrides tier).
7. Return the merged dict.
Args:
user: The authenticated User instance.
org_id: The active organization ID (may be None).
db: Async SQLAlchemy session.
Returns:
A dict of resolved capability flags.
"""
# ── Fast-Pass: SUPERADMIN bypasses everything ──
if user.role == UserRole.SUPERADMIN:
logger.debug(
"Capability Fast-Pass for user=%s (SUPERADMIN)",
user.id,
)
return {"_is_superadmin": True}
# ── No active organization → no capabilities ──
if org_id is None:
logger.debug(
"No active org for user=%s, returning empty capabilities",
user.id,
)
return {}
# ── Fetch Organization + SubscriptionTier ──
stmt = (
select(Organization)
.options(
joinedload(Organization.subscription_tier)
)
.where(Organization.id == org_id)
)
result = await db.execute(stmt)
org = result.unique().scalar_one_or_none()
if not org:
logger.warning(
"Organization id=%s not found for user=%s, returning empty capabilities",
org_id,
user.id,
)
return {}
# ── Extract tier features ──
tier_features: Dict[str, Any] = {}
if org.subscription_tier_id is not None:
# The subscription_tier is loaded via joinedload
tier = org.subscription_tier # type: Optional[SubscriptionTier]
if tier and tier.feature_capabilities:
tier_features = dict(tier.feature_capabilities)
# ── Extract org custom features ──
org_features: Dict[str, Any] = {}
if org.custom_features:
org_features = dict(org.custom_features)
# ── Merge: org custom_features override tier defaults ──
merged = {**tier_features, **org_features}
logger.debug(
"Resolved capabilities for org=%s user=%s: %s",
org_id,
user.id,
merged,
)
return merged

View File

@@ -141,6 +141,7 @@ async def search_providers(
category: Optional[str] = None,
city: Optional[str] = None,
category_ids: Optional[List[int]] = None,
org_type: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> ProviderSearchResponse:
@@ -174,6 +175,7 @@ async def search_providers(
category: Kategória szűrő
city: Város szűrő — AND kapcsolat a q-val
category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés)
org_type: Szervezet típus szűrő (pl. 'service_provider', 'insurance')
limit: Lapozási limit
offset: Lapozási offset
@@ -190,9 +192,12 @@ async def search_providers(
# --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) ---
org_conditions = [
Organization.org_type == OrgType.service_provider,
Organization.is_deleted == False,
]
if org_type:
org_conditions.append(Organization.org_type == org_type)
else:
org_conditions.append(Organization.org_type == OrgType.service_provider)
if q_tokens:
token_conditions = []
for token in q_tokens:
@@ -630,25 +635,10 @@ async def quick_add_provider(
db.add(branch)
# =====================================================================
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (P0 CRITICAL BUGFIX 2026-06-17)
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (KIHAGYVA - P0 ARCHITECTURE FIX)
# =====================================================================
# A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER).
# Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL).
# Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég
# nem jelenik meg a "Cégeim" garázsválasztóban.
member = OrganizationMember(
organization_id=org.id,
user_id=user_id,
role=OrgUserRole.ADMIN,
is_permanent=True,
is_verified=True,
)
db.add(member)
await db.flush()
logger.info(
f"OrganizationMember created: org_id={org.id}, user_id={user_id}, "
f"role=ADMIN (crowdsourced provider has no owner)"
)
# DÖNTÉS: A létrehozó felhasználó NEM lehet tagja a beszállító szervezetnek.
# A szerviz jöjjön létre owner_id=None beállítással, tagok nélkül.
# =====================================================================
# =====================================================================

View File

@@ -79,6 +79,36 @@ COST_CATEGORY_TREE = [
("SERVICE_TIRES", "Gumiabroncs", "Gumiabroncs csere és tárolás"),
],
},
{
"code": "TIRE",
"name": "Gumiabroncsok",
"description": "Gumiabroncsokkal kapcsolatos költségek",
"children": [
("TIRE_SUMMER", "Nyári gumi", "Nyári gumiabroncs vásárlás"),
("TIRE_WINTER", "Téli gumi", "Téli gumiabroncs vásárlás"),
("TIRE_STORAGE", "Gumi tárolás", "Gumiabroncs tárolási díj"),
],
},
{
"code": "CLEANING",
"name": "Ápolás & Kozmetika",
"description": "Jármű ápolásával és kozmetikájával kapcsolatos költségek",
"children": [
("CLEANING_WASH", "Mosás", "Járműmosás"),
("CLEANING_DETAILING", "Részletes tisztítás", "Részletes autókozmetika"),
("CLEANING_INTERIOR", "Belső tisztítás", "Belső tér tisztítása"),
],
},
{
"code": "OTHER",
"name": "Egyéb",
"description": "Egyéb, máshova nem sorolható költségek",
"children": [
("OTHER_FINE", "Bírság", "Közlekedési bírság"),
("OTHER_ACCESSORY", "Tartozék", "Jármű tartozékok"),
("OTHER_MISC", "Egyéb", "Egyéb költségek"),
],
},
]
# =============================================================================