2026.06.05 frontend javítgatás és új belépési logika megvalósítva

This commit is contained in:
Roo
2026-06-05 05:46:04 +00:00
parent 59a30ac428
commit 18524a08f2
38 changed files with 3967 additions and 1028 deletions

View File

@@ -68,7 +68,16 @@ async def get_current_user(
# JAVÍTVA: Modern SQLAlchemy 2.0 aszinkron lekérdezés with eager loading
# We need to load the person relationship to avoid lazy loading issues
stmt = select(User).where(User.id == int(user_id)).options(joinedload(User.person))
# Also load Person.address for nested profile response
from app.models.identity import Person
from app.models.identity.address import Address
stmt = (
select(User)
.where(User.id == int(user_id))
.options(
joinedload(User.person).joinedload(Person.address)
)
)
result = await db.execute(stmt)
user = result.unique().scalar_one_or_none()

View File

@@ -1,4 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/auth.py
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,9 +11,10 @@ from app.core.config import settings
from app.services.config_service import config
from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete
from app.api.deps import get_current_user
from app.models.identity import User # JAVÍTVA: Új központi modell
from app.models.identity import User, Device, UserDeviceLink # JAVÍTVA: Device-Hub modellek
from app.core.translation_helper import t # Translation helper
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime, timezone
router = APIRouter()
@@ -34,18 +36,71 @@ async def login(
response: Response,
db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
remember_me: bool = Form(False)
remember_me: bool = Form(False),
device_fingerprint: Optional[str] = Form(None)
):
"""
Bejelentkezés remember_me opcióval.
Bejelentkezés remember_me opcióval és Device Fingerprinting támogatással.
A remember_me paramétert explicit Form mezőként kell elküldeni, mivel az
OAuth2PasswordRequestForm nem támogatja alapból.
A device_fingerprint opcionális, a frontend FingerprintJS által generált hash.
"""
user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=401, detail=t("AUTH.INVALID_CREDENTIALS"))
# DEBUG: Inaktív fiók explicit ellenőrzése
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="AUTH.USER_INACTIVE"
)
# ── Device Fingerprint Tracking ──
if device_fingerprint:
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
existing_device = await db.execute(
select(Device).where(Device.fingerprint_hash == device_fingerprint)
)
device = existing_device.scalar_one_or_none()
if not device:
# Új eszköz létrehozása
device = Device(
fingerprint_hash=device_fingerprint,
risk_score=100,
is_banned=False
)
db.add(device)
# 2. Ellenőrizzük a UserDeviceLink-et
existing_link = await db.execute(
select(UserDeviceLink).where(
UserDeviceLink.user_id == user.id,
UserDeviceLink.device_hash == device_fingerprint
)
)
link = existing_link.scalar_one_or_none()
now = datetime.now(timezone.utc)
if link:
# Létező kapcsolat frissítése
link.last_seen_at = now
link.login_count = link.login_count + 1
else:
# Új kapcsolat létrehozása
link = UserDeviceLink(
user_id=user.id,
device_hash=device_fingerprint,
first_seen_at=now,
last_seen_at=now,
login_count=1
)
db.add(link)
await db.commit()
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
@@ -82,16 +137,112 @@ async def login(
class VerifyEmailRequest(BaseModel):
token: str = Field(..., description="Email verification token (UUID)")
device_fingerprint: Optional[str] = Field(None, description="Device fingerprint hash from FingerprintJS")
@router.post("/verify-email")
async def verify_email(request: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
@router.post("/verify-email", response_model=Token)
async def verify_email(
request: VerifyEmailRequest,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""
Email megerősítés token alapján.
Email megerősítés token alapján + automatikus bejelentkezés (Magic Link).
Sikeres verifikáció után azonnal JWT tokent ad vissza, így a felhasználó
bejelentkezik anélkül, hogy külön be kellene írnia a jelszavát.
"""
success = await AuthService.verify_email(db, request.token)
if not success:
user = await AuthService.verify_email(db, request.token)
if not user:
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
return {"status": "success", "message": t("AUTH.EMAIL_VERIFICATION_SUCCESS")}
# ── Device Fingerprint Tracking (Magic Link) ──
if request.device_fingerprint:
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
existing_device = await db.execute(
select(Device).where(Device.fingerprint_hash == request.device_fingerprint)
)
device = existing_device.scalar_one_or_none()
if not device:
# Új eszköz létrehozása
device = Device(
fingerprint_hash=request.device_fingerprint,
risk_score=100,
is_banned=False
)
db.add(device)
# 2. Ellenőrizzük a UserDeviceLink-et
existing_link = await db.execute(
select(UserDeviceLink).where(
UserDeviceLink.user_id == user.id,
UserDeviceLink.device_hash == request.device_fingerprint
)
)
link = existing_link.scalar_one_or_none()
now = datetime.now(timezone.utc)
if link:
# Létező kapcsolat frissítése
link.last_seen_at = now
link.login_count = link.login_count + 1
else:
# Új kapcsolat létrehozása
link = UserDeviceLink(
user_id=user.id,
device_hash=request.device_fingerprint,
first_seen_at=now,
last_seen_at=now,
login_count=1
)
db.add(link)
await db.commit()
# Generálj JWT tokent a most aktivált felhasználóhoz (ugyanaz a logika, mint login-nál)
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper()
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_key, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
# Magic Link esetén ne használjunk remember_me-t (alapértelmezett 1 napos token)
access, refresh = create_tokens(data=token_data, remember_me=False)
# Refresh token lejárat
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
max_age_sec = int(max_age_days) * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
max_age=max_age_sec
)
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer", "is_active": user.is_active}
class ResendVerificationRequest(BaseModel):
email: EmailStr = Field(..., description="Email cím az aktiváló levél újraküldéséhez")
@router.post("/resend-verification")
async def resend_verification(request: ResendVerificationRequest, db: AsyncSession = Depends(get_db)):
"""
Aktiváló e-mail újraküldése.
Mindig sikeres választ ad, hogy megelőzzük az email enumerációt.
"""
result = await AuthService.resend_verification(db, request.email)
return {
"status": "success",
"message": "Ha ez az email cím regisztrálva van és még nem aktív, akkor elküldtük az aktiváló e-mailt."
}
class ForgotPasswordRequest(BaseModel):
email: EmailStr = Field(..., description="Email cím a jelszó visszaállításhoz")
@@ -100,10 +251,18 @@ class ForgotPasswordRequest(BaseModel):
async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
"""
Elfelejtett jelszó folyamat indítása.
Mindig sikeres választ ad, hogy megelőzzük az email enumerációt.
DEBUG MODE: Explicit hibákat dob az email küldési problémák diagnosztizálásához.
"""
result = await AuthService.initiate_password_reset(db, request.email)
# Mindig ugyanazt a választ adjuk, függetlenül attól, hogy létezik-e a felhasználó
# DEBUG: Ha nem található a user, dobjunk explicit hibát
if result == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A felhasználó nem található ezzel az email címmel."
)
# Ha success, adjuk vissza a sikeres választ
return {
"status": "success",
"message": "Ha ez az email cím regisztrálva van, akkor elküldtünk egy jelszó-visszaállítási linket."

View File

@@ -1,8 +1,9 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/system_parameters.py
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import List, Optional
from sqlalchemy import select, update, and_
from typing import List, Optional, Dict, Any
from app.api.deps import get_db, get_current_user
from app.schemas.system import (
@@ -11,9 +12,11 @@ from app.schemas.system import (
SystemParameterCreate,
)
from app.models.system import SystemParameter, ParameterScope
from app.models.identity.address import GeoPostalCode
from app.models.identity import UserRole
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/", response_model=List[SystemParameterResponse])
@@ -41,6 +44,40 @@ async def list_system_parameters(
return parameters
@router.get("/zip-lookup", response_model=Dict[str, Any])
async def zip_lookup(
country_code: str = Query("HU", description="Országkód (pl. HU, AT, DE)"),
zip_code: str = Query(..., min_length=1, description="Irányítószám"),
db: AsyncSession = Depends(get_db),
):
"""
Belső irányítószám lekérdező végpont.
A system.geo_postal_codes táblában keres a megadott country_code és zip_code párosra.
Ha megtalálja, visszaadja a várost: {"city": "Budapest"}.
Ha nem találja, 404-es hibát dob.
"""
if not zip_code.strip():
raise HTTPException(status_code=400, detail="zip_code is required")
stmt = select(GeoPostalCode).where(
and_(
GeoPostalCode.country_code == country_code,
GeoPostalCode.zip_code == zip_code.strip(),
)
).limit(1)
result = await db.execute(stmt)
postal_code = result.scalar_one_or_none()
if not postal_code:
raise HTTPException(
status_code=404,
detail=f"No city found for country_code='{country_code}' and zip_code='{zip_code}'"
)
return {"city": postal_code.city}
@router.get("/{key}", response_model=SystemParameterResponse)
async def get_system_parameter(
key: str,
@@ -128,5 +165,4 @@ async def update_system_parameter(
await db.execute(stmt)
await db.commit()
await db.refresh(parameter)
return parameter

View File

@@ -1,16 +1,23 @@
#/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/users.py
import copy
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select, or_
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.attributes import flag_modified
from typing import Dict, Any, List, Optional
from datetime import datetime
from app.api.deps import get_db, get_current_user
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse
from app.models.identity import User
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest
from app.models.identity import User, Person
from app.models.identity.address import Address
from app.services.trust_engine import TrustEngine
from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.services.geo_service import GeoService
from app.services.auth_service import AuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP, verify_password, get_password_hash
from app.core.config import settings
from pydantic import BaseModel
@@ -33,26 +40,105 @@ class NetworkResponse(BaseModel):
level2: List[NetworkMemberL2L3]
level3: List[NetworkMemberL2L3]
def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dict:
"""
Segédfüggvény a UserResponse dict előállításához.
Beágyazza a Person és Address adatokat a 'person' mezőbe.
"""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
# Determine active organization ID
if active_org_id is None:
if user.scope_id is not None:
try:
active_org_id = int(user.scope_id)
except (ValueError, TypeError):
active_org_id = None
if active_org_id is None:
# 1. Check if user is a member of any organization with ADMIN/OWNER role
# (This is a helper - in real usage the caller should pass active_org_id)
pass
person = user.person
person_data = None
if person:
address_data = None
if person.address:
address_data = {
"zip": getattr(person.address, 'zip', None) or getattr(getattr(person.address, 'postal_code', None), 'zip_code', None),
"city": getattr(person.address, 'city', None) or getattr(getattr(person.address, 'postal_code', None), 'city', None),
"street_name": person.address.street_name,
"street_type": person.address.street_type,
"house_number": person.address.house_number,
"stairwell": person.address.stairwell,
"floor": person.address.floor,
"door": person.address.door,
"parcel_id": person.address.parcel_id,
"full_address_text": person.address.full_address_text,
"latitude": person.address.latitude,
"longitude": person.address.longitude,
}
person_data = {
"id": person.id,
"id_uuid": str(person.id_uuid) if person.id_uuid else None,
"first_name": person.first_name,
"last_name": person.last_name,
"phone": person.phone,
"mothers_last_name": person.mothers_last_name,
"mothers_first_name": person.mothers_first_name,
"birth_place": person.birth_place,
"birth_date": person.birth_date,
"identity_docs": person.identity_docs,
"ice_contact": person.ice_contact,
"is_active": person.is_active,
"address": address_data,
}
# Get first_name/last_name from person for backward compatibility
first_name = person.first_name if person else ""
last_name = person.last_name if person else ""
return {
"id": user.id,
"email": user.email,
"first_name": first_name,
"last_name": last_name,
"is_active": user.is_active,
"region_code": user.region_code,
"person_id": user.person_id,
"role": user.role.value if hasattr(user.role, 'value') else str(user.role),
"subscription_plan": user.subscription_plan,
"scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
"ui_mode": user.ui_mode or "personal",
"active_organization_id": active_org_id,
"preferred_language": user.preferred_language or "hu",
"person": person_data,
}
@router.get("/me", response_model=UserResponse)
async def read_users_me(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Visszaadja a bejelentkezett felhasználó profilját"""
from sqlalchemy import select, or_
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal."""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
# Determine active organization ID
active_org_id = None
# If user already has a scope_id, use it
if current_user.scope_id is not None:
try:
active_org_id = int(current_user.scope_id)
except (ValueError, TypeError):
active_org_id = None
# If still no active org ID, try to find user's primary organization
if active_org_id is None:
# 1. Check if user is a member of any organization with ADMIN/OWNER role
@@ -63,10 +149,10 @@ async def read_users_me(
OrganizationMember.role == OrgUserRole.OWNER
)
).limit(1)
result = await db.execute(stmt)
org_member_row = result.first()
if org_member_row:
active_org_id = org_member_row[0]
else:
@@ -76,7 +162,7 @@ async def read_users_me(
).limit(1)
result = await db.execute(stmt)
org_owner_row = result.first()
if org_owner_row:
active_org_id = org_owner_row[0]
else:
@@ -86,37 +172,138 @@ async def read_users_me(
).limit(1)
result = await db.execute(stmt)
org_row = result.first()
active_org_id = org_row[0] if org_row else None
# Create a response dictionary with the active_organization_id
# Get first_name and last_name from person relation if available
person = current_user.person
# Safe extraction with fallback to empty string
first_name = ""
last_name = ""
if person:
first_name = getattr(person, 'first_name', '')
last_name = getattr(person, 'last_name', '')
response_data = {
"id": current_user.id,
"email": current_user.email,
"first_name": first_name,
"last_name": last_name,
"is_active": current_user.is_active,
"region_code": current_user.region_code,
"person_id": current_user.person_id,
"role": current_user.role.value if hasattr(current_user.role, 'value') else str(current_user.role),
"subscription_plan": current_user.subscription_plan,
"scope_level": current_user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
"ui_mode": current_user.ui_mode or "personal",
"active_organization_id": active_org_id
}
response_data = _build_user_response(current_user, active_org_id)
return UserResponse.model_validate(response_data)
@router.put("/me/person", response_model=UserResponse)
async def update_my_person(
update_data: PersonUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Frissíti a bejelentkezett felhasználó Person (és Address) rekordját.
Elfogadja a PersonUpdate sémát, amely tartalmazza a személyes, okmány és cím adatokat.
Visszaadja a frissített UserResponse objektumot.
"""
# Reload user with eager loaded person + address
stmt = (
select(User)
.where(User.id == current_user.id)
.options(joinedload(User.person).joinedload(Person.address))
)
result = await db.execute(stmt)
user = result.unique().scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
person = user.person
if not person:
raise HTTPException(status_code=400, detail="No Person record found. Complete KYC first.")
# ── Update Person fields ──
update_dict = update_data.dict(exclude_unset=True)
person_fields = ["first_name", "last_name", "phone", "mothers_last_name",
"mothers_first_name", "birth_place", "birth_date"]
address_fields = ["address_zip", "address_city", "address_street_name",
"address_street_type", "address_house_number",
"address_stairwell", "address_floor", "address_door",
"address_hrsz"]
for field in person_fields:
if field in update_dict and update_dict[field] is not None:
setattr(person, field, update_dict[field])
# ── Update identity_docs (if provided and not None) ──
if "identity_docs" in update_dict and update_dict["identity_docs"] is not None:
# Use deep copy + flag_modified to ensure SQLAlchemy detects the JSON mutation
existing_docs = copy.deepcopy(person.identity_docs) if person.identity_docs else {}
if isinstance(existing_docs, dict) and isinstance(update_dict["identity_docs"], dict):
existing_docs.update(update_dict["identity_docs"])
person.identity_docs = existing_docs
flag_modified(person, "identity_docs")
else:
person.identity_docs = update_dict["identity_docs"]
flag_modified(person, "identity_docs")
# ── Update Address fields ──
has_address_update = any(f in update_dict for f in address_fields)
if has_address_update:
# Use GeoService to get or create address
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=update_dict.get("address_zip"),
city=update_dict.get("address_city"),
street_name=update_dict.get("address_street_name"),
street_type=update_dict.get("address_street_type"),
house_number=update_dict.get("address_house_number"),
parcel_id=update_dict.get("address_hrsz"),
)
if addr_id:
person.address_id = addr_id
try:
await db.commit()
# Refresh to get the latest state
await db.refresh(person)
if person.address:
await db.refresh(person.address)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
# Build and return the full response
response_data = _build_user_response(user)
return UserResponse.model_validate(response_data)
@router.put("/me/password", status_code=200)
async def change_my_password(
change_data: ChangePasswordRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jelszó módosítás a bejelentkezett felhasználó számára.
Ellenőrzi a jelenlegi jelszót, validálja az új jelszó komplexitását,
majd elmenti az új hash-t. Válaszként egy {'status': 'ok', 'message': ...} dict-et ad.
"""
# 1. Ellenőrizzük a jelenlegi jelszót
if not verify_password(change_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=400,
detail="A jelenlegi jelszó helytelen."
)
# 2. Ellenőrizzük, hogy az új jelszó nem egyezik-e a régivel
if verify_password(change_data.new_password, current_user.hashed_password):
raise HTTPException(
status_code=400,
detail="Az új jelszó nem egyezhet meg a jelenlegi jelszóval."
)
# 3. Jelszó komplexitás ellenőrzése (dinamikus admin beállítások alapján)
await AuthService._validate_password_complexity(db, change_data.new_password, current_user.region_code)
# 4. Új jelszó hash-elése és mentése
current_user.hashed_password = get_password_hash(change_data.new_password)
try:
await db.commit()
await db.refresh(current_user)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
return {"status": "ok", "message": "Jelszó sikeresen megváltoztatva."}
@router.get("/me/trust")
async def get_user_trust(
db: AsyncSession = Depends(get_db),
@@ -125,10 +312,10 @@ async def get_user_trust(
) -> Dict[str, Any]:
"""
Visszaadja a felhasználó Gondos Gazda Index (Trust Score) értékét.
A számítás dinamikusan betölti a paramétereket a SystemParameter rendszerből
(Global/Country/Region/User hierarchia).
Paraméterek:
- force_recalculate: Ha True, akkor újraszámolja a trust score-t
(alapértelmezetten cache-elt értéket ad vissza, ha kevesebb mint 24 órája számoltuk)
@@ -154,28 +341,29 @@ async def update_user_preferences(
update_dict = update_data.dict(exclude_unset=True)
if not update_dict:
raise HTTPException(status_code=400, detail="No fields to update")
# Validate ui_mode if present
if "ui_mode" in update_dict:
if update_dict["ui_mode"] not in ["personal", "fleet"]:
raise HTTPException(status_code=422, detail="ui_mode must be 'personal' or 'fleet'")
# Update user fields
for field, value in update_dict.items():
if hasattr(current_user, field):
setattr(current_user, field, value)
else:
raise HTTPException(status_code=400, detail=f"Invalid field: {field}")
try:
await db.commit()
await db.refresh(current_user)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
# Return the Pydantic model instead of raw SQLAlchemy object
return UserResponse.model_validate(current_user)
response_data = _build_user_response(current_user)
return UserResponse.model_validate(response_data)
@router.patch("/me/active-organization", response_model=UserWithTokenResponse)
@@ -186,18 +374,18 @@ async def update_active_organization(
):
"""
Update the user's active organization (scope_id).
Accepts an organization_id (UUID/string) or None to revert to personal mode.
Returns a new JWT token with updated scope_id in the payload.
"""
# Extract organization_id from request
org_id = update_data.organization_id
# Validate that the user has access to this organization if org_id is provided
if org_id is not None:
from sqlalchemy import select
from app.models.marketplace.organization import OrganizationMember
# Check if user is a member of the organization
stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
@@ -205,23 +393,23 @@ async def update_active_organization(
)
result = await db.execute(stmt)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(
status_code=403,
detail="You are not a member of this organization"
)
# Update user's scope_id
current_user.scope_id = org_id
try:
await db.commit()
await db.refresh(current_user)
except SQLAlchemyError as e:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
# Generate new JWT token with updated scope_id
role_key = current_user.role.value.upper()
token_payload = {
@@ -232,16 +420,18 @@ async def update_active_organization(
"scope_id": org_id,
"person_id": str(current_user.person_id) if current_user.person_id else None,
}
access_token, _ = create_tokens(data=token_payload)
# Return user data with new token
response_data = _build_user_response(current_user)
return UserWithTokenResponse(
user=UserResponse.model_validate(current_user),
user=UserResponse.model_validate(response_data),
access_token=access_token,
token_type="bearer"
)
@router.get("/me/network", response_model=NetworkResponse)
async def get_my_network(
db: AsyncSession = Depends(get_db),
@@ -257,7 +447,7 @@ async def get_my_network(
l1_stmt = select(User).where(User.referred_by_id == current_user.id)
l1_result = await db.execute(l1_stmt)
l1_users = l1_result.scalars().all()
level1 = [
NetworkMemberL1(
email=u.email,
@@ -267,7 +457,7 @@ async def get_my_network(
)
for u in l1_users
]
# L2: L1 userek által meghívottak
level2 = []
if l1_users:
@@ -275,7 +465,7 @@ async def get_my_network(
l2_stmt = select(User).where(User.referred_by_id.in_(l1_ids))
l2_result = await db.execute(l2_stmt)
l2_users = l2_result.scalars().all()
level2 = [
NetworkMemberL2L3(
referral_code=u.referral_code or "",
@@ -283,7 +473,7 @@ async def get_my_network(
)
for u in l2_users
]
# L3: L2 userek által meghívottak
level3 = []
if l2_users:
@@ -291,7 +481,7 @@ async def get_my_network(
l3_stmt = select(User).where(User.referred_by_id.in_(l2_ids))
l3_result = await db.execute(l3_stmt)
l3_users = l3_result.scalars().all()
level3 = [
NetworkMemberL2L3(
referral_code=u.referral_code or "",
@@ -299,7 +489,7 @@ async def get_my_network(
)
for u in l3_users
]
return NetworkResponse(
level1=level1,
level2=level2,

View File

@@ -75,8 +75,8 @@ class Settings(BaseSettings):
# --- Email ---
EMAIL_PROVIDER: str = "auto"
EMAILS_FROM_EMAIL: str = "info@profibot.hu"
EMAILS_FROM_NAME: str = "Profibot"
EMAILS_FROM_EMAIL: str = "noreply@servicefinder.hu"
EMAILS_FROM_NAME: str = "ServiceFinder"
SENDGRID_API_KEY: Optional[str] = None
SMTP_HOST: Optional[str] = None
@@ -85,7 +85,7 @@ class Settings(BaseSettings):
SMTP_PASSWORD: Optional[str] = None
# --- External URLs ---
FRONTEND_BASE_URL: str = "https://dev.servicefinder.hu"
FRONTEND_BASE_URL: str = "https://app.servicefinder.hu"
BACKEND_CORS_ORIGINS: List[str] = Field(
default=[
# Production domains
@@ -130,7 +130,7 @@ class Settings(BaseSettings):
# --- Google OAuth ---
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_CALLBACK_URL: str = "https://dev.profibot.hu/api/v1/auth/callback/google"
GOOGLE_CALLBACK_URL: str = "https://dev.servicefinder.hu/api/v1/auth/callback/google"
# --- Brute-Force & Security ---
LOGIN_RATE_LIMIT_ANON: str = "5/minute"

View File

@@ -8,6 +8,8 @@ from .identity import (
ActiveVoucher,
UserTrustProfile,
UserRole,
Device,
UserDeviceLink,
)
from .address import (
@@ -38,6 +40,8 @@ __all__ = [
"ActiveVoucher",
"UserTrustProfile",
"UserRole",
"Device",
"UserDeviceLink",
"Address",
"GeoPostalCode",
"GeoStreet",

View File

@@ -44,9 +44,9 @@ class Address(Base):
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
postal_code_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("system.geo_postal_codes.id"))
street_name: Mapped[str] = mapped_column(String(200), nullable=False)
street_type: Mapped[str] = mapped_column(String(50), nullable=False)
house_number: Mapped[str] = mapped_column(String(50), nullable=False)
street_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
house_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
floor: Mapped[Optional[str]] = mapped_column(String(20))
@@ -60,6 +60,9 @@ class Address(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolat az irányítószám táblához (zip/city lekéréshez)
postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined")
class Rating(Base):

View File

@@ -2,17 +2,18 @@
from __future__ import annotations
import uuid
import enum
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, List, Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Integer, BigInteger, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM, JSONB
from sqlalchemy.sql import func
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
if TYPE_CHECKING:
from .address import Address
from .organization import Organization, OrganizationMember
from .asset import VehicleOwnership
from .gamification import UserStats
@@ -69,6 +70,14 @@ class Person(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_ghost: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Soft KYC: Duplikált személyek összefésülése (merge)
merged_into_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("identity.persons.id", ondelete="SET NULL"),
nullable=True,
index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False)
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
@@ -94,6 +103,13 @@ class Person(Base):
nullable=True
)
# Kapcsolat a lakcímhez (system.addresses tábla)
address: Mapped[Optional["Address"]] = relationship(
"Address",
foreign_keys="[Person.address_id]",
lazy="joined"
)
memberships: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="person")
# Kapcsolat a tulajdonolt szervezetekhez (Organization táblában legal_owner_id)
@@ -113,7 +129,7 @@ class User(Base):
hashed_password: Mapped[Optional[str]] = mapped_column(String)
role: Mapped[UserRole] = mapped_column(
PG_ENUM(UserRole, name="userrole", schema="identity"),
PG_ENUM(UserRole, name="userrole", schema="identity"),
default=UserRole.user
)
@@ -142,6 +158,10 @@ class User(Base):
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
# E-mail történetiség és alternatív e-mailek (MB 2.0.1)
alternative_emails: Mapped[Any] = mapped_column(JSON, nullable=False, default=list, server_default=text("'[]'::jsonb"))
email_history: Mapped[Any] = mapped_column(JSON, nullable=False, default=list, server_default=text("'[]'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# --- KAPCSOLATOK ---
@@ -192,7 +212,7 @@ class Wallet(Base):
__table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), unique=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True)
earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
@@ -263,10 +283,66 @@ class UserTrustProfile(Base):
maintenance_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False)
quality_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False)
preventive_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0.0, nullable=False)
# Soft KYC / Identity Trust bővítés
identity_score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
verification_level: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
verified_channels: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
identity_risk_flag: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
last_calculated: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False
)
user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False)
user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False)
class Device(Base):
""" Ismert eszközök (Device Fingerprinting) a Device-Hub architektúrához. """
__tablename__ = "devices"
__table_args__ = {"schema": "identity"}
fingerprint_hash: Mapped[str] = mapped_column(String(255), primary_key=True, index=True)
risk_score: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
is_banned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False
)
class UserDeviceLink(Base):
""" Kapcsolótábla a felhasználók és az eszközök között. """
__tablename__ = "user_device_links"
__table_args__ = {"schema": "identity"}
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("identity.users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
device_hash: Mapped[str] = mapped_column(
String(255),
ForeignKey("identity.devices.fingerprint_hash", ondelete="CASCADE"),
nullable=False,
index=True
)
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False
)
last_seen_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
login_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)

View File

@@ -29,7 +29,7 @@ class SystemParameter(Base):
category: Mapped[str] = mapped_column(String, server_default="general", index=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
scope_level: Mapped[ParameterScope] = mapped_column(SQLEnum(ParameterScope, name="parameter_scope", schema="system"), server_default=ParameterScope.GLOBAL.value, index=True)
scope_level: Mapped[str] = mapped_column(String(50), server_default=ParameterScope.GLOBAL.value, index=True)
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -4,8 +4,8 @@ from typing import Optional, Dict, List
from datetime import date, datetime
class DocumentDetail(BaseModel):
number: str
expiry_date: date
number: Optional[str] = None
expiry_date: Optional[date] = None
class ICEContact(BaseModel):
name: str
@@ -26,7 +26,9 @@ class UserLiteRegister(BaseModel):
model_config = ConfigDict(from_attributes=True)
class UserKYCComplete(BaseModel):
""" Step 2: Teljes körű személyazonosítás és címadatok. """
""" Step 2: Teljes körű személyazonosítás és címadatok (Soft KYC). """
first_name: Optional[str] = None
last_name: Optional[str] = None
phone_number: Optional[str] = Field(default=None, pattern=r"^\+?[0-9]{7,15}$")
birth_place: Optional[str] = None
birth_date: Optional[date] = None
@@ -34,19 +36,19 @@ class UserKYCComplete(BaseModel):
mothers_first_name: Optional[str] = None
region_code: Optional[str] = "HU"
# Atomizált címadatok a pontos GPS-hez és Robot-munkához
# Soft KYC: Csak az irányítószám és város kötelező, a többi opcionális
address_zip: str
address_city: str
address_street_name: str
address_street_type: str # utca, út, tér...
address_house_number: str
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None # utca, út, tér...
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None # Külterület/Helyrajzi szám
# Okmányok és Vészhelyzet
identity_docs: Dict[str, DocumentDetail] # pl: {"ID_CARD": {...}, "LICENSE": {...}}
# Okmányok és Vészhelyzet (Opcionális Soft KYC)
identity_docs: Optional[Dict[str, DocumentDetail]] = None # pl: {"ID_CARD": {...}, "LICENSE": {...}}
ice_contact: Optional[ICEContact] = None
preferred_language: str = "hu"

View File

@@ -1,7 +1,46 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional
from datetime import date
from typing import Optional, Any
from datetime import date, datetime
class AddressResponse(BaseModel):
"""Beágyazott cím adatok a PersonResponse számára."""
zip: Optional[str] = None
city: Optional[str] = None
street_name: Optional[str] = None
street_type: Optional[str] = None
house_number: Optional[str] = None
stairwell: Optional[str] = None
floor: Optional[str] = None
door: Optional[str] = None
parcel_id: Optional[str] = None
full_address_text: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
model_config = ConfigDict(from_attributes=True)
class PersonResponse(BaseModel):
"""Beágyazott személyes adatok a UserResponse számára."""
id: int
id_uuid: uuid.UUID
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
mothers_last_name: Optional[str] = None
mothers_first_name: Optional[str] = None
birth_place: Optional[str] = None
birth_date: Optional[datetime] = None
identity_docs: Optional[Any] = None
ice_contact: Optional[Any] = None
is_active: bool = True
address: Optional[AddressResponse] = None
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel):
email: EmailStr
@@ -10,6 +49,7 @@ class UserBase(BaseModel):
is_active: bool = True
region_code: str = "HU"
class UserResponse(UserBase):
id: int
person_id: Optional[int] = None
@@ -19,19 +59,56 @@ class UserResponse(UserBase):
scope_id: Optional[str] = None
ui_mode: str = "personal"
active_organization_id: Optional[int] = None
preferred_language: str = "hu"
# Beágyazott személyes adatok (Person + Address)
person: Optional[PersonResponse] = None
model_config = ConfigDict(from_attributes=True)
class PersonUpdate(BaseModel):
"""PUT /users/me/person séma - a személyes adatok szerkesztéséhez."""
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
mothers_last_name: Optional[str] = None
mothers_first_name: Optional[str] = None
birth_place: Optional[str] = None
birth_date: Optional[date] = None
# Okmány adatok (identity_docs)
identity_docs: Optional[Any] = None
# Cím adatok
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""PUT /users/me/password séma - jelszó módosításhoz."""
current_password: str
new_password: str
class UserUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
preferred_language: Optional[str] = None
ui_mode: Optional[str] = None
class ActiveOrganizationUpdate(BaseModel):
organization_id: Optional[str] = None # UUID/string or None to revert to personal mode
class UserWithTokenResponse(BaseModel):
"""User response with new JWT token for organization switching"""
user: UserResponse
access_token: str
token_type: str = "bearer"
token_type: str = "bearer"

View File

@@ -93,7 +93,7 @@ class AuthService:
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
is_active=False,
is_active=False, # Email verification required before activation
identity_docs={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE
ice_contact={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE
lifetime_xp=0, # default -1, de explicit 0
@@ -117,7 +117,7 @@ class AuthService:
hashed_password=get_password_hash(user_in.password),
person_id=new_person.id,
role=assigned_role,
is_active=False,
is_active=False, # Email verification required before activation
is_deleted=False,
region_code=user_in.region_code,
preferred_language=user_in.lang,
@@ -217,6 +217,8 @@ class AuthService:
user.person_id = shadow_person.id
# Frissítjük a megtalált Person rekordot a KYC adatokkal
if kyc_in.first_name: shadow_person.first_name = kyc_in.first_name
if kyc_in.last_name: shadow_person.last_name = kyc_in.last_name
shadow_person.mothers_last_name = kyc_in.mothers_last_name
shadow_person.mothers_first_name = kyc_in.mothers_first_name
shadow_person.birth_place = kyc_in.birth_place
@@ -231,6 +233,8 @@ class AuthService:
p = shadow_person
else:
if kyc_in.first_name: p.first_name = kyc_in.first_name
if kyc_in.last_name: p.last_name = kyc_in.last_name
p.mothers_last_name = kyc_in.mothers_last_name
p.mothers_first_name = kyc_in.mothers_first_name
p.birth_place = kyc_in.birth_place
@@ -314,7 +318,10 @@ class AuthService:
@staticmethod
async def verify_email(db: AsyncSession, token_str: str):
""" Email megerősítés. """
"""
Email megerősítés.
Visszaadja az aktivált User objektumot sikeres verifikáció után (Magic Link támogatás).
"""
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).where(and_(
@@ -323,25 +330,28 @@ class AuthService:
VerificationToken.expires_at > datetime.now(timezone.utc)
))
token = (await db.execute(stmt)).scalar_one_or_none()
if not token: return False
if not token: return None
token.is_used = True
# Activate user
user_stmt = select(User).where(User.id == token.user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none()
if user:
user.is_active = True
# Activate person
person_stmt = select(Person).where(Person.user_id == user.id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
person.is_active = True
if not user: return None
user.is_active = True
# Activate person
person_stmt = select(Person).where(Person.user_id == user.id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
person.is_active = True
await db.commit()
return True
except: return False
return user # Return the activated User object
except Exception as e:
logger.error(f"Email verification error: {e}")
return None
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
@@ -349,23 +359,43 @@ class AuthService:
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none()
if user:
# Dinamikus lejárat az adminból
reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val, user_id=user.id, token_type="password_reset",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_h))
))
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email(
recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language
if not user:
return "not_found"
# Ha a felhasználó létezik, de inaktív, dobjunk hibát
if not user.is_active:
logger.warning(f"Password reset requested for inactive user: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="AUTH.USER_INACTIVE"
)
await db.commit()
return "success"
return "not_found"
# Dinamikus lejárat az adminból
reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val, user_id=user.id, token_type="password_reset",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_h))
))
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
logger.info(f"Email küldés indítása (password reset) ide: {email}...")
result = await email_manager.send_email(
recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language
)
logger.info(f"Email küldés eredménye (password reset): {result}")
# Ha az email küldés hibát dobott, ne titkoljuk el
if result.get("status") == "error":
logger.error(f"Password reset email FAILED for {email}: {result.get('message')}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Email küldési hiba: {result.get('message')}"
)
await db.commit()
return "success"
@staticmethod
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
@@ -396,6 +426,57 @@ class AuthService:
raise
except: return False
@staticmethod
async def resend_verification(db: AsyncSession, email: str):
"""
Aktiváló e-mail újraküldése.
Ellenőrzi, hogy a felhasználó létezik, de még nem aktív (is_active == False),
generál egy új verifikációs tokent, és kiküldi az e-mailt.
"""
# Eager load person relationship to avoid async lazy-load issue
stmt = select(User).options(joinedload(User.person)).where(
and_(User.email == email, User.is_deleted == False)
)
user = (await db.execute(stmt)).unique().scalar_one_or_none()
if not user:
# Ne áruljuk el, hogy létezik-e az email — biztonsági okokból mindig sikert adunk
logger.info(f"Resend verification requested for non-existent email: {email}")
return "not_found"
if user.is_active:
logger.info(f"Resend verification requested for already active user: {email}")
return "already_active"
# Dinamikus lejárat az adminból
reg_token_hours = await config.get_setting(db, "auth_registration_hours", region_code=user.region_code, default=48)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val,
user_id=user.id,
token_type="registration",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_token_hours))
))
# Email küldés — user.person már eager-loaded, így nem okoz MissingGreenlet hibát
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
person = user.person
first_name = person.first_name if person else user.email
logger.info(f"Email küldés indítása (resend verification) ide: {email}...")
result = await email_manager.send_email(
recipient=email,
template_key="reg",
variables={"first_name": first_name, "link": verification_link},
lang=user.preferred_language
)
logger.info(f"Email küldés eredménye (resend verification): {result}")
await db.commit()
logger.info(f"Resend verification email sent to {email}")
return "success"
@staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
""" Felhasználó törlése (Soft-Delete) auditálással. """

View File

@@ -81,7 +81,7 @@ class ConfigService:
Returns:
A talált érték (a megfelelő típusban) vagy a default.
"""
from sqlalchemy import select, and_, cast, String
from sqlalchemy import select, and_, cast, String, Text
try:
# Convert scope_level to string for comparison - handle both Enum and string
@@ -91,12 +91,12 @@ class ConfigService:
scope_str = str(scope_level)
# Build query with case-insensitive comparison for scope_level
# Use ilike or lower() for case-insensitive comparison since enum values might have inconsistent casing
# Use cast to Text (not String) to avoid asyncpg enum codec cache issues
from sqlalchemy import func
query = select(SystemParameter).where(
and_(
SystemParameter.key == key,
func.lower(cast(SystemParameter.scope_level, String)) == scope_str.lower(),
func.lower(cast(SystemParameter.scope_level, Text)) == scope_str.lower(),
SystemParameter.is_active == True
)
)

View File

@@ -16,27 +16,12 @@ from app.db.session import AsyncSessionLocal
logger = logging.getLogger("Email-Manager-2.0")
class EmailManager:
@staticmethod
def _get_base_url() -> str:
"""Return the appropriate base URL for email links."""
# Check environment variable first
base_url = os.getenv("EMAIL_BASE_URL")
if base_url:
return base_url.rstrip('/')
# Fallback to dev domain for external access
return "https://dev.servicefinder.hu"
@staticmethod
def _build_verification_link(token: str) -> str:
"""Build verification link with proper path."""
base = EmailManager._get_base_url()
# Use frontend verification route (adjust if needed)
return f"{base}/verify?token={token}"
@staticmethod
def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str:
"""HTML sablon generálása a fordítási fájlok alapján."""
"""HTML sablon generálása a fordítási fájlok alapján.
The caller MUST provide a 'link' key in variables. No fallback guessing.
"""
greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables)
body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables)
button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
@@ -44,10 +29,10 @@ class EmailManager:
link_fallback_text = locale_manager.get("email.link_fallback", lang=lang)
# If link is not provided but token is, build verification link
# Strict: only use variables.get('link'), no guessing
link = variables.get('link')
if not link and 'token' in variables:
link = EmailManager._build_verification_link(variables['token'])
if not link:
logger.error(f"No 'link' variable provided for template '{template_key}' (lang={lang})")
return f"""
<html>
@@ -83,8 +68,14 @@ class EmailManager:
session_internal = True
try:
# Check if emails are disabled via DB config
provider = await config.get_setting(db, "email_provider", default="smtp")
# --- FIX: Wrap DB config lookup in try-except with env fallback ---
# If the DB query fails (e.g. enum mismatch), fall back to environment variable
try:
provider = await config.get_setting(db, "email_provider", default=None)
except Exception as cfg_err:
logger.warning(f"DB config error reading email_provider: {cfg_err}. Falling back to ENV variables...")
provider = None
if provider == "disabled":
logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}")
return {"status": "success", "provider": "disabled", "message": "Email disabled by admin config"}
@@ -92,8 +83,9 @@ class EmailManager:
html = EmailManager._get_html_template(template_key, variables, lang)
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
# Get email provider from environment
email_provider = os.getenv("EMAIL_PROVIDER", "smtp").lower()
# Determine email provider: DB config > ENV > fallback to 'smtp'
email_provider_raw = provider if provider else os.getenv("EMAIL_PROVIDER", "smtp")
email_provider = str(email_provider_raw).lower().strip()
if email_provider == "brevo_api":
result = await EmailManager._send_via_brevo_api(recipient, subject, html, variables)
@@ -243,7 +235,14 @@ class EmailManager:
else:
logger.info(f"Connecting via SMTP to {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
server.starttls()
# Try STARTTLS, but fall back to plain if server doesn't support it
# (e.g. local Mailpit for testing)
try:
server.starttls()
except smtplib.SMTPNotSupportedError:
logger.warning(f"STARTTLS not supported by {smtp_host}:{smtp_port}, sending in plain text")
except Exception:
logger.warning(f"STARTTLS failed on {smtp_host}:{smtp_port}, sending in plain text")
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)