402 lines
15 KiB
Python
Executable File
402 lines
15 KiB
Python
Executable File
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/organizations.py
|
||
import os
|
||
import re
|
||
import uuid
|
||
import hashlib
|
||
import logging
|
||
from typing import List
|
||
from datetime import datetime, timezone
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select
|
||
|
||
from app.db.session import get_db
|
||
from app.api.deps import get_current_user
|
||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch
|
||
from app.models.identity import User # JAVÍTVA: Központi Identity modell
|
||
from app.core.config import settings
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
@router.post("/onboard", response_model=CorpOnboardResponse, status_code=status.HTTP_201_CREATED)
|
||
async def onboard_organization(
|
||
org_in: CorpOnboardIn,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Új szervezet (cég/szerviz) rögzítése.
|
||
Automatikusan generál slug-ot és létrehozza a NAS mappa-struktúrát.
|
||
"""
|
||
|
||
# 1. Magyar adószám validáció (XXXXXXXX-Y-ZZ)
|
||
if org_in.country_code == "HU":
|
||
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Érvénytelen magyar adószám formátum!"
|
||
)
|
||
|
||
# 2. Duplikáció ellenőrzés – adószám alapján
|
||
stmt_exist = select(Organization).where(Organization.tax_number == org_in.tax_number)
|
||
result_exist = await db.execute(stmt_exist)
|
||
if result_exist.scalar_one_or_none():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail="Ez a cég (adószám) már regisztrálva van a rendszerben."
|
||
)
|
||
|
||
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
||
# Mivel az adatbázisban NOT NULL, itt muszáj létrehozni
|
||
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
|
||
|
||
# 4. Mentés
|
||
new_org = Organization(
|
||
full_name=org_in.full_name,
|
||
name=org_in.name,
|
||
display_name=org_in.display_name,
|
||
tax_number=org_in.tax_number,
|
||
reg_number=org_in.reg_number,
|
||
folder_slug=temp_slug, # JAVÍTVA: Kötelező mező beillesztve
|
||
address_zip=org_in.address_zip,
|
||
address_city=org_in.address_city,
|
||
address_street_name=org_in.address_street_name,
|
||
address_street_type=org_in.address_street_type,
|
||
address_house_number=org_in.address_house_number,
|
||
address_hrsz=org_in.address_hrsz,
|
||
country_code=org_in.country_code,
|
||
org_type=OrgType.business,
|
||
status="pending_verification",
|
||
# --- EXPLICIT IDŐBÉLYEGEK A DB HIBA ELKERÜLÉSÉRE ---
|
||
first_registered_at=datetime.now(timezone.utc),
|
||
current_lifecycle_started_at=datetime.now(timezone.utc),
|
||
created_at=datetime.now(timezone.utc),
|
||
subscription_plan="FREE",
|
||
base_asset_limit=1,
|
||
purchased_extra_slots=0,
|
||
notification_settings={},
|
||
external_integration_config={},
|
||
is_ownership_transferable=True
|
||
)
|
||
|
||
db.add(new_org)
|
||
await db.flush()
|
||
|
||
# 5. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA
|
||
main_branch = Branch(
|
||
organization_id=new_org.id,
|
||
name="Központi Telephely",
|
||
is_main=True,
|
||
postal_code=org_in.address_zip,
|
||
city=org_in.address_city,
|
||
street_name=org_in.address_street_name,
|
||
street_type=org_in.address_street_type,
|
||
house_number=org_in.address_house_number,
|
||
hrsz=org_in.address_hrsz,
|
||
status="active"
|
||
)
|
||
db.add(main_branch)
|
||
|
||
# 6. TULAJDONOS RÖGZÍTÉSE
|
||
owner_member = OrganizationMember(
|
||
organization_id=new_org.id,
|
||
user_id=current_user.id,
|
||
role="OWNER" # JAVÍTVA: Enum kompatibilis nagybetűs forma
|
||
)
|
||
db.add(owner_member)
|
||
|
||
# 7. NAS Mappa létrehozása
|
||
try:
|
||
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
|
||
org_path = os.path.join(base_path, "organizations", str(new_org.id))
|
||
os.makedirs(os.path.join(org_path, "documents"), exist_ok=True)
|
||
logger.info(f"NAS mappa kész: {org_path}")
|
||
except Exception as e:
|
||
logger.error(f"NAS hiba: {e}")
|
||
|
||
await db.commit()
|
||
await db.refresh(new_org)
|
||
|
||
return {"organization_id": new_org.id, "status": new_org.status}
|
||
|
||
@router.get("/lookup-tax/{tax_number}")
|
||
async def lookup_tax_number(
|
||
tax_number: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
Adószám alapú cégadat lekérdezés a hivatalos NAV Online Számla API v3 rendszerén keresztül.
|
||
A queryTaxpayer végpontot használja valós idejű cégadatok lekérésére.
|
||
|
||
Duplikáció ellenőrzés: Mielőtt a NAV-hoz fordulnánk, ellenőrizzük, hogy az adószám
|
||
első 8 számjegye (törzsszám) alapján létezik-e már aktív cég az adatbázisban.
|
||
|
||
Hibakezelés:
|
||
- 409: Az adószám már regisztrálva van a rendszerben
|
||
- 503: NAV rendszer nem elérhető (hálózati hiba, DNS, timeout)
|
||
- 404: Adószám nem található a NAV adatbázisában
|
||
"""
|
||
from app.services.nav_service import NavService, NavServiceUnavailableError
|
||
|
||
# Duplikáció ellenőrzés: adószám első 8 számjegye (törzsszám) alapján
|
||
tax_core = tax_number.strip().replace("-", "").replace(" ", "")[:8]
|
||
stmt_existing = select(Organization).where(
|
||
Organization.tax_number.like(f"{tax_core}%"),
|
||
Organization.is_deleted == False,
|
||
)
|
||
result_existing = await db.execute(stmt_existing)
|
||
existing_org = result_existing.scalar_one_or_none()
|
||
if existing_org:
|
||
logger.warning(f"Duplikáció észlelve: adószám {tax_core} már létezik (org_id={existing_org.id})")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail="Ez az adószám már regisztrálva van a rendszerben."
|
||
)
|
||
|
||
try:
|
||
result = await NavService.query_taxpayer(tax_number)
|
||
except NavServiceUnavailableError as e:
|
||
logger.error(f"NAV szolgáltatás hiba: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="A NAV rendszere jelenleg nem elérhető."
|
||
)
|
||
|
||
if result is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Cég nem található a NAV adatbázisában"
|
||
)
|
||
return result
|
||
|
||
@router.get("/my", response_model=List[dict])
|
||
async def get_my_organizations(
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user)
|
||
):
|
||
""" A bejelentkezett felhasználóhoz tartozó összes szervezet listázása. """
|
||
stmt = (
|
||
select(Organization)
|
||
.join(OrganizationMember)
|
||
.where(OrganizationMember.user_id == current_user.id)
|
||
)
|
||
result = await db.execute(stmt)
|
||
orgs = result.scalars().all()
|
||
|
||
# Return full organization details
|
||
return [
|
||
{
|
||
"organization_id": o.id,
|
||
"status": o.status,
|
||
"name": o.name,
|
||
"full_name": o.full_name,
|
||
"display_name": o.display_name,
|
||
"tax_number": o.tax_number,
|
||
"country_code": o.country_code,
|
||
"is_active": o.is_active,
|
||
"is_deleted": o.is_deleted,
|
||
"subscription_plan": o.subscription_plan,
|
||
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
|
||
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None
|
||
}
|
||
for o in orgs
|
||
]
|
||
|
||
|
||
@router.patch("/{org_id}/visual-settings", response_model=OrganizationResponse)
|
||
async def update_organization_visual_settings(
|
||
org_id: int,
|
||
update_data: OrganizationUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Szervezet vizuális beállításainak részleges frissítése (JSONB merge).
|
||
|
||
A frontend küldhet csak egyetlen kulcsot is (pl. {"theme": "dark"}),
|
||
és az nem írja felül null-lal a többi mezőt. A meglévő visual_settings
|
||
objektum mélyen merge-elődik a beérkező adatokkal.
|
||
|
||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||
"""
|
||
# 1. Jogosultság ellenőrzése
|
||
stmt_member = select(OrganizationMember).where(
|
||
(OrganizationMember.organization_id == org_id) &
|
||
(OrganizationMember.user_id == current_user.id) &
|
||
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
||
)
|
||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||
if not member:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="Nincs jogosultságod a szervezet beállításainak módosításához (csak OWNER/ADMIN)."
|
||
)
|
||
|
||
# 2. Szervezet lekérése
|
||
stmt_org = select(Organization).where(
|
||
Organization.id == org_id,
|
||
Organization.is_deleted == False
|
||
)
|
||
result = await db.execute(stmt_org)
|
||
org = result.scalar_one_or_none()
|
||
if not org:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Szervezet nem található."
|
||
)
|
||
|
||
# 3. JSONB merge: csak a kapott kulcsokat frissítjük, a többit megtartjuk
|
||
update_dict = update_data.dict(exclude_unset=True)
|
||
|
||
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
|
||
incoming_vs = update_dict["visual_settings"]
|
||
current_vs = dict(org.visual_settings) if org.visual_settings else {}
|
||
# Mély merge: a meglévő kulcsok megmaradnak, csak a kapottak frissülnek
|
||
current_vs.update(incoming_vs)
|
||
org.visual_settings = current_vs
|
||
del update_dict["visual_settings"]
|
||
|
||
# 4. Egyéb mezők frissítése (display_name, language, default_currency)
|
||
for field, value in update_dict.items():
|
||
if hasattr(org, field) and value is not None:
|
||
setattr(org, field, value)
|
||
|
||
try:
|
||
await db.commit()
|
||
await db.refresh(org)
|
||
except Exception as e:
|
||
await db.rollback()
|
||
logger.error(f"Hiba a szervezet frissítésekor (org_id={org_id}): {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"Adatbázis hiba: {str(e)}"
|
||
)
|
||
|
||
return OrganizationResponse.model_validate(org)
|
||
# --- B2B MEGHÍVÓ LOGIKA ---
|
||
|
||
from pydantic import BaseModel, EmailStr
|
||
from app.models.identity import VerificationToken
|
||
import uuid
|
||
from datetime import timedelta
|
||
|
||
class OrgInvitationIn(BaseModel):
|
||
email: EmailStr
|
||
role: str = "DRIVER"
|
||
|
||
class OrgInvitationResponse(BaseModel):
|
||
status: str
|
||
message: str
|
||
|
||
@router.post("/{org_id}/invitations", response_model=OrgInvitationResponse, status_code=status.HTTP_200_OK)
|
||
async def invite_to_organization(
|
||
org_id: int,
|
||
invite_in: OrgInvitationIn,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user)
|
||
):
|
||
"""
|
||
B2B Meghívó küldése egy szervezetbe.
|
||
Ha a felhasználó már létezik, egy pending tagot hozunk létre.
|
||
Ha nem létezik, token készül az email címre.
|
||
"""
|
||
# 1. Jogosultság ellenőrzése
|
||
stmt_member = select(OrganizationMember).where(
|
||
(OrganizationMember.organization_id == org_id) &
|
||
(OrganizationMember.user_id == current_user.id) &
|
||
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
||
)
|
||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||
if not member:
|
||
raise HTTPException(status_code=403, detail="Nincs jogosultságod meghívót küldeni (csak OWNER/ADMIN).")
|
||
|
||
# 2. Célpont keresése
|
||
stmt_target = select(User).where(User.email == invite_in.email)
|
||
target_user = (await db.execute(stmt_target)).scalar_one_or_none()
|
||
|
||
if target_user:
|
||
# Létező felhasználó, van-e már tagsága?
|
||
stmt_exist = select(OrganizationMember).where(
|
||
(OrganizationMember.organization_id == org_id) &
|
||
(OrganizationMember.user_id == target_user.id)
|
||
)
|
||
if (await db.execute(stmt_exist)).scalar_one_or_none():
|
||
raise HTTPException(status_code=400, detail="A felhasználó már tagja a szervezetnek.")
|
||
|
||
new_member = OrganizationMember(
|
||
organization_id=org_id,
|
||
user_id=target_user.id,
|
||
role=invite_in.role.upper(),
|
||
status="pending" # Válaszolni kell a meghívóra
|
||
)
|
||
db.add(new_member)
|
||
await db.commit()
|
||
logger.info(f"Értesítő email küldve a létező felhasználónak: {invite_in.email}")
|
||
return {"status": "success", "message": "Meghívó elküldve a meglévő felhasználónak."}
|
||
else:
|
||
# Új felhasználó -> Token
|
||
token_val = uuid.uuid4()
|
||
new_token = VerificationToken(
|
||
token=token_val,
|
||
user_id=None, # Mivel még nincs User
|
||
token_type="org_invite",
|
||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||
extra_data={"org_id": org_id, "role": invite_in.role.upper(), "email": invite_in.email}
|
||
)
|
||
db.add(new_token)
|
||
await db.commit()
|
||
logger.info(f"Meghívó email küldve az új felhasználónak: {invite_in.email}, Token: {token_val}")
|
||
return {"status": "success", "message": "Meghívó email elküldve (új felhasználó)."}
|
||
|
||
@router.post("/invitations/{token}/accept", response_model=OrgInvitationResponse, status_code=status.HTTP_200_OK)
|
||
async def accept_invitation(
|
||
token: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Meghívó elfogadása token alapján. (Új felhasználó számára, miután regisztrált)
|
||
"""
|
||
stmt = select(VerificationToken).where(
|
||
(VerificationToken.token == token) &
|
||
(VerificationToken.token_type == "org_invite") &
|
||
(VerificationToken.is_used == False)
|
||
)
|
||
token_rec = (await db.execute(stmt)).scalar_one_or_none()
|
||
|
||
if not token_rec or token_rec.expires_at < datetime.now(timezone.utc):
|
||
raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt meghívó.")
|
||
|
||
extra = token_rec.extra_data
|
||
if not extra or extra.get("email") != current_user.email:
|
||
raise HTTPException(status_code=403, detail="A meghívó nem a te e-mail címedre szól.")
|
||
|
||
org_id = extra.get("org_id")
|
||
role = extra.get("role")
|
||
|
||
# Van-e már tagsága?
|
||
stmt_exist = select(OrganizationMember).where(
|
||
(OrganizationMember.organization_id == org_id) &
|
||
(OrganizationMember.user_id == current_user.id)
|
||
)
|
||
exist_member = (await db.execute(stmt_exist)).scalar_one_or_none()
|
||
if exist_member:
|
||
exist_member.status = "active"
|
||
exist_member.role = role
|
||
else:
|
||
new_member = OrganizationMember(
|
||
organization_id=org_id,
|
||
user_id=current_user.id,
|
||
role=role,
|
||
status="active"
|
||
)
|
||
db.add(new_member)
|
||
|
||
token_rec.is_used = True
|
||
await db.commit()
|
||
|
||
return {"status": "success", "message": "Meghívó sikeresen elfogadva."}
|
||
|