2026.06.04 frontend építés közben

This commit is contained in:
Roo
2026-06-04 07:26:22 +00:00
parent 7adf6cc3e3
commit 59a30ac428
3302 changed files with 24091 additions and 1771 deletions

View File

@@ -0,0 +1,289 @@
# /opt/docker/dev/service_finder/backend/app/services/asset_matcher_service.py
"""
Internal Asset Matcher Service
Cél: Belső katalógus (vehicle_model_definitions) alapján automatikus eszköz-azonosítás és adatgazdagítás.
Matching stratégia:
1. Exact match: make + marketing_name + year_of_manufacture
2. Fuzzy match: make + normalizált név (Levenshtein távolság)
3. Confidence > 90% esetén automatikus adatgazdagítás
"""
from __future__ import annotations
import logging
import difflib
import uuid
from datetime import datetime
from typing import Optional, Tuple, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, func
from sqlalchemy.orm import selectinload
from app.models import Asset, VehicleModelDefinition, AssetCatalog, AssetEvent, AssetTelemetry
from app.models.vehicle.vehicle_definitions import VehicleModelDefinition as VMD
logger = logging.getLogger(__name__)
class AssetMatcherService:
"""
Belső eszköz matcher szolgáltatás.
"""
@staticmethod
async def find_best_match(
db: AsyncSession,
asset: Asset,
threshold: float = 0.8
) -> Tuple[Optional[VehicleModelDefinition], float]:
"""
Megkeresi a legjobb egyezést az asset adatai alapján a vehicle_model_definitions táblában.
Args:
db: AsyncSession
asset: Asset objektum (már tartalmazza a make/model/year stb.)
threshold: Minimális confidence threshold (0-1)
Returns:
Tuple (matched_definition, confidence)
"""
# Gyűjtsük össze a keresési kritériumokat
make = asset.brand or (asset.catalog.make if asset.catalog else None)
model = asset.model or (asset.catalog.model if asset.catalog else None)
year = asset.year_of_manufacture
# Trim és ellenőrzés
if make:
make = make.strip()
if model:
model = model.strip()
if not make or not model:
logger.warning(f"Asset {asset.id} missing make or model, cannot match (make='{make}', model='{model}')")
return None, 0.0
# 1. EXACT MATCH: make + marketing_name + year_from
exact_match = await AssetMatcherService._exact_match(db, make, model, year)
if exact_match:
logger.info(f"Exact match found for asset {asset.id}: {make} {model} {year}")
return exact_match, 1.0
# 2. FUZZY MATCH: make + normalizált név (year within range)
fuzzy_matches = await AssetMatcherService._fuzzy_match(db, make, model, year, threshold)
if fuzzy_matches:
best_match, confidence = fuzzy_matches[0]
logger.info(f"Fuzzy match found for asset {asset.id}: {best_match.make} {best_match.marketing_name} (confidence: {confidence:.2f})")
return best_match, confidence
# 3. FALLBACK: csak make + model (year ignore)
fallback_match = await AssetMatcherService._fallback_match(db, make, model)
if fallback_match:
logger.info(f"Fallback match found for asset {asset.id}: {make} {model}")
return fallback_match, 0.7 # Alacsonyabb confidence
logger.warning(f"No match found for asset {asset.id}: {make} {model} {year}")
return None, 0.0
@staticmethod
async def _exact_match(
db: AsyncSession,
make: str,
model: str,
year: Optional[int]
) -> Optional[VehicleModelDefinition]:
"""
Pontos egyezés: make, marketing_name és year_from/year_to tartomány.
Több egyezés esetén a legújabb évjáratút választja.
"""
stmt = select(VMD).where(
VMD.make.ilike(make),
VMD.marketing_name.ilike(model)
)
if year:
# Évjárat tartományban legyen
stmt = stmt.where(
and_(
VMD.year_from <= year,
or_(VMD.year_to.is_(None), VMD.year_to >= year)
)
)
# Rendezés év szerint csökkenő, limit 1
stmt = stmt.order_by(VMD.year_from.desc()).limit(1)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def _fuzzy_match(
db: AsyncSession,
make: str,
model: str,
year: Optional[int],
threshold: float
) -> List[Tuple[VehicleModelDefinition, float]]:
"""
Fuzzy egyezés: hasonlóság a normalizált név alapján.
"""
# Először szűrjünk make és év alapján
stmt = select(VMD).where(VMD.make.ilike(make))
if year:
stmt = stmt.where(
and_(
VMD.year_from <= year,
or_(VMD.year_to.is_(None), VMD.year_to >= year)
)
)
result = await db.execute(stmt)
candidates = result.scalars().all()
if not candidates:
return []
# Számítsuk ki a hasonlóságot a model név és a marketing_name között
matches = []
for candidate in candidates:
similarity = AssetMatcherService._calculate_similarity(model, candidate.marketing_name)
if similarity >= threshold:
matches.append((candidate, similarity))
# Rendezzük confidence szerint csökkenő sorrendben
matches.sort(key=lambda x: x[1], reverse=True)
return matches
@staticmethod
async def _fallback_match(
db: AsyncSession,
make: str,
model: str
) -> Optional[VehicleModelDefinition]:
"""
Csak make + model alapján, évjárat figyelmen kívül hagyva.
"""
stmt = select(VMD).where(
VMD.make.ilike(make),
VMD.marketing_name.ilike(model)
).order_by(VMD.year_from.desc()).limit(1)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
def _calculate_similarity(str1: str, str2: str) -> float:
"""
Szöveg hasonlóság számítása SequenceMatcher segítségével.
"""
if not str1 or not str2:
return 0.0
return difflib.SequenceMatcher(None, str1.lower(), str2.lower()).ratio()
@staticmethod
async def enrich_asset_from_definition(
db: AsyncSession,
asset: Asset,
definition: VehicleModelDefinition,
confidence: float
) -> Asset:
"""
Gazdagítsa az asset adatait a definition technikai specifikációival.
Csak akkor, ha az asset megfelelő mezői üresek.
"""
# Technikai specifikációk másolása
if not asset.power_kw and definition.power_kw:
asset.power_kw = definition.power_kw
if not asset.torque_nm and definition.torque_nm:
asset.torque_nm = definition.torque_nm
if not asset.engine_capacity and definition.engine_capacity:
asset.engine_capacity = definition.engine_capacity
if not asset.transmission_type and definition.transmission_type:
asset.transmission_type = definition.transmission_type
if not asset.drive_type and definition.drive_type:
asset.drive_type = definition.drive_type
if not asset.fuel_type and definition.fuel_type:
asset.fuel_type = definition.fuel_type
if not asset.euro_classification and definition.euro_classification:
asset.euro_classification = definition.euro_classification
if not asset.vehicle_class and definition.vehicle_class:
asset.vehicle_class = definition.vehicle_class
if not asset.trim_level and definition.body_type:
asset.trim_level = definition.body_type # body_type -> trim_level mapping
# Évjárat ellenőrzés
if not asset.year_of_manufacture and definition.year_from:
asset.year_of_manufacture = definition.year_from
# Státusz frissítése
if confidence >= 0.9:
asset.data_status = 'verified'
logger.info(f"Asset {asset.id} enriched and marked as verified (confidence: {confidence:.2f})")
else:
asset.data_status = 'enriched'
logger.info(f"Asset {asset.id} enriched but not verified (confidence: {confidence:.2f})")
return asset
@staticmethod
async def match_and_enrich_asset(
db: AsyncSession,
asset_id: uuid.UUID,
threshold: float = 0.9
) -> Dict[str, Any]:
"""
Fő függvény: Asset ID alapján keres match-et és gazdagítja az adatokat.
Args:
db: AsyncSession
asset_id: Asset UUID
threshold: Confidence threshold a verification-hoz (alapértelmezett 90%)
Returns:
Dict with match results
"""
# Asset betöltése
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise ValueError(f"Asset {asset_id} not found")
logger.info(f"Matching asset {asset_id} ({asset.brand} {asset.model})")
# Match keresés
definition, confidence = await AssetMatcherService.find_best_match(db, asset, threshold=0.8)
if not definition:
return {
"asset_id": str(asset_id),
"matched": False,
"confidence": 0.0,
"message": "No matching definition found in internal catalog"
}
# Adatgazdagítás
enriched_asset = await AssetMatcherService.enrich_asset_from_definition(
db, asset, definition, confidence
)
# Mentés
await db.commit()
return {
"asset_id": str(asset_id),
"matched": True,
"confidence": confidence,
"definition_id": definition.id,
"definition": f"{definition.make} {definition.marketing_name}",
"data_status": enriched_asset.data_status,
"enriched_fields": [
field for field in [
"power_kw" if asset.power_kw != enriched_asset.power_kw else None,
"torque_nm" if asset.torque_nm != enriched_asset.torque_nm else None,
"engine_capacity" if asset.engine_capacity != enriched_asset.engine_capacity else None,
"transmission_type" if asset.transmission_type != enriched_asset.transmission_type else None,
"drive_type" if asset.drive_type != enriched_asset.drive_type else None,
"fuel_type" if asset.fuel_type != enriched_asset.fuel_type else None,
] if field is not None
]
}
# Singleton instance
asset_matcher_service = AssetMatcherService()

