jogosultsági szintek RBAC beállítva tesztelve

This commit is contained in:
Roo
2026-06-25 20:41:49 +00:00
parent 52011606ff
commit 36109fc722
242 changed files with 8672 additions and 2237 deletions

View File

@@ -2,8 +2,12 @@
🏢 Admin Garages (Organizations) CRM API
Végpontok:
GET /admin/organizations — Garázsok listázása előfizetési adatokkal
GET /admin/organizations — Garázsok listázása előfizetési adatokkal
GET /admin/organizations/{org_id}/details — Garázs részletes adatai (General Tab)
PATCH /admin/organizations/{org_id}/subscription — Előfizetés módosítása
POST /admin/organizations/{org_id}/members — Új tag hozzáadása a garázshoz
PUT /admin/organizations/{org_id}/members/{member_id} — Tag adatainak módosítása
DELETE /admin/organizations/{org_id}/members/{member_id} — Tag eltávolítása
"""
from __future__ import annotations
@@ -13,16 +17,23 @@ from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func, update as sa_update
from sqlalchemy import select, func, update as sa_update, delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload, contains_eager
from sqlalchemy.orm import selectinload, joinedload
from pydantic import BaseModel, Field
from app.api import deps
from app.db.session import get_db
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.identity import User
from app.models.fleet.organization import ContactPerson
from app.models.identity import User, Person
from app.schemas.organization import (
OrganizationMemberCreate,
OrganizationMemberUpdate,
OrganizationMemberResponse,
PersonBrief,
)
logger = logging.getLogger("admin-organizations")
router = APIRouter()
@@ -86,6 +97,63 @@ class GarageListResponse(BaseModel):
garages: List[GarageListItem]
# =============================================================================
# Pydantic Schemas — Garage Details
# =============================================================================
class ContactPersonInfo(BaseModel):
"""Kapcsolattartó személy adatai."""
id: int
full_name: str = ""
role: str = ""
department: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
is_primary: bool = False
class SubscriptionSummary(BaseModel):
"""Előfizetés összefoglaló."""
tier_name: str = "Free/Fallback"
tier_level: int = 0
valid_from: Optional[str] = None
expires_at: Optional[str] = None
is_active: bool = True
asset_count: int = 0
asset_limit: int = 1
class GarageDetailsResponse(BaseModel):
"""Garázs részletes adatai (General Tab)."""
# Szervezet adatok
id: int
name: str
full_name: str
display_name: Optional[str] = None
status: str
is_active: bool
is_verified: bool
org_type: str
# Cím adatok
address_city: Optional[str] = None
address_zip: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
tax_number: Optional[str] = None
reg_number: Optional[str] = None
# Előfizetés
subscription: Optional[SubscriptionSummary] = None
# Kapcsolattartó
primary_contact: Optional[ContactPersonInfo] = None
# Meta
created_at: Optional[str] = None
member_count: int = 0
# P0: Full member list with nested person data
members: List[OrganizationMemberResponse] = []
# =============================================================================
# GET / — Garázsok listázása
# =============================================================================
@@ -104,7 +172,8 @@ async def list_organizations(
status_filter: Optional[str] = Query(None, description="Szűrés státusz szerint (active/inactive/suspended/pending_verification)"),
org_type_filter: Optional[str] = Query(None, description="Szűrés szervezet típus szerint"),
db: AsyncSession = Depends(get_db),
admin: User = Depends(deps.get_current_admin),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("org:view")),
) -> GarageListResponse:
"""
Garázsok / Szervezetek listázása lapozással és kereséssel.
@@ -236,6 +305,200 @@ async def list_organizations(
)
# =============================================================================
# GET /{org_id}/details — Garázs részletes adatai (General Tab)
# =============================================================================
@router.get(
"/{org_id}/details",
response_model=GarageDetailsResponse,
summary="Garázs részletes adatai",
description="Visszaadja egy garázs teljes adatait, előfizetési összefoglalóját, "
"elsődleges kapcsolattartóját és taglistáját a General Tab számára.",
)
async def get_organization_details(
org_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("org:view")),
) -> GarageDetailsResponse:
"""
Garázs részletes adatainak lekérése.
Visszaadja:
- Teljes szervezet adatokat (név, cím, adószám, státusz)
- Előfizetés összefoglalót (tier név, lejárat, járműszám/korlát)
- Elsődleges kapcsolattartó adatait (fleet.contact_persons)
- Tagok teljes listáját (OrganizationMember) nested Person adatokkal
"""
# 1. Szervezet lekérése subscription_tier kapcsolattal
org_stmt = (
select(Organization)
.options(
selectinload(Organization.subscription_tier),
)
.where(Organization.id == org_id)
)
org_result = await db.execute(org_stmt)
org = org_result.scalar_one_or_none()
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Szervezet nem található ID-vel: {org_id}",
)
# 2. Aktív előfizetés lekérése
sub_stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
sub_result = await db.execute(sub_stmt)
active_sub = sub_result.scalar_one_or_none()
# 3. Tagok lekérése eager loading-gal (User + Person)
members_stmt = (
select(OrganizationMember)
.options(
selectinload(OrganizationMember.user).selectinload(User.person),
selectinload(OrganizationMember.person),
)
.where(
OrganizationMember.organization_id == org_id,
)
.order_by(OrganizationMember.id)
)
members_result = await db.execute(members_stmt)
member_rows = members_result.scalars().all()
# Taglétszám (aktív tagok száma)
member_count = sum(1 for m in member_rows if m.status == "active")
# 4. Elsődleges kapcsolattartó lekérése
contact_stmt = (
select(ContactPerson)
.options(selectinload(ContactPerson.person))
.where(
ContactPerson.organization_id == org_id,
ContactPerson.is_primary == True,
)
.limit(1)
)
contact_result = await db.execute(contact_stmt)
contact = contact_result.scalar_one_or_none()
# 5. Subscription összefoglaló
subscription_summary = None
if active_sub and active_sub.tier:
rules = active_sub.tier.rules or {}
asset_limit = (
rules.get("max_vehicles")
or active_sub.tier.feature_capabilities.get("max_vehicles")
or 1
)
subscription_summary = SubscriptionSummary(
tier_name=active_sub.tier.name,
tier_level=active_sub.tier.tier_level,
valid_from=active_sub.valid_from.isoformat() if active_sub.valid_from else None,
expires_at=active_sub.valid_until.isoformat() if active_sub.valid_until else None,
is_active=active_sub.is_active,
asset_count=0,
asset_limit=asset_limit,
)
elif org.subscription_tier:
rules = org.subscription_tier.rules or {}
asset_limit = (
rules.get("max_vehicles")
or org.subscription_tier.feature_capabilities.get("max_vehicles")
or 1
)
subscription_summary = SubscriptionSummary(
tier_name=org.subscription_tier.name,
tier_level=org.subscription_tier.tier_level,
expires_at=org.subscription_expires_at.isoformat() if org.subscription_expires_at else None,
asset_limit=asset_limit,
)
# 6. Kapcsolattartó adatok
primary_contact = None
if contact and contact.person:
primary_contact = ContactPersonInfo(
id=contact.id,
full_name=f"{contact.person.last_name} {contact.person.first_name}",
role=contact.role,
department=contact.department,
phone=contact.person.phone,
is_primary=contact.is_primary,
)
# 7. Tagok összeállítása nested Person adatokkal
members_list: List[OrganizationMemberResponse] = []
for m in member_rows:
person_data = None
# Try to get Person from the member's direct person relationship first
person_obj = m.person
# Fallback: get Person through the User relationship
if not person_obj and m.user and m.user.person:
person_obj = m.user.person
if person_obj:
# Get email from the user record
email = m.user.email if m.user else None
person_data = PersonBrief(
first_name=person_obj.first_name or "",
last_name=person_obj.last_name or "",
email=email,
phone=person_obj.phone,
)
members_list.append(OrganizationMemberResponse(
id=m.id,
user_id=m.user_id,
organization_id=m.organization_id,
role=m.role,
status=m.status or "active",
is_verified=m.is_verified,
person=person_data,
created_at=m.created_at.isoformat() if m.created_at else None,
updated_at=m.updated_at.isoformat() if m.updated_at else None,
))
logger.info(
f"Admin {current_user.id} ({current_user.email}) fetched details for org {org_id} "
f"({org.full_name}) — {len(members_list)} members"
)
return GarageDetailsResponse(
id=org.id,
name=org.name,
full_name=org.full_name,
display_name=org.display_name,
status=org.status,
is_active=org.is_active,
is_verified=org.is_verified,
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
address_city=org.address_city,
address_zip=org.address_zip,
address_street_name=org.address_street_name,
address_street_type=org.address_street_type,
address_house_number=org.address_house_number,
tax_number=org.tax_number,
reg_number=org.reg_number,
subscription=subscription_summary,
primary_contact=primary_contact,
created_at=org.created_at.isoformat() if org.created_at else None,
member_count=member_count,
members=members_list,
)
# =============================================================================
# PATCH /{org_id}/subscription — Előfizetés módosítása
# =============================================================================
@@ -251,7 +514,8 @@ async def update_org_subscription(
org_id: int,
payload: OrgSubscriptionUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(deps.get_current_admin),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("org:edit")),
) -> Dict[str, Any]:
"""
Garázs előfizetésének módosítása.
@@ -326,7 +590,7 @@ async def update_org_subscription(
await db.refresh(new_sub)
logger.info(
f"Admin {admin.id} ({admin.email}) updated subscription for org {org_id}: "
f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: "
f"tier_id={payload.tier_id}, tier_name={tier.name}, "
f"valid_until={valid_until.isoformat()}"
)
@@ -344,3 +608,257 @@ async def update_org_subscription(
"is_active": new_sub.is_active,
},
}
# =============================================================================
# POST /{org_id}/members — Új tag hozzáadása a garázshoz
# =============================================================================
@router.post(
"/{org_id}/members",
response_model=OrganizationMemberResponse,
status_code=status.HTTP_201_CREATED,
summary="Új tag hozzáadása",
description="Hozzáad egy felhasználót a garázshoz a megadott szerepkörrel.",
)
async def add_organization_member(
org_id: int,
payload: OrganizationMemberCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("org:manage-members")),
) -> OrganizationMemberResponse:
"""
Új tag hozzáadása egy garázshoz.
- Ellenőrzi, hogy a szervezet létezik-e.
- Ellenőrzi, hogy a user_id létezik-e az identity.users táblában.
- Ellenőrzi, hogy a felhasználó még nem tagja a szervezetnek.
- Létrehozza az OrganizationMember rekordot.
"""
# 1. Ellenőrizzük a szervezetet
org_stmt = select(Organization).where(Organization.id == org_id)
org_result = await db.execute(org_stmt)
org = org_result.scalar_one_or_none()
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Szervezet nem található ID-vel: {org_id}",
)
# 2. Ellenőrizzük a felhasználót
user_stmt = select(User).where(User.id == payload.user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Felhasználó nem található ID-vel: {payload.user_id}",
)
# 3. Ellenőrizzük, hogy a felhasználó még nem tagja a szervezetnek
existing_stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == payload.user_id,
OrganizationMember.status != "archived",
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A felhasználó (ID: {payload.user_id}) már tagja ennek a szervezetnek.",
)
# 4. Létrehozzuk az új tag rekordot
person_id = user.person_id
new_member = OrganizationMember(
organization_id=org_id,
user_id=payload.user_id,
person_id=person_id,
role=payload.role,
status=payload.status,
is_verified=False,
)
db.add(new_member)
await db.commit()
await db.refresh(new_member)
# 5. Visszatöltjük a kapcsolódó adatokat a válaszhoz
await db.refresh(new_member, attribute_names=["user", "person"])
# Person adatok összeállítása
person_data = None
person_obj = new_member.person
if not person_obj and new_member.user and new_member.user.person:
person_obj = new_member.user.person
if person_obj:
person_data = PersonBrief(
first_name=person_obj.first_name or "",
last_name=person_obj.last_name or "",
email=user.email,
phone=person_obj.phone,
)
logger.info(
f"Admin {current_user.id} ({current_user.email}) added member user_id={payload.user_id} "
f"to org {org_id} ({org.full_name}) with role={payload.role}"
)
return OrganizationMemberResponse(
id=new_member.id,
user_id=new_member.user_id,
organization_id=new_member.organization_id,
role=new_member.role,
status=new_member.status or "active",
is_verified=new_member.is_verified,
person=person_data,
created_at=new_member.created_at.isoformat() if new_member.created_at else None,
updated_at=new_member.updated_at.isoformat() if new_member.updated_at else None,
)
# =============================================================================
# PUT /{org_id}/members/{member_id} — Tag adatainak módosítása
# =============================================================================
@router.put(
"/{org_id}/members/{member_id}",
response_model=OrganizationMemberResponse,
summary="Tag adatainak módosítása",
description="Frissíti egy meglévő tag szerepkörét és/vagy státuszát.",
)
async def update_organization_member(
org_id: int,
member_id: int,
payload: OrganizationMemberUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("org:manage-members")),
) -> OrganizationMemberResponse:
"""
Meglévő tag adatainak módosítása.
- Ellenőrzi, hogy a tag rekord létezik és a megadott szervezethez tartozik.
- Frissíti a role és/vagy status mezőket.
"""
# 1. Ellenőrizzük a tag rekordot
member_stmt = (
select(OrganizationMember)
.options(
selectinload(OrganizationMember.user).selectinload(User.person),
selectinload(OrganizationMember.person),
)
.where(
OrganizationMember.id == member_id,
OrganizationMember.organization_id == org_id,
)
)
member_result = await db.execute(member_stmt)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tag nem található (ID: {member_id}) a szervezetben (ID: {org_id}).",
)
# 2. Frissítjük a mezőket
if payload.role is not None:
member.role = payload.role
if payload.status is not None:
member.status = payload.status
await db.commit()
await db.refresh(member)
# 3. Person adatok összeállítása
person_data = None
person_obj = member.person
if not person_obj and member.user and member.user.person:
person_obj = member.user.person
if person_obj:
email = member.user.email if member.user else None
person_data = PersonBrief(
first_name=person_obj.first_name or "",
last_name=person_obj.last_name or "",
email=email,
phone=person_obj.phone,
)
logger.info(
f"Admin {current_user.id} ({current_user.email}) updated member {member_id} in org {org_id}: "
f"role={payload.role}, status={payload.status}"
)
return OrganizationMemberResponse(
id=member.id,
user_id=member.user_id,
organization_id=member.organization_id,
role=member.role,
status=member.status or "active",
is_verified=member.is_verified,
person=person_data,
created_at=member.created_at.isoformat() if member.created_at else None,
updated_at=member.updated_at.isoformat() if member.updated_at else None,
)
# =============================================================================
# DELETE /{org_id}/members/{member_id} — Tag eltávolítása
# =============================================================================
@router.delete(
"/{org_id}/members/{member_id}",
status_code=status.HTTP_200_OK,
summary="Tag eltávolítása",
description="Eltávolít egy tagot a garázsból (soft delete: status → archived).",
)
async def remove_organization_member(
org_id: int,
member_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("org:manage-members")),
) -> Dict[str, Any]:
"""
Tag eltávolítása a garázsból.
Soft delete megközelítés: a rekord nem törlődik, csak a status mező
'archived' értékre változik. Ez megőrzi a történeti adatokat.
"""
# 1. Ellenőrizzük a tag rekordot
member_stmt = select(OrganizationMember).where(
OrganizationMember.id == member_id,
OrganizationMember.organization_id == org_id,
)
member_result = await db.execute(member_stmt)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tag nem található (ID: {member_id}) a szervezetben (ID: {org_id}).",
)
# 2. Soft delete: archived státusz
member.status = "archived"
await db.commit()
logger.info(
f"Admin {current_user.id} ({current_user.email}) removed member {member_id} from org {org_id} "
f"(status → archived)"
)
return {
"status": "success",
"message": f"Tag (ID: {member_id}) eltávolítva a szervezetből (ID: {org_id}).",
}