Files
service-finder/backend/app/api/v1/endpoints/providers.py
2026-07-24 09:56:21 +00:00

326 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
name_i18n=tag.name_i18n if tag.name_i18n else {},
description_i18n=tag.description_i18n,
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) és key mezőkben történik.
"""
try:
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}%"),
),
).order_by(ExpertiseTag.level, ExpertiseTag.name_i18n).limit(20)
result = await db.execute(stmt)
tags = result.scalars().all()
return [
CategoryAutocompleteItem(
id=t.id,
key=t.key,
name_i18n=t.name_i18n if t.name_i18n else {},
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.",
)