View File

@@ -132,14 +132,28 @@ class AssetService:
# Get default vehicle class from config if not provided
default_vehicle_class = await config.get_setting(db, "DEFAULT_VEHICLE_CLASS", default="car")
# --- BRANCH (GARÁZS) HOZZÁRENDELÉSI LOGIKA ---
branch_id = getattr(asset_data, 'branch_id', None)
if not branch_id and target_org_id:
from app.models.marketplace.organization import Branch
branch_stmt = select(Branch.id).where(Branch.organization_id == target_org_id, Branch.is_main == True)
main_branch_id = (await db.execute(branch_stmt)).scalar()
if not main_branch_id:
branch_stmt_any = select(Branch.id).where(Branch.organization_id == target_org_id).limit(1)
main_branch_id = (await db.execute(branch_stmt_any)).scalar()
branch_id = main_branch_id
print(f"DEBUG: resolved branch_id={branch_id} for target_org_id={target_org_id}")
asset_fields = {
'vin': vin_clean,
'license_plate': license_plate_clean,
'catalog_id': asset_data.catalog_id,
'current_organization_id': target_org_id,
'branch_id': branch_id,
'owner_person_id': user.person_id,
'owner_org_id': asset_data.owner_org_id or target_org_id,
'operator_org_id': asset_data.operator_org_id,
'owner_org_id': asset_data.organization_id or target_org_id,
'operator_org_id': None, # AssetCreate doesn't have operator_org_id
'status': status,
'individual_equipment': asset_data.individual_equipment or {},
'created_at': datetime.utcnow(),
@@ -184,8 +198,23 @@ class AssetService:
db.add(new_asset)
await db.flush()
# --- MATCHER INTEGRÁCIÓ (HA NINCS CATALOG_ID DE VANNAK ADATOK) ---
if not new_asset.catalog_id and new_asset.brand and new_asset.model:
from app.services.asset_matcher_service import AssetMatcherService
print(f"DEBUG: Matcher args: make={new_asset.brand}, model={new_asset.model}, year={new_asset.year_of_manufacture}")
matched_result = await AssetMatcherService.find_best_match(db, new_asset)
if matched_result:
matched_def, conf = matched_result
if matched_def:
new_asset.catalog_id = matched_def.id
await AssetMatcherService.enrich_asset_from_definition(db, new_asset, matched_def, conf)
await db.flush()
# Digitális Iker Alapmodulok
db.add(AssetAssignment(asset_id=new_asset.id, organization_id=target_org_id, status="active"))
# Only create AssetAssignment if we have an organization_id
if target_org_id is not None:
db.add(AssetAssignment(asset_id=new_asset.id, organization_id=target_org_id, status="active"))
db.add(AssetTelemetry(asset_id=new_asset.id))
db.add(AssetFinancials(
asset_id=new_asset.id,
@@ -202,6 +231,9 @@ class AssetService:
await AssetService._award_first_car_badge(db, user_id, target_org_id)
await db.commit()
from sqlalchemy.orm import selectinload
new_asset = (await db.execute(select(Asset).where(Asset.id == new_asset.id).options(selectinload(Asset.catalog)))).scalar_one()
return new_asset
except Exception as e:
@@ -269,16 +301,20 @@ class AssetService:
else:
logger.warning("execute_final_transfer called without user_id, ownership fields not updated")
db.add(AssetAssignment(asset_id=asset.id, organization_id=new_org_id, status="active"))
# Only create AssetAssignment if we have an organization_id
if new_org_id is not None:
db.add(AssetAssignment(asset_id=asset.id, organization_id=new_org_id, status="active"))
await db.commit()
return asset
# --- CATALOG METHODS ---
@staticmethod
async def get_makes(db: AsyncSession) -> List[str]:
"""Get all distinct makes from vehicle model definitions."""
async def get_makes(db: AsyncSession, vehicle_class: Optional[str] = None) -> List[str]:
"""Get all distinct makes from vehicle model definitions, optionally filtered by vehicle_class."""
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
result = await db.execute(stmt)
makes = result.scalars().all()
return [make for make in makes if make] # Filter out None/empty

View File

@@ -1,6 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/services/auth_service.py
import logging
import uuid
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, update
@@ -23,6 +24,41 @@ from app.services.gamification_service import GamificationService
logger = logging.getLogger(__name__)
class AuthService:
@staticmethod
async def _validate_password_complexity(db: AsyncSession, password: str, region_code: str = None):
"""
Dinamikus jelszó komplexitás ellenőrzése az admin beállítások alapján.
"""
# Alap beállítások lekérése
min_pass = await config.get_setting(db, "auth_min_password_length", default=8)
password_strict = await config.get_setting(db, "auth_password_strict", default=False)
# Minimum hossz ellenőrzése
if len(password) < int(min_pass):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"A jelszónak legalább {min_pass} karakter hosszúnak kell lennie."
)
# Ha a strict mód be van kapcsolva, komplexitás ellenőrzés
if password_strict and str(password_strict).lower() in ("true", "1", "yes"):
# Ellenőrizzük: legalább 1 nagybetű, 1 kisbetű, 1 szám vagy speciális karakter
if not re.search(r'[A-Z]', password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A jelszónak tartalmaznia kell legalább egy nagybetűt."
)
if not re.search(r'[a-z]', password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A jelszónak tartalmaznia kell legalább egy kisbetűt."
)
if not re.search(r'[0-9!@#$%^&*()_+\-=\[\]{};:"\\|,.<>/?]', password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A jelszónak tartalmaznia kell legalább egy számot vagy speciális karaktert."
)
@staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
""" 1. FÁZIS: Lite regisztráció dinamikus korlátokkal és Sentinel naplózással. """
@@ -32,11 +68,8 @@ class AuthService:
default_role_name = await config.get_setting(db, "auth_default_role", default="user")
reg_token_hours = await config.get_setting(db, "auth_registration_hours", region_code=user_in.region_code, default=48)
if len(user_in.password) < int(min_pass):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"A jelszónak legalább {min_pass} karakter hosszúnak kell lennie."
)
# Jelszó komplexitás ellenőrzése
await AuthService._validate_password_complexity(db, user_in.password, user_in.region_code)
# Check if email already exists
existing_user = await db.execute(select(User).where(User.email == user_in.email))
@@ -46,6 +79,17 @@ class AuthService:
detail="Ez az email cím már regisztrálva van."
)
# Meghívó keresése referral kód alapján
referred_by_id = None
if user_in.referred_by_code:
referrer_stmt = select(User).where(User.referral_code == user_in.referred_by_code)
referrer = (await db.execute(referrer_stmt)).scalar_one_or_none()
if referrer:
referred_by_id = referrer.id
logger.info(f"User {user_in.email} referred by {referrer.email} (ID: {referrer.id})")
else:
logger.warning(f"Referral code '{user_in.referred_by_code}' not found, ignoring.")
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
@@ -65,6 +109,9 @@ class AuthService:
# Szerepkör dinamikus feloldása
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.user
# Referral kód generálása
referral_code = generate_secure_slug(8).upper()
new_user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
@@ -80,6 +127,8 @@ class AuthService:
preferred_currency="HUF",
scope_level="individual",
custom_permissions={},
referral_code=referral_code,
referred_by_id=referred_by_id,
created_at=datetime.now(timezone.utc)
)
db.add(new_user)
@@ -94,21 +143,6 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_token_hours))
))
# Email küldés a beállított template alapján
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
email_result = await email_manager.send_email(
recipient=user_in.email,
template_key="reg",
variables={"first_name": user_in.first_name, "link": verification_link},
lang=user_in.lang
)
# Check if email sending failed
if email_result and email_result.get("status") == "error":
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Email delivery failed. Please contact support."
)
# Sentinel Audit Log
await security_service.log_event(
db, user_id=new_user.id, action="USER_REGISTER_LITE",
@@ -117,6 +151,23 @@ class AuthService:
)
await db.commit()
# Email küldés a beállított template alapján
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
email_result = await email_manager.send_email(
recipient=user_in.email,
template_key="reg",
variables={"first_name": user_in.first_name, "link": verification_link},
lang=user_in.lang
)
# Check if email sending failed - LOG but don't crash registration
if email_result and email_result.get("status") == "error":
logger.error(f"Email sending failed for {user_in.email}: {email_result.get('message')}")
# Don't raise exception - registration should succeed even if email fails
# Just log the error and continue
else:
logger.info(f"Email sent successfully to {user_in.email}")
return new_user
except Exception as e:
await db.rollback()
@@ -143,16 +194,53 @@ class AuthService:
house_number=kyc_in.address_house_number, parcel_id=kyc_in.address_hrsz
)
# Person adatok dúsítása
p = user.person
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
p.birth_date = kyc_in.birth_date
p.phone = kyc_in.phone_number
p.address_id = addr_id
p.identity_docs = jsonable_encoder(kyc_in.identity_docs)
p.is_active = True
# --- SHADOW IDENTITY CHECK ---
if kyc_in.birth_date:
shadow_stmt = select(Person).where(
and_(
Person.last_name == p.last_name,
Person.first_name == p.first_name,
Person.birth_date == kyc_in.birth_date,
Person.id != p.id # Ne a jelenlegit találja meg
)
)
shadow_person = (await db.execute(shadow_stmt)).scalar_one_or_none()
else:
shadow_person = None
if shadow_person:
logger.info(f"Shadow Identity megtalálva a {user.id} userhez: Person ID {shadow_person.id}")
user.person_id = shadow_person.id
# Frissítjük a megtalált Person rekordot a KYC adatokkal
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
shadow_person.phone = kyc_in.phone_number
shadow_person.address_id = addr_id
shadow_person.identity_docs = jsonable_encoder(kyc_in.identity_docs)
shadow_person.is_active = True
# Inaktiváljuk/töröljük a megárvult eredeti Person-t
p.is_active = False
p.is_ghost = True
p = shadow_person
else:
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
p.birth_date = kyc_in.birth_date
p.phone = kyc_in.phone_number
p.address_id = addr_id
p.identity_docs = jsonable_encoder(kyc_in.identity_docs)
p.is_active = True
# --- SHADOW CHECK VÉGE ---
# Dinamikus szervezet generálás
org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name)
@@ -183,16 +271,30 @@ class AuthService:
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
db.add(OrganizationMember(organization_id=new_org.id, user_id=user.id, role="OWNER"))
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
db.add(UserStats(user_id=user.id))
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli
user.is_active = True
user.folder_slug = generate_secure_slug(12)
# Set user's scope_id to the new personal organization ID
user.scope_id = str(new_org.id)
# Update region_code from KYC form (EU country selection)
if kyc_in.region_code:
user.region_code = kyc_in.region_code
# Gamification XP jóváírás
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
# P2P Referral XP a meghívónak (ha van referred_by_id)
if user.referred_by_id:
p2p_xp = await config.get_setting(db, "gamification_p2p_invite_xp", default=50)
await GamificationService.award_points(
db,
user_id=user.referred_by_id,
amount=int(p2p_xp),
reason="P2P_REFERRAL_SUCCESS"
)
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
await db.commit()
return user
except Exception as e:
@@ -224,7 +326,19 @@ class AuthService:
if not token: return False
token.is_used = True
# Itt aktiválhatnánk a júzert, ha a Lite regnél még nem tennénk meg
# 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
await db.commit()
return True
except: return False
@@ -268,13 +382,18 @@ class AuthService:
token_rec = (await db.execute(stmt)).scalar_one_or_none()
if not token_rec: return False
# Jelszó komplexitás ellenőrzése
user_stmt = select(User).where(User.id == token_rec.user_id)
user = (await db.execute(user_stmt)).scalar_one()
await AuthService._validate_password_complexity(db, new_password, user.region_code)
user.hashed_password = get_password_hash(new_password)
token_rec.is_used = True
await db.commit()
return True
except HTTPException:
raise
except: return False
@staticmethod

