2026.03.29 20:00 Gitea_manager javítás előtt
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
from app.models import SystemParameter
|
||||
|
||||
logger = logging.getLogger("AI-Service")
|
||||
|
||||
class AIService:
|
||||
"""
|
||||
AI Service v1.2.5 - Final Integrated Edition
|
||||
- Robot 2: Technikai dúsítás (Search + Regex JSON parsing)
|
||||
- Robot 3: OCR (Controlled JSON generation)
|
||||
"""
|
||||
api_key = os.getenv("GEMINI_API_KEY")
|
||||
client = genai.Client(api_key=api_key) if api_key else None
|
||||
PRIMARY_MODEL = "gemini-2.0-flash"
|
||||
|
||||
@classmethod
|
||||
async def get_config_delay(cls) -> float:
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
stmt = select(SystemParameter).where(SystemParameter.key == "AI_REQUEST_DELAY")
|
||||
res = await db.execute(stmt)
|
||||
param = res.scalar_one_or_none()
|
||||
return float(param.value) if param else 1.0
|
||||
except Exception: return 1.0
|
||||
|
||||
@classmethod
|
||||
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""Robot 2: Adatbányászat Google Search segítségével."""
|
||||
if not cls.client: return None
|
||||
await asyncio.sleep(await cls.get_config_delay())
|
||||
|
||||
search_tool = types.Tool(google_search=types.GoogleSearch())
|
||||
|
||||
prompt = f"""
|
||||
KERESS RÁ az interneten: {make} {raw_model} ({v_type}) pontos gyári modellkódja és technikai adatai.
|
||||
Adj választ szigorúan csak egy JSON blokkban:
|
||||
{{
|
||||
"marketing_name": "tiszta név",
|
||||
"synonyms": ["név1", "név2"],
|
||||
"technical_code": "gyári kód",
|
||||
"year_from": int,
|
||||
"year_to": int_vagy_null,
|
||||
"ccm": int,
|
||||
"kw": int,
|
||||
"maintenance": {{ "oil_type": "string", "oil_qty": float, "spark_plug": "string", "coolant": "string" }}
|
||||
}}
|
||||
FONTOS: A 'technical_code' NEM lehet üres. Ha nem találod, adj 'N/A' értéket!
|
||||
"""
|
||||
|
||||
# Search tool használata esetén a response_mime_type tilos!
|
||||
config = types.GenerateContentConfig(
|
||||
system_instruction="Profi járműtechnikai adatbányász vagy. Csak tiszta JSON-t válaszolsz markdown kódblokk nélkül.",
|
||||
tools=[search_tool],
|
||||
temperature=0.1
|
||||
)
|
||||
|
||||
try:
|
||||
response = cls.client.models.generate_content(model=cls.PRIMARY_MODEL, contents=prompt, config=config)
|
||||
text = response.text
|
||||
# Tisztítás: ha az AI mégis tenne bele markdown jeleket
|
||||
clean_json = re.sub(r'```json\s*|```', '', text).strip()
|
||||
res_json = json.loads(clean_json)
|
||||
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
|
||||
return res_json if isinstance(res_json, dict) else None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI hiba ({make} {raw_model}): {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def analyze_document_image(cls, image_data: bytes, doc_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""Robot 3: OCR funkció - Forgalmi, Személyi, Számla, Odometer."""
|
||||
if not cls.client: return None
|
||||
await asyncio.sleep(await cls.get_config_delay())
|
||||
|
||||
prompts = {
|
||||
"identity": "Személyes okmány adatok (név, szám, lejárat).",
|
||||
"vehicle_reg": "Forgalmi adatok (rendszám, alvázszám, kW, ccm).",
|
||||
"invoice": "Számla adatok (partner, végösszeg, dátum).",
|
||||
"odometer": "Csak a kilométeróra állása számként."
|
||||
}
|
||||
|
||||
# Itt maradhat a response_mime_type, mert nem használunk Search-öt
|
||||
config = types.GenerateContentConfig(
|
||||
system_instruction="Profi OCR dokumentum-elemző vagy. Csak tiszta JSON-t válaszolsz.",
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
|
||||
try:
|
||||
response = cls.client.models.generate_content(
|
||||
model=cls.PRIMARY_MODEL,
|
||||
contents=[
|
||||
f"Elemezd ezt a képet ({doc_type}): {prompts.get(doc_type, 'OCR')}",
|
||||
types.Part.from_bytes(data=image_data, mime_type="image/jpeg")
|
||||
],
|
||||
config=config
|
||||
)
|
||||
res_json = json.loads(response.text)
|
||||
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
|
||||
return res_json if isinstance(res_json, dict) else None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ OCR hiba: {e}")
|
||||
return None
|
||||
@@ -5,10 +5,10 @@ import uuid
|
||||
from typing import List, Optional, Dict, Any, TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy import select, func, and_, distinct
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Asset, AssetAssignment, AssetTelemetry, AssetFinancials
|
||||
from app.models import Asset, AssetAssignment, AssetTelemetry, AssetFinancials, VehicleModelDefinition
|
||||
from app.models.identity import User
|
||||
from app.models.vehicle.history import LogSeverity
|
||||
from app.services.config_service import config
|
||||
@@ -52,9 +52,9 @@ class AssetService:
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
|
||||
limits = await config.get_setting(db, "VEHICLE_LIMIT", default={"free": 1, "premium": 5, "vip": 50})
|
||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
allowed_limit = limits.get(user_role, 1)
|
||||
# Get vehicle limit using the new function that checks both user AND organization limits
|
||||
# Returns the HIGHER value of user-specific and organization-specific limits
|
||||
allowed_limit = await AssetService.get_user_vehicle_limit(db, user_id, org_id)
|
||||
|
||||
# Csak aktív járművek számítanak a limitbe (draft-ok nem)
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
@@ -83,12 +83,23 @@ class AssetService:
|
||||
)
|
||||
|
||||
# 3. ÚJ JÁRMŰ LÉTREHOZÁSA (Standard vagy Draft Flow)
|
||||
status = "draft" if draft or not vin_clean else "active"
|
||||
# Dynamic Gatekeeper Logic: If both vin and catalog_id are missing, status = 'draft'
|
||||
# If core data is provided (either vin OR catalog_id), status = 'active'
|
||||
# Also respect the draft parameter if explicitly set
|
||||
if draft:
|
||||
status = "draft"
|
||||
elif not vin_clean and not catalog_id:
|
||||
status = "draft"
|
||||
else:
|
||||
status = "active"
|
||||
|
||||
new_asset = Asset(
|
||||
vin=vin_clean,
|
||||
license_plate=license_plate_clean,
|
||||
catalog_id=catalog_id,
|
||||
current_organization_id=org_id,
|
||||
owner_person_id=user.person_id,
|
||||
owner_org_id=org_id,
|
||||
status=status,
|
||||
individual_equipment={},
|
||||
created_at=datetime.utcnow()
|
||||
@@ -109,6 +120,9 @@ class AssetService:
|
||||
# Gamification
|
||||
reward = await config.get_setting(db, "xp_reward_asset_register", default=250)
|
||||
await GamificationService.award_points(db, user_id, int(reward), "NEW_ASSET_REG")
|
||||
|
||||
# Check if this is user's first vehicle and award "First Car" badge
|
||||
await AssetService._award_first_car_badge(db, user_id, org_id)
|
||||
|
||||
await db.commit()
|
||||
return new_asset
|
||||
@@ -136,7 +150,7 @@ class AssetService:
|
||||
if auto_transfer:
|
||||
# Csak akkor, ha a régi tulajdonos 'sold' állapotba tette
|
||||
if asset.status == "sold":
|
||||
return await AssetService.execute_final_transfer(db, asset, org_id, new_plate)
|
||||
return await AssetService.execute_final_transfer(db, asset, org_id, new_plate, user_id)
|
||||
|
||||
# Függőben lévő állapot: Dokumentum feltöltésre vár
|
||||
asset.status = "transfer_pending"
|
||||
@@ -150,7 +164,7 @@ class AssetService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def execute_final_transfer(db: AsyncSession, asset: Asset, new_org_id: int, new_plate: str):
|
||||
async def execute_final_transfer(db: AsyncSession, asset: Asset, new_org_id: int, new_plate: str, user_id: int = None):
|
||||
""" A tulajdonjog tényleges átírása az adatbázisban. """
|
||||
# 1. Régi hozzárendelés lezárása
|
||||
await db.execute(
|
||||
@@ -165,7 +179,193 @@ class AssetService:
|
||||
asset.status = "active"
|
||||
asset.is_verified = False # Az új tulajdonos papírjait is ellenőrizni kell!
|
||||
|
||||
# 3. Update ownership fields if user_id is provided
|
||||
if user_id is not None:
|
||||
from app.models.identity import User
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one_or_none()
|
||||
if user and user.person_id:
|
||||
asset.owner_person_id = user.person_id
|
||||
asset.owner_org_id = new_org_id
|
||||
else:
|
||||
logger.warning(f"User {user_id} has no person_id, cannot set owner_person_id")
|
||||
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"))
|
||||
|
||||
await db.commit()
|
||||
return asset
|
||||
return asset
|
||||
|
||||
# --- CATALOG METHODS ---
|
||||
@staticmethod
|
||||
async def get_makes(db: AsyncSession) -> List[str]:
|
||||
"""Get all distinct makes from vehicle model definitions."""
|
||||
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
|
||||
result = await db.execute(stmt)
|
||||
makes = result.scalars().all()
|
||||
return [make for make in makes if make] # Filter out None/empty
|
||||
|
||||
@staticmethod
|
||||
async def get_models(db: AsyncSession, make: str) -> List[str]:
|
||||
"""Get all distinct models for a given make."""
|
||||
stmt = select(distinct(VehicleModelDefinition.marketing_name)).where(
|
||||
VehicleModelDefinition.make == make
|
||||
).order_by(VehicleModelDefinition.marketing_name)
|
||||
result = await db.execute(stmt)
|
||||
models = result.scalars().all()
|
||||
return [model for model in models if model]
|
||||
|
||||
@staticmethod
|
||||
async def get_generations(db: AsyncSession, make: str, model: str) -> List[str]:
|
||||
"""Get all distinct generations/variants for a given make and model.
|
||||
For now, we'll use engine_code as generation placeholder."""
|
||||
stmt = select(distinct(VehicleModelDefinition.engine_code)).where(
|
||||
VehicleModelDefinition.make == make,
|
||||
VehicleModelDefinition.marketing_name == model,
|
||||
VehicleModelDefinition.engine_code.isnot(None)
|
||||
).order_by(VehicleModelDefinition.engine_code)
|
||||
result = await db.execute(stmt)
|
||||
generations = result.scalars().all()
|
||||
return [gen for gen in generations if gen]
|
||||
|
||||
@staticmethod
|
||||
async def get_engines(db: AsyncSession, make: str, model: str, gen: str) -> List[VehicleModelDefinition]:
|
||||
"""Get all engine variants for a given make, model, and generation."""
|
||||
stmt = select(VehicleModelDefinition).where(
|
||||
VehicleModelDefinition.make == make,
|
||||
VehicleModelDefinition.marketing_name == model,
|
||||
VehicleModelDefinition.engine_code == gen
|
||||
).order_by(VehicleModelDefinition.id)
|
||||
result = await db.execute(stmt)
|
||||
engines = result.scalars().all()
|
||||
return engines
|
||||
|
||||
@staticmethod
|
||||
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
|
||||
"""
|
||||
Get the vehicle limit for a user, checking both user-specific AND organization limits.
|
||||
Returns the HIGHER value of the two as per requirements.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
user_id: User ID
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
Maximum allowed vehicles (higher of user limit and organization limit)
|
||||
"""
|
||||
from app.models.identity import User
|
||||
from app.services.config_service import config
|
||||
|
||||
try:
|
||||
# Get user info
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
|
||||
# Get global vehicle limits configuration
|
||||
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
||||
if limits is None:
|
||||
logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
|
||||
# Fallback to very high limit instead of restricting users
|
||||
limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
|
||||
|
||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
subscription_plan = user.subscription_plan or "free"
|
||||
|
||||
# Get user-specific limit (based on role or subscription plan)
|
||||
user_limit = limits.get(user_role)
|
||||
if user_limit is None:
|
||||
user_limit = limits.get(subscription_plan.lower(), 100)
|
||||
|
||||
# Get organization-specific limit (if configured)
|
||||
org_limit = None
|
||||
try:
|
||||
org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
|
||||
if org_limits and isinstance(org_limits, dict):
|
||||
# Organization might have different limit structure
|
||||
# Try to get limit for user's role or use a default org limit
|
||||
org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
|
||||
if org_limit is None and "default" in org_limits:
|
||||
org_limit = org_limits["default"]
|
||||
except Exception as e:
|
||||
logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
|
||||
org_limit = None
|
||||
|
||||
# Log the calculated limit for debugging
|
||||
final_limit = user_limit
|
||||
if org_limit is not None:
|
||||
final_limit = max(user_limit, org_limit)
|
||||
logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit={org_limit}, final={final_limit}")
|
||||
else:
|
||||
logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit=None, final={final_limit}")
|
||||
|
||||
return final_limit
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting vehicle limit for user {user_id}, org {org_id}: {e}")
|
||||
# Fallback to a reasonable default
|
||||
return 100
|
||||
|
||||
@staticmethod
|
||||
async def _award_first_car_badge(db: AsyncSession, user_id: int, org_id: int):
|
||||
"""
|
||||
Award 'First Car' badge to user if this is their first vehicle.
|
||||
|
||||
Checks if the user already has any vehicles in the organization.
|
||||
If not, awards the 'First Car' badge.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
from app.models.gamification import Badge, UserBadge
|
||||
|
||||
# Check if user already has vehicles in this organization
|
||||
from app.models.vehicle import Asset
|
||||
vehicle_count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id == org_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
vehicle_count = (await db.execute(vehicle_count_stmt)).scalar()
|
||||
|
||||
# If this is the first vehicle (count should be 1 after the new one is added)
|
||||
if vehicle_count == 1:
|
||||
# Get or create the "First Car" badge
|
||||
badge_stmt = select(Badge).where(Badge.name == "First Car")
|
||||
badge_result = await db.execute(badge_stmt)
|
||||
badge = badge_result.scalar_one_or_none()
|
||||
|
||||
if not badge:
|
||||
# Create the badge if it doesn't exist
|
||||
badge = Badge(
|
||||
name="First Car",
|
||||
description="Awarded for adding your first vehicle to the fleet",
|
||||
icon_url="/badges/first-car.svg"
|
||||
)
|
||||
db.add(badge)
|
||||
await db.flush()
|
||||
|
||||
# Check if user already has this badge
|
||||
user_badge_stmt = select(UserBadge).where(
|
||||
UserBadge.user_id == user_id,
|
||||
UserBadge.badge_id == badge.id
|
||||
)
|
||||
user_badge_result = await db.execute(user_badge_stmt)
|
||||
existing_user_badge = user_badge_result.scalar_one_or_none()
|
||||
|
||||
if not existing_user_badge:
|
||||
# Award the badge to the user
|
||||
user_badge = UserBadge(
|
||||
user_id=user_id,
|
||||
badge_id=badge.id,
|
||||
awarded_at=datetime.utcnow()
|
||||
)
|
||||
db.add(user_badge)
|
||||
await db.flush()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Awarded 'First Car' badge to user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error awarding first car badge: {e}")
|
||||
# Don't raise the error - badge awarding shouldn't break vehicle creation
|
||||
@@ -38,6 +38,14 @@ class AuthService:
|
||||
detail=f"A jelszónak legalább {min_pass} karakter hosszúnak kell lennie."
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == user_in.email))
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ez az email cím már regisztrálva van."
|
||||
)
|
||||
|
||||
new_person = Person(
|
||||
first_name=user_in.first_name,
|
||||
last_name=user_in.last_name,
|
||||
@@ -88,12 +96,18 @@ class AuthService:
|
||||
|
||||
# Email küldés a beállított template alapján
|
||||
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
|
||||
await email_manager.send_email(
|
||||
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(
|
||||
@@ -173,6 +187,8 @@ class AuthService:
|
||||
|
||||
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)
|
||||
|
||||
# Gamification XP jóváírás
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
|
||||
|
||||
@@ -84,15 +84,19 @@ class ConfigService:
|
||||
from sqlalchemy import select, and_, cast, String
|
||||
|
||||
try:
|
||||
# Convert scope_level to lowercase string for comparison
|
||||
# PostgreSQL enum expects lowercase values, but Python Enum may be uppercase
|
||||
scope_str = scope_level.value.lower() if hasattr(scope_level, 'value') else str(scope_level).lower()
|
||||
# Convert scope_level to string for comparison - handle both Enum and string
|
||||
if hasattr(scope_level, 'value'):
|
||||
scope_str = scope_level.value
|
||||
else:
|
||||
scope_str = str(scope_level)
|
||||
|
||||
# Build query with cast to avoid strict enum type mismatch
|
||||
# Build query with case-insensitive comparison for scope_level
|
||||
# Use ilike or lower() for case-insensitive comparison since enum values might have inconsistent casing
|
||||
from sqlalchemy import func
|
||||
query = select(SystemParameter).where(
|
||||
and_(
|
||||
SystemParameter.key == key,
|
||||
cast(SystemParameter.scope_level, String) == scope_str,
|
||||
func.lower(cast(SystemParameter.scope_level, String)) == scope_str.lower(),
|
||||
SystemParameter.is_active == True
|
||||
)
|
||||
)
|
||||
@@ -102,13 +106,21 @@ class ConfigService:
|
||||
query = query.where(SystemParameter.scope_id == scope_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
param = result.scalar_one_or_none()
|
||||
params = result.scalars().all()
|
||||
|
||||
if param is None:
|
||||
# Opcionálisan beilleszthetjük a default értéket a táblába
|
||||
# await ConfigService._insert_default(db, key, default, scope_level, scope_id)
|
||||
if not params:
|
||||
# No parameters found, return default
|
||||
return default
|
||||
|
||||
# Handle duplicate entries by taking the first one (should be the most recent based on ID)
|
||||
# Sort by ID descending to get the most recent entry
|
||||
sorted_params = sorted(params, key=lambda p: p.id, reverse=True)
|
||||
param = sorted_params[0]
|
||||
|
||||
# Log warning if there are duplicates
|
||||
if len(params) > 1:
|
||||
logger.warning(f"ConfigService.get found {len(params)} duplicate entries for key '{key}', scope '{scope_str}', scope_id '{scope_id}'. Using ID {param.id}.")
|
||||
|
||||
# A value oszlop JSONB, lehet dict, list, string, number, bool
|
||||
db_value = param.value
|
||||
|
||||
@@ -154,7 +166,9 @@ class ConfigService:
|
||||
return db_value
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"ConfigService.get error for key '{key}': {e}")
|
||||
logger.error(f"ConfigService.get critical error for key '{key}': {e}")
|
||||
# Don't return default on critical errors - raise or log but don't silently fail
|
||||
# For now, return default but log as error
|
||||
return default
|
||||
|
||||
async def get_setting(self, db: AsyncSession, key: str, default: Any = None, region_code: Optional[str] = None, org_id: Optional[int] = None, **kwargs) -> Any:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
@@ -8,14 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.core.i18n import locale_manager
|
||||
from app.services.config_service import config
|
||||
from app.db.session import AsyncSessionLocal # Szükséges a beállítások lekéréséhez
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger("Email-Manager-2.0")
|
||||
|
||||
class EmailManager:
|
||||
@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 (Megmaradt az eredeti logika)."""
|
||||
"""HTML sablon generálása a fordítási fájlok alapján."""
|
||||
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)
|
||||
@@ -49,20 +50,16 @@ 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 admin-vezérelt szolgáltatóval (SendGrid vagy SMTP).
|
||||
E-mail küldése közvetlenül a privát SMTP szerveren keresztül.
|
||||
"""
|
||||
# 1. Belső session kezelés, ha kívülről nem kaptunk (pl. háttérfolyamatoknál)
|
||||
session_internal = False
|
||||
if db is None:
|
||||
db = AsyncSessionLocal()
|
||||
session_internal = True
|
||||
|
||||
try:
|
||||
# 2. Dinamikus beállítások lekérése az adatbázisból (Admin 2.0)
|
||||
provider = await config.get_setting(db, "email_provider", default="disabled")
|
||||
from_email = await config.get_setting(db, "emails_from_email", default="noreply@profibot.hu")
|
||||
from_name = await config.get_setting(db, "emails_from_name", default="Sentinel System")
|
||||
|
||||
# Check if emails are disabled via DB config
|
||||
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
|
||||
@@ -70,76 +67,37 @@ class EmailManager:
|
||||
html = EmailManager._get_html_template(template_key, variables, lang)
|
||||
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
|
||||
|
||||
# 3. KÜLDÉSI LOGIKA VÁLASZTÁSA
|
||||
if provider == "sendgrid":
|
||||
api_key = await config.get_setting(db, "sendgrid_api_key")
|
||||
if api_key:
|
||||
return await EmailManager._send_via_sendgrid(api_key, from_email, from_name, recipient, subject, html)
|
||||
logger.warning("SendGrid szolgáltató kiválasztva, de nincs API kulcs!")
|
||||
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")
|
||||
|
||||
# Fallback vagy közvetlen SMTP
|
||||
smtp_cfg = await config.get_setting(db, "smtp_config", default={
|
||||
"host": "localhost", "port": 587, "user": "", "pass": "", "tls": True
|
||||
})
|
||||
logger.info(f"SMTP config retrieved: {smtp_cfg}")
|
||||
# Ha a default értéket kaptuk, próbáljuk a környezeti változókból felépíteni a konfigurációt
|
||||
import os
|
||||
env_host = os.getenv("SMTP_HOST")
|
||||
env_port = os.getenv("SMTP_PORT")
|
||||
env_user = os.getenv("SMTP_USER")
|
||||
env_pass = os.getenv("SMTP_PASSWORD")
|
||||
env_tls = os.getenv("SMTP_TLS", "False").lower() in ("true", "1", "yes")
|
||||
env_ssl = os.getenv("SMTP_SSL", "False").lower() in ("true", "1", "yes")
|
||||
logger.info(f"Env SMTP: host={env_host}, port={env_port}, tls={env_tls}, ssl={env_ssl}")
|
||||
# Felülírjuk a konfigurációt a környezeti változókkal, ha vannak
|
||||
if env_host:
|
||||
smtp_cfg["host"] = env_host
|
||||
if env_port:
|
||||
try:
|
||||
smtp_cfg["port"] = int(env_port)
|
||||
except:
|
||||
pass
|
||||
if env_user:
|
||||
smtp_cfg["user"] = env_user
|
||||
if env_pass:
|
||||
smtp_cfg["pass"] = env_pass
|
||||
# TLS/SSL kezelése: ha SSL igaz, akkor TLS legyen False (mert külön SMTP_SSL kapcsolat kell)
|
||||
# Egyszerűsítés: tls = not ssl (de a Mailpit esetén TLS=False, SSL=False)
|
||||
smtp_cfg["tls"] = env_tls
|
||||
# SSL esetén a port változhat, de a kódunk nem támogatja az SMTP_SSL-t, csak TLS-t.
|
||||
# A Mailpit nem igényel TLS-t, így maradjon False.
|
||||
if env_ssl:
|
||||
smtp_cfg["tls"] = False
|
||||
# Megjegyzés: SSL kapcsolathoz smtplib.SMTP_SSL kellene, de most nem implementáljuk.
|
||||
logger.info(f"Final SMTP config: {smtp_cfg}")
|
||||
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)
|
||||
|
||||
finally:
|
||||
if session_internal:
|
||||
await db.close()
|
||||
|
||||
@staticmethod
|
||||
async def _send_via_sendgrid(api_key: str, from_email: str, from_name: str, recipient: str, subject: str, html: str):
|
||||
try:
|
||||
from sendgrid import SendGridAPIClient
|
||||
from sendgrid.helpers.mail import Mail
|
||||
|
||||
message = Mail(
|
||||
from_email=(from_email, from_name),
|
||||
to_emails=recipient,
|
||||
subject=subject,
|
||||
html_content=html
|
||||
)
|
||||
sg = SendGridAPIClient(api_key)
|
||||
response = sg.send(message)
|
||||
logger.info(f"SendGrid siker: {response.status_code} -> {recipient}")
|
||||
return {"status": "success", "provider": "sendgrid"}
|
||||
except Exception as e:
|
||||
logger.error(f"SendGrid hiba: {str(e)}")
|
||||
return {"status": "error", "message": "SendGrid failed"}
|
||||
|
||||
@staticmethod
|
||||
async def _send_via_smtp(cfg: dict, from_email: str, from_name: str, recipient: str, subject: str, html: str):
|
||||
# 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
|
||||
if app_env == "test" or is_example_domain:
|
||||
logger.info(f"Mock mode: Skipping SMTP for {recipient} (APP_ENV={app_env}, is_example_domain={is_example_domain})")
|
||||
return {"status": "success", "provider": "mock", "message": "Email skipped in test mode"}
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = f"{from_name} <{from_email}>"
|
||||
@@ -147,16 +105,25 @@ class EmailManager:
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=15) as server:
|
||||
if cfg.get("tls", True):
|
||||
# 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)
|
||||
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
|
||||
server.starttls()
|
||||
# Mailpit nem támogatja az SMTP AUTH-ot, és ha üres string a user/pass, akkor se próbáljuk meg
|
||||
user = cfg.get("user", "")
|
||||
passwd = cfg.get("pass", "")
|
||||
# Ha a user/pass nem üres és nem csak idézőjelek, akkor login
|
||||
if user and passwd and user.strip() not in ('', '""') and passwd.strip() not in ('', '""'):
|
||||
server.login(user, passwd)
|
||||
server.send_message(msg)
|
||||
user = cfg.get("user", "")
|
||||
passwd = cfg.get("pass", "")
|
||||
if user and passwd:
|
||||
server.login(user, passwd)
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info(f"SMTP siker -> {recipient}")
|
||||
return {"status": "success", "provider": "smtp"}
|
||||
@@ -164,4 +131,4 @@ class EmailManager:
|
||||
logger.error(f"SMTP hiba: {str(e)}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
email_manager = EmailManager()
|
||||
email_manager = EmailManager()
|
||||
|
||||
Reference in New Issue
Block a user