céges meghívó kezelése,

This commit is contained in:
Roo
2026-06-17 22:07:55 +00:00
parent bf3a971ff1
commit 127b130401
28 changed files with 5806 additions and 1313 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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(

View File

@@ -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",

View File

@@ -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")

View File

@@ -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())

View File

@@ -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

View File

@@ -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,
},
]

View File

@@ -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,
)

File diff suppressed because it is too large Load Diff

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View 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())