View File

@@ -1,6 +1,8 @@
# /opt/docker/dev/service_finder/backend/app/services/email_manager.py
import os
import smtplib
import logging
import requests
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
@@ -14,6 +16,24 @@ 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."""
@@ -24,6 +44,11 @@ class EmailManager:
link_fallback_text = locale_manager.get("email.link_fallback", lang=lang)
# If link is not provided but token is, build verification link
link = variables.get('link')
if not link and 'token' in variables:
link = EmailManager._build_verification_link(variables['token'])
return f"""
<html>
<body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;">
@@ -31,14 +56,14 @@ class EmailManager:
<h2 style="color: #2c3e50;">{greeting}</h2>
<p>{body}</p>
<div style="text-align: center; margin: 40px 0;">
<a href="{variables.get('link', '#')}"
<a href="{link or '#'}"
style="background-color: #3498db; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 16px;">
{button_text}
</a>
</div>
<p style="font-size: 0.85em; color: #777; word-break: break-all;">
{link_fallback_text}<br>
<a href="{variables.get('link')}" style="color: #3498db;">{variables.get('link')}</a>
<a href="{link or '#'}" style="color: #3498db;">{link or '#'}</a>
</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 0.8em; color: #999; text-align: center;">{footer}</p>
@@ -50,7 +75,7 @@ class EmailManager:
@staticmethod
async def send_email(recipient: str, template_key: str, variables: dict, lang: str = "hu", db: Optional[AsyncSession] = None):
"""
E-mail küldése közvetlenül a privát SMTP szerveren keresztül.
E-mail küldése Brevo API-n vagy SMTP-n keresztül.
"""
session_internal = False
if db is None:
@@ -62,35 +87,130 @@ class EmailManager:
provider = await config.get_setting(db, "email_provider", default="smtp")
if provider == "disabled":
logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}")
return
return {"status": "success", "provider": "disabled", "message": "Email disabled by admin config"}
html = EmailManager._get_html_template(template_key, variables, lang)
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
smtp_host = os.getenv("SMTP_HOST", "mail.servicefinder.hu")
smtp_port = int(os.getenv("SMTP_PORT", "465"))
smtp_user = os.getenv("SMTP_USER", "noreply@servicefinder.hu")
smtp_pass = os.getenv("SMTP_PASSWORD", "")
# Get email provider from environment
email_provider = os.getenv("EMAIL_PROVIDER", "smtp").lower()
from_email = os.getenv("MAIL_FROM", "noreply@servicefinder.hu")
from_name = os.getenv("MAIL_FROM_NAME", "ServiceFinder")
smtp_cfg = {
"host": smtp_host,
"port": smtp_port,
"user": smtp_user,
"pass": smtp_pass
}
logger.info(f"Using SMTP config: host={smtp_cfg['host']}, port={smtp_cfg['port']}, user={smtp_cfg['user']}")
return await EmailManager._send_via_smtp(smtp_cfg, from_email, from_name, recipient, subject, html)
if email_provider == "brevo_api":
result = await EmailManager._send_via_brevo_api(recipient, subject, html, variables)
# Primary-Fallback Logic: If Brevo API fails, try SMTP
if result.get("status") == "error":
logger.error(f"Brevo API failed for {recipient}: {result.get('message')}. Falling back to SMTP.")
result = await EmailManager._send_via_smtp(recipient, subject, html)
elif email_provider == "brevo_smtp":
result = await EmailManager._send_via_brevo_smtp(recipient, subject, html)
else: # Default to SMTP
result = await EmailManager._send_via_smtp(recipient, subject, html)
logger.info(f"Email sending result for {recipient}: {result}")
return result
except Exception as e:
logger.error(f"CRITICAL: Email sending failed for {recipient}: {str(e)}")
# Don't crash - return error but don't raise exception
return {"status": "error", "message": str(e), "provider": "unknown"}
finally:
if session_internal:
await db.close()
@staticmethod
async def _send_via_smtp(cfg: dict, from_email: str, from_name: str, recipient: str, subject: str, html: str):
async def _send_via_brevo_api(recipient: str, subject: str, html: str, variables: dict):
"""Send email via Brevo REST API"""
try:
brevo_api_key = os.getenv("BREVO_API_KEY")
if not brevo_api_key:
logger.error("BREVO_API_KEY environment variable not set")
return {"status": "error", "message": "BREVO_API_KEY not configured", "provider": "brevo_api"}
sender_name = os.getenv("MAIL_FROM_NAME", "ServiceFinder")
sender_email = os.getenv("MAIL_FROM", "noreply@servicefinder.hu")
# Prepare Brevo API payload
payload = {
"sender": {
"name": sender_name,
"email": sender_email
},
"to": [{"email": recipient}],
"subject": subject,
"htmlContent": html,
"tags": ["verification"]
}
# Try to disable tracking - Brevo may ignore this if account-level tracking is enabled
# According to Brevo API v3 docs, tracking settings are at account level
# We'll add tracking object but it may not work for free tier
payload["tracking"] = {
"click": False,
"open": False
}
headers = {
"accept": "application/json",
"api-key": brevo_api_key,
"content-type": "application/json"
}
response = requests.post(
"https://api.brevo.com/v3/smtp/email",
json=payload,
headers=headers,
timeout=30
)
if response.status_code == 201:
logger.info(f"Brevo API email sent successfully to {recipient}")
return {"status": "success", "provider": "brevo_api", "message_id": response.json().get("messageId")}
else:
logger.error(f"Brevo API error: {response.status_code} - {response.text}")
return {"status": "error", "provider": "brevo_api", "message": response.text}
except Exception as e:
logger.error(f"Brevo API exception: {str(e)}")
return {"status": "error", "provider": "brevo_api", "message": str(e)}
@staticmethod
async def _send_via_brevo_smtp(recipient: str, subject: str, html: str):
"""Send email via Brevo SMTP relay"""
try:
smtp_host = "smtp-relay.brevo.com"
smtp_port = 587
smtp_user = os.getenv("BREVO_SMTP_USER")
smtp_pass = os.getenv("BREVO_SMTP_PASSWORD")
if not smtp_user or not smtp_pass:
logger.error("BREVO_SMTP_USER or BREVO_SMTP_PASSWORD not set")
return {"status": "error", "message": "Brevo SMTP credentials not configured", "provider": "brevo_smtp"}
from_email = os.getenv("MAIL_FROM", "noreply@servicefinder.hu")
from_name = os.getenv("MAIL_FROM_NAME", "ServiceFinder")
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{from_email}>"
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(html, "html"))
logger.info(f"Connecting to Brevo SMTP: {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info(f"Brevo SMTP email sent successfully to {recipient}")
return {"status": "success", "provider": "brevo_smtp"}
except Exception as e:
logger.error(f"Brevo SMTP exception: {str(e)}")
return {"status": "error", "provider": "brevo_smtp", "message": str(e)}
@staticmethod
async def _send_via_smtp(recipient: str, subject: str, html: str):
"""Send email via standard SMTP (fallback)"""
# Mock mode check: If APP_ENV=test or domain is example.com, skip SMTP and return success
app_env = os.getenv("APP_ENV", "").lower()
is_example_domain = recipient.endswith("@example.com") or "@example.com" in recipient
@@ -99,36 +219,40 @@ class EmailManager:
return {"status": "success", "provider": "mock", "message": "Email skipped in test mode"}
try:
smtp_host = os.getenv("SMTP_HOST", "mail.servicefinder.hu")
smtp_port = int(os.getenv("SMTP_PORT", "465"))
smtp_user = os.getenv("SMTP_USER", "noreply@servicefinder.hu")
smtp_pass = os.getenv("SMTP_PASSWORD", "")
from_email = os.getenv("MAIL_FROM", "noreply@servicefinder.hu")
from_name = os.getenv("MAIL_FROM_NAME", "ServiceFinder")
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{from_email}>"
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(html, "html"))
# Port 465 uses SMTP_SSL directly instead of STARTTLS
if cfg["port"] == 465:
logger.info(f"Connecting via SMTP_SSL to {cfg['host']}:{cfg['port']}")
with smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=15) as server:
user = cfg.get("user", "")
passwd = cfg.get("pass", "")
if user and passwd:
server.login(user, passwd)
if smtp_port == 465:
logger.info(f"Connecting via SMTP_SSL to {smtp_host}:{smtp_port}")
with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=15) as server:
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
else:
logger.info(f"Connecting via SMTP to {cfg['host']}:{cfg['port']}")
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=15) as server:
# Explicit STARTTLS if not 465, though we expect 465
logger.info(f"Connecting via SMTP to {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
server.starttls()
user = cfg.get("user", "")
passwd = cfg.get("pass", "")
if user and passwd:
server.login(user, passwd)
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info(f"SMTP siker -> {recipient}")
logger.info(f"SMTP email sent successfully to {recipient}")
return {"status": "success", "provider": "smtp"}
except Exception as e:
logger.error(f"SMTP hiba: {str(e)}")
return {"status": "error", "message": str(e)}
logger.error(f"SMTP exception: {str(e)}")
return {"status": "error", "provider": "smtp", "message": str(e)}
email_manager = EmailManager()
email_manager = EmailManager()

View File

@@ -1,3 +1,4 @@
# /opt/docker/dev/service_finder/backend/app/services/financial_orchestrator.py
"""
Financial Orchestrator - Unit of Work mintával a pénzügyi tranzakciók atomi kezeléséhez.

View File

@@ -35,11 +35,21 @@ class TranslationService:
logger.info(f"🌍 i18n Motor: {len(translations)} szöveg aktiválva a memóriában.")
@classmethod
def get_text(cls, key: str, lang: str = "hu", variables: Optional[Dict[str, Any]] = None) -> str:
def get_text(cls, key: str, lang: Optional[str] = None, variables: Optional[Dict[str, Any]] = None) -> str:
"""
Szerveroldali lekérés Fallback (EN) logikával és változó behelyettesítéssel.
Automatikusan használja a request context locale-ját, ha nincs explicit nyelv megadva.
Példa: get_text("AUTH.WELCOME", "hu", {"name": "Péter"})
"""
# Use context locale if no explicit language is provided
if lang is None:
try:
from app.core.context import get_current_locale
lang = get_current_locale()
except (ImportError, Exception):
# Fallback to default if context is not available
lang = "hu"
# 1. Kért nyelv lekérése
text = cls._published_cache.get(lang, {}).get(key)