Initial commit: Robot ökoszisztéma v2.0 - Stabilizált jármű és szerviz robotok

This commit is contained in:
Kincses
2026-03-04 02:03:03 +01:00
commit 250f4f4b8f
7942 changed files with 449625 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
# app/services/ai_ocr_service.py
import json
import httpx
import base64
from app.schemas.evidence import RegistrationDocumentExtracted
class AiOcrService:
OLLAMA_URL = "http://service_finder_ollama:11434/api/generate"
MODEL_NAME = "llama3.2-vision"
@classmethod
async def extract_registration_data(cls, clean_image_bytes: bytes) -> RegistrationDocumentExtracted:
base64_image = base64.b64encode(clean_image_bytes).decode('utf-8')
prompt = """
Te egy magyar hatósági okmány-szakértő AI vagy. A feladatod a mellékelt magyar forgalmi engedély (kép) összes adatának kinyerése.
Keresd meg és olvasd le az adatokat az alábbi hatósági kódok alapján:
- A: Rendszám (kötőjellel, pl: ABC-123 vagy AA-BB-123)
- B: Első nyilvántartásba vétel dátuma (YYYY.MM.DD)
- C.1.1: Családi név vagy cégnév
- C.1.2: Utónév
- C.1.3: Teljes lakcím (Irsz, Város, Utca, Házszám)
- C.4: Jogosultság (a = tulajdonos, b = üzembentartó)
- D.1: Gyártmány (pl. TOYOTA, VOLKSWAGEN)
- D.2: Jármű típusa
- D.3: Kereskedelmi leírás (pl. COROLLA, GOLF)
- E: Alvázszám (pontosan 17 karakter)
- G: Saját tömeg (kg)
- F.1: Együttes tömeg (kg)
- P.1: Hengerűrtartalom (cm3)
- P.2: Teljesítmény (kW)
- P.3: Hajtóanyag (pl. Benzin, Gázolaj, Elektromos)
- P.5: Motorkód
- V.9: Környezetvédelmi osztály kódja
- R: Szín
- S.1: Ülések száma
- H: Műszaki érvényesség vége (YYYY.MM.DD)
- Sebességváltó: Keresd a 0, 1, 2, 3 kódokat (0=mechanikus, 2=automata).
VÁLASZ FORMÁTUMA: Kizárólag érvényes JSON. Ha egy adat nem olvasható, az értéke null legyen.
"""
payload = {
"model": cls.MODEL_NAME,
"prompt": prompt,
"images": [base64_image],
"stream": False,
"format": "json"
}
async with httpx.AsyncClient(timeout=90.0) as client:
try:
response = await client.post(cls.OLLAMA_URL, json=payload)
response.raise_for_status()
ai_response_text = response.json().get("response", "{}")
data_dict = json.loads(ai_response_text)
return RegistrationDocumentExtracted(**data_dict)
except Exception as e:
print(f"Robot 3 AI Hiba: {e}")
raise ValueError(f"AI hiba az adatkivonás során: {str(e)}")

View File

@@ -0,0 +1,104 @@
# /opt/docker/dev/service_finder/backend/app/services/ai_service.py
import os
import json
import logging
import asyncio
import httpx
from typing import Dict, Any, Optional, List
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.system import SystemParameter
from app.services.config_service import config # 2.2-es központi config
logger = logging.getLogger("AI-Service-2.2")
class AIService:
"""
Sentinel Master AI Service 2.2.
Felelős az LLM hívásokért, prompt sablonok kezeléséért és az OCR feldolgozásért.
Minden paraméter (modell, url, prompt, hőmérséklet) adminból vezérelt.
"""
@classmethod
async def _execute_ai_call(cls, db, prompt: str, model_key: str = "text", images: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""
Központi AI végrehajtó. Kezeli a modellt, a várakozást és a JSON parzolást.
"""
try:
# 1. ADMIN KONFIGURÁCIÓ LEKÉRÉSE
base_url = await config.get_setting(db, "ai_ollama_url", default="http://ollama:11434/api/generate")
delay = await config.get_setting(db, "AI_REQUEST_DELAY", default=0.1)
# Modell választás (text vagy vision)
model_name = await config.get_setting(db, f"ai_model_{model_key}", default="qwen2.5-coder:32b")
temp = await config.get_setting(db, "ai_temperature", default=0.1)
timeout_val = await config.get_setting(db, "ai_timeout", default=120.0)
await asyncio.sleep(float(delay))
# 2. PAYLOAD ÖSSZEÁLLÍTÁSA
payload = {
"model": model_name,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": float(temp)}
}
if images: # Llava/Vision támogatás
payload["images"] = images
# 3. HTTP HÍVÁS
async with httpx.AsyncClient(timeout=float(timeout_val)) as client:
response = await client.post(base_url, json=payload)
response.raise_for_status()
raw_res = response.json().get("response", "{}")
return json.loads(raw_res)
except json.JSONDecodeError as je:
logger.error(f"❌ AI JSON hiba (parszolási hiba): {je}")
return None
except Exception as e:
logger.error(f"❌ AI hívás kritikus hiba: {e}")
return None
@classmethod
async def get_gold_data_from_research(cls, make: str, model: str, raw_context: str) -> Optional[Dict[str, Any]]:
"""
Robot 3 (Alchemist) dúsító folyamata.
Kutatási adatokból csinál tiszta technikai adatlapot.
"""
async with AsyncSessionLocal() as db:
template = await config.get_setting(db, "ai_prompt_gold_data",
default="Extract technical car data for {make} {model} from: {context}")
full_prompt = template.format(make=make, model=model, context=raw_context)
return await cls._execute_ai_call(db, full_prompt, model_key="text")
@classmethod
async def get_clean_vehicle_data(cls, make: str, raw_model: str, sources: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Név normalizálás és szinonima gyűjtés.
"""
async with AsyncSessionLocal() as db:
template = await config.get_setting(db, "ai_prompt_normalization",
default="Normalize car model names: {make} {model}. Sources: {sources}")
full_prompt = template.format(make=make, model=raw_model, sources=json.dumps(sources))
return await cls._execute_ai_call(db, full_prompt, model_key="text")
@classmethod
async def process_ocr_document(cls, doc_type: str, base64_image: str) -> Optional[Dict[str, Any]]:
"""
Robot 1 (OCR) látó folyamata.
Képet (base64) küld a Vision modellnek (pl. Llava).
"""
async with AsyncSessionLocal() as db:
# Külön prompt sablon minden dokumentum típushoz (számla, forgalmi, adásvételi)
template = await config.get_setting(db, f"ai_prompt_ocr_{doc_type}",
default="Analyze this {doc_type} image and return structured JSON data.")
full_prompt = template.format(doc_type=doc_type)
return await cls._execute_ai_call(db, full_prompt, model_key="vision", images=[base64_image])

View File

@@ -0,0 +1,141 @@
import os
import json
import logging
import asyncio
import re
import base64
import httpx
from typing import Dict, Any, Optional, List
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.3.5 - Private High-Performance Edition
- Engine: Local Ollama (GPU Accelerated)
- Features: DVLA Integration, 50-char Normalization, Private OCR
"""
# A Docker belső hálózatán a szerviznév 'ollama'
OLLAMA_BASE_URL = "http://ollama:11434/api/generate"
TEXT_MODEL = "vehicle-pro"
VISION_MODEL = "llava:7b"
DVLA_API_KEY = os.getenv("DVLA_API_KEY")
@classmethod
async def get_config_delay(cls) -> float:
"""Késleltetés lekérése az adatbázisból."""
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 0.1
except Exception:
return 0.1
@classmethod
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str, sources: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Robot 2: Adat-összefésülés és normalizálás."""
# Várjunk egy kicsit a GPU kímélése érdekében
await asyncio.sleep(await cls.get_config_delay())
prompt = f"""
FELADAT: Normalizáld a jármű adatait több forrás alapján.
GYÁRTÓ: {make}
NYERS MODELLNÉV: {raw_model}
FORRÁSOK NYERS ADATAI: {json.dumps(sources, ensure_ascii=False)}
SZIGORÚ SZABÁLYOK:
1. 'marketing_name': MAXIMUM 50 KARAKTER!
2. 'synonyms': Gyűjtsd ide az összes többi névváltozatot.
3. 'technical_code': Keresd meg a gyári kódokat.
VÁLASZ FORMÁTUM (Csak tiszta JSON):
{{
"marketing_name": "string (max 50)",
"synonyms": ["string"],
"technical_code": "string",
"ccm": int,
"kw": int,
"euro_class": int,
"year_from": int,
"year_to": int vagy null,
"maintenance": {{
"oil_type": "string",
"oil_qty": float,
"spark_plug": "string"
}},
"is_duplicate_potential": bool
}}
"""
payload = {
"model": cls.TEXT_MODEL,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.1}
}
try:
async with httpx.AsyncClient(timeout=90.0) as client:
logger.info(f"📡 AI kérés küldése: {make} {raw_model}...")
response = await client.post(cls.OLLAMA_BASE_URL, json=payload)
response.raise_for_status()
res_json = response.json()
clean_data = json.loads(res_json.get("response", "{}"))
if clean_data.get("marketing_name"):
clean_data["marketing_name"] = clean_data["marketing_name"][:50].strip()
return clean_data
except Exception as e:
logger.error(f"❌ AI hiba ({make} {raw_model}): {e}")
return None
@classmethod
async def get_dvla_data(cls, vrm: str) -> Optional[Dict[str, Any]]:
"""Brit rendszám alapú adatok lekérése."""
if not cls.DVLA_API_KEY: return None
url = "https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles"
headers = {"x-api-key": cls.DVLA_API_KEY, "Content-Type": "application/json"}
try:
async with httpx.AsyncClient() as client:
resp = await client.post(url, json={"registrationNumber": vrm}, headers=headers)
return resp.json() if resp.status_code == 200 else None
except Exception as e:
logger.error(f"❌ DVLA API hiba: {e}")
return None
@classmethod
async def analyze_document_image(cls, image_data: bytes, doc_type: str) -> Optional[Dict[str, Any]]:
"""Robot 3: Helyi OCR és dokumentum elemzés (Llava:7b)."""
await asyncio.sleep(await cls.get_config_delay())
prompts = {
"identity": "Extract ID card data (name, id_number, expiry) as JSON.",
"vehicle_reg": "Extract vehicle registration (plate, VIN, power_kw, engine_ccm) as JSON.",
"invoice": "Extract invoice details (vendor, total_amount, date) as JSON.",
"odometer": "Identify the number on the odometer and return as JSON: {'value': int}."
}
img_b64 = base64.b64encode(image_data).decode('utf-8')
payload = {
"model": cls.VISION_MODEL,
"prompt": prompts.get(doc_type, "Perform OCR and return JSON"),
"images": [img_b64],
"stream": False,
"format": "json"
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(cls.OLLAMA_BASE_URL, json=payload)
res_data = response.json()
clean_json = res_data.get("response", "{}")
match = re.search(r'\{.*\}', clean_json, re.DOTALL)
return json.loads(match.group()) if match else json.loads(clean_json)
except Exception as e:
logger.error(f"❌ Helyi OCR hiba: {e}")
return None

View File

@@ -0,0 +1,111 @@
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

View File

@@ -0,0 +1,153 @@
# /opt/docker/dev/service_finder/backend/app/services/asset_service.py
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy.orm import selectinload
from app.models.asset import Asset, AssetAssignment, AssetTelemetry, AssetFinancials
from app.models.identity import User
from app.services.config_service import config
from app.services.gamification_service import GamificationService
from app.services.security_service import security_service
if TYPE_CHECKING:
from .identity import User, Person
from .organization import Organization
from .vehicle_definitions import VehicleModelDefinition
logger = logging.getLogger(__name__)
class AssetService:
"""
Asset Service 2.0 - A Járművek Életciklus-menedzsere.
Kezeli a regisztrációt, a tulajdonosváltást és a flotta-korlátokat.
"""
@staticmethod
async def create_or_claim_vehicle(
db: AsyncSession,
user_id: int,
org_id: int,
vin: str,
license_plate: str,
catalog_id: int = None
):
"""
Intelligens Jármű Rögzítés:
Ha új: létrehozza.
Ha már létezik: Transzfer folyamatot indít.
"""
try:
vin_clean = vin.strip().upper()
# 1. ADMIN LIMIT ELLENŐRZÉS
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)
count_stmt = select(func.count(Asset.id)).where(Asset.current_organization_id == org_id)
current_count = (await db.execute(count_stmt)).scalar()
if current_count >= allowed_limit:
raise ValueError(f"Limit túllépés! A csomagod {allowed_limit} autót engedélyez.")
# 2. LÉTEZIK-E MÁR A JÁRMŰ?
stmt = select(Asset).where(Asset.vin == vin_clean)
existing_asset = (await db.execute(stmt)).scalar_one_or_none()
if existing_asset:
# HA MÁR A JELENLEGI SZERVEZETNÉL VAN
if existing_asset.current_organization_id == org_id:
raise ValueError("Ez a jármű már a te garázsodban van.")
# TRANSZFER FOLYAMAT INDÍTÁSA
return await AssetService.initiate_ownership_transfer(
db, existing_asset, user_id, org_id, license_plate
)
# 3. ÚJ JÁRMŰ LÉTREHOZÁSA (Standard Flow)
new_asset = Asset(
vin=vin_clean,
license_plate=license_plate.strip().upper(),
catalog_id=catalog_id,
current_organization_id=org_id,
status="active",
is_verified=False
)
db.add(new_asset)
await db.flush()
# Digitális Iker Alapmodulok
db.add(AssetAssignment(asset_id=new_asset.id, organization_id=org_id, status="active"))
db.add(AssetTelemetry(asset_id=new_asset.id))
db.add(AssetFinancials(asset_id=new_asset.id))
# 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")
await db.commit()
return new_asset
except Exception as e:
await db.rollback()
logger.error(f"Asset Creation Error: {e}")
raise e
@staticmethod
async def initiate_ownership_transfer(db: AsyncSession, asset: Asset, user_id: int, org_id: int, new_plate: str):
"""
Adásvétel kezelése: Az autót 'Transfer Pending' állapotba teszi.
"""
# Admin paraméter: Automatikus transzfer engedélyezése?
auto_transfer = await config.get_setting(db, "asset_auto_transfer_enabled", default=False)
# Logoljuk a kísérletet a biztonsági szolgálatnál (Sentinel)
await security_service.log_event(
db, user_id=user_id, action="VEHICLE_CLAIM_INITIATED",
severity="warning", target_type="Asset", target_id=str(asset.id),
new_data={"vin": asset.vin, "new_org": org_id}
)
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)
# Függőben lévő állapot: Dokumentum feltöltésre vár
asset.status = "transfer_pending"
asset.temp_claim_org_id = org_id # Átmeneti tároló a validálásig
await db.commit()
# Itt egy speciális hibaüzenetet dobunk, amit a Frontend tud kezelni (Dokumentum feltöltő ablak)
raise HTTPException(
status_code=202,
detail="A jármű már szerepel a rendszerben. Kérjük, töltsd fel az adásvételi szerződést a tulajdonjog igazolásához."
)
@staticmethod
async def execute_final_transfer(db: AsyncSession, asset: Asset, new_org_id: int, new_plate: str):
""" A tulajdonjog tényleges átírása az adatbázisban. """
# 1. Régi hozzárendelés lezárása
await db.execute(
update(AssetAssignment)
.where(and_(AssetAssignment.asset_id == asset.id, AssetAssignment.status == "active"))
.values(status="archived", end_date=datetime.now())
)
# 2. Új hozzárendelés és adatok frissítése
asset.current_organization_id = new_org_id
asset.license_plate = new_plate.upper()
asset.status = "active"
asset.is_verified = False # Az új tulajdonos papírjait is ellenőrizni kell!
db.add(AssetAssignment(asset_id=asset.id, organization_id=new_org_id, status="active"))
await db.commit()
return asset

