865 lines
30 KiB
Python
865 lines
30 KiB
Python
"""
|
|
🏢 Admin Garages (Organizations) CRM API
|
|
|
|
Végpontok:
|
|
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
|
|
|
|
import logging
|
|
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, delete as sa_delete
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
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.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()
|
|
|
|
|
|
# =============================================================================
|
|
# Pydantic Schemas
|
|
# =============================================================================
|
|
|
|
|
|
class OrgSubscriptionUpdate(BaseModel):
|
|
"""Előfizetés módosításának kérése."""
|
|
tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja")
|
|
expires_at: Optional[datetime] = Field(
|
|
default=None,
|
|
description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). "
|
|
"Ha None, a csomag duration.days alapján számolódik."
|
|
)
|
|
|
|
|
|
class OrgSubscriptionResponse(BaseModel):
|
|
"""Előfizetés adatainak válasza."""
|
|
id: int
|
|
org_id: int
|
|
tier_id: int
|
|
tier_name: str = ""
|
|
valid_from: Optional[str] = None
|
|
valid_until: Optional[str] = None
|
|
is_active: bool = True
|
|
extra_allowances: dict = {}
|
|
|
|
|
|
class GarageListItem(BaseModel):
|
|
"""Egy garázs adatai a listában."""
|
|
id: int
|
|
name: str
|
|
full_name: str
|
|
display_name: Optional[str] = None
|
|
email: Optional[str] = None
|
|
status: str
|
|
is_active: bool
|
|
is_verified: bool
|
|
is_deleted: bool
|
|
org_type: str
|
|
city: Optional[str] = None
|
|
region: Optional[str] = None
|
|
segment: Optional[str] = None
|
|
member_count: int = 0
|
|
created_at: Optional[str] = None
|
|
# Előfizetési adatok
|
|
subscription: Optional[OrgSubscriptionResponse] = None
|
|
subscription_tier_name: str = "Free/Fallback"
|
|
subscription_expires_at: Optional[str] = None
|
|
|
|
|
|
class GarageListResponse(BaseModel):
|
|
"""Garázsok listájának válasza."""
|
|
total: int
|
|
skip: int
|
|
limit: int
|
|
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
|
|
# =============================================================================
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=GarageListResponse,
|
|
summary="Garázsok listázása",
|
|
description="Visszaadja az összes szervezetet (garázst) előfizetési adatokkal és taglétszámmal.",
|
|
)
|
|
async def list_organizations(
|
|
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
|
|
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
|
|
search_term: Optional[str] = Query(None, description="Keresés név vagy e-mail alapján"),
|
|
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),
|
|
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.
|
|
|
|
Minden garázshoz visszaadja:
|
|
- Alapadatokat (név, státusz, típus, város)
|
|
- Taglétszámot (OrganizationMember-ek száma)
|
|
- Aktív előfizetés adatait (tier név, lejárati dátum)
|
|
"""
|
|
# Alap lekérdezés
|
|
query = select(Organization).options(
|
|
selectinload(Organization.subscription_tier),
|
|
)
|
|
|
|
# Szűrés
|
|
if search_term:
|
|
like_pattern = f"%{search_term}%"
|
|
query = query.where(
|
|
Organization.full_name.ilike(like_pattern) |
|
|
Organization.name.ilike(like_pattern) |
|
|
Organization.display_name.ilike(like_pattern)
|
|
)
|
|
if status_filter:
|
|
query = query.where(Organization.status == status_filter)
|
|
if org_type_filter:
|
|
query = query.where(Organization.org_type == org_type_filter)
|
|
|
|
# Teljes számolás
|
|
count_stmt = select(func.count()).select_from(query.subquery())
|
|
total_result = await db.execute(count_stmt)
|
|
total = total_result.scalar() or 0
|
|
|
|
# Lapozás
|
|
query = query.order_by(Organization.id.desc()).offset(skip).limit(limit)
|
|
result = await db.execute(query)
|
|
organizations = result.scalars().all()
|
|
|
|
# Összes org_id begyűjtése a tömeges subscription lekérdezéshez
|
|
org_ids = [org.id for org in organizations]
|
|
|
|
# Tömeges subscription lekérdezés
|
|
subscriptions_map: Dict[int, OrganizationSubscription] = {}
|
|
if org_ids:
|
|
sub_stmt = (
|
|
select(OrganizationSubscription)
|
|
.options(selectinload(OrganizationSubscription.tier))
|
|
.where(
|
|
OrganizationSubscription.org_id.in_(org_ids),
|
|
OrganizationSubscription.is_active == True,
|
|
)
|
|
)
|
|
sub_result = await db.execute(sub_stmt)
|
|
for sub in sub_result.scalars().all():
|
|
# Ha több subscription is van, a legutolsót vegyük
|
|
if sub.org_id not in subscriptions_map:
|
|
subscriptions_map[sub.org_id] = sub
|
|
|
|
# Tömeges taglétszám lekérdezés
|
|
member_counts: Dict[int, int] = {}
|
|
if org_ids:
|
|
from sqlalchemy import func as sa_func
|
|
member_stmt = (
|
|
select(
|
|
OrganizationMember.organization_id,
|
|
sa_func.count(OrganizationMember.id)
|
|
)
|
|
.where(
|
|
OrganizationMember.organization_id.in_(org_ids),
|
|
OrganizationMember.status == "active",
|
|
)
|
|
.group_by(OrganizationMember.organization_id)
|
|
)
|
|
member_result = await db.execute(member_stmt)
|
|
for row in member_result.all():
|
|
member_counts[row[0]] = row[1]
|
|
|
|
# Válasz összeállítása
|
|
garage_list = []
|
|
for org in organizations:
|
|
sub = subscriptions_map.get(org.id)
|
|
sub_response = None
|
|
tier_name = "Free/Fallback"
|
|
expires_at = None
|
|
|
|
if sub and sub.tier:
|
|
tier_name = sub.tier.name
|
|
expires_at = sub.valid_until.isoformat() if sub.valid_until else None
|
|
sub_response = OrgSubscriptionResponse(
|
|
id=sub.id,
|
|
org_id=sub.org_id,
|
|
tier_id=sub.tier_id,
|
|
tier_name=sub.tier.name,
|
|
valid_from=sub.valid_from.isoformat() if sub.valid_from else None,
|
|
valid_until=expires_at,
|
|
is_active=sub.is_active,
|
|
extra_allowances=sub.extra_allowances or {},
|
|
)
|
|
elif org.subscription_tier:
|
|
# Fallback: Organization.subscription_tier_id kapcsolat
|
|
tier_name = org.subscription_tier.name
|
|
expires_at = org.subscription_expires_at.isoformat() if org.subscription_expires_at else None
|
|
|
|
garage_list.append(GarageListItem(
|
|
id=org.id,
|
|
name=org.name,
|
|
full_name=org.full_name,
|
|
display_name=org.display_name,
|
|
email=None, # Organization-nak nincs közvetlen email mezője
|
|
status=org.status,
|
|
is_active=org.is_active,
|
|
is_verified=org.is_verified,
|
|
is_deleted=org.is_deleted,
|
|
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
|
city=org.address_city,
|
|
region=org.region,
|
|
segment=org.segment,
|
|
member_count=member_counts.get(org.id, 0),
|
|
created_at=org.created_at.isoformat() if org.created_at else None,
|
|
subscription=sub_response,
|
|
subscription_tier_name=tier_name,
|
|
subscription_expires_at=expires_at,
|
|
))
|
|
|
|
return GarageListResponse(
|
|
total=total,
|
|
skip=skip,
|
|
limit=limit,
|
|
garages=garage_list,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# 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
|
|
# =============================================================================
|
|
|
|
|
|
@router.patch(
|
|
"/{org_id}/subscription",
|
|
summary="Garázs előfizetésének módosítása",
|
|
description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. "
|
|
"Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal).",
|
|
)
|
|
async def update_org_subscription(
|
|
org_id: int,
|
|
payload: OrgSubscriptionUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
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.
|
|
|
|
- Ellenőrzi, hogy a szervezet létezik-e.
|
|
- Ellenőrzi, hogy a SubscriptionTier létezik-e.
|
|
- Ha van aktív OrganizationSubscription, inaktiválja (is_active=False).
|
|
- Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val
|
|
és opcionális expires_at dátummal.
|
|
- Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol.
|
|
"""
|
|
# 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 SubscriptionTier-t
|
|
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id)
|
|
tier_result = await db.execute(tier_stmt)
|
|
tier = tier_result.scalar_one_or_none()
|
|
|
|
if not tier:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Előfizetési csomag nem található ID-vel: {payload.tier_id}",
|
|
)
|
|
|
|
# 3. Inaktiváljuk a meglévő aktív subscription-öket
|
|
await db.execute(
|
|
sa_update(OrganizationSubscription)
|
|
.where(
|
|
OrganizationSubscription.org_id == org_id,
|
|
OrganizationSubscription.is_active == True,
|
|
)
|
|
.values(is_active=False)
|
|
)
|
|
|
|
# 4. Számoljuk a lejárati dátumot
|
|
now = datetime.utcnow()
|
|
if payload.expires_at:
|
|
valid_until = payload.expires_at
|
|
else:
|
|
# Ha nincs megadva, a csomag duration.days alapján számolunk
|
|
rules = tier.rules or {}
|
|
duration = rules.get("duration", {})
|
|
days = duration.get("days", 30) if isinstance(duration, dict) else 30
|
|
from datetime import timedelta
|
|
valid_until = now + timedelta(days=days)
|
|
|
|
# 5. Létrehozzuk az új subscription rekordot
|
|
new_sub = OrganizationSubscription(
|
|
org_id=org_id,
|
|
tier_id=payload.tier_id,
|
|
valid_from=now,
|
|
valid_until=valid_until,
|
|
is_active=True,
|
|
)
|
|
db.add(new_sub)
|
|
|
|
# 6. Frissítjük a Organization denormalizált mezőit is
|
|
org.subscription_tier_id = payload.tier_id
|
|
org.subscription_expires_at = valid_until
|
|
org.subscription_plan = tier.name
|
|
|
|
await db.commit()
|
|
await db.refresh(new_sub)
|
|
|
|
logger.info(
|
|
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()}"
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": f"Előfizetés frissítve a '{org.full_name}' garázs számára.",
|
|
"subscription": {
|
|
"id": new_sub.id,
|
|
"org_id": new_sub.org_id,
|
|
"tier_id": new_sub.tier_id,
|
|
"tier_name": tier.name,
|
|
"valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None,
|
|
"valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None,
|
|
"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}).",
|
|
}
|