céges meghívó kezelése,
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,23 @@
|
||||
"""
|
||||
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
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
@@ -20,6 +30,8 @@ from app.schemas.provider import (
|
||||
ProviderUpdateIn,
|
||||
ProviderUpdateResponse,
|
||||
ExpertiseCategoryOut,
|
||||
CategoryTreeNode,
|
||||
CategoryAutocompleteItem,
|
||||
)
|
||||
from app.services.provider_service import search_providers, quick_add_provider, update_provider
|
||||
|
||||
@@ -27,6 +39,114 @@ 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_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_hu, name_en és key mezőkben történik (ILIKE).
|
||||
"""
|
||||
try:
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.level >= 2,
|
||||
ExpertiseTag.is_official == True,
|
||||
or_(
|
||||
ExpertiseTag.name_hu.ilike(f"%{q}%"),
|
||||
ExpertiseTag.name_en.ilike(f"%{q}%"),
|
||||
ExpertiseTag.key.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,
|
||||
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),
|
||||
@@ -34,9 +154,10 @@ async def list_expertise_categories(
|
||||
):
|
||||
"""
|
||||
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.id)
|
||||
stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id)
|
||||
result = await db.execute(stmt)
|
||||
tags = result.scalars().all()
|
||||
return [
|
||||
@@ -46,6 +167,9 @@ async def list_expertise_categories(
|
||||
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
|
||||
]
|
||||
@@ -62,6 +186,7 @@ async def search_service_providers(
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
|
||||
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"),
|
||||
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),
|
||||
@@ -76,17 +201,35 @@ async def search_service_providers(
|
||||
- **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,
|
||||
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(
|
||||
@@ -143,6 +286,14 @@ async def update_service_provider(
|
||||
|
||||
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(
|
||||
@@ -157,6 +308,11 @@ async def update_service_provider(
|
||||
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(
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.database import Base
|
||||
from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole, OneTimePassword
|
||||
|
||||
# 2. Szervezeti felépítés (MUST be before Address/Rating due to FK references)
|
||||
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
|
||||
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, OrgRole, Branch
|
||||
|
||||
# 3. Földrajzi adatok és címek
|
||||
from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
|
||||
@@ -59,7 +59,7 @@ ServiceRecord = AssetEvent
|
||||
|
||||
__all__ = [
|
||||
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
|
||||
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole",
|
||||
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole", "OrgRole",
|
||||
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
|
||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
|
||||
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
|
||||
|
||||
@@ -27,10 +27,27 @@ class OrgType(str, enum.Enum):
|
||||
class OrgUserRole(str, enum.Enum):
|
||||
OWNER = "OWNER"
|
||||
ADMIN = "ADMIN"
|
||||
FLEET_MANAGER = "FLEET_MANAGER"
|
||||
DRIVER = "DRIVER"
|
||||
MECHANIC = "MECHANIC"
|
||||
RECEPTIONIST = "RECEPTIONIST"
|
||||
MANAGER = "MANAGER"
|
||||
MEMBER = "MEMBER"
|
||||
AGENT = "AGENT"
|
||||
|
||||
class OrgRole(Base):
|
||||
"""
|
||||
Dinamikus szerepkör modell (RBAC).
|
||||
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
|
||||
Alapértelmezett szerepkörök: OWNER, ADMIN, MANAGER, MEMBER, AGENT.
|
||||
"""
|
||||
__tablename__ = "org_roles"
|
||||
__table_args__ = {"schema": "fleet"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Organization(Base):
|
||||
"""
|
||||
@@ -72,7 +89,7 @@ class Organization(Base):
|
||||
full_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
display_name: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
folder_slug: Mapped[str] = mapped_column(String(12), unique=True, index=True)
|
||||
folder_slug: Mapped[str] = mapped_column(String(24), unique=True, index=True)
|
||||
|
||||
default_currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
country_code: Mapped[str] = mapped_column(String(2), default="HU")
|
||||
@@ -84,6 +101,7 @@ class Organization(Base):
|
||||
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
plus_code: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
|
||||
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
|
||||
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
@@ -115,6 +133,14 @@ class Organization(Base):
|
||||
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
||||
)
|
||||
|
||||
# Dinamikus szervezeti beállítások (pl. invite_expiry_days, role_hierarchy)
|
||||
settings: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=lambda: {"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False},
|
||||
server_default=text("'{\"invite_expiry_days\": 7, \"max_members\": 50, \"allow_public_join\": false}'::jsonb")
|
||||
)
|
||||
|
||||
# --- 🔍 CROWDSOURCED SEARCH FIELDS ---
|
||||
# Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"])
|
||||
aliases: Mapped[list] = mapped_column(
|
||||
@@ -181,16 +207,29 @@ class OrganizationMember(Base):
|
||||
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False)
|
||||
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
|
||||
role: Mapped[OrgUserRole] = mapped_column(
|
||||
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet"),
|
||||
default=OrgUserRole.DRIVER
|
||||
# Meghívott e-mail címe (ha a meghívottnak még nincs user fiókja)
|
||||
invited_email: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
# Meghívó lejárati ideje
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Dinamikus szerepkör (String, nem Enum) - az OrgRole táblából validáljuk
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="MEMBER",
|
||||
server_default=text("'MEMBER'")
|
||||
)
|
||||
permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
is_permanent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 🔴 HIÁNYZÓ OSZLOPOK - Pótolva a logic_spec alapján
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", server_default=text("'active'"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="members")
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="memberships")
|
||||
|
||||
@@ -80,9 +80,21 @@ class ExpertiseTag(Base):
|
||||
"""
|
||||
Szakmai címkék mesterlistája (MB 2.0).
|
||||
Ez a tábla vezérli a robotok keresését és a Gamification pontozást is.
|
||||
|
||||
4-Level Category Hierarchy (2026-06-17):
|
||||
=========================================
|
||||
Level 0: Járműtípus (Vehicle Type) — e.g. "Személyautó", "Motorkerékpár"
|
||||
Level 1: Iparág/Főcsoport (Industry) — e.g. "Karbantartás és javítás", "Üzemanyagtöltő"
|
||||
Level 2: Szakma (Profession) — e.g. "Autószerelő", "Gumiszerviz"
|
||||
Level 3: Specifikus feladat/címke (Specific Tag) — e.g. "Vezérműszíj csere", "Klíma töltés"
|
||||
|
||||
Adjacency List (parent_id) + Materialized Path (path) for efficient tree queries.
|
||||
"""
|
||||
__tablename__ = "expertise_tags"
|
||||
__table_args__ = {"schema": "marketplace"}
|
||||
__table_args__ = (
|
||||
Index('idx_expertise_path', 'path'),
|
||||
{"schema": "marketplace"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
@@ -90,6 +102,13 @@ class ExpertiseTag(Base):
|
||||
name_en: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
||||
|
||||
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("marketplace.expertise_tags.id"), nullable=True, index=True
|
||||
)
|
||||
level: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"), nullable=False)
|
||||
path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
# --- 🎮 GAMIFICATION ÉS DISCOVERY ---
|
||||
is_official: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true"))
|
||||
suggested_by_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
@@ -102,6 +121,20 @@ class ExpertiseTag(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Self-referential relationship for tree traversal
|
||||
# NOTE: cascade="all, delete-orphan" is intentionally NOT set here because
|
||||
# this is a self-referential tree. Deleting a parent should NOT cascade-delete
|
||||
# children (they may be re-parented). The "many" side of a self-referential
|
||||
# relationship cannot have delete-orphan without single_parent=True.
|
||||
children: Mapped[List["ExpertiseTag"]] = relationship(
|
||||
"ExpertiseTag", back_populates="parent",
|
||||
remote_side=[id],
|
||||
)
|
||||
parent: Mapped[Optional["ExpertiseTag"]] = relationship(
|
||||
"ExpertiseTag", back_populates="children",
|
||||
remote_side=[parent_id],
|
||||
)
|
||||
|
||||
services: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="tag")
|
||||
suggested_by: Mapped[Optional["Person"]] = relationship("Person")
|
||||
|
||||
@@ -173,4 +206,4 @@ class Cost(Base):
|
||||
occurrence_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
@@ -5,40 +5,114 @@ Tartalmazza a keresési, gyors felvételi és szerkesztési sémákat.
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
|
||||
- street -> address_street_name, address_street_type, address_house_number
|
||||
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
|
||||
|
||||
4-Level Category Architecture (2026-06-17):
|
||||
===========================================
|
||||
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
|
||||
- CategoryAutocompleteItem: Szöveges kereső találatok Szint 2 és Szint 3 címkékhez
|
||||
- ProviderUpdateIn: kibővítve category_ids listával a hibrid mentéshez
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class ExpertiseCategoryOut(BaseModel):
|
||||
"""Expertise kategória kimeneti sémája a frontend dropdown számára."""
|
||||
# ──────────────────────────────────────────────
|
||||
# 4-LEVEL CATEGORY SCHEMAS (2026-06-17)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class CategoryTreeNode(BaseModel):
|
||||
"""Egy csomópont a kategória hierarchikus fában.
|
||||
|
||||
Tartalmazza a gyermek csomópontokat rekurzívan.
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
category: str
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
is_official: bool = True
|
||||
children: List["CategoryTreeNode"] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CategoryAutocompleteItem(BaseModel):
|
||||
"""Egy találat a kategória autocomplete keresőben.
|
||||
|
||||
Csak Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkéket
|
||||
listáz, ahol is_official=True.
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# EXISTING SCHEMAS (extended)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class ExpertiseCategoryOut(BaseModel):
|
||||
"""Expertise kategória kimeneti sémája a frontend dropdown számára.
|
||||
|
||||
Kibővítve a 4-level hierarchy mezőkkel.
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
level: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
path: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CategoryInfo(BaseModel):
|
||||
"""Egy kategória rövid adatai a keresési találatokhoz.
|
||||
|
||||
Tartalmazza a kategória ID-ját, nevét és szintjét,
|
||||
hogy a frontend meg tudja jeleníteni a kapcsolódó
|
||||
szolgáltatásokat a kártyákon.
|
||||
"""
|
||||
id: int
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
level: int = 0
|
||||
key: str
|
||||
|
||||
|
||||
class ProviderSearchResult(BaseModel):
|
||||
"""Egy szolgáltató keresési találatának adatai.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- address_street_name, address_street_type, address_house_number
|
||||
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
|
||||
|
||||
FEATURE (2026-06-17): categories mező.
|
||||
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
|
||||
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
|
||||
"""
|
||||
id: int
|
||||
name: str
|
||||
category: Optional[str] = None
|
||||
specialization: List[str] = Field(default_factory=list)
|
||||
categories: List[CategoryInfo] = Field(default_factory=list)
|
||||
city: Optional[str] = None
|
||||
address: 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
|
||||
plus_code: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
@@ -63,14 +137,24 @@ class ProviderQuickAddIn(BaseModel):
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
||||
- A 4-level kategória rendszer bevezetésével a frontend a category_ids
|
||||
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
|
||||
- category_id megtartva opcionálisként a backward compatibility miatt.
|
||||
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3 checkboxokból)
|
||||
- new_tags: A user által gépelt új (még nem létező) címkék listája
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
category_id: int = Field(..., ge=1, description="Kategória ID (marketplace.expertise_tags.id)")
|
||||
category_id: Optional[int] = Field(None, ge=1, description="Elsődleges kategória ID (opcionális, backward compatibility)")
|
||||
category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)")
|
||||
new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
@@ -92,6 +176,10 @@ class ProviderUpdateIn(BaseModel):
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
|
||||
|
||||
4-Level Category (2026-06-17):
|
||||
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
|
||||
- new_tags: A user által gépelt, új (még nem létező) címkék listája
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
@@ -99,10 +187,17 @@ class ProviderUpdateIn(BaseModel):
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
|
||||
category_ids: Optional[List[int]] = Field(
|
||||
None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)"
|
||||
)
|
||||
new_tags: Optional[List[str]] = Field(
|
||||
None, description="User által gépelt új címkenevek (is_official=False, level=3)"
|
||||
)
|
||||
|
||||
|
||||
class ProviderUpdateResponse(BaseModel):
|
||||
@@ -110,3 +205,4 @@ class ProviderUpdateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
message: str = "Szolgáltató adatai sikeresen frissítve."
|
||||
earned_points: int = 0
|
||||
|
||||
@@ -51,6 +51,12 @@ SEED_RULES = [
|
||||
"description": "Szolgáltató értékelése (tagekkel)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "UPDATE_PROVIDER",
|
||||
"points": 100,
|
||||
"description": "Szolgáltató adatainak szerkesztése (kategóriák, címkék frissítése)",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,20 +19,27 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- update_provider: data.street -> data.address_street_name, address_street_type, address_house_number
|
||||
- search_providers: visszaadja az address_street_name, address_street_type, address_house_number mezőket
|
||||
- A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
===========================================
|
||||
- update_provider: category_ids -> ServiceExpertise kapcsolatok frissítése
|
||||
- update_provider: new_tags -> új ExpertiseTag létrehozása (is_official=False, level=3)
|
||||
- A hibrid logika biztosítja, hogy a meglévő kapcsolatok ne sérüljenek
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
||||
from app.models.marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||||
from app.models.identity.social import ServiceProvider
|
||||
from app.models.gamification.gamification import PointRule, UserStats
|
||||
from app.schemas.provider import (
|
||||
@@ -42,6 +49,7 @@ from app.schemas.provider import (
|
||||
ProviderQuickAddResponse,
|
||||
ProviderUpdateIn,
|
||||
ProviderUpdateResponse,
|
||||
CategoryInfo,
|
||||
)
|
||||
from app.services.gamification_service import gamification_service
|
||||
|
||||
@@ -132,6 +140,7 @@ async def search_providers(
|
||||
q: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
category_ids: Optional[List[int]] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> ProviderSearchResponse:
|
||||
@@ -144,11 +153,27 @@ async def search_providers(
|
||||
P1 CRITICAL ALIGN (2026-06-17): Az org lekérdezés most már visszaadja
|
||||
az address_street_name, address_street_type, address_house_number mezőket is.
|
||||
|
||||
FEATURE (2026-06-17): category_ids szűrő — SZIGORÚ AND KAPCSOLAT.
|
||||
- Ha több kategória ID van megadva, a szolgáltatónak MINDEGYIKHEZ
|
||||
tartoznia kell (AND), nem elég, ha csak az egyikhez (OR).
|
||||
- Nincs hierarchikus terjeszkedés: csak a pontosan megadott ID-kat
|
||||
ellenőrizzük a ServiceExpertise táblában.
|
||||
|
||||
FEATURE (2026-06-17): Keresés szigorítása.
|
||||
- A q (free-text) keresés csak a name és aliases mezőkben keres (ILIKE),
|
||||
a tags mezőt NEM vizsgálja.
|
||||
- A city keresés exact match vagy StartsWith, nem ILIKE.
|
||||
|
||||
FEATURE (2026-06-17): Kategória adatok visszaadása.
|
||||
- Minden ProviderSearchResult tartalmazza a kapcsolódó kategóriák
|
||||
(ExpertiseTag) ID-ját, nevét és szintjét a categories mezőben.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális
|
||||
category: Kategória szűrő
|
||||
city: Város szűrő — AND kapcsolat a q-val
|
||||
category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés)
|
||||
limit: Lapozási limit
|
||||
offset: Lapozási offset
|
||||
|
||||
@@ -175,18 +200,42 @@ async def search_providers(
|
||||
or_(
|
||||
Organization.name.ilike(f"%{token}%"),
|
||||
cast(Organization.aliases, Text).ilike(f"%{token}%"),
|
||||
cast(Organization.tags, Text).ilike(f"%{token}%"),
|
||||
)
|
||||
)
|
||||
org_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
city_clean = city.strip()
|
||||
org_conditions.append(
|
||||
or_(
|
||||
Organization.address_city.ilike(f"%{city}%"),
|
||||
Organization.address_zip.ilike(f"%{city}%"),
|
||||
Organization.address_city == city_clean,
|
||||
Organization.address_city.ilike(f"{city_clean}%"),
|
||||
Organization.address_zip == city_clean,
|
||||
Organization.address_zip.ilike(f"{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
# FEATURE (2026-06-17): Kategória szűrés category_ids alapján.
|
||||
# SZIGORÚ AND KAPCSOLAT (2026-06-17): Ha több kategória ID van megadva,
|
||||
# a szolgáltatónak MINDEGYIKHEZ tartoznia kell. Nincs hierarchikus
|
||||
# terjeszkedés (path LIKE), csak a pontosan megadott ID-kat ellenőrizzük.
|
||||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids None,
|
||||
# nem iterálunk rajta.
|
||||
safe_category_ids = category_ids or []
|
||||
if safe_category_ids:
|
||||
# Minden egyes megadott category_id-hoz külön subquery-t készítünk,
|
||||
# és AND kapcsolattal fűzzük össze őket. Ez biztosítja, hogy a
|
||||
# szolgáltató minden kategóriával rendelkezzen.
|
||||
for cid in safe_category_ids:
|
||||
profile_subq = (
|
||||
select(ServiceExpertise.service_id)
|
||||
.where(ServiceExpertise.expertise_id == cid)
|
||||
.subquery()
|
||||
)
|
||||
org_conditions.append(
|
||||
ServiceProfile.id.in_(select(profile_subq.c.service_id))
|
||||
)
|
||||
|
||||
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
|
||||
# címmezőket (address_street_name, address_street_type, address_house_number).
|
||||
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
|
||||
@@ -210,6 +259,7 @@ async def search_providers(
|
||||
Organization.address_street_name.label("address_street_name"),
|
||||
Organization.address_street_type.label("address_street_type"),
|
||||
Organization.address_house_number.label("address_house_number"),
|
||||
Organization.plus_code.label("plus_code"),
|
||||
Organization.is_verified.label("is_verified"),
|
||||
ServiceProfile.contact_phone.label("contact_phone"),
|
||||
ServiceProfile.contact_email.label("contact_email"),
|
||||
@@ -234,10 +284,14 @@ async def search_providers(
|
||||
)
|
||||
staging_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
city_clean = city.strip()
|
||||
staging_conditions.append(
|
||||
or_(
|
||||
ServiceStaging.city.ilike(f"%{city}%"),
|
||||
ServiceStaging.postal_code.ilike(f"%{city}%"),
|
||||
ServiceStaging.city == city_clean,
|
||||
ServiceStaging.city.ilike(f"{city_clean}%"),
|
||||
ServiceStaging.postal_code == city_clean,
|
||||
ServiceStaging.postal_code.ilike(f"{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -250,6 +304,7 @@ async def search_providers(
|
||||
literal(None).label("address_street_name"),
|
||||
literal(None).label("address_street_type"),
|
||||
literal(None).label("address_house_number"),
|
||||
literal(None).label("plus_code"),
|
||||
literal(False).label("is_verified"),
|
||||
literal(None).label("contact_phone"),
|
||||
literal(None).label("contact_email"),
|
||||
@@ -270,8 +325,13 @@ async def search_providers(
|
||||
)
|
||||
crowd_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
city_clean = city.strip()
|
||||
crowd_conditions.append(
|
||||
ServiceProvider.address.ilike(f"%{city}%")
|
||||
or_(
|
||||
ServiceProvider.address == city_clean,
|
||||
ServiceProvider.address.ilike(f"{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
crowd_stmt = select(
|
||||
@@ -283,6 +343,7 @@ async def search_providers(
|
||||
literal(None).label("address_street_name"),
|
||||
literal(None).label("address_street_type"),
|
||||
literal(None).label("address_house_number"),
|
||||
literal(None).label("plus_code"),
|
||||
literal(False).label("is_verified"),
|
||||
literal(None).label("contact_phone"),
|
||||
literal(None).label("contact_email"),
|
||||
@@ -315,6 +376,43 @@ async def search_providers(
|
||||
result = await db.execute(paginated_query)
|
||||
rows = result.fetchall()
|
||||
|
||||
# FEATURE (2026-06-17): Kategória adatok előtöltése.
|
||||
# Lekérdezzük az összes olyan ServiceProfile-hoz tartozó ExpertiseTag-eket,
|
||||
# amelyek a találati listában szereplő organization ID-khoz tartoznak.
|
||||
# Ezt egyetlen batch lekérdezéssel tesszük a N+1 probléma elkerülése érdekében.
|
||||
org_ids = [row.id for row in rows if row.source in ("verified_org", "crowd_added")]
|
||||
org_categories_map: dict[int, list[dict]] = {}
|
||||
if org_ids:
|
||||
cat_stmt = (
|
||||
select(
|
||||
ServiceProfile.organization_id,
|
||||
ExpertiseTag.id,
|
||||
ExpertiseTag.name_hu,
|
||||
ExpertiseTag.name_en,
|
||||
ExpertiseTag.level,
|
||||
ExpertiseTag.key,
|
||||
)
|
||||
.select_from(ServiceExpertise)
|
||||
.join(ExpertiseTag, ServiceExpertise.expertise_id == ExpertiseTag.id)
|
||||
.join(ServiceProfile, ServiceExpertise.service_id == ServiceProfile.id)
|
||||
.where(
|
||||
ServiceProfile.organization_id.in_(org_ids),
|
||||
)
|
||||
)
|
||||
cat_result = await db.execute(cat_stmt)
|
||||
cat_rows = cat_result.fetchall()
|
||||
for cat_row in cat_rows:
|
||||
org_id = cat_row.organization_id
|
||||
if org_id not in org_categories_map:
|
||||
org_categories_map[org_id] = []
|
||||
org_categories_map[org_id].append({
|
||||
"id": cat_row.id,
|
||||
"name_hu": cat_row.name_hu,
|
||||
"name_en": cat_row.name_en,
|
||||
"level": cat_row.level,
|
||||
"key": cat_row.key,
|
||||
})
|
||||
|
||||
for row in rows:
|
||||
tags_list: list = []
|
||||
spec_tags = getattr(row, "specialization_tags", None)
|
||||
@@ -323,6 +421,19 @@ async def search_providers(
|
||||
if user_tags:
|
||||
tags_list = list(user_tags)
|
||||
|
||||
# Kategória adatok összeállítása
|
||||
row_categories = org_categories_map.get(row.id, [])
|
||||
categories_out = [
|
||||
CategoryInfo(
|
||||
id=cat["id"],
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
level=cat["level"],
|
||||
key=cat["key"],
|
||||
)
|
||||
for cat in row_categories
|
||||
]
|
||||
|
||||
results.append(
|
||||
ProviderSearchResult(
|
||||
id=row.id,
|
||||
@@ -333,10 +444,12 @@ async def search_providers(
|
||||
address_street_name=getattr(row, "address_street_name", None),
|
||||
address_street_type=getattr(row, "address_street_type", None),
|
||||
address_house_number=getattr(row, "address_house_number", None),
|
||||
plus_code=getattr(row, "plus_code", None),
|
||||
contact_phone=getattr(row, "contact_phone", None),
|
||||
contact_email=getattr(row, "contact_email", None),
|
||||
website=getattr(row, "website", None),
|
||||
tags=tags_list,
|
||||
categories=categories_out,
|
||||
source=row.source,
|
||||
is_verified=row.is_verified,
|
||||
)
|
||||
@@ -364,11 +477,16 @@ async def quick_add_provider(
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
||||
- Ha category_id nincs megadva, a primary_category kihagyásra kerül.
|
||||
- A 4-level kategória rendszerből érkező category_ids és new_tags
|
||||
feldolgozásra kerülnek a ServiceExpertise kapcsolatok létrehozásához.
|
||||
|
||||
Folyamat:
|
||||
1. Kategória ellenőrzése (expertise_tags)
|
||||
1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags)
|
||||
2. Organization létrehozása (is_verified=False, org_type='service_provider')
|
||||
3. ServiceProfile létrehozása
|
||||
4. ServiceExpertise kapcsolat létrehozása
|
||||
4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags)
|
||||
5. Branch létrehozása a címmel
|
||||
6. OrganizationMember létrehozása a felhasználóhoz
|
||||
|
||||
@@ -383,10 +501,15 @@ async def quick_add_provider(
|
||||
Raises:
|
||||
ValueError: Ha a kategória nem létezik
|
||||
"""
|
||||
# 1. Kategória ellenőrzése
|
||||
category = await db.get(ExpertiseTag, data.category_id)
|
||||
if not category:
|
||||
raise ValueError(f"Category with id={data.category_id} not found")
|
||||
# 1. (Opcionális) Elsődleges kategória ellenőrzése
|
||||
primary_category_key = None
|
||||
if data.category_id is not None:
|
||||
category = await db.get(ExpertiseTag, data.category_id)
|
||||
if not category:
|
||||
raise ValueError(f"Category with id={data.category_id} not found")
|
||||
primary_category_key = category.key
|
||||
else:
|
||||
category = None
|
||||
|
||||
# 2. Organization létrehozása
|
||||
folder_slug = hashlib.md5(
|
||||
@@ -414,6 +537,7 @@ async def quick_add_provider(
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
plus_code=data.plus_code,
|
||||
owner_id=None,
|
||||
first_registered_at=datetime.now(timezone.utc),
|
||||
current_lifecycle_started_at=datetime.now(timezone.utc),
|
||||
@@ -428,7 +552,9 @@ async def quick_add_provider(
|
||||
).hexdigest()[:64]
|
||||
|
||||
# specialization_tags összeállítása: primary_category + user-supplied tags
|
||||
spec_tags = {"primary_category": category.key}
|
||||
spec_tags: dict = {}
|
||||
if primary_category_key:
|
||||
spec_tags["primary_category"] = primary_category_key
|
||||
if data.tags:
|
||||
spec_tags["user_tags"] = data.tags
|
||||
|
||||
@@ -445,13 +571,49 @@ async def quick_add_provider(
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
|
||||
# 4. ServiceExpertise kapcsolat
|
||||
expertise = ServiceExpertise(
|
||||
service_id=profile.id,
|
||||
expertise_id=data.category_id,
|
||||
confidence_level=50, # Közepes bizonyosság (crowdsourced)
|
||||
)
|
||||
db.add(expertise)
|
||||
# =====================================================================
|
||||
# 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
|
||||
# =====================================================================
|
||||
# Összegyűjtjük az összes kategória ID-t a ServiceExpertise kapcsolatokhoz.
|
||||
all_category_ids: List[int] = []
|
||||
|
||||
# 4a. Elsődleges kategória (ha meg van adva)
|
||||
if data.category_id is not None:
|
||||
all_category_ids.append(data.category_id)
|
||||
|
||||
# 4b. category_ids a 4-level checkboxokból
|
||||
safe_category_ids = data.category_ids or []
|
||||
if safe_category_ids:
|
||||
all_category_ids.extend(safe_category_ids)
|
||||
|
||||
# 4c. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||||
safe_new_tags = data.new_tags or []
|
||||
new_tag_ids: List[int] = []
|
||||
if safe_new_tags:
|
||||
new_tag_ids = await _create_new_tags(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
new_tag_names=safe_new_tags,
|
||||
)
|
||||
if new_tag_ids:
|
||||
all_category_ids.extend(new_tag_ids)
|
||||
|
||||
# 4d. ServiceExpertise kapcsolatok létrehozása
|
||||
if all_category_ids:
|
||||
# Deduplikáció: csak egyedi ID-k
|
||||
unique_ids = list(set(all_category_ids))
|
||||
for expertise_id in unique_ids:
|
||||
expertise = ServiceExpertise(
|
||||
service_id=profile.id,
|
||||
expertise_id=expertise_id,
|
||||
confidence_level=50, # Közepes bizonyosság (crowdsourced)
|
||||
)
|
||||
db.add(expertise)
|
||||
logger.info(
|
||||
f"ServiceExpertise kapcsolatok létrehozva: service_id={profile.id}, "
|
||||
f"expertise_ids={unique_ids}"
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
# 5. Branch létrehozása atomizált címmezőkkel
|
||||
branch = Branch(
|
||||
@@ -467,6 +629,28 @@ async def quick_add_provider(
|
||||
)
|
||||
db.add(branch)
|
||||
|
||||
# =====================================================================
|
||||
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (P0 CRITICAL BUGFIX 2026-06-17)
|
||||
# =====================================================================
|
||||
# A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER).
|
||||
# Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL).
|
||||
# Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég
|
||||
# nem jelenik meg a "Cégeim" garázsválasztóban.
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user_id,
|
||||
role=OrgUserRole.ADMIN,
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(member)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"OrganizationMember created: org_id={org.id}, user_id={user_id}, "
|
||||
f"role=ADMIN (crowdsourced provider has no owner)"
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
# =====================================================================
|
||||
# 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
||||
# =====================================================================
|
||||
@@ -494,6 +678,173 @@ async def quick_add_provider(
|
||||
)
|
||||
|
||||
|
||||
async def _sync_service_expertises(
|
||||
db: AsyncSession,
|
||||
service_profile_id: int,
|
||||
category_ids: List[int],
|
||||
) -> None:
|
||||
"""
|
||||
Szinkronizálja a ServiceExpertise kapcsolatokat a megadott kategória ID-k alapján.
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
- Eltávolítja a meglévő kapcsolatokat, amelyek nincsenek az új listában
|
||||
- Hozzáadja az új kapcsolatokat, amelyek még nem léteznek
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
service_profile_id: A ServiceProfile ID-ja
|
||||
category_ids: Az új kategória ID-k listája
|
||||
"""
|
||||
if not category_ids:
|
||||
return
|
||||
|
||||
# Meglévő kapcsolatok lekérése
|
||||
existing_stmt = select(ServiceExpertise).where(
|
||||
ServiceExpertise.service_id == service_profile_id
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing_expertises = existing_result.scalars().all()
|
||||
existing_ids = {e.expertise_id for e in existing_expertises}
|
||||
|
||||
# Eltávolítandó kapcsolatok (amik nincsenek az új listában)
|
||||
ids_to_remove = existing_ids - set(category_ids)
|
||||
if ids_to_remove:
|
||||
delete_stmt = delete(ServiceExpertise).where(
|
||||
ServiceExpertise.service_id == service_profile_id,
|
||||
ServiceExpertise.expertise_id.in_(ids_to_remove),
|
||||
)
|
||||
await db.execute(delete_stmt)
|
||||
logger.info(
|
||||
f"Eltávolított ServiceExpertise kapcsolatok: service_id={service_profile_id}, "
|
||||
f"expertise_ids={ids_to_remove}"
|
||||
)
|
||||
|
||||
# Hozzáadandó kapcsolatok (amik újak)
|
||||
ids_to_add = set(category_ids) - existing_ids
|
||||
for expertise_id in ids_to_add:
|
||||
new_expertise = ServiceExpertise(
|
||||
service_id=service_profile_id,
|
||||
expertise_id=expertise_id,
|
||||
confidence_level=50,
|
||||
)
|
||||
db.add(new_expertise)
|
||||
logger.info(
|
||||
f"Új ServiceExpertise kapcsolat: service_id={service_profile_id}, "
|
||||
f"expertise_id={expertise_id}"
|
||||
)
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""
|
||||
Biztonságos slug létrehozása magyar ékezetes karakterekkel.
|
||||
|
||||
- Kisbetűsítés
|
||||
- Ékezetes karakterek cseréje (á→a, é→e, í→i, ó→o, ö→o, ő→o, ú→u, ü→u, ű→u)
|
||||
- Nem alfanumerikus karakterek cseréje alulvonásra
|
||||
- Maximum 50 karakter
|
||||
"""
|
||||
replacements = {
|
||||
'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ö': 'o', 'ő': 'o',
|
||||
'ú': 'u', 'ü': 'u', 'ű': 'u',
|
||||
'Á': 'a', 'É': 'e', 'Í': 'i', 'Ó': 'o', 'Ö': 'o', 'Ő': 'o',
|
||||
'Ú': 'u', 'Ü': 'u', 'Ű': 'u',
|
||||
}
|
||||
result = text.lower().strip()
|
||||
for hungarian, ascii_char in replacements.items():
|
||||
result = result.replace(hungarian, ascii_char)
|
||||
# Replace any non-alphanumeric character (except underscore) with underscore
|
||||
result = re.sub(r'[^a-z0-9_]', '_', result)
|
||||
# Collapse multiple underscores
|
||||
result = re.sub(r'_+', '_', result)
|
||||
# Strip leading/trailing underscores
|
||||
result = result.strip('_')
|
||||
return result[:50]
|
||||
|
||||
|
||||
async def _create_new_tags(
|
||||
db: AsyncSession,
|
||||
service_profile_id: int,
|
||||
new_tag_names: List[str],
|
||||
) -> List[int]:
|
||||
"""
|
||||
Létrehoz új ExpertiseTag rekordokat a user által gépelt címkékből.
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): try-except blokk + slugify + hibanaplózás.
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
- Ha a címke string már létezik az ExpertiseTag táblában, visszaadja az ID-ját
|
||||
- Ha a címke vadiúj, létrehozza is_official=False és level=3 értékekkel
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
service_profile_id: A ServiceProfile ID-ja (a kapcsolathoz)
|
||||
new_tag_names: A user által gépelt új címkenevek listája
|
||||
|
||||
Returns:
|
||||
List[int]: A létrehozott/megtalált ExpertiseTag ID-k listája
|
||||
"""
|
||||
if not new_tag_names:
|
||||
return []
|
||||
|
||||
created_ids = []
|
||||
|
||||
for tag_name in new_tag_names:
|
||||
tag_name = tag_name.strip()
|
||||
if not tag_name:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Ellenőrizzük, hogy létezik-e már
|
||||
existing_stmt = select(ExpertiseTag).where(
|
||||
or_(
|
||||
ExpertiseTag.key == _slugify(tag_name),
|
||||
ExpertiseTag.name_hu == tag_name,
|
||||
ExpertiseTag.name_en == tag_name,
|
||||
)
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing_tag = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
# Már létezik, használjuk a meglévő ID-t
|
||||
created_ids.append(existing_tag.id)
|
||||
logger.info(
|
||||
f"Meglévő címke használata: tag_name={tag_name}, "
|
||||
f"existing_id={existing_tag.id}"
|
||||
)
|
||||
else:
|
||||
# Vadiúj címke létrehozása slugify-jal
|
||||
new_key = _slugify(tag_name)
|
||||
new_tag = ExpertiseTag(
|
||||
key=new_key,
|
||||
name_hu=tag_name,
|
||||
name_en=tag_name,
|
||||
level=3,
|
||||
is_official=False,
|
||||
category="user_created",
|
||||
parent_id=None,
|
||||
path=None,
|
||||
search_keywords=[tag_name.lower()],
|
||||
description=f"User-created tag: {tag_name}",
|
||||
)
|
||||
db.add(new_tag)
|
||||
await db.flush()
|
||||
created_ids.append(new_tag.id)
|
||||
logger.info(
|
||||
f"Új címke létrehozva: tag_name={tag_name}, "
|
||||
f"new_id={new_tag.id}, key={new_key}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"HIBA az új címke létrehozásakor: tag_name={tag_name}, "
|
||||
f"error={e}", exc_info=True
|
||||
)
|
||||
# Nem szakítjuk meg a teljes folyamatot egyetlen hibás címke miatt
|
||||
continue
|
||||
|
||||
return created_ids
|
||||
|
||||
|
||||
async def update_provider(
|
||||
db: AsyncSession,
|
||||
provider_id: int,
|
||||
@@ -507,6 +858,11 @@ async def update_provider(
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
- category_ids: ServiceExpertise kapcsolatok frissítése
|
||||
- new_tags: Új ExpertiseTag létrehozása (is_official=False, level=3)
|
||||
majd ServiceExpertise kapcsolat létrehozása
|
||||
|
||||
Multi-source update (2026-06-17): Támogatja mindhárom forrást:
|
||||
1. fleet.organizations (verified orgs) - közvetlen frissítés
|
||||
2. marketplace.service_staging (robot adatok) - migrálás Organization-be
|
||||
@@ -527,15 +883,43 @@ async def update_provider(
|
||||
|
||||
Raises:
|
||||
ValueError: Ha a szolgáltató nem található
|
||||
PermissionError: Ha a felhasználó nem tagja a szervezetnek
|
||||
"""
|
||||
# 1. Organization lekérdezése
|
||||
org = await db.get(Organization, provider_id)
|
||||
|
||||
if org:
|
||||
# =====================================================================
|
||||
# 1a. 🔐 ACCESS CONTROL ELLENŐRZÉS (P0 CRITICAL BUGFIX 2026-06-17)
|
||||
# =====================================================================
|
||||
# Csak azok a felhasználók szerkeszthetik a szolgáltatót, akik
|
||||
# OWNER vagy ADMIN szerepkörű tagok a szervezetben.
|
||||
member_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org.id,
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN]),
|
||||
)
|
||||
member_result = await db.execute(member_stmt)
|
||||
member = member_result.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
logger.warning(
|
||||
f"Access denied: user_id={user_id} attempted to update "
|
||||
f"org_id={org.id} but is not an OWNER or ADMIN member"
|
||||
)
|
||||
raise PermissionError(
|
||||
f"Nincs jogosultságod szerkeszteni ezt a szolgáltatót. "
|
||||
f"Csak a szervezet tulajdonosa vagy adminisztrátora "
|
||||
f"módosíthatja az adatokat."
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
if not org:
|
||||
# 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk
|
||||
staging = await db.get(ServiceStaging, provider_id)
|
||||
if staging:
|
||||
# Migráljuk Organization-be
|
||||
now = datetime.now(timezone.utc)
|
||||
org = Organization(
|
||||
id=staging.id,
|
||||
name=staging.name[:100],
|
||||
@@ -549,7 +933,16 @@ async def update_provider(
|
||||
address_house_number=data.address_house_number,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=f"sp-{staging.id}-{uuid.uuid4().hex[:6]}",
|
||||
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||
first_registered_at=now,
|
||||
current_lifecycle_started_at=now,
|
||||
created_at=now,
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={},
|
||||
is_ownership_transferable=True,
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
@@ -562,6 +955,7 @@ async def update_provider(
|
||||
crowd = await db.get(ServiceProvider, provider_id)
|
||||
if crowd:
|
||||
# Migráljuk Organization-be
|
||||
now = datetime.now(timezone.utc)
|
||||
org = Organization(
|
||||
id=crowd.id,
|
||||
name=crowd.name[:100],
|
||||
@@ -575,7 +969,16 @@ async def update_provider(
|
||||
address_house_number=data.address_house_number,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}",
|
||||
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||
first_registered_at=now,
|
||||
current_lifecycle_started_at=now,
|
||||
created_at=now,
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={},
|
||||
is_ownership_transferable=True,
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
@@ -605,6 +1008,8 @@ async def update_provider(
|
||||
org.address_street_type = data.address_street_type
|
||||
if data.address_house_number is not None:
|
||||
org.address_house_number = data.address_house_number
|
||||
if data.plus_code is not None:
|
||||
org.plus_code = data.plus_code
|
||||
|
||||
# 3. ServiceProfile lekérdezése (ha létezik)
|
||||
profile_stmt = select(ServiceProfile).where(
|
||||
@@ -625,26 +1030,156 @@ async def update_provider(
|
||||
spec_tags["user_tags"] = data.tags
|
||||
profile.specialization_tags = spec_tags
|
||||
|
||||
# =====================================================================
|
||||
# 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17)
|
||||
# =====================================================================
|
||||
|
||||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy
|
||||
# new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et.
|
||||
|
||||
# Összegyűjtjük az összes kategória ID-t (meglévő + új)
|
||||
all_category_ids: List[int] = []
|
||||
|
||||
# 5a. category_ids -> ServiceExpertise kapcsolatok frissítése
|
||||
safe_category_ids = data.category_ids or []
|
||||
if safe_category_ids:
|
||||
all_category_ids.extend(safe_category_ids)
|
||||
|
||||
# 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||||
safe_new_tags = data.new_tags or []
|
||||
if safe_new_tags:
|
||||
new_tag_ids = await _create_new_tags(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
new_tag_names=safe_new_tags,
|
||||
)
|
||||
if new_tag_ids:
|
||||
all_category_ids.extend(new_tag_ids)
|
||||
|
||||
# 5c. Egyszeri szinkronizálás az összes kategória ID-val
|
||||
if all_category_ids:
|
||||
await _sync_service_expertises(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
category_ids=all_category_ids,
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
logger.info(
|
||||
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
|
||||
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
|
||||
f"website={data.website}, tags={data.tags}"
|
||||
f"website={data.website}, tags={data.tags}, "
|
||||
f"category_ids={data.category_ids}, new_tags={data.new_tags}"
|
||||
)
|
||||
else:
|
||||
# P0 CRITICAL BUGFIX (2026-06-17): Ha nincs ServiceProfile, létrehozzuk.
|
||||
# Ez biztosítja, hogy a contact_phone, contact_email, website, tags
|
||||
# és category_ids adatok mentésre kerüljenek, még akkor is, ha a
|
||||
# szolgáltató korábban csak Organization-ként létezett.
|
||||
logger.warning(
|
||||
f"Provider update: org_id={org.id} has no ServiceProfile. "
|
||||
f"Only Organization fields updated."
|
||||
f"Creating one now."
|
||||
)
|
||||
fingerprint = hashlib.sha256(
|
||||
f"update-create-{org.id}-{datetime.now().isoformat()}".encode()
|
||||
).hexdigest()[:64]
|
||||
profile = ServiceProfile(
|
||||
organization_id=org.id,
|
||||
fingerprint=fingerprint,
|
||||
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
||||
contact_phone=data.contact_phone,
|
||||
contact_email=data.contact_email,
|
||||
website=data.website,
|
||||
specialization_tags={"user_tags": data.tags} if data.tags else {},
|
||||
status=ServiceStatus.active,
|
||||
)
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ServiceProfile created for org_id={org.id}, profile_id={profile.id}"
|
||||
)
|
||||
|
||||
# 4. ServiceProfile mezők frissítése
|
||||
profile.contact_phone = data.contact_phone
|
||||
profile.contact_email = data.contact_email
|
||||
profile.website = data.website
|
||||
|
||||
# specialization_tags frissítése
|
||||
if data.tags is not None:
|
||||
spec_tags = profile.specialization_tags or {}
|
||||
spec_tags["user_tags"] = data.tags
|
||||
profile.specialization_tags = spec_tags
|
||||
|
||||
# =====================================================================
|
||||
# 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17)
|
||||
# =====================================================================
|
||||
|
||||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy
|
||||
# new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et.
|
||||
|
||||
# Összegyűjtjük az összes kategória ID-t (meglévő + új)
|
||||
all_category_ids: List[int] = []
|
||||
|
||||
# 5a. category_ids -> ServiceExpertise kapcsolatok frissítése
|
||||
safe_category_ids = data.category_ids or []
|
||||
if safe_category_ids:
|
||||
all_category_ids.extend(safe_category_ids)
|
||||
|
||||
# 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||||
safe_new_tags = data.new_tags or []
|
||||
if safe_new_tags:
|
||||
new_tag_ids = await _create_new_tags(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
new_tag_names=safe_new_tags,
|
||||
)
|
||||
if new_tag_ids:
|
||||
all_category_ids.extend(new_tag_ids)
|
||||
|
||||
# 5c. Egyszeri szinkronizálás az összes kategória ID-val
|
||||
if all_category_ids:
|
||||
await _sync_service_expertises(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
category_ids=all_category_ids,
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
logger.info(
|
||||
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
|
||||
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
|
||||
f"website={data.website}, tags={data.tags}, "
|
||||
f"category_ids={data.category_ids}, new_tags={data.new_tags}"
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# 6. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás szerkesztésért)
|
||||
# =====================================================================
|
||||
# A felhasználó aktuális penalty_level-jétől (restriction_level) függően
|
||||
# kalkuláljuk a kapott pontot. A GamificationService.process_activity()
|
||||
# automatikusan alkalmazza a büntetési szorzót a _calculate_multiplier-en
|
||||
# keresztül, így:
|
||||
# - Szint 0 (L0) = 100% pont
|
||||
# - Szint 1 (L1) = 50% pont
|
||||
# - Szint 2 (L2) = 10% pont
|
||||
# - Szint 3 (L3) = 0% pont (blokkolt)
|
||||
earned_points = await _award_provider_points(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
action_key="UPDATE_PROVIDER",
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Provider updated successfully: org_id={org.id}, "
|
||||
f"name={data.name}, user_id={user_id}"
|
||||
f"name={data.name}, user_id={user_id}, earned_points={earned_points}"
|
||||
)
|
||||
|
||||
return ProviderUpdateResponse(
|
||||
id=org.id,
|
||||
name=data.name,
|
||||
message="Szolgáltató adatai sikeresen frissítve.",
|
||||
earned_points=earned_points,
|
||||
)
|
||||
|
||||
1011
backend/scripts/seed_expertise_taxonomy.py
Normal file
1011
backend/scripts/seed_expertise_taxonomy.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,5 +34,43 @@
|
||||
"BUTTON": "Go to Dashboard",
|
||||
"FOOTER": "Service Finder - Test system message"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organization not found.",
|
||||
"INVALID_TAX_FORMAT": "Invalid Hungarian tax number format!",
|
||||
"TAX_LOOKUP_FAILED": "Tax number lookup failed.",
|
||||
"FORBIDDEN": "You do not have permission to access this organization.",
|
||||
"INVALID_ROLE": "Invalid role specified.",
|
||||
"CANNOT_REMOVE_OWNER": "The owner cannot be removed from the organization.",
|
||||
"CANNOT_DEMOTE_OWNER": "The owner's role cannot be changed.",
|
||||
"ALREADY_MEMBER": "You are already a member of this organization.",
|
||||
"NO_ACTIVE_ADMIN": "This organization has no active admin. Use the /claim endpoint.",
|
||||
"HAS_ACTIVE_ADMIN": "This organization has an active admin. Use the /join-request endpoint.",
|
||||
"EMAIL_MISMATCH": "The email does not match the organization owner's email.",
|
||||
"INVALID_CODE": "Invalid or expired code.",
|
||||
"CODE_ORG_MISMATCH": "The code does not belong to this organization.",
|
||||
"INVITE_EXPIRED": "The invitation has expired.",
|
||||
"INVITE_INVALID": "Invalid invitation token."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Company successfully created and registered.",
|
||||
"MEMBER_REMOVED": "Member successfully removed.",
|
||||
"ROLE_UPDATED": "Role successfully updated.",
|
||||
"UPDATED": "Organization data successfully updated.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Visual settings successfully updated."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Invitation created successfully.",
|
||||
"ACCEPTED": "Invitation accepted.",
|
||||
"REVOKED": "Invitation revoked."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Your join request has been submitted. Waiting for admin approval."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "A 6-digit verification code has been sent to the organization owner's email.",
|
||||
"SUCCESS": "You have successfully taken over the organization. You are now the admin."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,5 +41,43 @@
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Sikeres mentés!"
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Szervezet nem található.",
|
||||
"INVALID_TAX_FORMAT": "Érvénytelen magyar adószám formátum!",
|
||||
"TAX_LOOKUP_FAILED": "Adószám lekérdezés sikertelen.",
|
||||
"FORBIDDEN": "Nincs jogosultságod ehhez a szervezethez.",
|
||||
"INVALID_ROLE": "Érvénytelen szerepkör.",
|
||||
"CANNOT_REMOVE_OWNER": "A tulajdonos nem távolítható el a szervezetből.",
|
||||
"CANNOT_DEMOTE_OWNER": "A tulajdonos szerepköre nem módosítható.",
|
||||
"ALREADY_MEMBER": "Már tagja vagy ennek a szervezetnek.",
|
||||
"NO_ACTIVE_ADMIN": "A szervezetnek nincs aktív adminja. Használd a /claim végpontot.",
|
||||
"HAS_ACTIVE_ADMIN": "A szervezetnek van aktív adminja. Használd a /join-request végpontot.",
|
||||
"EMAIL_MISMATCH": "Az email cím nem egyezik a cég tulajdonosának email címével.",
|
||||
"INVALID_CODE": "Érvénytelen vagy lejárt kód.",
|
||||
"CODE_ORG_MISMATCH": "A kód nem ehhez a szervezethez tartozik.",
|
||||
"INVITE_EXPIRED": "A meghívó lejárt.",
|
||||
"INVITE_INVALID": "Érvénytelen meghívó token."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Cég sikeresen létrehozva és regisztrálva.",
|
||||
"MEMBER_REMOVED": "Tag sikeresen eltávolítva.",
|
||||
"ROLE_UPDATED": "Szerepkör sikeresen frissítve.",
|
||||
"UPDATED": "Szervezet adatai sikeresen frissítve.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Vizuális beállítások sikeresen frissítve."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Meghívó sikeresen létrehozva.",
|
||||
"ACCEPTED": "Meghívó elfogadva.",
|
||||
"REVOKED": "Meghívó visszavonva."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Csatlakozási kérelmed rögzítettük. Az admin jóváhagyására vársz."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.",
|
||||
"SUCCESS": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin."
|
||||
}
|
||||
}
|
||||
}
|
||||
219
backend/tests/test_garage_selector_and_team_api.py
Normal file
219
backend/tests/test_garage_selector_and_team_api.py
Normal file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Teszt script a P0 Garage Selector Fix & Team Management API ellenőrzéséhez.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/tests/active/test_garage_selector_and_team_api.py
|
||||
|
||||
Ez a script:
|
||||
1. Bejelentkezik admin@profibot.hu / Admin123! (Vezető Tervező, user_id=2)
|
||||
2. Meghívja a GET /api/v1/organizations/my végpontot
|
||||
3. Ellenőrzi, hogy a teszt cég (owner_id=2) megjelenik-e
|
||||
4. Teszteli az új Team Management API végpontokat
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Add the backend directory to path
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
API_PREFIX = "/api/v1"
|
||||
|
||||
|
||||
async def login(email: str, password: str) -> str:
|
||||
"""Bejelentkezés és token lekérése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.post(
|
||||
f"{API_PREFIX}/auth/login",
|
||||
data={"username": email, "password": password},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Login failed: {resp.status_code} - {resp.text}")
|
||||
sys.exit(1)
|
||||
data = resp.json()
|
||||
token = data.get("access_token") or data.get("token")
|
||||
print(f"✅ Login successful (user: {email})")
|
||||
return token
|
||||
|
||||
|
||||
async def get_my_organizations(token: str) -> list:
|
||||
"""GET /api/v1/organizations/my - Saját szervezetek lekérése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.get(
|
||||
f"{API_PREFIX}/organizations/my",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 GET /api/v1/organizations/my")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Error: {resp.text}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
print(f"Count: {len(data)} organizations found")
|
||||
print(f"\nRaw response:")
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def list_members(token: str, org_id: int) -> list:
|
||||
"""GET /api/v1/organizations/{org_id}/members - Tagok listázása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.get(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 GET /api/v1/organizations/{org_id}/members")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Error: {resp.text}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
print(f"Count: {len(data)} members")
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
return data
|
||||
|
||||
|
||||
async def create_invitation(token: str, org_id: int, email: str, role: str = "MANAGER") -> dict:
|
||||
"""POST /api/v1/organizations/{org_id}/invitations - Meghívó küldése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.post(
|
||||
f"{API_PREFIX}/organizations/{org_id}/invitations",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"email": email, "role": role},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 POST /api/v1/organizations/{org_id}/invitations (email={email}, role={role})")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code in (200, 201):
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def update_member_role(token: str, org_id: int, member_id: int, role: str) -> dict:
|
||||
"""PATCH /api/v1/organizations/{org_id}/members/{member_id} - Szerepkör módosítása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.patch(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members/{member_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"role": role},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 PATCH /api/v1/organizations/{org_id}/members/{member_id} (role={role})")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def remove_member(token: str, org_id: int, member_id: int) -> dict:
|
||||
"""DELETE /api/v1/organizations/{org_id}/members/{member_id} - Tag eltávolítása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.delete(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members/{member_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 DELETE /api/v1/organizations/{org_id}/members/{member_id}")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🔍 P0 GARAGE SELECTOR FIX & TEAM MANAGEMENT API TESZT")
|
||||
print("=" * 70)
|
||||
|
||||
# 1. Bejelentkezés admin@profibot.hu (Vezető Tervező, user_id=2)
|
||||
token = await login("admin@profibot.hu", "Admin123!")
|
||||
|
||||
# 2. Saját szervezetek lekérése
|
||||
orgs = await get_my_organizations(token)
|
||||
|
||||
if not orgs:
|
||||
print("\n❌ CRITICAL: No organizations returned! Garage Selector would be empty!")
|
||||
print(" This means the get_my_organizations query is still broken.")
|
||||
sys.exit(1)
|
||||
|
||||
# 3. Ellenőrizzük, hogy van-e olyan org ahol owner_id=2 (admin user)
|
||||
admin_owned = [o for o in orgs if o.get("owner_id") == 2]
|
||||
if admin_owned:
|
||||
print(f"\n✅ Garage Selector FIX VERIFIED: {len(admin_owned)} organization(s) where owner_id=2:")
|
||||
for o in admin_owned:
|
||||
print(f" - ID: {o['organization_id']}, Name: {o.get('name', 'N/A')}, Role: {o.get('user_role', 'N/A')}")
|
||||
else:
|
||||
print(f"\n⚠️ No organizations found with owner_id=2. Checking all orgs...")
|
||||
for o in orgs:
|
||||
print(f" - ID: {o['organization_id']}, Name: {o.get('name', 'N/A')}, Owner: {o.get('owner_id', 'N/A')}, Role: {o.get('user_role', 'N/A')}")
|
||||
|
||||
# 4. Ha van legalább egy szervezet, teszteljük a Team Management API-t
|
||||
if orgs:
|
||||
test_org_id = orgs[0]["organization_id"]
|
||||
print(f"\n{'='*70}")
|
||||
print(f"🧪 TEAM MANAGEMENT API TESZTEK (org_id={test_org_id})")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# 4a. Tagok listázása
|
||||
members = await list_members(token, test_org_id)
|
||||
|
||||
# 4b. Meghívó küldése egy nem létező emailre (invited_email teszt)
|
||||
test_email = f"test_invite_{datetime.now(timezone.utc).timestamp()}@example.com"
|
||||
invite_result = await create_invitation(token, test_org_id, test_email, "MANAGER")
|
||||
|
||||
if invite_result:
|
||||
print(f"\n✅ Invitation created successfully!")
|
||||
|
||||
# 4c. Tagok újralistázása (ellenőrizzük, hogy megjelent-e a meghívó)
|
||||
members_after = await list_members(token, test_org_id)
|
||||
|
||||
# Keressük az új meghívót
|
||||
pending_invites = [m for m in members_after if m.get("status") == "pending"]
|
||||
if pending_invites:
|
||||
print(f"\n✅ Pending invitation found in member list!")
|
||||
new_member_id = pending_invites[0]["id"]
|
||||
|
||||
# 4d. Szerepkör módosítása
|
||||
role_result = await update_member_role(token, test_org_id, new_member_id, "ADMIN")
|
||||
if role_result:
|
||||
print(f"\n✅ Role update successful!")
|
||||
|
||||
# 4e. Tag eltávolítása (meghívó visszavonása)
|
||||
remove_result = await remove_member(token, test_org_id, new_member_id)
|
||||
if remove_result:
|
||||
print(f"\n✅ Member removal successful!")
|
||||
else:
|
||||
print(f"\n⚠️ No pending invitations found in member list.")
|
||||
else:
|
||||
print(f"\n⚠️ Could not create test invitation (might already exist).")
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print("✅ TESZT BEFEJEZŐDÖTT")
|
||||
print(f"{'='*70}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user