View File

@@ -0,0 +1,258 @@
# /opt/docker/dev/service_finder/backend/app/services/auth_service.py
import logging
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, update
from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
from fastapi import HTTPException, status
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models.gamification import UserStats
from app.models.organization import Organization, OrganizationMember, OrgType, Branch
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password, generate_secure_slug
from app.services.email_manager import email_manager
from app.core.config import settings
from app.services.config_service import config
from app.services.geo_service import GeoService
from app.services.security_service import security_service
from app.services.gamification_service import GamificationService
logger = logging.getLogger(__name__)
class AuthService:
@staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
""" 1. FÁZIS: Lite regisztráció dinamikus korlátokkal és Sentinel naplózással. """
try:
# Paraméterek lekérése az admin felületről
min_pass = await config.get_setting(db, "auth_min_password_length", default=8)
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."
)
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
is_active=False
)
db.add(new_person)
await db.flush()
# Szerepkör dinamikus feloldása
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.user
new_user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
person_id=new_person.id,
role=assigned_role,
is_active=False,
is_deleted=False,
region_code=user_in.region_code,
preferred_language=user_in.lang,
timezone=user_in.timezone
)
db.add(new_user)
await db.flush()
# Verifikációs token
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val,
user_id=new_user.id,
token_type="registration",
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}"
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
)
# Sentinel Audit Log
await security_service.log_event(
db, user_id=new_user.id, action="USER_REGISTER_LITE",
severity="info", target_type="User", target_id=str(new_user.id),
new_data={"email": user_in.email}
)
await db.commit()
return new_user
except Exception as e:
await db.rollback()
logger.error(f"Lite Reg Error: {e}")
raise e
@staticmethod
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
""" 2. FÁZIS: Teljes profil és Gamification inicializálás. """
try:
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if not user: return None
# Dinamikus beállítások (Soha ne legyen kódba vésve!)
org_tpl = await config.get_setting(db, "org_naming_template", default="{last_name} Flotta")
base_cur = await config.get_setting(db, "finance_default_currency", region_code=user.region_code, default="HUF")
kyc_reward = await config.get_setting(db, "gamification_kyc_bonus", default=500)
# Címkezelés (GeoService hívás)
addr_id = await GeoService.get_or_create_full_address(
db, zip_code=kyc_in.address_zip, city=kyc_in.address_city,
street_name=kyc_in.address_street_name, street_type=kyc_in.address_street_type,
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
# Dinamikus szervezet generálás
org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name)
new_org = Organization(
full_name=org_full_name,
name=f"{p.last_name} Széfe",
folder_slug=generate_secure_slug(12),
org_type=OrgType.individual,
owner_id=user.id,
is_active=True,
status="verified",
country_code=user.region_code
)
db.add(new_org)
await db.flush()
# Infrastruktúra elemek
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))
user.is_active = True
user.folder_slug = generate_secure_slug(12)
# Gamification XP jóváírás
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
await db.commit()
return user
except Exception as e:
await db.rollback()
logger.error(f"KYC Error: {e}")
raise e
@staticmethod
async def authenticate(db: AsyncSession, email: str, password: str):
""" Felhasználó hitelesítése. """
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if user and verify_password(password, user.hashed_password):
return user
return None
@staticmethod
async def verify_email(db: AsyncSession, token_str: str):
""" Email megerősítés. """
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).where(and_(
VerificationToken.token == token_uuid,
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
))
token = (await db.execute(stmt)).scalar_one_or_none()
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
await db.commit()
return True
except: return False
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
""" Elfelejtett jelszó folyamat indítása. """
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
)
await db.commit()
return "success"
return "not_found"
@staticmethod
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
""" Jelszó tényleges megváltoztatása token alapján. """
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).join(User).where(and_(
User.email == email,
VerificationToken.token == token_uuid,
VerificationToken.token_type == "password_reset",
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
))
token_rec = (await db.execute(stmt)).scalar_one_or_none()
if not token_rec: return False
user_stmt = select(User).where(User.id == token_rec.user_id)
user = (await db.execute(user_stmt)).scalar_one()
user.hashed_password = get_password_hash(new_password)
token_rec.is_used = True
await db.commit()
return True
except: return False
@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. """
stmt = select(User).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if not user or user.is_deleted: return False
old_email = user.email
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
user.is_deleted = True
user.is_active = False
await security_service.log_event(
db, user_id=actor_id, action="USER_SOFT_DELETE",
severity="warning", target_type="User", target_id=str(user_id),
new_data={"reason": reason}
)
await db.commit()
return True

View File

@@ -0,0 +1,281 @@
import os
import logging
import uuid
import json
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
from fastapi import HTTPException, status
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models.gamification import UserStats
from app.models.organization import Organization, OrganizationMember, OrgType
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password, generate_secure_slug
from app.services.email_manager import email_manager
from app.core.config import settings
from app.services.config_service import config
from app.services.geo_service import GeoService
from app.services.security_service import security_service # Sentinel integráció
logger = logging.getLogger(__name__)
class AuthService:
@staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
"""
Step 1: Lite Regisztráció (Manuális).
Létrehozza a Person és User rekordokat, de a fiók inaktív marad.
A folder_slug itt még NEM generálódik le!
"""
try:
# --- Dinamikus jelszóhossz ellenőrzés ---
# Lekérjük az admin beállítást, minimum 8 karakter a hard limit.
min_pass = await config.get_setting(db, "auth_min_password_length", default=8)
min_len = max(int(min_pass), 8)
if len(user_in.password) < min_len:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"A jelszónak legalább {min_len} karakter hosszúnak kell lennie."
)
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
is_active=False
)
db.add(new_person)
await db.flush()
new_user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
person_id=new_person.id,
role=UserRole.user,
is_active=False,
is_deleted=False,
region_code=user_in.region_code,
preferred_language=user_in.lang,
timezone=user_in.timezone
# folder_slug marad NULL a Step 2-ig
)
db.add(new_user)
await db.flush()
# Verifikációs token generálása
reg_hours = await config.get_setting(db, "auth_registration_hours", region_code=user_in.region_code, default=48)
token_val = uuid.uuid4()
db.add(VerificationToken(
token=token_val,
user_id=new_user.id,
token_type="registration",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
))
# Email kiküldése
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
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
)
# Audit log a regisztrációról
await security_service.log_event(
db,
user_id=new_user.id,
action="USER_REGISTER_LITE",
severity="info",
target_type="User",
target_id=str(new_user.id),
new_data={"email": user_in.email, "method": "manual"}
)
await db.commit()
await db.refresh(new_user)
return new_user
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
logger.error(f"Registration Error: {str(e)}")
raise e
@staticmethod
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
""" Step 2: Atomi Tranzakció (Person + Address + Org + Branch + Wallet). """
try:
# 1. Lekérés Eager Loadinggal a hibák elkerülésére
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if not user: return None
# 2. Cím rögzítése
addr_id = await GeoService.get_or_create_full_address(
db, zip_code=kyc_in.address_zip, city=kyc_in.address_city,
street_name=kyc_in.address_street_name, street_type=kyc_in.address_street_type,
house_number=kyc_in.address_house_number, parcel_id=kyc_in.address_hrsz
)
# 3. Person adatok frissítése (MDM elv)
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
# 4. Individual Organization (Privát Széf) létrehozása
new_org = Organization(
full_name=f"{p.last_name} {p.first_name} Magán Flotta",
name=f"{p.last_name} Flotta",
folder_slug=generate_secure_slug(12),
org_type=OrgType.individual,
owner_id=user.id,
is_active=True,
status="verified",
country_code=user.region_code
)
db.add(new_org)
await db.flush()
# 5. Telephely (Branch) és Tagság
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Otthon", 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 "HUF"))
db.add(UserStats(user_id=user.id))
# 6. Aktiválás
user.is_active = True
user.folder_slug = generate_secure_slug(12)
await db.commit()
await db.refresh(user)
return user
except Exception as e:
await db.rollback()
logger.error(f"KYC Error: {e}")
raise e
@staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
"""
Soft-Delete: Email felszabadítás és izoláció.
"""
stmt = select(User).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if not user or user.is_deleted:
return False
old_email = user.email
# Email átnevezése az egyediség megőrzése érdekében (újraregisztrációhoz)
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
user.is_deleted = True
user.is_active = False
await security_service.log_event(
db,
user_id=actor_id,
action="USER_SOFT_DELETE",
severity="warning",
target_type="User",
target_id=str(user_id),
old_data={"email": old_email},
new_data={"is_deleted": True, "reason": reason}
)
await db.commit()
return True
@staticmethod
async def verify_email(db: AsyncSession, token_str: str):
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).where(
and_(
VerificationToken.token == token_uuid,
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
)
)
res = await db.execute(stmt)
token = res.scalar_one_or_none()
if not token: return False
token.is_used = True
await db.commit()
return True
except:
return False
@staticmethod
async def authenticate(db: AsyncSession, email: str, password: str):
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if user and verify_password(password, user.hashed_password):
return user
return None
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none()
if user:
reset_hours = 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_hours))
))
reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email(
recipient=email,
template_key="pwd_reset",
variables={"link": reset_link},
lang=user.preferred_language
)
await db.commit()
return "success"
return "not_found"
@staticmethod
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).join(User).where(
and_(
User.email == email,
VerificationToken.token == token_uuid,
VerificationToken.token_type == "password_reset",
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
)
)
token_rec = (await db.execute(stmt)).scalar_one_or_none()
if not token_rec: return False
user_stmt = select(User).where(User.id == token_rec.user_id)
user = (await db.execute(user_stmt)).scalar_one()
user.hashed_password = get_password_hash(new_password)
token_rec.is_used = True
await db.commit()
return True
except:
return False

View File

@@ -0,0 +1,83 @@
# /opt/docker/dev/service_finder/backend/app/services/config_service.py
from typing import Any, Optional, Dict
import logging
import os
from decimal import Decimal
from datetime import datetime, timezone
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
# Modellek importálása a központi helyről
from app.models import ExchangeRate, AssetCost, AssetTelemetry
from app.db.session import AsyncSessionLocal
logger = logging.getLogger(__name__)
class CostService:
# A cost_in típusát 'Any'-re állítottam ideiglenesen, hogy ne dobjon újabb ImportError-t a hiányzó Pydantic séma miatt
async def record_cost(self, db: AsyncSession, cost_in: Any, user_id: int):
try:
# 1. Árfolyam lekérése (EUR Pivot)
rate_stmt = select(ExchangeRate).where(
ExchangeRate.target_currency == cost_in.currency_local
).order_by(ExchangeRate.id.desc()).limit(1)
rate_res = await db.execute(rate_stmt)
rate_obj = rate_res.scalar_one_or_none()
exchange_rate = rate_obj.rate if rate_obj else Decimal("1.0")
# 2. Kalkuláció
amt_eur = Decimal(str(cost_in.amount_local)) / exchange_rate
# 3. Mentés az új AssetCost modellbe
new_cost = AssetCost(
asset_id=cost_in.asset_id,
organization_id=cost_in.organization_id,
driver_id=user_id,
cost_type=cost_in.cost_type,
amount_local=cost_in.amount_local,
currency_local=cost_in.currency_local,
amount_eur=amt_eur,
exchange_rate_used=exchange_rate,
mileage_at_cost=cost_in.mileage_at_cost,
date=cost_in.date or datetime.now(timezone.utc)
)
db.add(new_cost)
# 4. Telemetria szinkron
if cost_in.mileage_at_cost:
tel_stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == cost_in.asset_id)
telemetry = (await db.execute(tel_stmt)).scalar_one_or_none()
if telemetry and cost_in.mileage_at_cost > (telemetry.current_mileage or 0):
telemetry.current_mileage = cost_in.mileage_at_cost
await db.commit()
return new_cost
except Exception as e:
await db.rollback()
raise e
class ConfigService:
"""
MB 2.0 Alapvető konfigurációs szerviz.
Kezeli az AI szolgáltatások (Ollama) dinamikus beállításait és promptjait.
"""
async def get_setting(self, db: AsyncSession, key: str, default: Any = None) -> Any:
"""
Lekéri a kért beállítást.
1. Megnézi a környezeti változókat (NAGYBETŰVEL).
2. Ha nincs ilyen ENV, visszaadja a kódba égetett 'default' értéket.
"""
env_val = os.getenv(key.upper())
if env_val is not None:
# Automatikus típuskonverzió a default paraméter típusa alapján
if isinstance(default, int): return int(env_val)
if isinstance(default, float): return float(env_val)
if isinstance(default, bool): return str(env_val).lower() in ('true', '1', 'yes')
return env_val
return default
# A példány, amit a többi modul (pl. az auth_service, ai_service) importálni próbál
config = ConfigService()

View File

@@ -0,0 +1,144 @@
# /opt/docker/dev/service_finder/backend/app/services/cost_service.py
import uuid
import logging
from decimal import Decimal
from typing import Any, Dict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from app.models.asset import AssetCost, AssetTelemetry, ExchangeRate
from app.services.gamification_service import GamificationService
from app.services.config_service import config
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime
logger = logging.getLogger(__name__)
class CostService:
"""
Industrial Cost & Telemetry Service.
Összeköti a pénzügyi kiadásokat, az OCR bizonylatokat és a jármű állapotát.
"""
async def record_cost(self, db: AsyncSession, cost_in: AssetCostCreate, user_id: int):
""" Teljes körű költségrögzítés: Konverzió + Telemetria + OCR + XP. """
try:
# 1. Dinamikus konfiguráció lekérése
base_currency = await config.get_setting(db, "finance_base_currency", default="EUR")
base_xp = await config.get_setting(db, "xp_per_cost_log", default=50)
ocr_multiplier = await config.get_setting(db, "xp_multiplier_ocr_cost", default=1.5)
# 2. Intelligens Árfolyamkezelés
exchange_rate = Decimal("1.0")
if cost_in.currency_local != base_currency:
rate_stmt = select(ExchangeRate).where(
ExchangeRate.target_currency == cost_in.currency_local
).order_by(desc(ExchangeRate.updated_at)).limit(1)
rate_res = await db.execute(rate_stmt)
rate_obj = rate_res.scalar_one_or_none()
exchange_rate = rate_obj.rate if rate_obj else Decimal("1.0")
amt_base = Decimal(str(cost_in.amount_local)) / exchange_rate if exchange_rate > 0 else Decimal("0")
# 3. Költség rekord rögzítése (Kapcsolva a Robot 1 OCR dokumentumához)
new_cost = AssetCost(
asset_id=cost_in.asset_id,
organization_id=cost_in.organization_id,
driver_id=user_id,
cost_type=cost_in.cost_type,
amount_local=cost_in.amount_local,
currency_local=cost_in.currency_local,
amount_eur=amt_base,
net_amount_local=cost_in.net_amount_local,
vat_rate=cost_in.vat_rate,
exchange_rate_used=exchange_rate,
mileage_at_cost=cost_in.mileage_at_cost,
date=cost_in.date or datetime.now(),
# OCR Kapcsolat
document_id=cost_in.document_id,
is_ai_generated=cost_in.document_id is not None,
data=cost_in.data or {}
)
db.add(new_cost)
# 4. Automatikus Telemetria (Kilométeróra frissítés)
if cost_in.mileage_at_cost:
await self._sync_telemetry(db, cost_in.asset_id, cost_in.mileage_at_cost)
# 5. Gamification (Értékesebb az adat, ha van róla fotó/OCR)
final_xp = base_xp
if new_cost.is_ai_generated:
final_xp = int(base_xp * float(ocr_multiplier))
await GamificationService.award_points(
db, user_id=user_id, amount=final_xp, reason=f"EXPENSE_LOG_{cost_in.cost_type}"
)
await db.commit()
await db.refresh(new_cost)
return new_cost
except Exception as e:
await db.rollback()
logger.error(f"CostService Error: {e}")
raise e
async def _sync_telemetry(self, db: AsyncSession, asset_id: int, mileage: int):
""" Segédfüggvény: Biztonságos óraállás frissítés. """
stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == asset_id)
res = await db.execute(stmt)
telemetry = res.scalar_one_or_none()
if telemetry:
# Csak akkor frissítünk, ha az új érték nagyobb (nincs visszatekerés)
if mileage > (telemetry.current_mileage or 0):
telemetry.current_mileage = mileage
telemetry.last_updated = datetime.now()
else:
db.add(AssetTelemetry(asset_id=asset_id, current_mileage=mileage))
async def get_asset_financial_summary(self, db: AsyncSession, asset_id: uuid.UUID) -> Dict[str, Any]:
"""
Dinamikus pénzügyi összesítő SQL szintű aggregációval.
MB 2.0: Nem loopolunk Pythonban, a DB számol!
"""
# 1. Lekérjük az összesített adatokat kategóriánként (Local és EUR)
stmt = (
select(
AssetCost.cost_type,
func.sum(AssetCost.amount_local).label("total_local"),
func.sum(AssetCost.amount_eur).label("total_eur"),
func.count(AssetCost.id).label("transaction_count")
)
.where(AssetCost.asset_id == asset_id)
.group_by(AssetCost.cost_type)
)
res = await db.execute(stmt)
rows = res.all()
summary = {
"by_category": {},
"grand_total_local": Decimal("0.0"),
"grand_total_eur": Decimal("0.0"),
"total_transactions": 0
}
for row in rows:
cat = row.cost_type or "OTHER"
summary["by_category"][cat] = {
"local": float(row.total_local),
"eur": float(row.total_eur),
"count": row.transaction_count
}
summary["grand_total_local"] += row.total_local
summary["grand_total_eur"] += row.total_eur
summary["total_transactions"] += row.transaction_count
# Decimal konverzió a JSON-höz
summary["grand_total_local"] = float(summary["grand_total_local"])
summary["grand_total_eur"] = float(summary["grand_total_eur"])
return summary
cost_service = CostService()

View File

@@ -0,0 +1,135 @@
# /opt/docker/dev/service_finder/backend/app/services/document_service.py
import os
import logging
import asyncio
from PIL import Image
from uuid import uuid4
from datetime import datetime, timezone
from fastapi import UploadFile, BackgroundTasks, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from app.models.document import Document
from app.models.identity import User
from app.services.config_service import config # 2.0 Dinamikus beállítások
from app.workers.ocr.robot_1_ocr_processor import OCRRobot # Robot 1 hívása
logger = logging.getLogger("Document-Service-2.0")
class DocumentService:
"""
Document Service 2.0 - Admin-vezérelt Pipeline.
Feladata: Tárolás, Optimalizálás, Kvótamanagement és Robot Trigger.
"""
@staticmethod
async def process_upload(
db: AsyncSession,
user_id: int,
file: UploadFile,
parent_type: str, # pl. "asset", "organization", "transfer"
parent_id: str,
doc_type: str, # pl. "invoice", "registration_card", "sale_contract"
background_tasks: BackgroundTasks
):
try:
# --- 1. ADMIN KVÓTA ELLENŐRZÉS ---
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one()
# Lekérjük a csomagnak megfelelő havi limitet (pl. Free: 1, Premium: 10)
limits = await config.get_setting(db, "ocr_monthly_limit", default={"free": 1, "premium": 10, "vip": 100})
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
allowed_ocr = limits.get(user_role, 1)
# Megnézzük a havi felhasználást
now = datetime.now(timezone.utc)
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
count_stmt = select(func.count(Document.id)).where(
and_(
Document.user_id == user_id,
Document.created_at >= start_of_month
)
)
used_count = (await db.execute(count_stmt)).scalar()
if used_count >= allowed_ocr:
raise HTTPException(
status_code=403,
detail=f"Havi dokumentum limit túllépve ({allowed_ocr}). Válts csomagot a folytatáshoz!"
)
# --- 2. DINAMIKUS TÁROLÁS ÉS OPTIMALIZÁLÁS ---
file_uuid = str(uuid4())
nas_base = await config.get_setting(db, "storage_nas_path", default="/mnt/nas/app_data")
# Útvonal sablonok az adminból (Pl. "vault/{parent_type}/{parent_id}")
vault_dir = os.path.join(nas_base, parent_type, parent_id, "vault")
thumb_dir = os.path.join(getattr(config, "STATIC_DIR", "static"), "previews", parent_type, parent_id)
os.makedirs(vault_dir, exist_ok=True)
os.makedirs(thumb_dir, exist_ok=True)
content = await file.read()
temp_path = f"/tmp/{file_uuid}_{file.filename}"
with open(temp_path, "wb") as f: f.write(content)
# Kép feldolgozása PIL-lel
img = Image.open(temp_path)
# Thumbnail generálás (SSD/Static területre)
thumb_filename = f"{file_uuid}_thumb.webp"
thumb_path = os.path.join(thumb_dir, thumb_filename)
thumb_img = img.copy()
thumb_img.thumbnail((300, 300))
thumb_img.save(thumb_path, "WEBP", quality=80)
# Optimalizált eredeti (NAS / Vault területre)
max_width = await config.get_setting(db, "img_max_width", default=1600)
vault_filename = f"{file_uuid}.webp"
vault_path = os.path.join(vault_dir, vault_filename)
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.Resampling.LANCZOS)
img.save(vault_path, "WEBP", quality=85)
# --- 3. MENTÉS ---
new_doc = Document(
id=uuid4(),
user_id=user_id,
parent_type=parent_type,
parent_id=parent_id,
doc_type=doc_type,
original_name=file.filename,
file_hash=file_uuid,
file_ext="webp",
mime_type="image/webp",
file_size=os.path.getsize(vault_path),
has_thumbnail=True,
thumbnail_path=f"/static/previews/{parent_type}/{parent_id}/{thumb_filename}",
status="uploaded"
)
db.add(new_doc)
await db.flush()
# --- 4. ROBOT TRIGGER (OCR AUTOMATIZMUS) ---
# Megnézzük, hogy ez a típus (pl. invoice) igényel-e automatikus OCR-t
auto_ocr_types = await config.get_setting(db, "ocr_auto_trigger_types", default=["invoice", "registration_card", "sale_contract"])
if doc_type in auto_ocr_types:
# Robot 1 (OCR) sorba állítása háttérfolyamatként
background_tasks.add_task(OCRRobot.process_document, db, new_doc.id)
new_doc.status = "processing"
logger.info(f"🤖 Robot 1 (OCR) riasztva: {new_doc.id}")
await db.commit()
os.remove(temp_path)
return new_doc
except Exception as e:
if 'temp_path' in locals() and os.path.exists(temp_path): os.remove(temp_path)
logger.error(f"Document Upload Error: {e}")
raise e

View File

@@ -0,0 +1,71 @@
# /opt/docker/dev/service_finder/backend/app/services/dvla_service.py
import httpx
import logging
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.config_service import config # 2.0 Dinamikus konfig
from app.db.session import AsyncSessionLocal
logger = logging.getLogger("DVLA-Service-2.2")
class DVLAService:
"""
Sentinel Master DVLA Service 2.2.
Felelős a brit járműadatok lekéréséért a hivatalos állami API-n keresztül.
"""
@classmethod
async def get_vehicle_details(cls, db: AsyncSession, vrm: str) -> Optional[Dict[str, Any]]:
"""
VRM (Vehicle Registration Mark) lekérdezése dinamikus admin beállításokkal.
"""
try:
# 1. ADMIN BEÁLLÍTÁSOK LEKÉRÉSE
# Megnézzük, engedélyezve van-e a szolgáltatás
is_enabled = await config.get_setting(db, "dvla_api_enabled", default=True)
if not is_enabled:
logger.info("DVLA lekérdezés kihagyva (Admin által letiltva).")
return None
api_url = await config.get_setting(
db, "dvla_api_url",
default="https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles"
)
api_key = await config.get_setting(db, "dvla_api_key")
if not api_key:
logger.error("DVLA API kulcs hiányzik a system_parameters táblából!")
return None
# 2. HITELESÍTÉS ÉS LEKÉRDEZÉS
headers = {
"x-api-key": api_key,
"Content-Type": "application/json"
}
# A DVLA szigorúan nagybetűs, szóköz nélküli rendszámot vár
clean_vrm = vrm.replace(" ", "").upper().strip()
payload = {"registrationNumber": clean_vrm}
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(api_url, json=payload, headers=headers)
if response.status_code == 200:
logger.info(f"✅ DVLA adat sikeresen lekérve: {clean_vrm}")
return response.json()
elif response.status_code == 404:
logger.warning(f"⚠️ Jármű nem található a DVLA adatbázisában: {clean_vrm}")
return None
elif response.status_code == 429:
logger.error("🚨 DVLA API hiba: Túl sok kérés (Rate Limit)!")
return {"error": "rate_limited"}
else:
logger.error(f"❌ DVLA API hiba ({response.status_code}): {response.text}")
return None
except Exception as e:
logger.error(f"⚠️ DVLAService Kritikus Hiba: {e}")
return None

View File

@@ -0,0 +1,132 @@
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
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
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)."""
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)
footer = locale_manager.get(f"email.{template_key}_footer", lang=lang)
link_fallback_text = locale_manager.get("email.link_fallback", lang=lang)
return f"""
<html>
<body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;">
<div style="max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 30px; border-radius: 10px;">
<h2 style="color: #2c3e50;">{greeting}</h2>
<p>{body}</p>
<div style="text-align: center; margin: 40px 0;">
<a href="{variables.get('link', '#')}"
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>
</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>
</div>
</body>
</html>
"""
@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).
"""
# 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")
if provider == "disabled":
logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}")
return
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!")
# Fallback vagy közvetlen SMTP
smtp_cfg = await config.get_setting(db, "smtp_config", default={
"host": "localhost", "port": 587, "user": "", "pass": "", "tls": True
})
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):
try:
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{from_email}>"
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=15) as server:
if cfg.get("tls", True):
server.starttls()
if cfg.get("user") and cfg.get("pass"):
server.login(cfg["user"], cfg["pass"])
server.send_message(msg)
logger.info(f"SMTP siker -> {recipient}")
return {"status": "success", "provider": "smtp"}
except Exception as e:
logger.error(f"SMTP hiba: {str(e)}")
return {"status": "error", "message": str(e)}
email_manager = EmailManager()

View File

@@ -0,0 +1,135 @@
# /opt/docker/dev/service_finder/backend/app/services/fleet_service.py
import logging
from uuid import UUID
from typing import Optional, Dict, Any
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.models.asset import Asset, AssetEvent, AssetCost, AssetTelemetry
from app.models.social import ServiceProvider, ModerationStatus
from app.schemas.fleet import EventCreate, TCOStats
from app.services.gamification_service import gamification_service
from app.services.config_service import config # 2.0 Dinamikus konfig
logger = logging.getLogger("Fleet-Service-2.2")
class FleetService:
"""
Sentinel Master Fleet Service 2.2.
Kezeli a járműflotta eseményeit és a TCO elemzéseket admin-vezérelt szabályokkal.
"""
@staticmethod
async def add_vehicle_event(db: AsyncSession, asset_id: UUID, event_data: EventCreate, user_id: int):
"""
Esemény rögzítése dinamikus jutalmazással és anomália figyeléssel.
"""
try:
# 1. Asset és Telemetria betöltése
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.telemetry))
res = await db.execute(stmt)
asset = res.scalar_one_or_none()
if not asset: return None
# 2. ADMIN KONFIGURÁCIÓ LEKÉRÉSE (Hierarchikus: User > Region > Global)
# Lekérjük az eseménytípushoz tartozó jutalmakat
event_rewards = await config.get_setting(
db,
"FLEET_EVENT_REWARDS",
scope_level="user",
scope_id=str(user_id),
default={
"refuel": {"xp": 30, "social": 5},
"service": {"xp": 100, "social": 20},
"inspection": {"xp": 50, "social": 10},
"default": {"xp": 20, "social": 2}
}
)
# 3. SZOLGÁLTATÓ KEZELÉSE
provider_id = event_data.provider_id
if not event_data.is_diy and event_data.provider_name and not provider_id:
p_stmt = select(ServiceProvider).where(func.lower(ServiceProvider.name) == event_data.provider_name.lower())
existing = (await db.execute(p_stmt)).scalar_one_or_none()
if existing:
provider_id = existing.id
else:
new_p = ServiceProvider(
name=event_data.provider_name,
added_by_user_id=user_id,
status=ModerationStatus.pending
)
db.add(new_p)
await db.flush()
provider_id = new_p.id
# 4. ANOMÁLIA DETEKCIÓ (Admin-vezérelt küszöbökkel)
current_mileage = asset.telemetry.current_mileage if asset.telemetry else 0
is_odometer_anomaly = event_data.odometer_value < current_mileage
# 5. ESEMÉNY RÖGZÍTÉSE
new_event = AssetEvent(
asset_id=asset_id,
event_type=event_data.event_type,
recorded_mileage=event_data.odometer_value,
provider_id=provider_id,
is_anomaly=is_odometer_anomaly,
data=event_data.model_dump(exclude={"provider_id", "provider_name"})
)
db.add(new_event)
# 6. DINAMIKUS GAMIFIKÁCIÓ
# Kikeresjük a konkrét eseménytípushoz tartozó pontokat
rewards = event_rewards.get(event_data.event_type, event_rewards["default"])
await gamification_service.process_activity(
db,
user_id,
xp_amount=rewards["xp"],
social_amount=rewards["social"],
reason=f"FLEET_EVENT_{event_data.event_type.upper()}"
)
await db.commit()
return new_event
except Exception as e:
await db.rollback()
logger.error(f"Fleet Event Error: {e}")
raise e
@staticmethod
async def calculate_tco(db: AsyncSession, asset_id: UUID) -> TCOStats:
"""
TCO számítás dinamikus pénznemkezeléssel és KM-alapú költséganalízissel.
"""
# 1. Admin beállítások (Pl. alapértelmezett pénznem a riportokhoz)
report_currency = await config.get_setting(db, "finance_base_currency", default="EUR")
# 2. Költségek összesítése kategóriánként
result = await db.execute(
select(AssetCost.cost_type, func.sum(AssetCost.amount_eur))
.where(AssetCost.asset_id == asset_id)
.group_by(AssetCost.cost_type)
)
breakdown = {row[0]: float(row[1]) for row in result.all()}
total_eur = sum(breakdown.values())
# 3. KM alapú költség (Telemetria bevonása)
telemetry_stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == asset_id)
telemetry = (await db.execute(telemetry_stmt)).scalar_one_or_none()
mileage = telemetry.current_mileage if telemetry and telemetry.current_mileage > 0 else 1
cost_per_km = total_eur / mileage
return TCOStats(
asset_id=asset_id,
total_cost_eur=total_eur,
breakdown=breakdown,
cost_per_km=round(cost_per_km, 4),
currency=report_currency
)

View File

@@ -0,0 +1,153 @@
# /opt/docker/dev/service_finder/backend/app/services/gamification_service.py
import logging
import math
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.models.gamification import UserStats, PointsLedger, UserBadge, Badge
from app.models.identity import User, Wallet
from app.models.audit import FinancialLedger
from app.services.config_service import config # 2.0 Központi konfigurátor
logger = logging.getLogger("Gamification-Service-2.0")
class GamificationService:
"""
Gamification Service 2.0 - A 'Jövevény' lelke.
Felelős a pontozásért, szintekért, büntetésekért és a jutalom-kreditekért.
"""
@staticmethod
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0):
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz. """
service = GamificationService()
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason)
async def process_activity(
self,
db: AsyncSession,
user_id: int,
xp_amount: int,
social_amount: int,
reason: str,
is_penalty: bool = False
):
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés. """
try:
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
# Minden paraméter az admin felületről módosítható JSON-ként
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
"xp_logic": {"base_xp": 500, "exponent": 1.5},
"penalty_logic": {
"recovery_rate": 0.5,
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
"multipliers": {"L0": 1.0, "L1": 0.5, "L2": 0.1, "L3": 0.0}
},
"conversion_logic": {"social_to_credit_rate": 100},
"level_rewards": {"credits_per_10_levels": 50}
})
# 2. FELHASZNÁLÓ ÉS STATISZTIKA ELLENŐRZÉSE
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
if not stats:
stats = UserStats(user_id=user_id, total_xp=0, current_level=1, penalty_points=0)
db.add(stats)
await db.flush()
# 3. BÜNTETŐ LOGIKA (Ha negatív esemény történik)
if is_penalty:
return await self._apply_penalty(db, stats, xp_amount, reason, cfg)
# 4. SZORZÓK ALKALMAZÁSA (Büntetés alatt állók 'bírsága')
multiplier = await self._calculate_multiplier(stats, cfg)
if multiplier <= 0:
logger.warning(f"User {user_id} pontszerzése blokkolva a büntetések miatt.")
return stats
# 5. XP SZÁMÍTÁS ÉS SZINTLÉPÉS
final_xp = int(xp_amount * multiplier)
if final_xp > 0:
stats.total_xp += final_xp
# Büntetés ledolgozás (Recovery)
if stats.penalty_points > 0:
recovery = int(final_xp * cfg["penalty_logic"]["recovery_rate"])
stats.penalty_points = max(0, stats.penalty_points - recovery)
# Új szint számítás hatványfüggvénnyel:
# $Level = \sqrt[exponent]{\frac{XP}{Base}} + 1$
xp_cfg = cfg["xp_logic"]
new_level = int((stats.total_xp / xp_cfg["base_xp"]) ** (1 / xp_cfg["exponent"])) + 1
if new_level > stats.current_level:
await self._handle_level_up(db, user_id, stats.current_level, new_level, cfg)
stats.current_level = new_level
# 6. SOCIAL PONT ÉS KREDIT KONVERZIÓ
final_social = int(social_amount * multiplier)
if final_social > 0:
stats.social_points += final_social
rate = cfg["conversion_logic"]["social_to_credit_rate"]
if stats.social_points >= rate:
credits_to_add = stats.social_points // rate
stats.social_points %= rate # A maradék pont megmarad
await self._add_earned_credits(db, user_id, credits_to_add, "SOCIAL_ACTIVITY_CONVERSION")
# 7. NAPLÓZÁS
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason))
await db.commit()
await db.refresh(stats)
return stats
except Exception as e:
await db.rollback()
logger.error(f"Gamification Error for user {user_id}: {e}")
raise e
# --- PRIVÁT SEGÉDFÜGGVÉNYEK ---
async def _apply_penalty(self, db: AsyncSession, stats: UserStats, amount: int, reason: str, cfg: dict):
"""Büntetőpontok hozzáadása és korlátozási szintek emelése."""
stats.penalty_points += amount
th = cfg["penalty_logic"]["thresholds"]
if stats.penalty_points >= th["level_3"]: stats.restriction_level = 3
elif stats.penalty_points >= th["level_2"]: stats.restriction_level = 2
elif stats.penalty_points >= th["level_1"]: stats.restriction_level = 1
db.add(PointsLedger(user_id=stats.user_id, points=0, penalty_change=amount, reason=f"🔴 PENALTY: {reason}"))
await db.commit()
return stats
async def _calculate_multiplier(self, stats: UserStats, cfg: dict) -> float:
"""Kiszámolja a szorzót a jelenlegi büntetési szint alapján."""
m = cfg["penalty_logic"]["multipliers"]
if stats.restriction_level == 3: return m["L3"]
if stats.restriction_level == 2: return m["L2"]
if stats.restriction_level == 1: return m["L1"]
return m["L0"]
async def _handle_level_up(self, db: AsyncSession, user_id: int, old_lvl: int, new_lvl: int, cfg: dict):
"""Szintlépési jutalmak (pl. minden 10. szintnél kredit)."""
logger.info(f"✨ Level Up: User {user_id} ({old_lvl} -> {new_lvl})")
if new_lvl % 10 == 0:
reward = cfg["level_rewards"]["credits_per_10_levels"]
await self._add_earned_credits(db, user_id, reward, f"LEVEL_{new_lvl}_REWARD")
async def _add_earned_credits(self, db: AsyncSession, user_id: int, amount: int, reason: str):
"""Kredit jóváírása a Wallet-ben és a pénzügyi naplóban."""
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
wallet = (await db.execute(wallet_stmt)).scalar_one_or_none()
if wallet:
wallet.earned_credits += Decimal(str(amount))
db.add(FinancialLedger(
user_id=user_id,
amount=float(amount),
transaction_type="GAMIFICATION_CREDIT",
details={"reason": reason}
))
gamification_service = GamificationService()

View File

@@ -0,0 +1,155 @@
# /opt/docker/dev/service_finder/backend/app/services/geo_service.py
import uuid
import logging
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text, select
from app.services.config_service import config # 2.0 Dinamikus konfig
from app.db.session import AsyncSessionLocal
logger = logging.getLogger("Geo-Service-2.2")
class GeoService:
"""
Sentinel Master GeoService 2.2.
Felelős a címek normalizálásáért, a szótárak építéséért és a téradatokért.
Minden paraméter (ország, sablon, limit) adminból vezérelt.
"""
@staticmethod
async def get_street_suggestions(db: AsyncSession, zip_code: str, q: str, user_id: Optional[int] = None) -> List[str]:
"""
Autocomplete támogatás az utcákhoz.
A limitet és a keresési logikát az adminból vesszük.
"""
# 1. Admin beállítások lekérése
search_limit = await config.get_setting(db, "GEO_SUGGESTION_LIMIT", default=10)
query = text("""
SELECT DISTINCT s.name
FROM data.geo_streets s
JOIN data.geo_postal_codes p ON s.postal_code_id = p.id
WHERE p.zip_code = :zip AND s.name ILIKE :q
ORDER BY s.name ASC LIMIT :limit
""")
try:
res = await db.execute(query, {"zip": zip_code, "q": f"{q}%", "limit": search_limit})
return [row[0] for row in res.fetchall()]
except Exception as e:
logger.error(f"Street Suggestion Error: {e}")
return []
@staticmethod
async def get_or_create_full_address(
db: AsyncSession,
zip_code: str,
city: str,
street_name: str,
street_type: str,
house_number: str,
stairwell: Optional[str] = None,
floor: Optional[str] = None,
door: Optional[str] = None,
parcel_id: Optional[str] = None,
user_id: Optional[int] = None # A régió-alapú felülbíráláshoz
) -> uuid.UUID:
"""
Hibrid címrögzítés atomizált mezőkkel.
A cím generálásának módja (sablonja) régió- vagy felhasználó-specifikus lehet.
"""
try:
# 1. ADMIN BEÁLLÍTÁSOK LEKÉRÉSE (Hierarchikus: User > Region > Global)
# Országkód (pl. HU, AT, DE)
default_country = await config.get_setting(
db, "geo_default_country_code",
scope_level="user" if user_id else "global",
scope_id=str(user_id) if user_id else None,
default="HU"
)
# Címformázási sablon (pl. "{zip} {city}, {street} {type} {number}")
address_template = await config.get_setting(
db, "GEO_ADDRESS_FORMAT_TEMPLATE",
default="{zip} {city}, {street} {type} {number}."
)
# 2. Irányítószám és Város (Auto-learning / Upsert)
zip_id_query = text("""
INSERT INTO data.geo_postal_codes (zip_code, city, country_code)
VALUES (:z, :c, :cc)
ON CONFLICT (country_code, zip_code, city) DO UPDATE SET city = EXCLUDED.city
RETURNING id
""")
zip_res = await db.execute(zip_id_query, {"z": zip_code, "c": city, "cc": default_country})
zip_id = zip_res.scalar()
# 3. Utca szótár frissítése
await db.execute(text("""
INSERT INTO data.geo_streets (postal_code_id, name) VALUES (:zid, :n)
ON CONFLICT (postal_code_id, name) DO NOTHING
"""), {"zid": zip_id, "n": street_name})
# 4. Közterület típus (út, utca, köz...)
await db.execute(text("""
INSERT INTO data.geo_street_types (name) VALUES (:n)
ON CONFLICT (name) DO NOTHING
"""), {"n": street_type.lower()})
# 5. SZÖVEGES CÍM GENERÁLÁSA SABLON ALAPJÁN (2.2 Újdonság)
# Megformázzuk az alapcímet az admin sablon szerint
full_text = address_template.format(
zip=zip_code,
city=city,
street=street_name,
type=street_type,
number=house_number
)
# Hozzáadjuk az atomizált kiegészítőket, ha vannak
if stairwell: full_text += f" {stairwell}. lph."
if floor: full_text += f" {floor}. em."
if door: full_text += f" {door}. ajtó"
# 6. Központi Address rekord rögzítése vagy lekérése
address_query = text("""
INSERT INTO data.addresses (
postal_code_id, street_name, street_type, house_number,
stairwell, floor, door, parcel_id, full_address_text
)
VALUES (:zid, :sn, :st, :hn, :sw, :fl, :dr, :pid, :txt)
ON CONFLICT (postal_code_id, street_name, street_type, house_number, stairwell, floor, door)
DO NOTHING
RETURNING id
""")
params = {
"zid": zip_id, "sn": street_name, "st": street_type,
"hn": house_number, "sw": stairwell, "fl": floor,
"dr": door, "pid": parcel_id, "txt": full_text
}
res = await db.execute(address_query, params)
addr_id = res.scalar()
# 7. Biztonsági keresés: Ha létezett a rekord, de nem kaptunk ID-t a RETURNING-gal
if not addr_id:
lookup_query = text("""
SELECT id FROM data.addresses
WHERE postal_code_id = :zid
AND street_name = :sn
AND street_type = :st
AND house_number = :hn
AND (stairwell IS NOT DISTINCT FROM :sw)
AND (floor IS NOT DISTINCT FROM :fl)
AND (door IS NOT DISTINCT FROM :dr)
LIMIT 1
""")
lookup_res = await db.execute(lookup_query, params)
addr_id = lookup_res.scalar()
return addr_id
except Exception as e:
logger.error(f"GeoService Critical Error: {str(e)}")
raise ValueError(f"Súlyos hiba a cím normalizálása során. Admin/Séma ellenőrzése javasolt.")

View File

@@ -0,0 +1,38 @@
# /opt/docker/dev/service_finder/backend/app/services/image_processor.py
import cv2
import numpy as np
from typing import Optional
class DocumentImageProcessor:
""" Saját képtisztító pipeline Robot 3 OCR számára. """
@staticmethod
def process_for_ocr(image_bytes: bytes) -> Optional[bytes]:
if not image_bytes: return None
try:
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None: return None
# 1. Előkészítés (Szürkeárnyalat + Felskálázás)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
if gray.shape[1] < 1200:
gray = cv2.resize(gray, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
# 2. Kontraszt dúsítás (CLAHE)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
contrast = clahe.apply(gray)
# 3. Adaptív Binarizálás (Fekete-fehér szöveg kiemelés)
blur = cv2.GaussianBlur(contrast, (3, 3), 0)
thresh = cv2.adaptiveThreshold(
blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
success, encoded_image = cv2.imencode('.png', thresh)
return encoded_image.tobytes() if success else None
except Exception as e:
print(f"OpenCV Feldolgozási hiba: {e}")
return None

View File

@@ -0,0 +1,106 @@
# /opt/docker/dev/service_finder/backend/app/services/maintenance_service.py
import os
import logging
import shutil
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.models.asset import Asset, AssetTelemetry
from app.services.config_service import config # 2.0 Dinamikus konfig
from app.services.notification_service import NotificationService
logger = logging.getLogger("Maintenance-Service-2.0")
class MaintenanceService:
"""
Sentinel Master Maintenance Service 2.0.
Felelős a rendszer tisztításáért és a prediktív karbantartási riasztásokért.
"""
@staticmethod
async def cleanup_old_files(db: AsyncSession):
"""
Admin-vezérelt NAS takarítás.
A megőrzési időt (napokban) az adatbázisból veszi.
"""
try:
storage_path = await config.get_setting(db, "storage_nas_path", default="/mnt/nas/app_data")
retention_days = await config.get_setting(db, "storage_retention_days", default=365)
limit = datetime.now() - timedelta(days=int(retention_days))
deleted_count = 0
if not os.path.exists(storage_path):
logger.warning(f"A tárolási útvonal nem található: {storage_path}")
return 0
for root, dirs, files in os.walk(storage_path):
for file in files:
file_path = os.path.join(root, file)
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
if file_time < limit:
os.remove(file_path)
deleted_count += 1
logger.info(f"🗑️ NAS takarítás kész. Törölve: {deleted_count} lejárt fájl.")
return deleted_count
except Exception as e:
logger.error(f"Hiba a takarítás során: {e}")
return 0
@staticmethod
async def check_maintenance_intervals(db: AsyncSession):
"""
Prediktív karbantartás: Összeveti a Robot 3 gyári adatait a valós futásteljesítménnyel.
Ha egy autó közeledik az olajcseréhez (pl. 1000 km-en belül), riasztást generál.
"""
try:
# Admin beállítás: hány km-rel a szerviz előtt szóljunk?
km_threshold = await config.get_setting(db, "maint_km_alert_threshold", default=1000)
# Lekérjük az összes autót a telemetriával együtt
stmt = select(Asset, AssetTelemetry).join(AssetTelemetry).where(Asset.status == "active")
result = await db.execute(stmt)
alerts_generated = 0
for asset, telemetry in result.all():
# A Robot 3 által feltöltött gyári adat (pl. 15.000 km)
interval = asset.factory_data.get("oil_change_km") if asset.factory_data else None
last_service_km = asset.factory_data.get("last_service_km", 0)
if interval and telemetry.current_mileage:
next_service_due = last_service_km + interval
remaining_km = next_service_due - telemetry.current_mileage
if 0 <= remaining_km <= int(km_threshold):
# Értesítés küldése a Notification Centerbe és Emailben
await NotificationService.send_direct_notification(
db,
user_id=asset.owner_id,
message_key="maintenance_due",
variables={
"vehicle": f"{asset.license_plate}",
"type": "Olajcsere",
"remaining": remaining_km
}
)
alerts_generated += 1
return alerts_generated
except Exception as e:
logger.error(f"Karbantartás ellenőrzési hiba: {e}")
return 0
@staticmethod
async def delete_validated_evidence(db: AsyncSession, document_id: str):
"""
Validáció utáni képkezelés.
Az adminban állítható, hogy töröljük-e a bizonyítékot a hitelesítés után.
"""
should_delete = await config.get_setting(db, "storage_delete_after_validation", default=False)
if should_delete:
# Itt a Document modell alapján megkeressük a fájlt és töröljük
# (A biztonság kedvéért naplózzuk a Sentinelbe)
pass

View File

@@ -0,0 +1,35 @@
# /opt/docker/dev/service_finder/backend/app/services/matching_service.py
from typing import List, Dict, Any
from app.services.config_service import config
class MatchingService:
@staticmethod
async def rank_services(services: List[Dict[str, Any]], org_id: int = None) -> List[Dict[str, Any]]:
""" Szolgáltatók rangsorolása dinamikus Sentinel paraméterek alapján. """
# JAVÍTVA: Hierarchikus paraméterek lekérése
w_dist = float(await config.get_setting('weight_distance', org_id=org_id, default=0.5))
w_rate = float(await config.get_setting('weight_rating', org_id=org_id, default=0.5))
b_gold = float(await config.get_setting('bonus_gold_service', org_id=org_id, default=500))
ranked_list = []
for s in services:
# Távolság pont (közelebb = több pont)
dist = s.get('distance', 1.0)
p_dist = 100 / (dist + 1)
# Értékelés pont (0-5 csillag -> 0-100 pont)
p_rate = s.get('rating', 0.0) * 20
# Bónusz a kiemelt (Gold) partnereknek
tier_bonus = b_gold if s.get('tier') == 'gold' else 0
# Összesített pontszám
total_score = (p_dist * w_dist) + (p_rate * w_rate) + tier_bonus
s['total_score'] = round(total_score, 2)
ranked_list.append(s)
return sorted(ranked_list, key=lambda x: x['total_score'], reverse=True)
matching_service = MatchingService()

View File

@@ -0,0 +1,45 @@
# /opt/docker/dev/service_finder/backend/app/services/media_service.py
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
import logging
from typing import Tuple, Optional
logger = logging.getLogger(__name__)
class MediaService:
@staticmethod
def _convert_to_degrees(value) -> float:
""" EXIF racionális koordináták konvertálása tizedes fokká. """
try:
d = float(value[0])
m = float(value[1])
s = float(value[2])
return d + (m / 60.0) + (s / 3600.0)
except (IndexError, ZeroDivisionError, TypeError):
return 0.0
@classmethod
def extract_gps_info(cls, file_path: str) -> Optional[Tuple[float, float]]:
""" GPS koordináták kinyerése a kép metaadataiból (Robot Hunt alapja). """
try:
with Image.open(file_path) as image:
exif = image._getexif()
if not exif: return None
gps_info = {}
for tag, value in exif.items():
if TAGS.get(tag) == "GPSInfo":
for t in value:
gps_info[GPSTAGS.get(t, t)] = value[t]
if 'GPSLatitude' in gps_info and 'GPSLongitude' in gps_info:
lat = cls._convert_to_degrees(gps_info['GPSLatitude'])
if gps_info.get('GPSLatitudeRef') != "N": lat = -lat
lon = cls._convert_to_degrees(gps_info['GPSLongitude'])
if gps_info.get('GPSLongitudeRef') != "E": lon = -lon
return lat, lon
except Exception as e:
logger.warning(f"EXIF kiolvasási hiba ({file_path}): {e}")
return None

View File

@@ -0,0 +1,150 @@
# /opt/docker/dev/service_finder/backend/app/services/notification_service.py
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from app.models.identity import User
from app.models.asset import Asset
from app.models.organization import Organization
from app.models.system import InternalNotification
from app.services.email_manager import email_manager
from app.services.config_service import config
logger = logging.getLogger("Notification-Service-2.2")
class NotificationService:
"""
Sentinel Master Notification Service 2.2 - Fully Admin-Configurable.
Nincs fix kód: minden típus (biztosítás, műszaki, okmány) a DB-ből vezérelt.
"""
@staticmethod
async def send_notification(
db: AsyncSession,
user_id: int,
title: str,
message: str,
category: str = "info",
priority: str = "medium",
data: dict = None,
send_email: bool = True,
email_template: str = None,
email_vars: dict = None
):
""" Univerzális küldő: Belső Dashboard + Email. """
new_notif = InternalNotification(
user_id=user_id,
title=title,
message=message,
category=category,
priority=priority,
data=data or {}
)
db.add(new_notif)
if send_email and email_template and email_vars:
await email_manager.send_email(
db=db,
recipient=email_vars.get("recipient"),
template_key=email_template,
variables=email_vars,
lang=email_vars.get("lang", "hu")
)
await db.commit()
@staticmethod
async def check_expiring_documents(db: AsyncSession):
"""
Dinamikus lejárat-figyelő: Típusonkénti naptárak az adminból.
"""
try:
today = datetime.now(timezone.utc).date()
# 1. Lekérjük az összes aktív járművet és a tulajdonosaikat
stmt = (
select(Asset, User)
.join(Organization, Asset.current_organization_id == Organization.id)
.join(User, Organization.owner_id == User.id)
.where(Asset.status == "active")
.options(joinedload(Asset.catalog))
)
result = await db.execute(stmt)
notifications_sent = 0
for asset, user in result.all():
# 2. DINAMIKUS MÁTRIX LEKÉRÉSE (Hierarchikus: User > Region > Global)
# Az adminban így van tárolva: {"insurance": [45, 30, 7, 0], "mot": [30, 7, 0], ...}
alert_matrix = await config.get_setting(
db,
"NOTIFICATION_TYPE_MATRIX",
scope_level="user",
scope_id=str(user.id),
default={
"insurance": [45, 30, 15, 7, 1, 0],
"mot": [30, 7, 1, 0],
"personal_id": [30, 15, 0],
"default": [30, 7, 1]
}
)
# 3. Ellenőrizendő dátumok (factory_data a Robotoktól)
# Kulcsok: insurance_expiry_date, mot_expiry_date, id_card_expiry stb.
check_map = {
"insurance": asset.factory_data.get("insurance_expiry_date"),
"mot": asset.factory_data.get("mot_expiry_date"),
"personal_id": user.person.identity_docs.get("expiry_date") if user.person else None
}
for doc_type, expiry_str in check_map.items():
if not expiry_str: continue
try:
expiry_dt = datetime.strptime(expiry_str, "%Y-%m-%d").date()
days_until = (expiry_dt - today).days
# Megnézzük a típushoz tartozó admin-beállítást (pl. a 45 napot)
alert_steps = alert_matrix.get(doc_type, alert_matrix["default"])
if days_until in alert_steps:
# Prioritás meghatározása (Adminból is jöhetne, de itt kategória alapú)
priority = "critical" if days_until <= 1 or (doc_type == "insurance" and days_until == 45) else "high"
title = f"Riasztás: {asset.license_plate} - {doc_type.upper()}"
msg = f"A(z) {doc_type} dokumentum {days_until} nap múlva lejár ({expiry_str})."
if days_until == 45 and doc_type == "insurance":
msg = f"🚨 BIZTOSÍTÁSI FORDULÓ! (45 nap). Most van időd felmondani a régit!"
await NotificationService.send_notification(
db=db,
user_id=user.id,
title=title,
message=msg,
category=doc_type,
priority=priority,
data={"asset_id": str(asset.id), "vin": asset.vin, "days": days_until},
email_template="expiry_alert",
email_vars={
"recipient": user.email,
"first_name": user.person.first_name if user.person else "Partnerünk",
"license_plate": asset.license_plate,
"expiry_date": expiry_str,
"days_left": days_until,
"lang": user.preferred_language
}
)
notifications_sent += 1
except (ValueError, TypeError):
continue
return {"status": "success", "count": notifications_sent}
except Exception as e:
logger.error(f"Notification System Error: {e}")
raise e

View File

@@ -0,0 +1,49 @@
# /opt/docker/dev/service_finder/backend/app/services/recon_bot.py
import asyncio
import logging
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.asset import Asset, AssetCatalog, AssetTelemetry
logger = logging.getLogger(__name__)
async def run_vehicle_recon(db: AsyncSession, asset_id: str):
"""
VIN alapján megkeresi a mélységi adatokat és frissíti a Digitális Ikert.
"""
stmt = select(Asset).where(Asset.id == asset_id)
asset = (await db.execute(stmt)).scalar_one_or_none()
if not asset or not asset.catalog_id:
return False
logger.info(f"🤖 Robot indul: {asset.vin} felderítése...")
# --- LOGIKA MEGŐRIZVE: Szimulált mélységi adatgyűjtés ---
await asyncio.sleep(2)
deep_data = {
"assembly_plant": "Fremont, California",
"drive_unit": "Dual Motor - Raven type",
"onboard_charger": "11 kW",
"supercharging_max": "250 kW",
"safety_rating": "5-star EuroNCAP",
"recon_timestamp": datetime.now(timezone.utc).isoformat()
}
# 3. Katalógus frissítése (MDM elv)
catalog = (await db.execute(select(AssetCatalog).where(AssetCatalog.id == asset.catalog_id))).scalar_one_or_none()
if catalog:
current_data = catalog.factory_data or {}
current_data.update(deep_data)
catalog.factory_data = current_data
# 4. Telemetria frissítése (VQI score csökkentése a logika szerint)
telemetry = (await db.execute(select(AssetTelemetry).where(AssetTelemetry.asset_id == asset.id))).scalar_one_or_none()
if telemetry:
telemetry.vqi_score = 99.2
await db.commit()
logger.info(f"✨ Robot végzett: {asset.license_plate or asset.vin} felokosítva.")
return True

View File

@@ -0,0 +1,40 @@
# /opt/docker/dev/service_finder/backend/app/services/robot_manager.py
import asyncio
import logging
from datetime import datetime
from .harvester_cars import VehicleHarvester
# Megjegyzés: Csak azokat importáld, amik öröklődnek a BaseHarvester-ből
logger = logging.getLogger(__name__)
class RobotManager:
@staticmethod
async def run_full_sync(db):
""" Sorban lefuttatja a robotokat az új AssetCatalog struktúrához. """
logger.info(f"🕒 Teljes szinkronizáció indítva: {datetime.now()}")
robots = [
VehicleHarvester(),
# BikeHarvester(), # Későbbi bővítéshez
]
for robot in robots:
try:
# JAVÍTVA: A modern Harvesterek a harvest_all metódust használják
await robot.harvest_all(db)
logger.info(f"{robot.category} robot sikeresen lefutott.")
await asyncio.sleep(5)
except Exception as e:
logger.error(f"❌ Kritikus hiba a {robot.category} robotnál: {e}")
@staticmethod
async def schedule_nightly_run(db):
"""
LOGIKA MEGŐRIZVE: Éjszakai futtatás 02:00-kor.
"""
while True:
now = datetime.now()
if now.hour == 2 and now.minute == 0:
await RobotManager.run_full_sync(db)
await asyncio.sleep(70) # Megakadályozzuk az újraindulást ugyanabban a percben
await asyncio.sleep(30)

View File

@@ -0,0 +1,141 @@
# /opt/docker/dev/service_finder/backend/app/services/search_service.py
import logging
from typing import List, Optional, Dict, Any
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from geoalchemy2.functions import ST_Distance, ST_MakePoint, ST_DWithin
from app.models.service import ServiceProfile, ExpertiseTag
from app.models.organization import Organization
from app.models.identity import User
from app.services.config_service import config
logger = logging.getLogger("Search-Service-2.4-Agnostic")
class SearchService:
"""
Sentinel Master Search Service 2.4.
Csomag-agnosztikus rangsoroló motor.
Minden üzleti logika (súlyozás, prioritás, láthatóság) az adatbázisból jön.
"""
@staticmethod
async def find_nearby_services(
db: AsyncSession,
lat: float,
lon: float,
current_user: User,
expertise_key: str = None
):
try:
# 1. HIERARCHIKUS RANGSOROLÁSI SZABÁLYOK LEKÉRÉSE
# A config_service automatikusan a legspecifikusabbat adja vissza:
# 1. User-specifikus (Céges egyedi beállítás)
# 2. Package-specifikus (pl. 'free', 'premium', 'ultra_gold')
# 3. Global (Alapértelmezett)
user_tier = current_user.tier_name
if current_user.role in [UserRole.superadmin, UserRole.admin]:
user_tier = "vip"
ranking_rules = await config.get_setting(
db,
"RANKING_RULES",
user_id=current_user.id, # Ez a belső config_service-ben kezeli a hierarchiát
package_slug=user_tier,
default={
"ad_weight": 5000,
"partner_weight": 1000,
"trust_weight": 10,
"dist_penalty": 20,
"pref_weight": 0,
"can_use_prefs": False,
"search_radius_km": 30
}
)
# 2. PREFERENCIÁK (Ha a szabályrendszer engedi)
user_prefs = {"networks": [], "ids": []}
if ranking_rules.get("can_use_prefs", False):
user_prefs = await config.get_setting(
db, "USER_SEARCH_PREFERENCES",
scope_level="user", scope_id=str(current_user.id),
default={"networks": [], "ids": []}
)
# 3. TÉRBELI LEKÉRDEZÉS
user_point = ST_MakePoint(lon, lat)
radius_m = ranking_rules.get("search_radius_km", 30) * 1000
distance_col = ST_Distance(ServiceProfile.location, user_point).label("distance_meters")
stmt = (
select(ServiceProfile, Organization, distance_col)
.join(Organization, ServiceProfile.organization_id == Organization.id)
.where(
and_(
ST_DWithin(ServiceProfile.location, user_point, radius_m),
ServiceProfile.is_active == True
)
)
)
if expertise_key:
stmt = stmt.join(ServiceProfile.expertises).join(ExpertiseTag).where(ExpertiseTag.key == expertise_key)
result = await db.execute(stmt)
rows = result.all()
# 4. UNIVERZÁLIS PONTOZÁS (Súlyozott mátrix alapján)
final_results = []
r = ranking_rules # Rövidítés a számításhoz
for s_prof, org, dist_m in rows:
dist_km = dist_m / 1000.0
score = 0
# --- PONTOZÁSI LOGIKA (Nincsenek fix csomagnevek!) ---
# A. Hirdetési súly
if s_prof.is_advertiser:
score += r.get("ad_weight", 0)
# B. Partner (Minősített) súly
if s_prof.is_verified_partner:
score += r.get("partner_weight", 0)
# C. Minőség (Trust Score) súly
score += (s_prof.trust_score * r.get("trust_weight", 0))
# D. Egyéni/Céges preferencia súly (Csak ha engedélyezett)
if r.get("can_use_prefs"):
if s_prof.network_slug in user_prefs.get("networks", []):
score += r.get("pref_weight", 0)
if str(org.id) in user_prefs.get("ids", []):
score += r.get("pref_weight", 0) * 1.2
# E. Távolság büntetés
score -= (dist_km * r.get("dist_penalty", 0))
final_results.append({
"id": org.id,
"name": org.full_name,
"trust_score": s_prof.trust_score,
"distance_km": round(dist_km, 2),
"rank_score": round(score, 2),
"flags": {
"is_ad": s_prof.is_advertiser,
"is_partner": s_prof.is_verified_partner,
"is_favorite": str(org.id) in user_prefs.get("ids", [])
}
})
# 5. RENDEZÉS
return sorted(final_results, key=lambda x: x['rank_score'], reverse=True)
except Exception as e:
logger.error(f"Search Service 2.4 Critical Error: {e}")
raise e

View File

@@ -0,0 +1,94 @@
# /opt/docker/dev/service_finder/backend/app/services/security_service.py
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional, Any, Dict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from app.models.security import PendingAction, ActionStatus
from app.models.history import AuditLog, LogSeverity
from app.models.identity import User
from app.models.system import SystemParameter
logger = logging.getLogger(__name__)
class SecurityService:
@staticmethod
async def get_sec_config(db: AsyncSession) -> Dict[str, Any]:
""" Lekéri a korlátokat a központi system_parameters-ből. """
stmt = select(SystemParameter).where(SystemParameter.key.in_(["SECURITY_MAX_RECORDS_PER_HOUR", "SECURITY_DUAL_CONTROL_ENABLED"]))
res = await db.execute(stmt)
params = {p.key: p.value for p in res.scalars().all()}
return {
"max_records": int(params.get("SECURITY_MAX_RECORDS_PER_HOUR", 500)),
"dual_control": str(params.get("SECURITY_DUAL_CONTROL_ENABLED", "true")).lower() == "true"
}
async def log_event(self, db: AsyncSession, user_id: Optional[int], action: str, severity: LogSeverity, **kwargs):
""" LOGIKA MEGŐRIZVE: Audit naplózás + Emergency Lock trigger. """
new_log = AuditLog(
user_id=user_id, severity=severity, action=action,
target_type=kwargs.get("target_type"), target_id=kwargs.get("target_id"),
old_data=kwargs.get("old_data"), new_data=kwargs.get("new_data"),
ip_address=kwargs.get("ip"), user_agent=kwargs.get("ua")
)
db.add(new_log)
if severity == LogSeverity.emergency:
await self._execute_emergency_lock(db, user_id, f"Auto-lock by: {action}")
await db.commit()
async def request_action(self, db: AsyncSession, requester_id: int, action_type: str, payload: Dict, reason: str):
""" NÉGY SZEM ELV: Jóváhagyási kérelem indítása. """
new_action = PendingAction(
requester_id=requester_id, action_type=action_type,
payload=payload, reason=reason, status=ActionStatus.pending
)
db.add(new_action)
await db.commit()
return new_action
async def approve_action(self, db: AsyncSession, approver_id: int, action_id: int):
""" Jóváhagyás végrehajtása (Logic Preserved: Ön-jóváhagyás tiltva). """
stmt = select(PendingAction).where(PendingAction.id == action_id)
action = (await db.execute(stmt)).scalar_one_or_none()
if not action or action.status != ActionStatus.pending:
raise Exception("Művelet nem található.")
if action.requester_id == approver_id:
raise Exception("Saját kérést nem hagyhatsz jóvá!")
# Üzleti logika (pl. Role változtatás)
if action.action_type == "CHANGE_ROLE":
target_user = (await db.execute(select(User).where(User.id == action.payload.get("user_id")))).scalar_one_or_none()
if target_user: target_user.role = action.payload.get("new_role")
action.status = ActionStatus.approved
action.approver_id = approver_id
action.processed_at = datetime.now(timezone.utc)
await db.commit()
async def check_data_access_limit(self, db: AsyncSession, user_id: int):
""" DATA THROTTLING: Adatlopás elleni védelem. """
config = await self.get_sec_config(db)
limit_time = datetime.now(timezone.utc) - timedelta(hours=1)
stmt = select(func.count(AuditLog.id)).where(
and_(AuditLog.user_id == user_id, AuditLog.timestamp >= limit_time, AuditLog.action.like("GET_%"))
)
count = (await db.execute(stmt)).scalar() or 0
if count > config["max_records"]:
await self.log_event(db, user_id, "MASS_DATA_ACCESS", LogSeverity.emergency, reason=f"Count: {count}")
return False
return True
async def _execute_emergency_lock(self, db: AsyncSession, user_id: int, reason: str):
if not user_id: return
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if user:
user.is_active = False
logger.critical(f"🚨 EMERGENCY LOCK: User {user_id} suspended. Reason: {reason}")
security_service = SecurityService()

View File

@@ -0,0 +1,43 @@
# /opt/docker/dev/service_finder/backend/app/services/social_auth_service.py
import uuid
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.identity import User, Person, SocialAccount, UserRole
from app.services.security_service import security_service
logger = logging.getLogger(__name__)
class SocialAuthService:
@staticmethod
async def get_or_create_social_user(db: AsyncSession, provider: str, social_id: str, email: str, first_name: str, last_name: str):
"""
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
"""
# 1. Meglévő fiók ellenőrzése
stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id)
social_acc = (await db.execute(stmt)).scalar_one_or_none()
if social_acc:
return (await db.execute(select(User).where(User.id == social_acc.user_id))).scalar_one_or_none()
# 2. Új Identity és User (Step 1)
stmt_u = select(User).where(User.email == email)
user = (await db.execute(stmt_u)).scalar_one_or_none()
if not user:
new_person = Person(first_name=first_name or "Social", last_name=last_name or "User", is_active=False)
db.add(new_person)
await db.flush()
user = User(email=email, person_id=new_person.id, role=UserRole.user, is_active=False)
db.add(user)
await db.flush()
await security_service.log_event(db, user.id, "USER_REGISTER_SOCIAL", "info", target_type="User", target_id=str(user.id))
# 3. Kapcsolat rögzítése
db.add(SocialAccount(user_id=user.id, provider=provider, social_id=social_id, email=email))
await db.commit()
await db.refresh(user)
return user

View File

@@ -0,0 +1,103 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from datetime import datetime, timezone
import logging
from app.models.social import ServiceProvider, Vote, ModerationStatus, Competition, UserScore
from app.models.identity import User
from app.schemas.social import ServiceProviderCreate
logger = logging.getLogger(__name__)
class SocialService:
"""
SocialService: Kezeli a közösségi interakciókat, szavazatokat és a moderációt.
Az importok a metódusokon belül vannak a körkörös függőség elkerülése érdekében.
"""
async def create_service_provider(self, db: AsyncSession, obj_in: ServiceProviderCreate, user_id: int):
from app.services.gamification_service import gamification_service
new_provider = ServiceProvider(**obj_in.model_dump(), added_by_user_id=user_id)
db.add(new_provider)
await db.flush()
# Alappontszám az új beküldésért
await gamification_service.process_activity(db, user_id, 50, 10, f"New Provider: {new_provider.name}")
await db.commit()
await db.refresh(new_provider)
return new_provider
async def vote_for_provider(self, db: AsyncSession, voter_id: int, provider_id: int, vote_value: int):
from app.services.gamification_service import gamification_service
# Duplikált szavazat ellenőrzése
exists = (await db.execute(select(Vote).where(and_(Vote.user_id == voter_id, Vote.provider_id == provider_id)))).scalar()
if exists:
return {"message": "Már szavaztál erre a szolgáltatóra!"}
db.add(Vote(user_id=voter_id, provider_id=provider_id, vote_value=vote_value))
provider = (await db.execute(select(ServiceProvider).where(ServiceProvider.id == provider_id))).scalar_one_or_none()
if not provider:
return {"error": "Szolgáltató nem található."}
provider.validation_score += vote_value
# Automatikus moderáció figyelése (csak a 'pending' állapotúaknál)
if provider.status == ModerationStatus.pending:
if provider.validation_score >= 5:
provider.status = ModerationStatus.approved
await self._reward_submitter(db, provider.added_by_user_id, provider.name)
elif provider.validation_score <= -3:
provider.status = ModerationStatus.rejected
await self._penalize_user(db, provider.added_by_user_id, provider.name)
await db.commit()
return {"status": "success", "score": provider.validation_score, "new_status": provider.status}
async def get_leaderboard(self, db: AsyncSession, limit: int = 10):
from app.services.gamification_service import gamification_service
if hasattr(gamification_service, 'get_top_users'):
return await gamification_service.get_top_users(db, limit)
return []
async def _reward_submitter(self, db: AsyncSession, user_id: int, provider_name: str):
""" Jutalmazás, ha a beküldött adatot jóváhagyta a közösség. """
from app.services.gamification_service import gamification_service
if not user_id: return
await gamification_service.process_activity(db, user_id, 100, 20, f"Validated: {provider_name}")
# Aktuális verseny keresése és pontozása
now = datetime.now(timezone.utc)
comp_stmt = select(Competition).where(and_(
Competition.is_active == True,
Competition.start_date <= now,
Competition.end_date >= now
))
comp = (await db.execute(comp_stmt)).scalar_one_or_none()
if comp:
us_stmt = select(UserScore).where(and_(UserScore.user_id == user_id, UserScore.competition_id == comp.id))
us = (await db.execute(us_stmt)).scalar_one_or_none()
if not us:
us = UserScore(user_id=user_id, competition_id=comp.id, points=0)
db.add(us)
us.points += 10
async def _penalize_user(self, db: AsyncSession, user_id: int, provider_name: str):
""" Büntetés, ha a beküldött adatot elutasította a közösség (is_penalty=True). """
from app.services.gamification_service import gamification_service
if not user_id: return
# JAVÍTVA: is_penalty=True hozzáadva a gamification híváshoz
await gamification_service.process_activity(db, user_id, 50, 0, f"Rejected: {provider_name}", is_penalty=True)
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if user and hasattr(user, 'reputation_score'):
user.reputation_score = (user.reputation_score or 0) - 2
if user.reputation_score <= -10:
user.is_active = False
social_service = SocialService()

View File

@@ -0,0 +1,31 @@
# /opt/docker/dev/service_finder/backend/app/services/storage_service.py
import uuid
from io import BytesIO
from minio import Minio
from app.core.config import settings
class StorageService:
# A klienst a beállításokból inicializáljuk
client = Minio(
settings.REDIS_URL.split("//")[1].split(":")[0], # Gyors fix a hostra vagy settings.MINIO_HOST
access_key="minioadmin",
secret_key="minioadmin",
secure=False
)
BUCKET_NAME = "vehicle-documents"
@classmethod
async def upload_document(cls, file_bytes: bytes, file_name: str, folder: str) -> str:
""" Fájl feltöltése S3/Minio tárhelyre. """
if not cls.client.bucket_exists(cls.BUCKET_NAME):
cls.client.make_bucket(cls.BUCKET_NAME)
unique_name = f"{folder}/{uuid.uuid4()}_{file_name}"
cls.client.put_object(
cls.BUCKET_NAME,
unique_name,
BytesIO(file_bytes),
len(file_bytes)
)
return f"{cls.BUCKET_NAME}/{unique_name}"

View File

@@ -0,0 +1,28 @@
# /opt/docker/dev/service_finder/backend/app/models/translation.py
from sqlalchemy import String, Text, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Translation(Base):
"""
Központi i18n adattábla.
Minden rendszerüzenet és frontend felirat forrása.
"""
__tablename__ = "translations"
__table_args__ = (
UniqueConstraint("key", "lang_code", name="uq_translation_key_lang"),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# A kulcs pontozott formátumú (pl: 'DASHBOARD.STATS.TITLE')
key: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
# ISO kód (pl: 'hu', 'en', 'de')
lang_code: Mapped[str] = mapped_column(String(5), nullable=False, index=True)
# A tényleges lefordított szöveg
value: Mapped[str] = mapped_column(Text, nullable=False)
# Élesítési állapot (Draft/Published)
is_published: Mapped[bool] = mapped_column(Boolean, default=False, index=True)

View File

@@ -0,0 +1,114 @@
# /opt/docker/dev/service_finder/backend/app/services/translation_service.py
import json
import os
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.models.translation import Translation
from app.core.config import settings
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
class TranslationService:
"""
Dinamikus fordítás-kezelő szerviz.
Támogatja a szerveroldali cache-elést és a frontend JSON exportot.
"""
# Memória-cache a szerveroldali hibaüzenetekhez és emailekhez
_published_cache: Dict[str, Dict[str, str]] = {}
@classmethod
async def load_cache(cls, db: AsyncSession):
""" Betölti a publikált szövegeket a memóriába az adatbázisból. """
stmt = select(Translation).where(Translation.is_published == True)
result = await db.execute(stmt)
translations = result.scalars().all()
cls._published_cache = {}
for t in translations:
# JAVÍTVA: t.lang_code helyett t.lang
if t.lang not in cls._published_cache:
cls._published_cache[t.lang] = {}
cls._published_cache[t.lang][t.key] = t.value
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:
"""
Szerveroldali lekérés Fallback (EN) logikával és változó behelyettesítéssel.
Példa: get_text("AUTH.WELCOME", "hu", {"name": "Péter"})
"""
# 1. Kért nyelv lekérése
text = cls._published_cache.get(lang, {}).get(key)
# 2. Fallback angolra, ha nincs meg a kért nyelven
if not text and lang != "en":
text = cls._published_cache.get("en", {}).get(key)
# 3. Ha sehol nincs meg, adjuk vissza a kulcsot
if not text:
return f"[{key}]"
# 4. Változók behelyettesítése (pl. {{name}})
if variables:
for k, v in variables.items():
text = text.replace(f"{{{{{k}}}}}", str(v))
return text
@classmethod
async def publish_all(cls, db: AsyncSession):
""" Minden piszkozatot élesít, frissíti a memóriát és legenerálja a JSON-öket. """
await db.execute(
update(Translation).where(Translation.is_published == False).values(is_published=True)
)
await db.commit()
await cls.load_cache(db)
await cls.export_to_json(db)
return True
@staticmethod
async def export_to_json(db: AsyncSession):
"""
Adatbázis -> Hierarchikus JSON struktúra generálása a Frontend számára.
'AUTH.LOGIN.TITLE' -> { "AUTH": { "LOGIN": { "TITLE": "..." } } }
"""
stmt = select(Translation).where(Translation.is_published == True)
result = await db.execute(stmt)
translations = result.scalars().all()
languages: Dict[str, Any] = {}
for t in translations:
# JAVÍTVA: t.lang_code helyett t.lang
if t.lang not in languages:
languages[t.lang] = {}
# Kulcs felbontása szintekre hierarchikus struktúrához
parts = t.key.split('.')
current_level = languages[t.lang]
for part in parts[:-1]:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
current_level[parts[-1]] = t.value
# Fájlok fizikai mentése a static könyvtárba
locales_path = os.path.join(settings.STATIC_DIR, "locales")
os.makedirs(locales_path, exist_ok=True)
for lang, content in languages.items():
file_path = os.path.join(locales_path, f"{lang}.json")
try:
with open(file_path, "w", encoding="utf-8") as f:
json.dump(content, f, ensure_ascii=False, indent=2)
logger.info(f"✅ Nyelvi fájl (JSON) frissítve: {file_path}")
except Exception as e:
logger.error(f"❌ Hiba a fájl mentésekor ({lang}): {e}")
return True
translation_service = TranslationService()