338 lines
13 KiB
Python
Executable File
338 lines
13 KiB
Python
Executable File
"""
|
||
Provider végpontok – Szolgáltató keresés és gyors felvétel (crowdsourced).
|
||
|
||
4-Level Category Architecture (2026-06-17):
|
||
============================================
|
||
- GET /categories/tree — Teljes hierarchikus fa a frontend checkboxai számára
|
||
- GET /categories/autocomplete — Szöveges kereső Szint 2 és Szint 3 címkékhez
|
||
- GET /categories — Meglévő endpoint (ExpertiseTag lista)
|
||
- GET /search — Szolgáltató keresés
|
||
- POST /quick-add — Gyors szolgáltató felvétel
|
||
- PUT /{provider_id} — Szolgáltató szerkesztés (hibrid mentés)
|
||
"""
|
||
|
||
import logging
|
||
from typing import Optional, List
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from sqlalchemy import select, or_, cast, String
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import joinedload
|
||
from sqlalchemy.dialects.postgresql import JSONB
|
||
|
||
from app.db.session import get_db
|
||
from app.api.deps import get_current_user
|
||
from app.models.identity import User
|
||
from app.models.marketplace.service import ExpertiseTag
|
||
from app.schemas.provider import (
|
||
ProviderSearchResponse,
|
||
ProviderQuickAddIn,
|
||
ProviderQuickAddResponse,
|
||
ProviderUpdateIn,
|
||
ProviderUpdateResponse,
|
||
ExpertiseCategoryOut,
|
||
CategoryTreeNode,
|
||
CategoryAutocompleteItem,
|
||
)
|
||
from app.services.provider_service import search_providers, quick_add_provider, update_provider
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 4-LEVEL CATEGORY ENDPOINTS (2026-06-17)
|
||
# ──────────────────────────────────────────────
|
||
|
||
@router.get("/categories/tree", response_model=List[CategoryTreeNode])
|
||
async def get_category_tree(
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
Visszaadja a teljes kategória hierarchikus fát a frontend checkboxai számára.
|
||
|
||
Level 0: Járműtípus (Vehicle Type)
|
||
Level 1: Iparág/Főcsoport (Industry)
|
||
Level 2: Szakma (Profession)
|
||
Level 3: Specifikus feladat/címke (Specific Tag)
|
||
|
||
Rekurzívan építi fel a fa struktúrát a parent_id és path mezők alapján.
|
||
"""
|
||
try:
|
||
# Összes tag betöltése
|
||
stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id)
|
||
result = await db.execute(stmt)
|
||
all_tags = result.scalars().all()
|
||
|
||
# Indexelés ID alapján
|
||
tag_map = {t.id: t for t in all_tags}
|
||
|
||
# Fa építése: csak a gyökér elemeket (parent_id IS NULL) adjuk vissza
|
||
def build_tree(parent_id: Optional[int] = None) -> List[CategoryTreeNode]:
|
||
nodes = []
|
||
for tag in all_tags:
|
||
if tag.parent_id == parent_id:
|
||
children = build_tree(tag.id)
|
||
nodes.append(CategoryTreeNode(
|
||
id=tag.id,
|
||
key=tag.key,
|
||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||
name_i18n=tag.name_i18n if tag.name_i18n else {},
|
||
description_i18n=tag.description_i18n,
|
||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||
name_hu=tag.name_hu,
|
||
name_en=tag.name_en,
|
||
level=tag.level,
|
||
path=tag.path,
|
||
is_official=tag.is_official,
|
||
children=children,
|
||
))
|
||
return nodes
|
||
|
||
tree = build_tree(parent_id=None)
|
||
return tree
|
||
|
||
except Exception as e:
|
||
logger.error(f"Hiba a kategória fa lekérése során: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="Hiba történt a kategória fa lekérése során.",
|
||
)
|
||
|
||
|
||
@router.get("/categories/autocomplete", response_model=List[CategoryAutocompleteItem])
|
||
async def autocomplete_categories(
|
||
q: str = Query(..., min_length=2, max_length=100, description="Keresőszó"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
|
||
|
||
Csak azokat listázza, ahol is_official=True.
|
||
A keresés a name_i18n JSONB (minden nyelven), key és name_hu/name_en mezőkben történik.
|
||
"""
|
||
try:
|
||
# --- 🏗️ i18n JSONB Migration (Phase 3): JSONB → String cast keresés ---
|
||
stmt = select(ExpertiseTag).where(
|
||
ExpertiseTag.level >= 2,
|
||
ExpertiseTag.is_official == True,
|
||
or_(
|
||
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
|
||
ExpertiseTag.key.ilike(f"%{q}%"),
|
||
# --- [DEPRECATED] Old column fallback (Phase 3) ---
|
||
ExpertiseTag.name_hu.ilike(f"%{q}%"),
|
||
ExpertiseTag.name_en.ilike(f"%{q}%"),
|
||
),
|
||
).order_by(ExpertiseTag.level, ExpertiseTag.name_hu).limit(20)
|
||
|
||
result = await db.execute(stmt)
|
||
tags = result.scalars().all()
|
||
|
||
return [
|
||
CategoryAutocompleteItem(
|
||
id=t.id,
|
||
key=t.key,
|
||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||
name_i18n=t.name_i18n if t.name_i18n else {},
|
||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||
name_hu=t.name_hu,
|
||
name_en=t.name_en,
|
||
level=t.level,
|
||
path=t.path,
|
||
parent_id=t.parent_id,
|
||
)
|
||
for t in tags
|
||
]
|
||
|
||
except Exception as e:
|
||
logger.error(f"Hiba a kategória autocomplete során: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="Hiba történt a kategória keresés során.",
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# EXISTING ENDPOINTS (extended)
|
||
# ──────────────────────────────────────────────
|
||
|
||
@router.get("/categories", response_model=List[ExpertiseCategoryOut])
|
||
async def list_expertise_categories(
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
Visszaadja az összes expertise kategóriát a frontend dropdown számára.
|
||
Kibővítve a 4-level hierarchy mezőkkel (level, parent_id, path).
|
||
"""
|
||
try:
|
||
stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id)
|
||
result = await db.execute(stmt)
|
||
tags = result.scalars().all()
|
||
return [
|
||
ExpertiseCategoryOut(
|
||
id=t.id,
|
||
key=t.key,
|
||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||
name_i18n=t.name_i18n if t.name_i18n else {},
|
||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
||
name_hu=t.name_hu,
|
||
name_en=t.name_en,
|
||
category=t.category,
|
||
level=t.level,
|
||
parent_id=t.parent_id,
|
||
path=t.path,
|
||
)
|
||
for t in tags
|
||
]
|
||
except Exception as e:
|
||
logger.error(f"Hiba a kategóriák lekérése során: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="Hiba történt a kategóriák lekérése során.",
|
||
)
|
||
|
||
|
||
@router.get("/search", response_model=ProviderSearchResponse)
|
||
async def search_service_providers(
|
||
q: Optional[str] = Query(None, min_length=3, max_length=200, description="Keresőszó (min. 3 karakter)"),
|
||
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),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
Szolgáltatók keresése egyesített forrásokból.
|
||
|
||
Három forrásból keres:
|
||
- **verified_org**: Verifikált szervezetek (fleet.organizations, org_type='service_provider')
|
||
- **staged_data**: Robotok által gyűjtött adatok (marketplace.service_staging)
|
||
- **crowd_added**: Közösség által hozzáadott adatok (marketplace.service_providers)
|
||
|
||
A találatok forrás szerint csoportosítva, lapozva érkeznek.
|
||
|
||
**category_ids**: Ha meg van adva, csak azok a szervezetek jelennek meg,
|
||
amelyek ServiceProfile-jához tartozik legalább egy megadott ExpertiseTag
|
||
(közvetlenül vagy hierarchikusan a path LIKE segítségével).
|
||
"""
|
||
try:
|
||
# Parse category_ids: vesszővel elválasztott string -> List[int]
|
||
parsed_category_ids: Optional[List[int]] = None
|
||
if category_ids:
|
||
try:
|
||
parsed_category_ids = [int(x.strip()) for x in category_ids.split(",") if x.strip()]
|
||
except (ValueError, TypeError):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||
detail="Érvénytelen category_ids formátum. Vesszővel elválasztott számok szükségesek (pl. '201,205').",
|
||
)
|
||
|
||
result = await search_providers(
|
||
db=db,
|
||
q=q,
|
||
category=category,
|
||
city=city,
|
||
category_ids=parsed_category_ids,
|
||
org_type=org_type,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
return result
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="Hiba történt a keresés során.",
|
||
)
|
||
|
||
|
||
@router.post("/quick-add", response_model=ProviderQuickAddResponse, status_code=status.HTTP_201_CREATED)
|
||
async def quick_add_service_provider(
|
||
data: ProviderQuickAddIn,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
Gyors szolgáltató felvétel crowdsourcingból.
|
||
|
||
Létrehoz egy új Organization-t (is_verified=False, org_type='service_provider'),
|
||
hozzáköt egy ServiceProfile-t, a megadott kategóriát (expertise_tags),
|
||
létrehoz egy Branch-t a címmel, és a felhasználót beállítja OWNER-nek.
|
||
|
||
A szolgáltató 'pending_verification' státuszba kerül, amíg a robotok
|
||
vagy az adminok nem ellenőrzik.
|
||
"""
|
||
try:
|
||
result = await quick_add_provider(
|
||
db=db,
|
||
data=data,
|
||
user_id=current_user.id,
|
||
)
|
||
return result
|
||
except ValueError as e:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=str(e),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Hiba a gyors szolgáltató felvétel során: {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="Hiba történt a szolgáltató rögzítése során.",
|
||
)
|
||
|
||
|
||
@router.put("/{provider_id}", response_model=ProviderUpdateResponse)
|
||
async def update_service_provider(
|
||
provider_id: int,
|
||
data: ProviderUpdateIn,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
Meglévő szolgáltató (Organization) adatainak szerkesztése.
|
||
|
||
Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó
|
||
ServiceProfile kapcsolati mezőit (telefon, email, weboldal, címkék).
|
||
|
||
4-Level Category Hybrid Save (2026-06-17):
|
||
- category_ids: A kiválasztott kategória ID-k (Szint 0-3 checkboxokból)
|
||
-> ServiceExpertise kapcsolatok frissítése
|
||
- new_tags: A user által gépelt új címkenevek
|
||
-> Létrehozás ExpertiseTag-ban (is_official=False, level=3)
|
||
-> ServiceExpertise kapcsolat létrehozása
|
||
- tags: Meglévő specialization_tags frissítése
|
||
"""
|
||
try:
|
||
result = await update_provider(
|
||
db=db,
|
||
provider_id=provider_id,
|
||
data=data,
|
||
user_id=current_user.id,
|
||
)
|
||
return result
|
||
except ValueError as e:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=str(e),
|
||
)
|
||
except PermissionError as e:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail=str(e),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="Hiba történt a szolgáltató frissítése során.",
|
||
)
|