backend admin flotta bekötése
This commit is contained in:
@@ -17,7 +17,7 @@ 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 import select, func, or_, 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
|
||||
@@ -125,7 +125,7 @@ class SubscriptionSummary(BaseModel):
|
||||
|
||||
|
||||
class GarageDetailsResponse(BaseModel):
|
||||
"""Garázs részletes adatai (General Tab)."""
|
||||
"""Garázs részletes adatai (General Tab) — kibővített CRM profil."""
|
||||
# Szervezet adatok
|
||||
id: int
|
||||
name: str
|
||||
@@ -135,7 +135,7 @@ class GarageDetailsResponse(BaseModel):
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
org_type: str
|
||||
# Cím adatok
|
||||
# Cím adatok (elsődleges)
|
||||
address_city: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
@@ -143,9 +143,25 @@ class GarageDetailsResponse(BaseModel):
|
||||
address_house_number: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
reg_number: Optional[str] = None
|
||||
# ── Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
# ── Számlázási cím (Billing Address) ──
|
||||
billing_zip: Optional[str] = None
|
||||
billing_city: Optional[str] = None
|
||||
billing_street_name: Optional[str] = None
|
||||
billing_street_type: Optional[str] = None
|
||||
billing_house_number: Optional[str] = None
|
||||
# ── Értesítési cím (Notification Address) ──
|
||||
notification_zip: Optional[str] = None
|
||||
notification_city: Optional[str] = None
|
||||
notification_street_name: Optional[str] = None
|
||||
notification_street_type: Optional[str] = None
|
||||
notification_house_number: Optional[str] = None
|
||||
# Előfizetés
|
||||
subscription: Optional[SubscriptionSummary] = None
|
||||
# Kapcsolattartó
|
||||
# Kapcsolattartó (ContactPerson modellből)
|
||||
primary_contact: Optional[ContactPersonInfo] = None
|
||||
# Meta
|
||||
created_at: Optional[str] = None
|
||||
@@ -171,6 +187,7 @@ async def list_organizations(
|
||||
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"),
|
||||
tier_name_filter: Optional[str] = Query(None, description="Szűrés előfizetési csomag neve alapján (pl. 'Premium', 'Free/Fallback')"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:view")),
|
||||
@@ -183,33 +200,54 @@ async def list_organizations(
|
||||
- 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),
|
||||
)
|
||||
# Alap lekérdezés — outerjoin owner (User) hogy a tulajdonos emailjét is kereshessük
|
||||
base_query = select(Organization.id).outerjoin(Organization.owner)
|
||||
|
||||
# Szűrés
|
||||
if search_term:
|
||||
like_pattern = f"%{search_term}%"
|
||||
query = query.where(
|
||||
base_query = base_query.where(
|
||||
Organization.full_name.ilike(like_pattern) |
|
||||
Organization.name.ilike(like_pattern) |
|
||||
Organization.display_name.ilike(like_pattern)
|
||||
Organization.display_name.ilike(like_pattern) |
|
||||
Organization.contact_email.ilike(like_pattern) |
|
||||
Organization.contact_person_name.ilike(like_pattern) |
|
||||
User.email.ilike(like_pattern)
|
||||
)
|
||||
if status_filter:
|
||||
query = query.where(Organization.status == status_filter)
|
||||
base_query = base_query.where(Organization.status == status_filter)
|
||||
if org_type_filter:
|
||||
query = query.where(Organization.org_type == org_type_filter)
|
||||
base_query = base_query.where(Organization.org_type == org_type_filter)
|
||||
if tier_name_filter:
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
base_query = base_query.join(SubscriptionTier, Organization.subscription_tier_id == SubscriptionTier.id)
|
||||
base_query = base_query.where(SubscriptionTier.name.ilike(f"%{tier_name_filter}%"))
|
||||
|
||||
# Teljes számolás
|
||||
count_stmt = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
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()
|
||||
# Lapozás — get IDs first, then fetch full objects
|
||||
id_query = base_query.order_by(Organization.id.desc()).offset(skip).limit(limit)
|
||||
id_result = await db.execute(id_query)
|
||||
org_ids_subset = [row[0] for row in id_result.all()]
|
||||
|
||||
# Fetch full Organization objects by IDs with joinedload to avoid MissingGreenlet
|
||||
if org_ids_subset:
|
||||
from sqlalchemy.orm import joinedload
|
||||
query = select(Organization).options(
|
||||
joinedload(Organization.subscription_tier),
|
||||
joinedload(Organization.owner),
|
||||
).where(Organization.id.in_(org_ids_subset))
|
||||
result = await db.execute(query)
|
||||
# Use unique() because joinedload may produce duplicate rows
|
||||
organizations = result.unique().scalars().all()
|
||||
# Preserve the ordering from the id_query
|
||||
org_map = {org.id: org for org in organizations}
|
||||
organizations = [org_map[oid] for oid in org_ids_subset if oid in org_map]
|
||||
else:
|
||||
organizations = []
|
||||
|
||||
# Összes org_id begyűjtése a tömeges subscription lekérdezéshez
|
||||
org_ids = [org.id for org in organizations]
|
||||
@@ -276,12 +314,15 @@ async def list_organizations(
|
||||
tier_name = org.subscription_tier.name
|
||||
expires_at = org.subscription_expires_at.isoformat() if org.subscription_expires_at else None
|
||||
|
||||
# Resolve email: prefer contact_email, fallback to owner's email
|
||||
resolved_email = org.contact_email or (org.owner.email if org.owner 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
|
||||
email=resolved_email,
|
||||
status=org.status,
|
||||
is_active=org.is_active,
|
||||
is_verified=org.is_verified,
|
||||
@@ -331,12 +372,17 @@ async def get_organization_details(
|
||||
- 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
|
||||
|
||||
P0 Zero-Duplication:
|
||||
- Ha contact_email/phone/person_name NULL, az owner adatai kerülnek bele.
|
||||
- Az owner mindig szerepel a members listában (OWNER role).
|
||||
"""
|
||||
# 1. Szervezet lekérése subscription_tier kapcsolattal
|
||||
# 1. Szervezet lekérése subscription_tier kapcsolattal + owner
|
||||
org_stmt = (
|
||||
select(Organization)
|
||||
.options(
|
||||
selectinload(Organization.subscription_tier),
|
||||
selectinload(Organization.owner).selectinload(User.person),
|
||||
)
|
||||
.where(Organization.id == org_id)
|
||||
)
|
||||
@@ -438,8 +484,34 @@ async def get_organization_details(
|
||||
is_primary=contact.is_primary,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# P0: Owner adatok betöltése a Zero-Duplication Contact Logic-hoz
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
owner: Optional[User] = org.owner
|
||||
owner_person: Optional[Person] = None
|
||||
owner_full_name: Optional[str] = None
|
||||
owner_email: Optional[str] = None
|
||||
owner_phone: Optional[str] = None
|
||||
|
||||
if owner:
|
||||
owner_email = owner.email
|
||||
owner_person = owner.person
|
||||
if owner_person:
|
||||
owner_full_name = f"{owner_person.last_name} {owner_person.first_name}".strip()
|
||||
owner_phone = owner_person.phone
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# P0: Zero-Duplication Override Pattern
|
||||
# Ha a contact mező NULL, az owner adatait injectáljuk
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
resolved_contact_person_name = org.contact_person_name or owner_full_name
|
||||
resolved_contact_email = org.contact_email or owner_email
|
||||
resolved_contact_phone = org.contact_phone or owner_phone
|
||||
|
||||
# 7. Tagok összeállítása nested Person adatokkal
|
||||
members_list: List[OrganizationMemberResponse] = []
|
||||
owner_found_in_members = False
|
||||
|
||||
for m in member_rows:
|
||||
person_data = None
|
||||
# Try to get Person from the member's direct person relationship first
|
||||
@@ -458,6 +530,10 @@ async def get_organization_details(
|
||||
phone=person_obj.phone,
|
||||
)
|
||||
|
||||
# Check if this member is the owner
|
||||
if owner and m.user_id == owner.id and m.role == "OWNER":
|
||||
owner_found_in_members = True
|
||||
|
||||
members_list.append(OrganizationMemberResponse(
|
||||
id=m.id,
|
||||
user_id=m.user_id,
|
||||
@@ -470,6 +546,48 @@ async def get_organization_details(
|
||||
updated_at=m.updated_at.isoformat() if m.updated_at else None,
|
||||
))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# P0: Ha az owner NEM szerepel a members listában, dinamikusan hozzáfűzzük
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
if owner and not owner_found_in_members:
|
||||
# Build a fully-formed PersonBrief — even if owner_person is None,
|
||||
# we still provide an empty PersonBrief so the frontend never crashes
|
||||
# on null person access.
|
||||
owner_person_data = PersonBrief(
|
||||
first_name=owner_person.first_name or "" if owner_person else "",
|
||||
last_name=owner_person.last_name or "" if owner_person else "",
|
||||
email=owner_email,
|
||||
phone=owner_phone,
|
||||
)
|
||||
# Create a synthetic member entry for the owner.
|
||||
# P0 HOTFIX: The synthetic member MUST include display_name/name/email
|
||||
# at the top level so the frontend memberFullName() helper can render
|
||||
# a human-readable name instead of falling back to user_id.
|
||||
synthetic_owner = OrganizationMemberResponse(
|
||||
id=0, # synthetic marker
|
||||
user_id=owner.id,
|
||||
organization_id=org_id,
|
||||
role="OWNER",
|
||||
status="active",
|
||||
is_verified=True,
|
||||
person=owner_person_data,
|
||||
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||
updated_at=None,
|
||||
)
|
||||
# Inject top-level display fields for frontend memberFullName()
|
||||
# (the Pydantic model doesn't have these fields, so we use object.__setattr__)
|
||||
object.__setattr__(synthetic_owner, 'display_name', owner_full_name or owner_email or f"Owner #{owner.id}")
|
||||
object.__setattr__(synthetic_owner, 'name', owner_full_name or owner_email or f"Owner #{owner.id}")
|
||||
object.__setattr__(synthetic_owner, 'email', owner_email)
|
||||
# Prepend the owner so they appear first in the list
|
||||
members_list.insert(0, synthetic_owner)
|
||||
# Also bump the member_count
|
||||
member_count += 1
|
||||
logger.info(
|
||||
f"P0: Dynamically prepended owner user_id={owner.id} to members list "
|
||||
f"for org {org_id} ({org.full_name})"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) fetched details for org {org_id} "
|
||||
f"({org.full_name}) — {len(members_list)} members"
|
||||
@@ -491,6 +609,22 @@ async def get_organization_details(
|
||||
address_house_number=org.address_house_number,
|
||||
tax_number=org.tax_number,
|
||||
reg_number=org.reg_number,
|
||||
# ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ──
|
||||
contact_person_name=resolved_contact_person_name,
|
||||
contact_email=resolved_contact_email,
|
||||
contact_phone=resolved_contact_phone,
|
||||
# ── Számlázási cím ──
|
||||
billing_zip=org.billing_zip,
|
||||
billing_city=org.billing_city,
|
||||
billing_street_name=org.billing_street_name,
|
||||
billing_street_type=org.billing_street_type,
|
||||
billing_house_number=org.billing_house_number,
|
||||
# ── Értesítési cím ──
|
||||
notification_zip=org.notification_zip,
|
||||
notification_city=org.notification_city,
|
||||
notification_street_name=org.notification_street_name,
|
||||
notification_street_type=org.notification_street_type,
|
||||
notification_house_number=org.notification_house_number,
|
||||
subscription=subscription_summary,
|
||||
primary_contact=primary_contact,
|
||||
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||
@@ -499,6 +633,240 @@ async def get_organization_details(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUT /{org_id}/status — Garázs státuszának módosítása
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrgStatusUpdate(BaseModel):
|
||||
"""Státusz módosításának kérése."""
|
||||
status: str = Field(..., description="Új státusz: active, inactive, suspended, pending_verification")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{org_id}/status",
|
||||
summary="Garázs státuszának módosítása",
|
||||
description="Frissíti egy garázs/szervezet státuszát. "
|
||||
"Lehetséges értékek: active, inactive, suspended, pending_verification. "
|
||||
"A státuszváltás hatással van az is_active mezőre is.",
|
||||
)
|
||||
async def update_org_status(
|
||||
org_id: int,
|
||||
payload: OrgStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:edit")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Garázs státuszának módosítása.
|
||||
|
||||
- Ellenőrzi, hogy a szervezet létezik-e.
|
||||
- Frissíti a status mezőt a megadott értékre.
|
||||
- Az is_active mezőt automatikusan szinkronizálja:
|
||||
- 'active' → is_active = True
|
||||
- 'inactive' vagy 'suspended' → is_active = False
|
||||
"""
|
||||
# 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. Validáljuk a státusz értéket
|
||||
valid_statuses = {"active", "inactive", "suspended", "pending_verification"}
|
||||
new_status = payload.status.lower()
|
||||
if new_status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Érvénytelen státusz: '{payload.status}'. "
|
||||
f"Lehetséges értékek: {', '.join(sorted(valid_statuses))}",
|
||||
)
|
||||
|
||||
# 3. Mentjük a régi státuszt naplózáshoz
|
||||
old_status = org.status
|
||||
|
||||
# 4. Frissítjük a státuszt és az is_active mezőt
|
||||
org.status = new_status
|
||||
org.is_active = (new_status == "active")
|
||||
|
||||
# Ha inaktiváljuk, jegyezzük fel az időpontot
|
||||
if new_status in ("inactive", "suspended") and old_status == "active":
|
||||
from datetime import datetime as dt
|
||||
org.last_deactivated_at = dt.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) changed status for org {org_id} "
|
||||
f"({org.full_name}): '{old_status}' → '{new_status}'"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"A(z) '{org.full_name}' garázs státusza frissítve: '{old_status}' → '{new_status}'.",
|
||||
"org_id": org.id,
|
||||
"old_status": old_status,
|
||||
"new_status": new_status,
|
||||
"is_active": org.is_active,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUT /{org_id} — Garázs adatainak szerkesztése
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrganizationUpdate(BaseModel):
|
||||
"""Garázs adatainak szerkesztésére szolgáló séma (kibővített CRM)."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255, description="Rövid név (slug-szerű)")
|
||||
full_name: Optional[str] = Field(None, min_length=1, max_length=255, description="Teljes cégnév")
|
||||
display_name: Optional[str] = Field(None, max_length=50, description="Megjelenítési név")
|
||||
tax_number: Optional[str] = Field(None, max_length=20, description="Adószám")
|
||||
reg_number: Optional[str] = Field(None, max_length=50, description="Cégjegyzékszám")
|
||||
# ── Org-level email/phone (used for individual garages to propagate to Person/User) ──
|
||||
email: Optional[str] = Field(None, max_length=255, description="Szervezeti e-mail (individual garages esetén a User rekordba propagálódik)")
|
||||
phone: Optional[str] = Field(None, max_length=50, description="Szervezeti telefon (individual garages esetén a Person rekordba propagálódik)")
|
||||
# ── Elsődleges cím (Primary Address) ──
|
||||
address_zip: Optional[str] = Field(None, max_length=10, description="Irányítószám")
|
||||
address_city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (utca, tér, stb.)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám")
|
||||
# ── Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó neve")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó e-mail címe")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Kapcsolattartó telefonszáma")
|
||||
# ── Számlázási cím (Billing Address) ──
|
||||
billing_zip: Optional[str] = Field(None, max_length=10, description="Számlázási irányítószám")
|
||||
billing_city: Optional[str] = Field(None, max_length=100, description="Számlázási város")
|
||||
billing_street_name: Optional[str] = Field(None, max_length=150, description="Számlázási utca neve")
|
||||
billing_street_type: Optional[str] = Field(None, max_length=50, description="Számlázási utca típusa")
|
||||
billing_house_number: Optional[str] = Field(None, max_length=20, description="Számlázási házszám")
|
||||
# ── Értesítési cím (Notification Address) ──
|
||||
notification_zip: Optional[str] = Field(None, max_length=10, description="Értesítési irányítószám")
|
||||
notification_city: Optional[str] = Field(None, max_length=100, description="Értesítési város")
|
||||
notification_street_name: Optional[str] = Field(None, max_length=150, description="Értesítési utca neve")
|
||||
notification_street_type: Optional[str] = Field(None, max_length=50, description="Értesítési utca típusa")
|
||||
notification_house_number: Optional[str] = Field(None, max_length=20, description="Értesítési házszám")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{org_id}",
|
||||
summary="Garázs adatainak szerkesztése",
|
||||
description="Frissíti egy garázs alapadatait (név, adószám, cím, stb.). "
|
||||
"Csak a megadott mezők frissülnek; a None értékű mezők változatlanok maradnak.",
|
||||
)
|
||||
async def update_organization(
|
||||
org_id: int,
|
||||
payload: OrganizationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:edit")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Garázs adatainak szerkesztése.
|
||||
|
||||
- Ellenőrzi, hogy a szervezet létezik-e.
|
||||
- Csak a megadott (nem None) mezőket frissíti.
|
||||
- Visszaadja a frissített adatokat.
|
||||
"""
|
||||
# 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. Összegyűjtjük a változásokat
|
||||
update_fields = {}
|
||||
for field_name in payload.model_dump(exclude_none=True):
|
||||
value = getattr(payload, field_name)
|
||||
if value is not None and hasattr(org, field_name):
|
||||
setattr(org, field_name, value)
|
||||
update_fields[field_name] = value
|
||||
|
||||
if not update_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nem adtál meg egyetlen módosítandó mezőt sem.",
|
||||
)
|
||||
|
||||
# 3. Individual (private) garages: propagate contact info to Person/User
|
||||
if org.org_type == 'individual' and org.legal_owner_id:
|
||||
person_stmt = select(Person).where(Person.id == org.legal_owner_id)
|
||||
person_result = await db.execute(person_stmt)
|
||||
person = person_result.scalar_one_or_none()
|
||||
|
||||
if person:
|
||||
person_updated = False
|
||||
|
||||
# Propagate phone to Person record (from either contact_phone or phone field)
|
||||
phone_value = update_fields.get('contact_phone') or update_fields.get('phone')
|
||||
if phone_value:
|
||||
person.phone = phone_value
|
||||
person_updated = True
|
||||
|
||||
# Propagate email to the linked User record (from either contact_email or email field)
|
||||
email_value = update_fields.get('contact_email') or update_fields.get('email')
|
||||
if email_value and person.user_id:
|
||||
user_stmt = select(User).where(User.id == person.user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
user.email = email_value
|
||||
logger.info(
|
||||
f"Propagated email to user {user.id} for individual org {org_id}"
|
||||
)
|
||||
|
||||
if person_updated:
|
||||
logger.info(
|
||||
f"Propagated phone to person {person.id} for individual org {org_id}"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) updated org {org_id} "
|
||||
f"({org.full_name}): fields={list(update_fields.keys())}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"A(z) '{org.full_name}' garázs adatai frissítve.",
|
||||
"org_id": org.id,
|
||||
"updated_fields": list(update_fields.keys()),
|
||||
"organization": {
|
||||
"id": org.id,
|
||||
"name": org.name,
|
||||
"full_name": org.full_name,
|
||||
"display_name": org.display_name,
|
||||
"tax_number": org.tax_number,
|
||||
"reg_number": org.reg_number,
|
||||
"address_zip": org.address_zip,
|
||||
"address_city": org.address_city,
|
||||
"address_street_name": org.address_street_name,
|
||||
"address_street_type": org.address_street_type,
|
||||
"address_house_number": org.address_house_number,
|
||||
"status": org.status,
|
||||
"is_active": org.is_active,
|
||||
"contact_email": org.contact_email,
|
||||
"contact_phone": org.contact_phone,
|
||||
"contact_person_name": org.contact_person_name,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PATCH /{org_id}/subscription — Előfizetés módosítása
|
||||
# =============================================================================
|
||||
@@ -620,7 +988,8 @@ async def update_org_subscription(
|
||||
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.",
|
||||
description="Hozzáad egy felhasználót a garázshoz a megadott szerepkörrel. "
|
||||
"Támogatja a user_id és email alapú hozzáadást is.",
|
||||
)
|
||||
async def add_organization_member(
|
||||
org_id: int,
|
||||
@@ -632,8 +1001,12 @@ async def add_organization_member(
|
||||
"""
|
||||
Új tag hozzáadása egy garázshoz.
|
||||
|
||||
Két mód támogatott:
|
||||
1. user_id alapján (közvetlen)
|
||||
2. email alapján (a rendszer megkeresi a felhasználót)
|
||||
|
||||
- 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ó 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.
|
||||
"""
|
||||
@@ -648,21 +1021,39 @@ async def add_organization_member(
|
||||
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:
|
||||
# 2. Felhasználó azonosítása: user_id vagy email alapján
|
||||
resolved_user_id = payload.user_id
|
||||
if resolved_user_id is None and payload.email:
|
||||
# Email-based lookup
|
||||
user_stmt = select(User).where(User.email == payload.email)
|
||||
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ó e-mail címmel: {payload.email}",
|
||||
)
|
||||
resolved_user_id = user.id
|
||||
elif resolved_user_id is not None:
|
||||
# user_id-based lookup
|
||||
user_stmt = select(User).where(User.id == resolved_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: {resolved_user_id}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Felhasználó nem található ID-vel: {payload.user_id}",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Meg kell adni a user_id vagy email mezőt.",
|
||||
)
|
||||
|
||||
# 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.user_id == resolved_user_id,
|
||||
OrganizationMember.status != "archived",
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
@@ -671,14 +1062,14 @@ async def add_organization_member(
|
||||
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.",
|
||||
detail=f"A felhasználó (ID: {resolved_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,
|
||||
user_id=resolved_user_id,
|
||||
person_id=person_id,
|
||||
role=payload.role,
|
||||
status=payload.status,
|
||||
@@ -706,7 +1097,7 @@ async def add_organization_member(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) added member user_id={payload.user_id} "
|
||||
f"Admin {current_user.id} ({current_user.email}) added member user_id={resolved_user_id} "
|
||||
f"to org {org_id} ({org.full_name}) with role={payload.role}"
|
||||
)
|
||||
|
||||
@@ -862,3 +1253,122 @@ async def remove_organization_member(
|
||||
"status": "success",
|
||||
"message": f"Tag (ID: {member_id}) eltávolítva a szervezetből (ID: {org_id}).",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /{org_id}/vehicles — Garázs járműveinek listázása (Flotta Tab)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GarageVehicleItem(BaseModel):
|
||||
"""Egy jármű adatai a garázs flotta listájában."""
|
||||
id: str
|
||||
license_plate: Optional[str] = None
|
||||
vin: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
status: str = "active"
|
||||
branch_name: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class GarageFleetResponse(BaseModel):
|
||||
"""Garázs flotta járműveinek válasza."""
|
||||
total: int
|
||||
vehicles: List[GarageVehicleItem]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{org_id}/vehicles",
|
||||
response_model=GarageFleetResponse,
|
||||
summary="Garázs járműveinek listázása",
|
||||
description="Visszaadja egy garázshoz tartozó összes járművet a Flotta Tab számára.",
|
||||
)
|
||||
async def get_organization_vehicles(
|
||||
org_id: int,
|
||||
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"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("fleet:view")),
|
||||
) -> GarageFleetResponse:
|
||||
"""
|
||||
Garázs járműveinek lekérése.
|
||||
|
||||
Két forrásból keres:
|
||||
1. vehicle.assets.current_organization_id (elsődleges)
|
||||
2. fleet.asset_assignments (történeti kapcsolatok)
|
||||
"""
|
||||
# 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. Járművek lekérése a vehicle.assets táblából
|
||||
from app.models.vehicle.asset import Asset
|
||||
from app.models.marketplace.organization import Branch
|
||||
from sqlalchemy import or_
|
||||
|
||||
# P0 DIRECT OWNERSHIP: Query by both owner_org_id AND current_organization_id
|
||||
# A jármű akkor tartozik a garázshoz, ha:
|
||||
# - közvetlenül a tulajdonában van (owner_org_id), VAGY
|
||||
# - jelenleg oda van rendelve (current_organization_id)
|
||||
# NEM kell AssetAssignment JOIN — a direkt ownership mezők az Asset táblában vannak.
|
||||
ownership_filter = or_(
|
||||
Asset.owner_org_id == org_id,
|
||||
Asset.current_organization_id == org_id,
|
||||
)
|
||||
|
||||
# P0 HOTFIX: Asset.branch relationship nem létezik a modellben.
|
||||
# Explicit LEFT JOIN a fleet.branches táblához a branch_name lekéréséhez.
|
||||
vehicle_stmt = (
|
||||
select(Asset, Branch.name.label("branch_name"))
|
||||
.outerjoin(Branch, Asset.branch_id == Branch.id)
|
||||
.where(ownership_filter)
|
||||
.order_by(Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
vehicle_result = await db.execute(vehicle_stmt)
|
||||
rows = vehicle_result.all()
|
||||
|
||||
# Teljes számolás
|
||||
count_stmt = select(func.count()).select_from(
|
||||
select(Asset).where(ownership_filter).subquery()
|
||||
)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 3. Válasz összeállítása
|
||||
vehicle_list = []
|
||||
for row in rows:
|
||||
v = row[0] # Asset object
|
||||
branch_name = row.branch_name # from the LEFT JOIN label
|
||||
vehicle_list.append(GarageVehicleItem(
|
||||
id=str(v.id),
|
||||
license_plate=v.license_plate,
|
||||
vin=v.vin,
|
||||
brand=v.brand,
|
||||
model=v.model,
|
||||
year=v.year_of_manufacture,
|
||||
status=v.status or "active",
|
||||
branch_name=branch_name,
|
||||
created_at=v.created_at.isoformat() if v.created_at else None,
|
||||
))
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) fetched fleet for org {org_id} "
|
||||
f"({org.full_name}) — {len(vehicle_list)} vehicles"
|
||||
)
|
||||
|
||||
return GarageFleetResponse(
|
||||
total=total,
|
||||
vehicles=vehicle_list,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user