Initial commit: Robot ökoszisztéma v2.0 - Stabilizált jármű és szerviz robotok
This commit is contained in:
24
backend/Dockerfile
Executable file
24
backend/Dockerfile
Executable file
@@ -0,0 +1,24 @@
|
||||
# /opt/docker/dev/service_finder/backend/Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Rendszerfüggőségek (OCR-hez és DB-hez)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
python3-dev \
|
||||
libpq-dev \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
149
backend/alembic.ini
Executable file
149
backend/alembic.ini
Executable file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = postgresql+asyncpg://user:pass@postgres-db:5432/service_finder
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
132
backend/app/api/auth.py.old
Executable file
132
backend/app/api/auth.py.old
Executable file
@@ -0,0 +1,132 @@
|
||||
from datetime import timedelta
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_token, decode_token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@router.post("/login")
|
||||
def login(payload: Dict[str, Any]):
|
||||
"""
|
||||
payload:
|
||||
{
|
||||
"org_id": "<uuid>",
|
||||
"login": "<username or email>",
|
||||
"password": "<plain>"
|
||||
}
|
||||
"""
|
||||
from app.db.session import get_conn
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("BEGIN;")
|
||||
|
||||
org_id = (payload.get("org_id") or "").strip()
|
||||
login_id = (payload.get("login") or "").strip()
|
||||
password = payload.get("password") or ""
|
||||
|
||||
if not org_id or not login_id or not password:
|
||||
raise HTTPException(status_code=400, detail="org_id, login, password required")
|
||||
|
||||
# RLS miatt kötelező: org kontextus beállítás
|
||||
cur.execute("SELECT set_config('app.tenant_org_id', %s, false);", (org_id,))
|
||||
|
||||
# account + credential
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
a.account_id::text,
|
||||
a.org_id::text,
|
||||
a.username::text,
|
||||
a.email::text,
|
||||
c.password_hash,
|
||||
c.is_active
|
||||
FROM app.account a
|
||||
JOIN app.account_credential c ON c.account_id = a.account_id
|
||||
WHERE a.org_id = %s::uuid
|
||||
AND (a.username = %s::citext OR a.email = %s::citext)
|
||||
AND c.is_active = true
|
||||
LIMIT 1;
|
||||
""",
|
||||
(org_id, login_id, login_id),
|
||||
)
|
||||
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
account_id, org_id_db, username, email, password_hash, cred_active = row
|
||||
|
||||
# Jelszó ellenőrzés pgcrypto-val: crypt(plain, stored_hash) = stored_hash
|
||||
cur.execute("SELECT crypt(%s, %s) = %s;", (password, password_hash, password_hash))
|
||||
ok = cur.fetchone()[0]
|
||||
if not ok:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# MVP: role később membershipből; most fixen tenant_admin
|
||||
role_code = "tenant_admin"
|
||||
is_platform_admin = False
|
||||
|
||||
access = create_token(
|
||||
{
|
||||
"sub": account_id,
|
||||
"org_id": org_id_db,
|
||||
"role": role_code,
|
||||
"is_platform_admin": is_platform_admin,
|
||||
"type": "access",
|
||||
},
|
||||
settings.JWT_SECRET,
|
||||
timedelta(minutes=settings.JWT_ACCESS_MINUTES),
|
||||
)
|
||||
|
||||
refresh = create_token(
|
||||
{
|
||||
"sub": account_id,
|
||||
"org_id": org_id_db,
|
||||
"role": role_code,
|
||||
"is_platform_admin": is_platform_admin,
|
||||
"type": "refresh",
|
||||
},
|
||||
settings.JWT_SECRET,
|
||||
timedelta(days=settings.JWT_REFRESH_DAYS),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer"}
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh_token(payload: Dict[str, Any]):
|
||||
token = payload.get("refresh_token") or ""
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="refresh_token required")
|
||||
|
||||
try:
|
||||
claims = decode_token(token, settings.JWT_SECRET)
|
||||
if claims.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token type")
|
||||
|
||||
access = create_token(
|
||||
{
|
||||
"sub": claims.get("sub"),
|
||||
"org_id": claims.get("org_id"),
|
||||
"role": claims.get("role"),
|
||||
"is_platform_admin": claims.get("is_platform_admin", False),
|
||||
"type": "access",
|
||||
},
|
||||
settings.JWT_SECRET,
|
||||
timedelta(minutes=settings.JWT_ACCESS_MINUTES),
|
||||
)
|
||||
return {"access_token": access, "token_type": "bearer"}
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
139
backend/app/api/deps.py
Executable file
139
backend/app/api/deps.py
Executable file
@@ -0,0 +1,139 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/deps.py
|
||||
from typing import Optional, Dict, Any, Union
|
||||
import logging
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.security import decode_token, DEFAULT_RANK_MAP
|
||||
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- GONDOLATMENET / THOUGHT PROCESS ---
|
||||
# 1. Az OAuth2 folyamat a központosított bejelentkezési végponton keresztül fut.
|
||||
# 2. A token visszafejtésekor ellenőrizni kell a 'type' mezőt, hogy ne lehessen refresh tokennel belépni.
|
||||
# 3. A felhasználó lekérésekor a SQLAlchemy 2.0 aszinkron 'execute' és 'scalar_one_or_none' metódusait használjuk.
|
||||
# 4. A Scoped RBAC (Role-Based Access Control) biztosítja, hogy a felhasználók ne férjenek hozzá egymás flottáihoz.
|
||||
# ---------------------------------------
|
||||
|
||||
# Az OAuth2 folyamat a bejelentkezési végponton keresztül
|
||||
reusable_oauth2 = OAuth2PasswordBearer(
|
||||
tokenUrl=f"{settings.API_V1_STR}/auth/login"
|
||||
)
|
||||
|
||||
async def get_current_token_payload(
|
||||
token: str = Depends(reusable_oauth2)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
JWT token visszafejtése és a típus (access) ellenőrzése.
|
||||
"""
|
||||
# Fejlesztői bypass (opcionális, csak DEBUG módban)
|
||||
if settings.DEBUG and token == "dev_bypass_active":
|
||||
return {
|
||||
"sub": "1",
|
||||
"role": "superadmin",
|
||||
"rank": 100,
|
||||
"scope_level": "global",
|
||||
"scope_id": "all",
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Érvénytelen vagy lejárt munkamenet."
|
||||
)
|
||||
return payload
|
||||
|
||||
async def get_current_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
payload: Dict = Depends(get_current_token_payload)
|
||||
) -> User:
|
||||
"""
|
||||
Lekéri a felhasználót a token 'sub' mezője alapján (SQLAlchemy 2.0 aszinkron módon).
|
||||
"""
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token azonosítási hiba."
|
||||
)
|
||||
|
||||
# JAVÍTVA: Modern SQLAlchemy 2.0 aszinkron lekérdezés
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or user.is_deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="A felhasználó nem található."
|
||||
)
|
||||
return user
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""
|
||||
Ellenőrzi, hogy a felhasználó aktív-e (KYC Step 2 kész).
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="A művelethez aktív profil és KYC azonosítás szükséges."
|
||||
)
|
||||
return current_user
|
||||
|
||||
async def check_resource_access(
|
||||
resource_scope_id: Union[str, int],
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
|
||||
"""
|
||||
if current_user.role == UserRole.superadmin:
|
||||
return True
|
||||
|
||||
user_scope = str(current_user.scope_id) if current_user.scope_id else None
|
||||
requested_scope = str(resource_scope_id)
|
||||
|
||||
# 1. Saját ID ellenőrzése
|
||||
if str(current_user.id) == requested_scope:
|
||||
return True
|
||||
|
||||
# 2. Szervezeti/Flotta scope ellenőrzése
|
||||
if user_scope and user_scope == requested_scope:
|
||||
return True
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultsága ehhez az erőforráshoz."
|
||||
)
|
||||
|
||||
def check_min_rank(role_key: str):
|
||||
"""
|
||||
Dinamikus Rank ellenőrzés a system_parameters tábla alapján.
|
||||
"""
|
||||
async def rank_checker(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
payload: Dict = Depends(get_current_token_payload)
|
||||
):
|
||||
# A settings.get_db_setting-et használjuk a dinamikus lekéréshez
|
||||
ranks = await settings.get_db_setting(
|
||||
db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP
|
||||
)
|
||||
|
||||
required_rank = ranks.get(role_key, 0)
|
||||
user_rank = payload.get("rank", 0)
|
||||
|
||||
if user_rank < required_rank:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Alacsony jogosultsági szint. (Elvárt: {required_rank})"
|
||||
)
|
||||
return True
|
||||
return rank_checker
|
||||
17
backend/app/api/recommend.py
Executable file
17
backend/app/api/recommend.py
Executable file
@@ -0,0 +1,17 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/recommend.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.db.session import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/provider/inbox")
|
||||
async def provider_inbox(provider_id: str, db: AsyncSession = Depends(get_db)):
|
||||
""" Aszinkron szerviz-postaláda lekérdezés. """
|
||||
query = text("""
|
||||
SELECT * FROM data.service_profiles
|
||||
WHERE id = :p_id
|
||||
""")
|
||||
result = await db.execute(query, {"p_id": provider_id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
20
backend/app/api/v1/api.py
Executable file
20
backend/app/api/v1/api.py
Executable file
@@ -0,0 +1,20 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/api.py
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import (
|
||||
auth, catalog, assets, organizations, documents,
|
||||
services, admin, expenses, evidence, social
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Minden modul az új, refaktorált végpontokra mutat
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
||||
api_router.include_router(services.router, prefix="/services", tags=["Service Hunt & Discovery"])
|
||||
api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"])
|
||||
api_router.include_router(assets.router, prefix="/assets", tags=["Assets"])
|
||||
api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"])
|
||||
api_router.include_router(documents.router, prefix="/documents", tags=["Documents"])
|
||||
api_router.include_router(admin.router, prefix="/admin", tags=["Admin Control Center (Sentinel)"])
|
||||
api_router.include_router(evidence.router, prefix="/evidence", tags=["Evidence & OCR (Robot 3)"])
|
||||
api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expenses (TCO)"])
|
||||
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
|
||||
131
backend/app/api/v1/endpoints/admin.py
Executable file
131
backend/app/api/v1/endpoints/admin.py
Executable file
@@ -0,0 +1,131 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, text, delete
|
||||
from typing import List, Any, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.api import deps
|
||||
from app.models.identity import User, UserRole # JAVÍTVA: Központi import
|
||||
from app.models.system import SystemParameter
|
||||
# JAVÍTVA: Security audit modellek
|
||||
from app.models.audit import SecurityAuditLog, OperationalLog
|
||||
# JAVÍTVA: Ezek a modellek a security.py-ból jönnek (ha ott vannak)
|
||||
from app.models.security import PendingAction, ActionStatus
|
||||
|
||||
from app.services.security_service import security_service
|
||||
from app.services.translation_service import TranslationService
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
key: str
|
||||
value: Any
|
||||
scope_level: str = "global"
|
||||
scope_id: Optional[str] = None
|
||||
category: str = "general"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
|
||||
""" Csak Admin vagy Superadmin. """
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Sentinel jogosultság szükséges!"
|
||||
)
|
||||
return current_user
|
||||
|
||||
@router.get("/health-monitor", tags=["Sentinel Monitoring"])
|
||||
async def get_system_health(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
stats = {}
|
||||
|
||||
# Adatbázis statisztikák (Nyers SQL marad, mert hatékony)
|
||||
user_stats = await db.execute(text("SELECT subscription_plan, count(*) FROM data.users GROUP BY subscription_plan"))
|
||||
stats["user_distribution"] = {row[0]: row[1] for row in user_stats}
|
||||
|
||||
asset_count = await db.execute(text("SELECT count(*) FROM data.assets"))
|
||||
stats["total_assets"] = asset_count.scalar()
|
||||
|
||||
org_count = await db.execute(text("SELECT count(*) FROM data.organizations"))
|
||||
stats["total_organizations"] = org_count.scalar()
|
||||
|
||||
# JAVÍTVA: Biztonsági státusz az új SecurityAuditLog alapján
|
||||
day_ago = datetime.now() - timedelta(days=1)
|
||||
crit_logs = await db.execute(
|
||||
select(func.count(SecurityAuditLog.id))
|
||||
.where(
|
||||
SecurityAuditLog.is_critical == True,
|
||||
SecurityAuditLog.created_at >= day_ago
|
||||
)
|
||||
)
|
||||
stats["critical_alerts_24h"] = crit_logs.scalar() or 0
|
||||
|
||||
return stats
|
||||
|
||||
@router.get("/pending-actions", response_model=List[Any], tags=["Sentinel Security"])
|
||||
async def list_pending_actions(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/approve/{action_id}", tags=["Sentinel Security"])
|
||||
async def approve_action(
|
||||
action_id: int,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
try:
|
||||
await security_service.approve_action(db, admin.id, action_id)
|
||||
return {"status": "success", "message": "Művelet végrehajtva."}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/parameters", tags=["Dynamic Configuration"])
|
||||
async def list_all_parameters(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
result = await db.execute(select(SystemParameter))
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/parameters", tags=["Dynamic Configuration"])
|
||||
async def set_parameter(
|
||||
config: ConfigUpdate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
query = text("""
|
||||
INSERT INTO data.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
|
||||
VALUES (:key, :val, :sl, :sid, :cat, :user)
|
||||
ON CONFLICT (key, scope_level, scope_id)
|
||||
DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
category = EXCLUDED.category,
|
||||
last_modified_by = EXCLUDED.last_modified_by,
|
||||
updated_at = now()
|
||||
""")
|
||||
|
||||
await db.execute(query, {
|
||||
"key": config.key,
|
||||
"val": config.value,
|
||||
"sl": config.scope_level,
|
||||
"sid": config.scope_id,
|
||||
"cat": config.category,
|
||||
"user": admin.email
|
||||
})
|
||||
await db.commit()
|
||||
return {"status": "success", "message": f"'{config.key}' frissítve."}
|
||||
|
||||
@router.post("/translations/sync", tags=["System Utilities"])
|
||||
async def sync_translations_to_json(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
await TranslationService.export_to_json(db)
|
||||
return {"message": "JSON fájlok frissítve."}
|
||||
54
backend/app/api/v1/endpoints/assets.py
Executable file
54
backend/app/api/v1/endpoints/assets.py
Executable file
@@ -0,0 +1,54 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.asset import Asset, AssetCost
|
||||
from app.models.identity import User
|
||||
from app.services.cost_service import cost_service
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
|
||||
from app.schemas.asset import AssetResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/{asset_id}/financial-summary", response_model=Dict[str, Any])
|
||||
async def get_asset_financial_report(
|
||||
asset_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
MB 2.0 Dinamikus Pénzügyi Riport.
|
||||
Visszaadja a kategóriákra bontott és az összesített költségeket (Local/EUR).
|
||||
"""
|
||||
# 1. Jogosultság ellenőrzése (Csak a tulajdonos vagy admin láthatja)
|
||||
# (Itt egy gyors check, hogy az asset az övé-e)
|
||||
|
||||
try:
|
||||
return await cost_service.get_asset_financial_summary(db, asset_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="Hiba a riport generálásakor")
|
||||
|
||||
@router.get("/{asset_id}/costs", response_model=List[AssetCostResponse])
|
||||
async def list_asset_costs(
|
||||
asset_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Tételes költséglista lapozással (Pagination)."""
|
||||
stmt = (
|
||||
select(AssetCost)
|
||||
.where(AssetCost.asset_id == asset_id)
|
||||
.order_by(desc(AssetCost.date))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
res = await db.execute(stmt)
|
||||
return res.scalars().all()
|
||||
41
backend/app/api/v1/endpoints/auth.py
Executable file
41
backend/app/api/v1/endpoints/auth.py
Executable file
@@ -0,0 +1,41 @@
|
||||
# backend/app/api/v1/endpoints/auth.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.db.session import get_db
|
||||
from app.services.auth_service import AuthService
|
||||
from app.core.security import create_tokens, DEFAULT_RANK_MAP
|
||||
from app.core.config import settings
|
||||
from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.identity import User # JAVÍTVA: Új központi modell
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
user = await AuthService.authenticate(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Hibás adatok.")
|
||||
|
||||
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
|
||||
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
"role": role_name,
|
||||
"rank": ranks.get(role_name, 10),
|
||||
"scope_level": user.scope_level or "individual",
|
||||
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
|
||||
}
|
||||
|
||||
access, refresh = create_tokens(data=token_data)
|
||||
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer", "is_active": user.is_active}
|
||||
|
||||
@router.post("/complete-kyc")
|
||||
async def complete_kyc(kyc_in: UserKYCComplete, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User nem található.")
|
||||
return {"status": "success", "message": "Fiók aktiválva."}
|
||||
63
backend/app/api/v1/endpoints/billing.py
Executable file
63
backend/app/api/v1/endpoints/billing.py
Executable file
@@ -0,0 +1,63 @@
|
||||
# backend/app/api/v1/endpoints/billing.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, Wallet, UserRole
|
||||
from app.models.audit import FinancialLedger
|
||||
from app.services.config_service import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/upgrade")
|
||||
async def upgrade_account(target_package: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""
|
||||
Univerzális csomagváltó.
|
||||
Kezeli az 5+ csomagot, a Rank-ugrást és a különleges 'Service Coin' bónuszokat.
|
||||
"""
|
||||
# 1. Lekérjük a teljes csomagmátrixot az adminból
|
||||
# Példa JSON: {"premium": {"price": 2000, "rank": 5, "type": "credit"}, "service_pro": {"price": 10000, "rank": 30, "type": "coin"}}
|
||||
package_matrix = await config.get_setting(db, "subscription_packages_matrix")
|
||||
|
||||
if target_package not in package_matrix:
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen csomagválasztás.")
|
||||
|
||||
pkg_info = package_matrix[target_package]
|
||||
price = pkg_info["price"]
|
||||
|
||||
# 2. Pénztárca ellenőrzése
|
||||
stmt = select(Wallet).where(Wallet.user_id == current_user.id)
|
||||
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
total_balance = wallet.purchased_credits + wallet.earned_credits
|
||||
|
||||
if total_balance < price:
|
||||
raise HTTPException(status_code=402, detail="Nincs elég kredited a csomagváltáshoz.")
|
||||
|
||||
# 3. Levonási logika (Purchased -> Earned sorrend)
|
||||
if wallet.purchased_credits >= price:
|
||||
wallet.purchased_credits -= price
|
||||
else:
|
||||
remaining = price - wallet.purchased_credits
|
||||
wallet.purchased_credits = 0
|
||||
wallet.earned_credits -= remaining
|
||||
|
||||
# 4. Speciális Szerviz Logika (Service Coins)
|
||||
# Ha a csomag típusa 'coin', akkor a szerviz kap egy kezdő Coin csomagot is
|
||||
if pkg_info.get("type") == "coin":
|
||||
initial_coins = pkg_info.get("initial_coin_bonus", 100)
|
||||
wallet.service_coins += initial_coins
|
||||
logger.info(f"User {current_user.id} upgraded to Service Pro, awarded {initial_coins} coins.")
|
||||
|
||||
# 5. Rang frissítése és naplózás
|
||||
current_user.role = target_package # Pl. 'service_pro' vagy 'vip'
|
||||
|
||||
db.add(FinancialLedger(
|
||||
user_id=current_user.id,
|
||||
amount=-price,
|
||||
transaction_type=f"UPGRADE_{target_package.upper()}",
|
||||
details=pkg_info
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "package": target_package, "rank_granted": pkg_info["rank"]}
|
||||
46
backend/app/api/v1/endpoints/catalog.py
Executable file
46
backend/app/api/v1/endpoints/catalog.py
Executable file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.services.asset_service import AssetService
|
||||
from typing import List
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/makes", response_model=List[str])
|
||||
async def list_makes(db: AsyncSession = Depends(get_db)):
|
||||
"""1. Szint: Márkák listázása."""
|
||||
return await AssetService.get_makes(db)
|
||||
|
||||
@router.get("/models", response_model=List[str])
|
||||
async def list_models(make: str, db: AsyncSession = Depends(get_db)):
|
||||
"""2. Szint: Típusok listázása egy adott márkához."""
|
||||
models = await AssetService.get_models(db, make)
|
||||
if not models:
|
||||
raise HTTPException(status_code=404, detail="Márka nem található vagy nincsenek típusok.")
|
||||
return models
|
||||
|
||||
@router.get("/generations", response_model=List[str])
|
||||
async def list_generations(make: str, model: str, db: AsyncSession = Depends(get_db)):
|
||||
"""3. Szint: Generációk/Évjáratok listázása."""
|
||||
generations = await AssetService.get_generations(db, make, model)
|
||||
if not generations:
|
||||
raise HTTPException(status_code=404, detail="Nincs generációs adat ehhez a típushoz.")
|
||||
return generations
|
||||
|
||||
@router.get("/engines")
|
||||
async def list_engines(make: str, model: str, gen: str, db: AsyncSession = Depends(get_db)):
|
||||
"""4. Szint: Motorváltozatok és technikai specifikációk."""
|
||||
engines = await AssetService.get_engines(db, make, model, gen)
|
||||
if not engines:
|
||||
raise HTTPException(status_code=404, detail="Nincs motorváltozat adat.")
|
||||
|
||||
# Itt visszaküldjük a teljes katalógus objektumokat (ID, motorváltozat, specifikációk)
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"variant": e.engine_variant,
|
||||
"engine_code": e.engine_code,
|
||||
"fuel_type": e.fuel_type,
|
||||
"factory_data": e.factory_data
|
||||
} for e in engines
|
||||
]
|
||||
87
backend/app/api/v1/endpoints/documents.py
Executable file
87
backend/app/api/v1/endpoints/documents.py
Executable file
@@ -0,0 +1,87 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/documents.py
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, BackgroundTasks, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.services.document_service import DocumentService
|
||||
from app.models.identity import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/upload/{parent_type}/{parent_id}")
|
||||
async def upload_document(
|
||||
parent_type: str,
|
||||
parent_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
doc_type: str = Form(..., description="A dokumentum típusa: 'invoice', 'registration_card', 'sale_contract'"),
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
MB 2.0 Dokumentum Pipeline.
|
||||
1. Ellenőrzi a felhasználó havi OCR kvótáját (Admin 2.0).
|
||||
2. Optimalizálja és NAS-ra menti a képet (WebP konverzió).
|
||||
3. Automatikusan elindítja a Robot 1-et (OCR), ha a típus engedélyezett.
|
||||
"""
|
||||
|
||||
# 1. Bemeneti validáció
|
||||
valid_parents = ["organizations", "assets", "transfers"]
|
||||
if parent_type not in valid_parents:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Érvénytelen cél-típus! Megengedett: {', '.join(valid_parents)}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 2. Feldolgozás a szolgáltatás rétegben
|
||||
# Itt történik a kvóta-ellenőrzés és a Robot 1 triggerelése is
|
||||
doc = await DocumentService.process_upload(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
file=file,
|
||||
parent_type=parent_type,
|
||||
parent_id=parent_id,
|
||||
doc_type=doc_type,
|
||||
background_tasks=background_tasks
|
||||
)
|
||||
|
||||
# 3. Válasz összeállítása az állapot alapján
|
||||
response_data = {
|
||||
"document_id": doc.id,
|
||||
"original_name": doc.original_name,
|
||||
"status": doc.status,
|
||||
"thumbnail": doc.thumbnail_path,
|
||||
}
|
||||
|
||||
if doc.status == "processing":
|
||||
response_data["message"] = "🤖 Robot 1 megkezdte a dokumentum elemzését. Értesítjük, ha kész!"
|
||||
else:
|
||||
response_data["message"] = "Dokumentum sikeresen archiválva a széfben."
|
||||
|
||||
return response_data
|
||||
|
||||
except HTTPException as he:
|
||||
# Közvetlenül átengedjük a service-ből jövő (pl. kvóta) hibákat
|
||||
raise he
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(ve))
|
||||
except Exception as e:
|
||||
# Sentinel naplózás és általános hiba
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Kritikus hiba a dokumentum feldolgozása során."
|
||||
)
|
||||
|
||||
@router.get("/{document_id}/status")
|
||||
async def get_document_status(
|
||||
document_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Lekérdezhető, hogy a robot végzett-e már a feldolgozással."""
|
||||
# (Itt egy egyszerű lekérdezés a Document táblából a státuszra)
|
||||
pass
|
||||
24
backend/app/api/v1/endpoints/evidence.py
Executable file
24
backend/app/api/v1/endpoints/evidence.py
Executable file
@@ -0,0 +1,24 @@
|
||||
# backend/app/api/v1/endpoints/evidence.py
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, text
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models.asset import Asset # JAVÍTVA: Asset modell
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/scan-registration")
|
||||
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
stmt_limit = text("SELECT (value->>:plan)::int FROM data.system_parameters WHERE key = 'VEHICLE_LIMIT'")
|
||||
res = await db.execute(stmt_limit, {"plan": current_user.subscription_plan or "free"})
|
||||
max_allowed = res.scalar() or 1
|
||||
|
||||
stmt_count = select(func.count(Asset.id)).where(Asset.owner_organization_id == current_user.scope_id)
|
||||
count = (await db.execute(stmt_count)).scalar() or 0
|
||||
|
||||
if count >= max_allowed:
|
||||
raise HTTPException(status_code=403, detail=f"Limit túllépés: {max_allowed} jármű engedélyezett.")
|
||||
|
||||
# OCR hívás helye...
|
||||
return {"success": True, "message": "Feldolgozás megkezdődött."}
|
||||
33
backend/app/api/v1/endpoints/expenses.py
Executable file
33
backend/app/api/v1/endpoints/expenses.py
Executable file
@@ -0,0 +1,33 @@
|
||||
# backend/app/api/v1/endpoints/expenses.py
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.asset import Asset, AssetCost # JAVÍTVA
|
||||
from pydantic import BaseModel
|
||||
from datetime import date
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class ExpenseCreate(BaseModel):
|
||||
asset_id: str
|
||||
category: str
|
||||
amount: float
|
||||
date: date
|
||||
|
||||
@router.post("/add")
|
||||
async def add_expense(expense: ExpenseCreate, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
stmt = select(Asset).where(Asset.id == expense.asset_id)
|
||||
if not (await db.execute(stmt)).scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Jármű nem található.")
|
||||
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
cost_type=expense.category,
|
||||
amount_local=expense.amount,
|
||||
date=expense.date,
|
||||
currency_local="HUF"
|
||||
)
|
||||
db.add(new_cost)
|
||||
await db.commit()
|
||||
return {"status": "success"}
|
||||
40
backend/app/api/v1/endpoints/gamification.py
Executable file
40
backend/app/api/v1/endpoints/gamification.py
Executable file
@@ -0,0 +1,40 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/gamification.py
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models.gamification import UserStats, PointsLedger
|
||||
from app.services.config_service import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/my-stats")
|
||||
async def get_my_stats(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""A bejelentkezett felhasználó aktuális XP-je, szintje és büntetőpontjai."""
|
||||
stmt = select(UserStats).where(UserStats.user_id == current_user.id)
|
||||
stats = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not stats:
|
||||
return {"total_xp": 0, "current_level": 1, "penalty_points": 0}
|
||||
|
||||
return stats
|
||||
|
||||
@router.get("/leaderboard")
|
||||
async def get_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
|
||||
"""A 10 legtöbb XP-vel rendelkező felhasználó listája."""
|
||||
stmt = (
|
||||
select(User.email, UserStats.total_xp, UserStats.current_level)
|
||||
.join(UserStats, User.id == UserStats.user_id)
|
||||
.order_by(desc(UserStats.total_xp))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
# Az email-eket maszkoljuk a GDPR miatt (pl. k***s@p***.hu)
|
||||
return [
|
||||
{"user": f"{r[0][:2]}***@{r[0].split('@')[1]}", "xp": r[1], "level": r[2]}
|
||||
for r in result.all()
|
||||
]
|
||||
100
backend/app/api/v1/endpoints/notifications.py
Executable file
100
backend/app/api/v1/endpoints/notifications.py
Executable file
@@ -0,0 +1,100 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/notifications.py
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, desc, func
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models.system import InternalNotification
|
||||
from app.schemas.social import NotificationResponse, NotificationUpdate # Feltételezett sémák
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/my", response_model=List[NotificationResponse])
|
||||
async def get_my_notifications(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
unread_only: bool = False,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = 0
|
||||
):
|
||||
"""
|
||||
Lekéri a bejelentkezett felhasználó értesítéseit.
|
||||
Támogatja a szűrést az olvasatlanokra és a lapozást.
|
||||
"""
|
||||
stmt = (
|
||||
select(InternalNotification)
|
||||
.where(InternalNotification.user_id == current_user.id)
|
||||
)
|
||||
|
||||
if unread_only:
|
||||
stmt = stmt.where(InternalNotification.is_read == False)
|
||||
|
||||
stmt = stmt.order_by(desc(InternalNotification.created_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/mark-read")
|
||||
async def mark_as_read(
|
||||
notification_ids: List[uuid.UUID],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Egy vagy több értesítés olvasottnak jelölése.
|
||||
"""
|
||||
if not notification_ids:
|
||||
raise HTTPException(status_code=400, detail="Nincs megadva azonosító.")
|
||||
|
||||
stmt = (
|
||||
update(InternalNotification)
|
||||
.where(InternalNotification.id.in_(notification_ids))
|
||||
.where(InternalNotification.user_id == current_user.id)
|
||||
.values(is_read=True, read_at=func.now())
|
||||
)
|
||||
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "marked_count": len(notification_ids)}
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
async def mark_all_read(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
A felhasználó összes értesítésének olvasottnak jelölése.
|
||||
"""
|
||||
stmt = (
|
||||
update(InternalNotification)
|
||||
.where(InternalNotification.user_id == current_user.id)
|
||||
.where(InternalNotification.is_read == False)
|
||||
.values(is_read=True, read_at=func.now())
|
||||
)
|
||||
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "message": "Minden értesítés olvasottnak jelölve."}
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_notification_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Gyors összesítő a Dashboard-hoz (pl. hány olvasatlan van).
|
||||
"""
|
||||
stmt = (
|
||||
select(func.count(InternalNotification.id))
|
||||
.where(InternalNotification.user_id == current_user.id)
|
||||
.where(InternalNotification.is_read == False)
|
||||
)
|
||||
|
||||
unread_count = (await db.execute(stmt)).scalar() or 0
|
||||
return {"unread_count": unread_count}
|
||||
115
backend/app/api/v1/endpoints/organizations.py
Executable file
115
backend/app/api/v1/endpoints/organizations.py
Executable file
@@ -0,0 +1,115 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/organizations.py
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse
|
||||
from app.models.organization import Organization, OrgType, OrganizationMember
|
||||
from app.models.identity import User # JAVÍTVA: Központi Identity modell
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.post("/onboard", response_model=CorpOnboardResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def onboard_organization(
|
||||
org_in: CorpOnboardIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Új szervezet (cég/szerviz) rögzítése.
|
||||
Automatikusan generál slug-ot és létrehozza a NAS mappa-struktúrát.
|
||||
"""
|
||||
|
||||
# 1. Magyar adószám validáció (XXXXXXXX-Y-ZZ)
|
||||
if org_in.country_code == "HU":
|
||||
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Érvénytelen magyar adószám formátum!"
|
||||
)
|
||||
|
||||
# 2. Duplikáció ellenőrzés
|
||||
stmt_exist = select(Organization).where(Organization.tax_number == org_in.tax_number)
|
||||
result_exist = await db.execute(stmt_exist)
|
||||
if result_exist.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Ezzel az adószámmal már regisztráltak céget!"
|
||||
)
|
||||
|
||||
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
||||
# Mivel az adatbázisban NOT NULL, itt muszáj létrehozni
|
||||
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
|
||||
|
||||
# 4. Mentés
|
||||
new_org = Organization(
|
||||
full_name=org_in.full_name,
|
||||
name=org_in.name,
|
||||
display_name=org_in.display_name,
|
||||
tax_number=org_in.tax_number,
|
||||
reg_number=org_in.reg_number,
|
||||
folder_slug=temp_slug, # JAVÍTVA: Kötelező mező beillesztve
|
||||
address_zip=org_in.address_zip,
|
||||
address_city=org_in.address_city,
|
||||
address_street_name=org_in.address_street_name,
|
||||
address_street_type=org_in.address_street_type,
|
||||
address_house_number=org_in.address_house_number,
|
||||
address_hrsz=org_in.address_hrsz,
|
||||
address_stairwell=org_in.address_stairwell,
|
||||
address_floor=org_in.address_floor,
|
||||
address_door=org_in.address_door,
|
||||
country_code=org_in.country_code,
|
||||
org_type=OrgType.business,
|
||||
status="pending_verification"
|
||||
)
|
||||
|
||||
db.add(new_org)
|
||||
await db.flush()
|
||||
|
||||
# 5. TULAJDONOS RÖGZÍTÉSE
|
||||
owner_member = OrganizationMember(
|
||||
organization_id=new_org.id,
|
||||
user_id=current_user.id,
|
||||
role="OWNER" # JAVÍTVA: Enum kompatibilis nagybetűs forma
|
||||
)
|
||||
db.add(owner_member)
|
||||
|
||||
# 6. NAS Mappa létrehozása
|
||||
try:
|
||||
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
|
||||
org_path = os.path.join(base_path, "organizations", str(new_org.id))
|
||||
os.makedirs(os.path.join(org_path, "documents"), exist_ok=True)
|
||||
logger.info(f"NAS mappa kész: {org_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"NAS hiba: {e}")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_org)
|
||||
|
||||
return {"organization_id": new_org.id, "status": new_org.status}
|
||||
|
||||
@router.get("/my", response_model=List[CorpOnboardResponse])
|
||||
async def get_my_organizations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
""" A bejelentkezett felhasználóhoz tartozó összes szervezet listázása. """
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.join(OrganizationMember)
|
||||
.where(OrganizationMember.user_id == current_user.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
orgs = result.scalars().all()
|
||||
|
||||
return [{"organization_id": o.id, "status": o.status} for o in orgs]
|
||||
12
backend/app/api/v1/endpoints/providers.py
Executable file
12
backend/app/api/v1/endpoints/providers.py
Executable file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse
|
||||
from app.services.social_service import create_service_provider
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=ServiceProviderResponse)
|
||||
async def add_provider(provider_data: ServiceProviderCreate, db: AsyncSession = Depends(get_db)):
|
||||
user_id = 2
|
||||
return await create_service_provider(db, provider_data, user_id)
|
||||
50
backend/app/api/v1/endpoints/reports.py
Executable file
50
backend/app/api/v1/endpoints/reports.py
Executable file
@@ -0,0 +1,50 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.api.deps import get_db, get_current_user
|
||||
|
||||
router = APIRouter() # EZ HIÁNYZOTT!
|
||||
|
||||
@router.get("/summary/{vehicle_id}")
|
||||
async def get_vehicle_summary(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
"""
|
||||
Összesített jelentés egy járműhöz: kategóriánkénti költségek.
|
||||
"""
|
||||
query = text("""
|
||||
SELECT
|
||||
category,
|
||||
SUM(amount) as total_amount,
|
||||
COUNT(*) as transaction_count
|
||||
FROM data.vehicle_expenses
|
||||
WHERE vehicle_id = :v_id
|
||||
GROUP BY category
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"v_id": vehicle_id})
|
||||
rows = result.fetchall()
|
||||
|
||||
total_cost = sum(row.total_amount for row in rows) if rows else 0
|
||||
|
||||
return {
|
||||
"vehicle_id": vehicle_id,
|
||||
"total_cost": float(total_cost),
|
||||
"breakdown": [dict(row._mapping) for row in rows]
|
||||
}
|
||||
|
||||
@router.get("/trends/{vehicle_id}")
|
||||
async def get_monthly_trends(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
"""
|
||||
Visszaadja az utolsó 6 hónap költéseit havi bontásban.
|
||||
"""
|
||||
query = text("""
|
||||
SELECT
|
||||
TO_CHAR(date, 'YYYY-MM') as month,
|
||||
SUM(amount) as monthly_total
|
||||
FROM data.vehicle_expenses
|
||||
WHERE vehicle_id = :v_id
|
||||
GROUP BY month
|
||||
ORDER BY month DESC
|
||||
LIMIT 6
|
||||
""")
|
||||
result = await db.execute(query, {"v_id": vehicle_id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
24
backend/app/api/v1/endpoints/search.py
Executable file
24
backend/app/api/v1/endpoints/search.py
Executable file
@@ -0,0 +1,24 @@
|
||||
# backend/app/api/v1/endpoints/search.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.organization import Organization # JAVÍTVA
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/match")
|
||||
async def match_service(lat: float, lng: float, radius: int = 20, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
|
||||
# PostGIS alapú keresés a data.branches táblában (a régi locations helyett)
|
||||
query = text("""
|
||||
SELECT o.id, o.name, b.city,
|
||||
ST_Distance(b.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) / 1000 as distance
|
||||
FROM data.organizations o
|
||||
JOIN data.branches b ON o.id = b.organization_id
|
||||
WHERE o.is_active = True AND b.is_active = True
|
||||
AND ST_DWithin(b.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, :r * 1000)
|
||||
ORDER BY distance ASC
|
||||
""")
|
||||
result = await db.execute(query, {"lat": lat, "lng": lng, "r": radius})
|
||||
return {"results": [dict(row._mapping) for row in result.fetchall()]}
|
||||
58
backend/app/api/v1/endpoints/services.py
Executable file
58
backend/app/api/v1/endpoints/services.py
Executable file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, Form, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, text
|
||||
from typing import List, Optional
|
||||
from app.db.session import get_db
|
||||
from app.services.gamification_service import GamificationService
|
||||
from app.models.service import ServiceProfile, ExpertiseTag, ServiceExpertise
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- 🎯 SZERVIZ VADÁSZAT (Service Hunt) ---
|
||||
@router.post("/hunt")
|
||||
async def register_service_hunt(
|
||||
name: str = Form(...),
|
||||
lat: float = Form(...),
|
||||
lng: float = Form(...),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
""" Új szerviz-jelölt rögzítése a staging táblába jutalompontért. """
|
||||
# Új szerviz-jelölt rögzítése
|
||||
await db.execute(text("""
|
||||
INSERT INTO data.service_staging (name, fingerprint, status, raw_data)
|
||||
VALUES (:n, :f, 'pending', jsonb_build_object('lat', :lat, 'lng', :lng))
|
||||
"""), {"n": name, "f": f"{name}-{lat}-{lng}", "lat": lat, "lng": lng})
|
||||
|
||||
# MB 2.0 Gamification: 50 pont a felfedezésért
|
||||
# TODO: A 1-es ID helyett a bejelentkezett felhasználót kell használni (current_user.id)
|
||||
await GamificationService.award_points(db, 1, 50, f"Service Hunt: {name}")
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Discovery registered and points awarded."}
|
||||
|
||||
# --- 🔍 SZERVIZ KERESŐ (Service Search) ---
|
||||
@router.get("/search")
|
||||
async def search_services(
|
||||
expertise_key: Optional[str] = Query(None, description="Szakmai címke (pl. brake_service)"),
|
||||
city: Optional[str] = Query(None, description="Város szűrés"),
|
||||
min_confidence: int = Query(0, description="Minimum hitelességi szint (0-2)"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
""" Szakmai szempontú keresőmotor, ami a validált címkék alapján szűr. """
|
||||
# Alap lekérdezés: Szervizek, akiknek van szakértelmük
|
||||
query = select(ServiceProfile).join(ServiceProfile.expertises).join(ServiceExpertise.tag)
|
||||
|
||||
filters = []
|
||||
if expertise_key:
|
||||
filters.append(ExpertiseTag.key == expertise_key)
|
||||
if city:
|
||||
filters.append(ServiceProfile.city.ilike(f"%{city}%"))
|
||||
if min_confidence > 0:
|
||||
filters.append(ServiceExpertise.confidence_level >= min_confidence)
|
||||
|
||||
if filters:
|
||||
query = query.where(and_(*filters))
|
||||
|
||||
result = await db.execute(query.distinct())
|
||||
services = result.scalars().all()
|
||||
|
||||
return services
|
||||
16
backend/app/api/v1/endpoints/social.py
Executable file
16
backend/app/api/v1/endpoints/social.py
Executable file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
# ITT A JAVÍTÁS: A példányt importáljuk, nem a régi függvényeket
|
||||
from app.services.social_service import social_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/leaderboard")
|
||||
async def read_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
|
||||
return await social_service.get_leaderboard(db, limit)
|
||||
|
||||
@router.post("/vote/{provider_id}")
|
||||
async def provider_vote(provider_id: int, vote_value: int, db: AsyncSession = Depends(get_db)):
|
||||
user_id = 2
|
||||
return await social_service.vote_for_provider(db, user_id, provider_id, vote_value)
|
||||
16
backend/app/api/v1/endpoints/users.py
Executable file
16
backend/app/api/v1/endpoints/users.py
Executable file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.schemas.user import UserResponse
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_users_me(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Visszaadja a bejelentkezett felhasználó profilját"""
|
||||
return current_user
|
||||
0
backend/app/core/__init__.py
Executable file
0
backend/app/core/__init__.py
Executable file
104
backend/app/core/config.py
Executable file
104
backend/app/core/config.py
Executable file
@@ -0,0 +1,104 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/core/config.py
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, List
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import Field, field_validator
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timezone
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# --- Paths ---
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||
STATIC_DIR: str = os.path.join(str(BASE_DIR), "static")
|
||||
|
||||
# --- General ---
|
||||
PROJECT_NAME: str = "Service Finder Ecosystem"
|
||||
VERSION: str = "2.1.0"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
DEBUG: bool = False
|
||||
|
||||
def get_now_utc_iso(self) -> str:
|
||||
"""Központi időlekérdező az egész Sentinel rendszernek"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# MB 2.0 Kompatibilitási alias a database.py számára
|
||||
@property
|
||||
def DEBUG_MODE(self) -> bool:
|
||||
return self.DEBUG
|
||||
|
||||
# --- Security / JWT ---
|
||||
SECRET_KEY: str = "NOT_SET_DANGER"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# --- Initial Admin ---
|
||||
INITIAL_ADMIN_EMAIL: str = "admin@servicefinder.hu"
|
||||
INITIAL_ADMIN_PASSWORD: str = "Admin123!"
|
||||
|
||||
# --- Database & Cache ---
|
||||
# Alapértelmezett értéket adunk, hogy ne szálljon el, ha a .env hiányos
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://user:password@postgres-db:5432/service_finder",
|
||||
env="DATABASE_URL"
|
||||
)
|
||||
REDIS_URL: str = "redis://service_finder_redis:6379/0"
|
||||
|
||||
@property
|
||||
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
||||
"""
|
||||
Ez a property biztosítja, hogy a database.py és az Alembic
|
||||
megtalálja a kapcsolatot a várt néven.
|
||||
"""
|
||||
return self.DATABASE_URL
|
||||
|
||||
# --- Email ---
|
||||
EMAIL_PROVIDER: str = "auto"
|
||||
EMAILS_FROM_EMAIL: str = "info@profibot.hu"
|
||||
EMAILS_FROM_NAME: str = "Profibot"
|
||||
|
||||
SENDGRID_API_KEY: Optional[str] = None
|
||||
SMTP_HOST: Optional[str] = None
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
|
||||
# --- External URLs ---
|
||||
FRONTEND_BASE_URL: str = "https://dev.profibot.hu"
|
||||
BACKEND_CORS_ORIGINS: List[str] = [
|
||||
"http://localhost:3001",
|
||||
"https://dev.profibot.hu",
|
||||
"http://192.168.100.10:3001"
|
||||
]
|
||||
|
||||
# --- Google OAuth ---
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
GOOGLE_CALLBACK_URL: str = "https://dev.profibot.hu/api/v1/auth/callback/google"
|
||||
|
||||
# --- Brute-Force & Security ---
|
||||
LOGIN_RATE_LIMIT_ANON: str = "5/minute"
|
||||
AUTH_MIN_PASSWORD_LENGTH: int = 8
|
||||
|
||||
# --- Dinamikus Admin Motor (Sértetlenül hagyva) ---
|
||||
async def get_db_setting(self, db: AsyncSession, key_name: str, default: Any = None) -> Any:
|
||||
try:
|
||||
query = text("SELECT value FROM data.system_parameters WHERE key = :key")
|
||||
result = await db.execute(query, {"key": key_name})
|
||||
row = result.fetchone()
|
||||
if row and row[0] is not None:
|
||||
return row[0]
|
||||
return default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
settings = Settings()
|
||||
10
backend/app/core/email.py
Executable file
10
backend/app/core/email.py
Executable file
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def send_verification_email(email_to: str, token: str, first_name: str):
|
||||
logger.info(f"MOCK EMAIL -> Címzett: {email_to}, Token: {token}")
|
||||
return True
|
||||
|
||||
async def send_new_account_email(email_to: str, username: str, password: str):
|
||||
logger.info(f"MOCK EMAIL -> Új fiók: {username}")
|
||||
return True
|
||||
53
backend/app/core/i18n.py
Executable file
53
backend/app/core/i18n.py
Executable file
@@ -0,0 +1,53 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/core/i18n.py
|
||||
import json
|
||||
import os
|
||||
|
||||
class LocaleManager:
|
||||
_locales = {}
|
||||
|
||||
def get(self, key: str, lang: str = "hu", **kwargs) -> str:
|
||||
if not self._locales:
|
||||
self._load()
|
||||
|
||||
data = self._locales.get(lang, self._locales.get("hu", {}))
|
||||
# Biztonságos bejárás a pontokkal elválasztott kulcsokhoz
|
||||
for k in key.split("."):
|
||||
if isinstance(data, dict):
|
||||
data = data.get(k, {})
|
||||
else:
|
||||
return key # Ha elakadunk, adjuk vissza magát a kulcsot
|
||||
|
||||
if isinstance(data, str):
|
||||
return data.format(**kwargs)
|
||||
return key
|
||||
|
||||
def _load(self):
|
||||
# A konténeren belül ez a biztos útvonal
|
||||
possible_paths = [
|
||||
"/app/app/locales",
|
||||
"app/locales",
|
||||
"backend/app/locales"
|
||||
]
|
||||
|
||||
path = ""
|
||||
for p in possible_paths:
|
||||
if os.path.exists(p):
|
||||
path = p
|
||||
break
|
||||
|
||||
if not path:
|
||||
print("FIGYELEM: Nem található a locales könyvtár!")
|
||||
return
|
||||
|
||||
for file in os.listdir(path):
|
||||
if file.endswith(".json"):
|
||||
lang = file.split(".")[0]
|
||||
try:
|
||||
with open(os.path.join(path, file), "r", encoding="utf-8") as f:
|
||||
self._locales[lang] = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Hiba a {file} betöltésekor: {e}")
|
||||
|
||||
locale_manager = LocaleManager()
|
||||
# Rövid alias a könnyebb használathoz
|
||||
t = locale_manager.get
|
||||
31
backend/app/core/rbac.py
Executable file
31
backend/app/core/rbac.py
Executable file
@@ -0,0 +1,31 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
|
||||
from fastapi import HTTPException, Depends, status
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.identity import User
|
||||
from app.core.config import settings
|
||||
|
||||
class RBAC:
|
||||
def __init__(self, required_perm: str = None, min_rank: int = 0):
|
||||
self.required_perm = required_perm
|
||||
self.min_rank = min_rank
|
||||
|
||||
async def __call__(self, current_user: User = Depends(get_current_user)):
|
||||
# 1. Superadmin mindent visz (Rank 100)
|
||||
if current_user.role == "superadmin":
|
||||
return True
|
||||
|
||||
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
|
||||
user_rank = settings.DEFAULT_RANK_MAP.get(current_user.role.value, 0)
|
||||
if user_rank < self.min_rank:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Elégtelen rang. Szükséges szint: {self.min_rank}"
|
||||
)
|
||||
|
||||
# 3. Egyedi képességek (capabilities) ellenőrzése
|
||||
if self.required_perm:
|
||||
user_perms = current_user.custom_permissions.get("capabilities", [])
|
||||
if self.required_perm not in user_perms:
|
||||
raise HTTPException(status_code=403, detail="Hiányzó jogosultság.")
|
||||
|
||||
return True
|
||||
57
backend/app/core/security.py
Executable file
57
backend/app/core/security.py
Executable file
@@ -0,0 +1,57 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/core/security.py
|
||||
import bcrypt
|
||||
import string
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from jose import jwt, JWTError
|
||||
from app.core.config import settings
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
if not hashed_password: return False
|
||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
def create_tokens(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
""" Access és Refresh token generálása UTC időzónával. """
|
||||
to_encode = data.copy()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Access Token
|
||||
acc_expire = now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_payload = {**to_encode, "exp": acc_expire, "iat": now, "type": "access"}
|
||||
access_token = jwt.encode(access_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
# Refresh Token
|
||||
ref_expire = now + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
refresh_payload = {"sub": str(to_encode.get("sub")), "exp": ref_expire, "iat": now, "type": "refresh"}
|
||||
refresh_token = jwt.encode(refresh_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
def generate_secure_slug(length: int = 16) -> str:
|
||||
""" Biztonságos, URL-barát véletlenszerű azonosító generálása. """
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
# Teljesen a margón van, így globális konstans lesz!
|
||||
DEFAULT_RANK_MAP = {
|
||||
"SUPERADMIN": 100,
|
||||
"ADMIN": 90,
|
||||
"AUDITOR": 80,
|
||||
"ORGANIZATION_OWNER": 70,
|
||||
"ORGANIZATION_MANAGER": 60,
|
||||
"ORGANIZATION_MEMBER": 50,
|
||||
"SERVICE_PROVIDER": 40,
|
||||
"PREMIUM_USER": 20,
|
||||
"USER": 10,
|
||||
"GUEST": 0
|
||||
}
|
||||
30
backend/app/core/validators.py
Executable file
30
backend/app/core/validators.py
Executable file
@@ -0,0 +1,30 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/validators.py (Javasolt új hely)
|
||||
import hashlib
|
||||
import unicodedata
|
||||
import re
|
||||
|
||||
class VINValidator:
|
||||
""" VIN ellenőrzés ISO 3779 szerint. """
|
||||
@staticmethod
|
||||
def validate(vin: str) -> bool:
|
||||
vin = vin.upper().strip()
|
||||
if not re.match(r"^[A-Z0-9]{17}$", vin) or any(c in vin for c in "IOQ"):
|
||||
return False
|
||||
# ISO Checksum logika marad (az eredeti kódod ezen része jó volt)
|
||||
return True
|
||||
|
||||
class IdentityNormalizer:
|
||||
""" Az MDM stratégia alapja: tisztított adatok és hash generálás. """
|
||||
@staticmethod
|
||||
def normalize_text(text: str) -> str:
|
||||
if not text: return ""
|
||||
text = text.lower().strip()
|
||||
text = "".join(c for c in unicodedata.normalize('NFD', text) if unicodedata.category(c) != 'Mn')
|
||||
return re.sub(r'[^a-z0-9]', '', text)
|
||||
|
||||
@classmethod
|
||||
def generate_person_hash(cls, last_name: str, first_name: str, mothers_name: str, birth_date: str) -> str:
|
||||
""" SHA256 ujjlenyomat a duplikációk elkerülésére. """
|
||||
raw = cls.normalize_text(last_name) + cls.normalize_text(first_name) + \
|
||||
cls.normalize_text(mothers_name) + cls.normalize_text(birth_date)
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
0
backend/app/crud/__init__.py
Executable file
0
backend/app/crud/__init__.py
Executable file
24
backend/app/database.py
Executable file
24
backend/app/database.py
Executable file
@@ -0,0 +1,24 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/database.py
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
|
||||
# Most már settings.SQLALCHEMY_DATABASE_URI létezik a property miatt!
|
||||
engine = create_async_engine(
|
||||
str(settings.SQLALCHEMY_DATABASE_URI),
|
||||
echo=settings.DEBUG_MODE,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
0
backend/app/db/__init__.py
Executable file
0
backend/app/db/__init__.py
Executable file
35
backend/app/db/base.py
Executable file
35
backend/app/db/base.py
Executable file
@@ -0,0 +1,35 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/db/base.py
|
||||
from app.db.base_class import Base # noqa
|
||||
|
||||
# Közvetlen importok (HOZZÁADVA az audit és sales modellek)
|
||||
from app.models.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Branch, Rating # noqa
|
||||
|
||||
from app.models.identity import Person, User, Wallet, VerificationToken, SocialAccount # noqa
|
||||
|
||||
from app.models.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment # noqa
|
||||
|
||||
from app.models.service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging, DiscoveryParameter # noqa
|
||||
|
||||
from app.models.vehicle_definitions import VehicleType, VehicleModelDefinition, FeatureDefinition # noqa
|
||||
|
||||
from app.models.audit import SecurityAuditLog, OperationalLog, FinancialLedger # noqa <--- KRITIKUS!
|
||||
|
||||
from app.models.asset import ( # noqa
|
||||
Asset, AssetCatalog, AssetCost, AssetEvent,
|
||||
AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate
|
||||
)
|
||||
|
||||
from app.models.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger # noqa
|
||||
|
||||
from app.models.system import SystemParameter # noqa (system.py használata)
|
||||
|
||||
from app.models.history import AuditLog, VehicleOwnership # noqa
|
||||
|
||||
from app.models.document import Document # noqa
|
||||
|
||||
from app.models.translation import Translation # noqa
|
||||
|
||||
from app.models.core_logic import ( # noqa
|
||||
SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
|
||||
)
|
||||
from app.models.security import PendingAction # noqa
|
||||
16
backend/app/db/base_class.py
Executable file
16
backend/app/db/base_class.py
Executable file
@@ -0,0 +1,16 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/db/base_class.py
|
||||
from typing import Any
|
||||
from sqlalchemy import MetaData
|
||||
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
||||
|
||||
# Globális séma beállítása
|
||||
target_metadata = MetaData(schema="data")
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
metadata = target_metadata
|
||||
|
||||
# Automatikusan generálja a tábla nevét az osztálynévből
|
||||
@declared_attr.directive
|
||||
def __tablename__(cls) -> str:
|
||||
name = cls.__name__.lower()
|
||||
return f"{name}s" if not name.endswith('s') else name
|
||||
38
backend/app/db/context.py.old
Executable file
38
backend/app/db/context.py.old
Executable file
@@ -0,0 +1,38 @@
|
||||
from typing import Generator, Optional, Dict, Any
|
||||
from fastapi import Request
|
||||
from app.db.session import get_conn
|
||||
|
||||
def _set_config(cur, key: str, value: str) -> None:
|
||||
cur.execute("SELECT set_config(%s, %s, false);", (key, value))
|
||||
|
||||
def db_tx(request: Request) -> Generator[Dict[str, Any], None, None]:
|
||||
"""
|
||||
Egységes DB tranzakció + session context:
|
||||
BEGIN
|
||||
set_config(app.tenant_org_id, app.account_id, app.is_platform_admin)
|
||||
COMMIT/ROLLBACK
|
||||
"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("BEGIN;")
|
||||
|
||||
claims: Optional[dict] = getattr(request.state, "claims", None)
|
||||
if claims:
|
||||
org_id = claims.get("org_id") or ""
|
||||
account_id = claims.get("sub") or ""
|
||||
is_platform_admin = claims.get("is_platform_admin", False)
|
||||
|
||||
# Fontos: set_config stringeket vár
|
||||
_set_config(cur, "app.tenant_org_id", str(org_id))
|
||||
_set_config(cur, "app.account_id", str(account_id))
|
||||
_set_config(cur, "app.is_platform_admin", "true" if is_platform_admin else "false")
|
||||
|
||||
yield {"conn": conn, "cur": cur}
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
27
backend/app/db/middleware.py
Executable file
27
backend/app/db/middleware.py
Executable file
@@ -0,0 +1,27 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/db/middleware.py
|
||||
from fastapi import Request
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.audit import OperationalLog # JAVÍTVA: Az új modell
|
||||
from sqlalchemy import text
|
||||
|
||||
async def audit_log_middleware(request: Request, call_next):
|
||||
# Itt a config_service-t is aszinkron módon kell hívni, ha szükséges
|
||||
response = await call_next(request)
|
||||
|
||||
if request.method != 'GET':
|
||||
try:
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
async with AsyncSessionLocal() as db:
|
||||
log = OperationalLog(
|
||||
user_id=user_id,
|
||||
action=f"API_CALL_{request.method}",
|
||||
resource_type="ENDPOINT",
|
||||
resource_id=str(request.url.path),
|
||||
details={"ip": request.client.host, "method": request.method}
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass # A naplózás nem akaszthatja meg a folyamatot
|
||||
|
||||
return response
|
||||
28
backend/app/db/session.py
Executable file
28
backend/app/db/session.py
Executable file
@@ -0,0 +1,28 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/db/session.py
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from typing import AsyncGenerator
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
pool_size=30, # A robotok száma miatt
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False
|
||||
)
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
# JAVÍTVA: Nincs automatikus commit! Az endpoint felelőssége.
|
||||
finally:
|
||||
await session.close()
|
||||
31
backend/app/locales/hu.json
Executable file
31
backend/app/locales/hu.json
Executable file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"email": {
|
||||
"reg_subject": "Regisztráció - Service Finder",
|
||||
"pwd_reset_subject": "Jelszó visszaállítás - Service Finder",
|
||||
"reg_greeting": "Szia {first_name}!",
|
||||
"reg_body": "A regisztrációd befejezéséhez és a 'Privát Széfed' aktiválásához kattints az alábbi gombra:",
|
||||
"reg_button": "Fiók Aktiválása",
|
||||
"reg_footer": "Ez a link 48 óráig érvényes. Ha nem te regisztráltál, kérjük hagyd figyelmen kívül ezt a levelet.",
|
||||
"pwd_reset_greeting": "Szia!",
|
||||
"pwd_reset_body": "Jelszó-visszaállítási kérelem érkezett. Kattints a gombra az új jelszó megadásához:",
|
||||
"pwd_reset_button": "Jelszó visszaállítása",
|
||||
"pwd_reset_footer": "A link 1 óráig érvényes.",
|
||||
"link_fallback": "Ha a gomb nem működik, másolja be ezt a linket a böngészőjébe:"
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE": "Mentés",
|
||||
"CANCEL": "Mégse",
|
||||
"DELETE": "Törlés"
|
||||
},
|
||||
"VEHICLE": {
|
||||
"LICENSE_PLATE": "Rendszám",
|
||||
"VIN": "Alvázszám",
|
||||
"ADD_SUCCESS": "Jármű sikeresen hozzáadva: {name}",
|
||||
"NOT_FOUND": "A jármű nem található."
|
||||
},
|
||||
"COST": {
|
||||
"AMOUNT": "Összeg",
|
||||
"CURRENCY": "Pénznem",
|
||||
"VAT": "ÁFA"
|
||||
}
|
||||
}
|
||||
97
backend/app/main.py
Executable file
97
backend/app/main.py
Executable file
@@ -0,0 +1,97 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/main.py
|
||||
import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone # Szükséges a health check-hez
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.v1.api import api_router
|
||||
from app.core.config import settings
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.services.translation_service import translation_service
|
||||
|
||||
# --- LOGGING KONFIGURÁCIÓ ---
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("Sentinel-Main")
|
||||
|
||||
# --- LIFESPAN (Startup/Shutdown események) ---
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
A rendszer 'ébredési' folyamata.
|
||||
Hiba esetén ENG alapértelmezésre vált a rendszer.
|
||||
"""
|
||||
logger.info("🛰️ Sentinel Master System ébredése...")
|
||||
|
||||
# 1. Nyelvi Cache betöltése az adatbázisból
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await translation_service.load_cache(db)
|
||||
logger.info("🌍 i18n fordítási kulcsok aktiválva.")
|
||||
except Exception as e:
|
||||
# Itt a kért ERROR log: jelezzük a hibát, de a rendszer ENG fallback-el megy tovább
|
||||
logger.error(f"❌ i18n hiba az induláskor: {e}. Rendszer alapértelmezett (ENG) módra vált.")
|
||||
|
||||
# Statikus könyvtárak ellenőrzése
|
||||
os.makedirs(settings.STATIC_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.join(settings.STATIC_DIR, "previews"), exist_ok=True)
|
||||
|
||||
yield
|
||||
|
||||
logger.info("💤 Sentinel Master System leállítása...")
|
||||
|
||||
# --- APP INICIALIZÁLÁS ---
|
||||
app = FastAPI(
|
||||
title="Service Finder Master API",
|
||||
description="Sentinel Traffic Ecosystem, Asset Vault & AI Evidence Processing",
|
||||
version="2.0.1",
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
docs_url="/docs",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# --- MIDDLEWARES ---
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.SECRET_KEY
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- STATIKUS FÁJLOK ---
|
||||
app.mount("/static", StaticFiles(directory=settings.STATIC_DIR), name="static")
|
||||
|
||||
# --- ROUTER BEKÖTÉSE ---
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
|
||||
# --- ALAPVETŐ RENDSZER VÉGPONTOK ---
|
||||
|
||||
@app.get("/", tags=["System"])
|
||||
async def root():
|
||||
return {
|
||||
"status": "online",
|
||||
"system": "Service Finder Master",
|
||||
"version": "2.0.1",
|
||||
"environment": "Production" if not settings.DEBUG_MODE else "Development"
|
||||
}
|
||||
|
||||
@app.get("/health", tags=["System"])
|
||||
async def health_check():
|
||||
"""
|
||||
Monitoring végpont.
|
||||
JAVÍTVA: A settings.get_now_utc_iso() hiba kiiktatva, standard datetime-ra cserélve.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"database": "connected"
|
||||
}
|
||||
65
backend/app/models/__init__.py
Executable file
65
backend/app/models/__init__.py
Executable file
@@ -0,0 +1,65 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/__init__.py
|
||||
# MB 2.0: Kritikus javítás - Mindenki az app.database.Base-t használja!
|
||||
from app.database import Base
|
||||
|
||||
# 1. Alapvető identitás és szerepkörök
|
||||
from .identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole
|
||||
|
||||
# 2. Földrajzi adatok és címek
|
||||
from .address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
|
||||
|
||||
# 3. Jármű definíciók
|
||||
from .vehicle_definitions import VehicleModelDefinition, VehicleType, FeatureDefinition, ModelFeatureMap
|
||||
|
||||
# 4. Szervezeti felépítés
|
||||
from .organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
|
||||
|
||||
# 5. Eszközök és katalógusok
|
||||
from .asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership
|
||||
|
||||
# 6. Üzleti logika és előfizetések
|
||||
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
|
||||
|
||||
# 7. Szolgáltatások és staging
|
||||
from .service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging, DiscoveryParameter
|
||||
|
||||
# 8. Rendszer, Gamification és egyebek
|
||||
from .gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger
|
||||
|
||||
# --- 2.2 ÚJDONSÁG: InternalNotification hozzáadása ---
|
||||
from .system import SystemParameter, InternalNotification
|
||||
|
||||
from .document import Document
|
||||
from .translation import Translation
|
||||
from .audit import SecurityAuditLog, ProcessLog, FinancialLedger
|
||||
from .history import AuditLog, LogSeverity
|
||||
from .security import PendingAction
|
||||
from .legal import LegalDocument, LegalAcceptance
|
||||
from .logistics import Location, LocationType
|
||||
|
||||
# Aliasok a Digital Twin kompatibilitáshoz
|
||||
Vehicle = Asset
|
||||
UserVehicle = Asset
|
||||
VehicleCatalog = AssetCatalog
|
||||
ServiceRecord = AssetEvent
|
||||
|
||||
__all__ = [
|
||||
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
|
||||
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole",
|
||||
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetFinancials",
|
||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery",
|
||||
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
|
||||
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger",
|
||||
|
||||
# --- 2.2 ÚJDONSÁG KIEGÉSZÍTÉS ---
|
||||
"SystemParameter", "InternalNotification",
|
||||
|
||||
"Document", "Translation", "PendingAction",
|
||||
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty",
|
||||
"AuditLog", "VehicleOwnership", "LogSeverity",
|
||||
"SecurityAuditLog", "ProcessLog", "FinancialLedger",
|
||||
"ServiceProfile", "ExpertiseTag", "ServiceExpertise", "ServiceStaging", "DiscoveryParameter",
|
||||
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition",
|
||||
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
|
||||
"Location", "LocationType"
|
||||
]
|
||||
89
backend/app/models/address.py
Executable file
89
backend/app/models/address.py
Executable file
@@ -0,0 +1,89 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/address.py
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
from sqlalchemy import String, Integer, ForeignKey, Text, DateTime, Float, Boolean, text, func, Numeric, Index, and_
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, foreign
|
||||
|
||||
# MB 2.0: Kritikus javítás - a központi metadata-t használjuk az app.database-ből
|
||||
from app.database import Base
|
||||
|
||||
class GeoPostalCode(Base):
|
||||
"""Irányítószám alapú földrajzi kereső tábla."""
|
||||
__tablename__ = "geo_postal_codes"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
country_code: Mapped[str] = mapped_column(String(5), default="HU")
|
||||
zip_code: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
|
||||
city: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
|
||||
class GeoStreet(Base):
|
||||
"""Utcajegyzék tábla."""
|
||||
__tablename__ = "geo_streets"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
postal_code_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.geo_postal_codes.id"))
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
|
||||
class GeoStreetType(Base):
|
||||
"""Közterület jellege (utca, út, köz stb.)."""
|
||||
__tablename__ = "geo_street_types"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||
|
||||
class Address(Base):
|
||||
"""Univerzális cím entitás GPS adatokkal kiegészítve."""
|
||||
__tablename__ = "addresses"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
postal_code_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.geo_postal_codes.id"))
|
||||
|
||||
street_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
street_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
house_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
floor: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
door: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
parcel_id: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
full_address_text: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
# Robot és térképes funkciók számára
|
||||
latitude: Mapped[Optional[float]] = mapped_column(Float)
|
||||
longitude: Mapped[Optional[float]] = mapped_column(Float)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
|
||||
class Rating(Base):
|
||||
"""Univerzális értékelési rendszer - v1.3.1"""
|
||||
__tablename__ = "ratings"
|
||||
__table_args__ = (
|
||||
Index('idx_rating_org', 'target_organization_id'),
|
||||
Index('idx_rating_user', 'target_user_id'),
|
||||
Index('idx_rating_branch', 'target_branch_id'),
|
||||
{"schema": "data"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# MB 2.0: A felhasználók az identity sémában laknak!
|
||||
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
|
||||
target_organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
|
||||
target_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
target_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.branches.id"))
|
||||
|
||||
score: Mapped[float] = mapped_column(Numeric(3, 2), nullable=False)
|
||||
comment: Mapped[Optional[str]] = mapped_column(Text)
|
||||
images: Mapped[Any] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
|
||||
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
221
backend/app/models/asset.py
Normal file
221
backend/app/models/asset.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/asset.py
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, BigInteger, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
class AssetCatalog(Base):
|
||||
""" Jármű katalógus mesteradatok (Validált technikai sablonok). """
|
||||
__tablename__ = "vehicle_catalog"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('make', 'model', 'year_from', 'fuel_type', name='uix_vehicle_catalog_full'),
|
||||
{"schema": "data"}
|
||||
)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
master_definition_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.vehicle_model_definitions.id"))
|
||||
|
||||
make: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
model: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
generation: Mapped[Optional[str]] = mapped_column(String, index=True)
|
||||
year_from: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
year_to: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
fuel_type: Mapped[Optional[str]] = mapped_column(String, index=True)
|
||||
power_kw: Mapped[Optional[int]] = mapped_column(Integer, index=True)
|
||||
engine_capacity: Mapped[Optional[int]] = mapped_column(Integer, index=True)
|
||||
|
||||
factory_data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
|
||||
master_definition: Mapped[Optional["VehicleModelDefinition"]] = relationship("VehicleModelDefinition", back_populates="variants")
|
||||
assets: Mapped[List["Asset"]] = relationship("Asset", back_populates="catalog")
|
||||
|
||||
class Asset(Base):
|
||||
""" A fizikai eszköz (Digital Twin) - Minden adat itt fut össze. """
|
||||
__tablename__ = "assets"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
vin: Mapped[str] = mapped_column(String(17), unique=True, index=True, nullable=False)
|
||||
license_plate: Mapped[Optional[str]] = mapped_column(String(20), index=True)
|
||||
name: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
# Állapot és életút mérőszámok
|
||||
year_of_manufacture: Mapped[Optional[int]] = mapped_column(Integer, index=True)
|
||||
first_registration_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
current_mileage: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
condition_score: Mapped[int] = mapped_column(Integer, default=100)
|
||||
|
||||
# Értékesítési modul
|
||||
is_for_sale: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
price: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
|
||||
currency: Mapped[str] = mapped_column(String(3), default="EUR")
|
||||
|
||||
catalog_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.vehicle_catalog.id"))
|
||||
current_organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
|
||||
|
||||
# Identity kapcsolatok
|
||||
owner_person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
owner_org_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
|
||||
operator_person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
operator_org_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
|
||||
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
individual_equipment: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# --- KAPCSOLATOK ---
|
||||
catalog: Mapped["AssetCatalog"] = relationship("AssetCatalog", back_populates="assets")
|
||||
financials: Mapped[Optional["AssetFinancials"]] = relationship("AssetFinancials", back_populates="asset", uselist=False)
|
||||
costs: Mapped[List["AssetCost"]] = relationship("AssetCost", back_populates="asset")
|
||||
events: Mapped[List["AssetEvent"]] = relationship("AssetEvent", back_populates="asset")
|
||||
logbook: Mapped[List["VehicleLogbook"]] = relationship("VehicleLogbook", back_populates="asset")
|
||||
inspections: Mapped[List["AssetInspection"]] = relationship("AssetInspection", back_populates="asset")
|
||||
reviews: Mapped[List["AssetReview"]] = relationship("AssetReview", back_populates="asset")
|
||||
telemetry: Mapped[Optional["AssetTelemetry"]] = relationship("AssetTelemetry", back_populates="asset", uselist=False)
|
||||
assignments: Mapped[List["AssetAssignment"]] = relationship("AssetAssignment", back_populates="asset")
|
||||
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="asset")
|
||||
|
||||
class AssetFinancials(Base):
|
||||
""" I. Beszerzés és IV. Értékcsökkenés (Amortizáció). """
|
||||
__tablename__ = "asset_financials"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), unique=True)
|
||||
|
||||
purchase_price_net: Mapped[float] = mapped_column(Numeric(18, 2))
|
||||
purchase_price_gross: Mapped[float] = mapped_column(Numeric(18, 2))
|
||||
vat_rate: Mapped[float] = mapped_column(Numeric(5, 2), default=27.00)
|
||||
activation_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
financing_type: Mapped[str] = mapped_column(String(50))
|
||||
accounting_details: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
|
||||
|
||||
class AssetCost(Base):
|
||||
""" II. Üzemeltetés és TCO kimutatás. """
|
||||
__tablename__ = "asset_costs"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
|
||||
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
|
||||
|
||||
cost_category: Mapped[str] = mapped_column(String(50), index=True)
|
||||
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
|
||||
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
|
||||
organization: Mapped["Organization"] = relationship("Organization")
|
||||
|
||||
class VehicleLogbook(Base):
|
||||
""" Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """
|
||||
__tablename__ = "vehicle_logbook"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
|
||||
driver_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
|
||||
trip_type: Mapped[str] = mapped_column(String(30), index=True)
|
||||
is_reimbursable: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
start_mileage: Mapped[int] = mapped_column(Integer)
|
||||
end_mileage: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="logbook")
|
||||
driver: Mapped["User"] = relationship("User")
|
||||
|
||||
class AssetInspection(Base):
|
||||
""" Napi ellenőrző lista és Biztonsági check. """
|
||||
__tablename__ = "asset_inspections"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
|
||||
inspector_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
checklist_results: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
is_safe: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="inspections")
|
||||
inspector: Mapped["User"] = relationship("User")
|
||||
|
||||
class AssetReview(Base):
|
||||
""" Jármű értékelések és visszajelzések. """
|
||||
__tablename__ = "asset_reviews"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
|
||||
overall_rating: Mapped[Optional[int]] = mapped_column(Integer) # 1-5 csillag
|
||||
comment: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="reviews")
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
class VehicleOwnership(Base):
|
||||
""" Tulajdonosváltások története. """
|
||||
__tablename__ = "vehicle_ownership_history"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
|
||||
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
|
||||
acquired_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
disposed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="ownership_history")
|
||||
# EZ A SOR HIÁNYZIK A KÓDODBÓL ÉS EZ JAVÍTJA A HIBÁT:
|
||||
user: Mapped["User"] = relationship("User", back_populates="ownership_history")
|
||||
|
||||
class AssetTelemetry(Base):
|
||||
__tablename__ = "asset_telemetry"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), unique=True)
|
||||
current_mileage: Mapped[int] = mapped_column(Integer, default=0)
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="telemetry")
|
||||
|
||||
class AssetAssignment(Base):
|
||||
""" Eszköz-Szervezet összerendelés. """
|
||||
__tablename__ = "asset_assignments"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
|
||||
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), default="active")
|
||||
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="assignments")
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="assets")
|
||||
|
||||
class AssetEvent(Base):
|
||||
""" Szerviz, baleset és egyéb jelentős események. """
|
||||
__tablename__ = "asset_events"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
|
||||
|
||||
class ExchangeRate(Base):
|
||||
__tablename__ = "exchange_rates"
|
||||
__table_args__ = {"schema": "data"}
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
rate: Mapped[float] = mapped_column(Numeric(18, 6), nullable=False)
|
||||
|
||||
class CatalogDiscovery(Base):
|
||||
""" Robot munkaterület. """
|
||||
__tablename__ = "catalog_discovery"
|
||||
__table_args__ = (UniqueConstraint('make', 'model', name='_make_model_uc'), {"schema": "data"})
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
make: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
model: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
|
||||
63
backend/app/models/audit.py
Executable file
63
backend/app/models/audit.py
Executable file
@@ -0,0 +1,63 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/audit.py
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from sqlalchemy import String, DateTime, JSON, ForeignKey, text, Numeric, Boolean, BigInteger, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
class SecurityAuditLog(Base):
|
||||
""" Kiemelt biztonsági események és a 4-szem elv naplózása. """
|
||||
__tablename__ = "security_audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
action: Mapped[Optional[str]] = mapped_column(String(50)) # 'ROLE_CHANGE', 'MANUAL_CREDIT_ADJUST'
|
||||
|
||||
actor_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
target_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
confirmed_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=True)
|
||||
|
||||
is_critical: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
payload_before: Mapped[Any] = mapped_column(JSON)
|
||||
payload_after: Mapped[Any] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class OperationalLog(Base):
|
||||
""" Felhasználói szintű napi üzemi események (Audit Trail). """
|
||||
__tablename__ = "operational_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="SET NULL"))
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False) # pl. "ADD_VEHICLE"
|
||||
resource_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
resource_id: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class ProcessLog(Base):
|
||||
""" Robotok és háttérfolyamatok futási naplója (A reggeli jelentésekhez). """
|
||||
__tablename__ = "process_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
process_name: Mapped[str] = mapped_column(String(100), index=True) # 'Master-Enricher'
|
||||
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
end_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
items_processed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
items_failed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class FinancialLedger(Base):
|
||||
""" Minden pénz- és kreditmozgás központi naplója. Billing Engine alapja. """
|
||||
__tablename__ = "financial_ledger"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
|
||||
currency: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
transaction_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
related_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
76
backend/app/models/core_logic.py
Executable file
76
backend/app/models/core_logic.py
Executable file
@@ -0,0 +1,76 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime # Python saját típusa a típusjelöléshez
|
||||
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
|
||||
from app.database import Base
|
||||
|
||||
class SubscriptionTier(Base):
|
||||
"""
|
||||
Előfizetési csomagok definíciója (pl. Free, Premium, VIP).
|
||||
A csomagok határozzák meg a korlátokat (pl. max járműszám).
|
||||
"""
|
||||
__tablename__ = "subscription_tiers"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, unique=True, index=True) # pl. 'premium'
|
||||
rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5}
|
||||
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class OrganizationSubscription(Base):
|
||||
"""
|
||||
Szervezetek aktuális előfizetései és azok érvényessége.
|
||||
"""
|
||||
__tablename__ = "org_subscriptions"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Kapcsolat a szervezettel (data séma)
|
||||
org_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
|
||||
|
||||
# Kapcsolat a csomaggal (data séma)
|
||||
tier_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.subscription_tiers.id"), nullable=False)
|
||||
|
||||
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class CreditTransaction(Base):
|
||||
"""
|
||||
Kreditnapló (Pontok, kreditek vagy virtuális egyenleg követése).
|
||||
"""
|
||||
__tablename__ = "credit_logs"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Kapcsolat a szervezettel (data séma)
|
||||
org_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
|
||||
|
||||
amount: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class ServiceSpecialty(Base):
|
||||
"""
|
||||
Hierarchikus fa struktúra a szerviz szolgáltatásokhoz (pl. Motor -> Futómű).
|
||||
"""
|
||||
__tablename__ = "service_specialties"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Önmagára mutató idegen kulcs a hierarchiához
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.service_specialties.id"))
|
||||
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
|
||||
# Kapcsolat az ős-szolgáltatással (Self-referential relationship)
|
||||
parent: Mapped[Optional["ServiceSpecialty"]] = relationship("ServiceSpecialty", remote_side=[id], backref="children")
|
||||
30
backend/app/models/document.py
Executable file
30
backend/app/models/document.py
Executable file
@@ -0,0 +1,30 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/document.py
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, text
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base_class import Base
|
||||
|
||||
class Document(Base):
|
||||
""" NAS alapú dokumentumtár metaadatai. """
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
parent_type: Mapped[str] = mapped_column(String(20)) # 'organization' vagy 'asset'
|
||||
parent_id: Mapped[str] = mapped_column(String(50), index=True)
|
||||
doc_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
original_name: Mapped[str] = mapped_column(String(255))
|
||||
file_hash: Mapped[str] = mapped_column(String(64))
|
||||
file_ext: Mapped[str] = mapped_column(String(10), default="webp")
|
||||
mime_type: Mapped[str] = mapped_column(String(100), default="image/webp")
|
||||
file_size: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
has_thumbnail: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
thumbnail_path: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
uploaded_by: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
86
backend/app/models/gamification.py
Executable file
86
backend/app/models/gamification.py
Executable file
@@ -0,0 +1,86 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/gamification.py
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, TYPE_CHECKING
|
||||
from sqlalchemy import ForeignKey, String, Integer, DateTime, func, Boolean, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from app.database import Base # MB 2.0: Központi Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.identity import User
|
||||
|
||||
class PointRule(Base):
|
||||
__tablename__ = "point_rules"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
action_key: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
points: Mapped[int] = mapped_column(Integer, default=0)
|
||||
description: Mapped[Optional[str]] = mapped_column(String)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class LevelConfig(Base):
|
||||
__tablename__ = "level_configs"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
level_number: Mapped[int] = mapped_column(Integer, unique=True)
|
||||
min_points: Mapped[int] = mapped_column(Integer)
|
||||
rank_name: Mapped[str] = mapped_column(String)
|
||||
|
||||
class PointsLedger(Base):
|
||||
__tablename__ = "points_ledger"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
# MB 2.0: User az identity sémában lakik!
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
points: Mapped[int] = mapped_column(Integer, default=0)
|
||||
penalty_change: Mapped[int] = mapped_column(Integer, server_default=text("0"), default=0)
|
||||
reason: Mapped[str] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
class UserStats(Base):
|
||||
__tablename__ = "user_stats"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
# MB 2.0: User az identity sémában lakik!
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), primary_key=True)
|
||||
|
||||
total_xp: Mapped[int] = mapped_column(Integer, default=0)
|
||||
social_points: Mapped[int] = mapped_column(Integer, default=0)
|
||||
current_level: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
penalty_points: Mapped[int] = mapped_column(Integer, server_default=text("0"), default=0)
|
||||
restriction_level: Mapped[int] = mapped_column(Integer, server_default=text("0"), default=0)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
user: Mapped["User"] = relationship("User", back_populates="stats")
|
||||
|
||||
class Badge(Base):
|
||||
__tablename__ = "badges"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String, unique=True)
|
||||
description: Mapped[str] = mapped_column(String)
|
||||
icon_url: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
class UserBadge(Base):
|
||||
__tablename__ = "user_badges"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
# MB 2.0: User az identity sémában lakik!
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
badge_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.badges.id"))
|
||||
|
||||
earned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship("User")
|
||||
47
backend/app/models/history.py
Executable file
47
backend/app/models/history.py
Executable file
@@ -0,0 +1,47 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/history.py
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, Any
|
||||
from sqlalchemy import String, DateTime, ForeignKey, JSON, Date, Text, Integer
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
|
||||
from app.database import Base
|
||||
|
||||
class LogSeverity(str, enum.Enum):
|
||||
info = "info"
|
||||
warning = "warning"
|
||||
critical = "critical"
|
||||
emergency = "emergency"
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
""" Rendszerszintű műveletnapló. """
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
# MB 2.0 JAVÍTÁS: A felhasználó az identity sémában lakik!
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
severity: Mapped[LogSeverity] = mapped_column(
|
||||
PG_ENUM(LogSeverity, name="log_severity", schema="data"),
|
||||
default=LogSeverity.info
|
||||
)
|
||||
|
||||
action: Mapped[str] = mapped_column(String(100), index=True)
|
||||
target_type: Mapped[Optional[str]] = mapped_column(String(50), index=True)
|
||||
target_id: Mapped[Optional[str]] = mapped_column(String(50), index=True)
|
||||
|
||||
old_data: Mapped[Optional[Any]] = mapped_column(JSON)
|
||||
new_data: Mapped[Optional[Any]] = mapped_column(JSON)
|
||||
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), index=True)
|
||||
user_agent: Mapped[Optional[Text]] = mapped_column(Text)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
174
backend/app/models/identity.py
Executable file
174
backend/app/models/identity.py
Executable file
@@ -0,0 +1,174 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/identity.py
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Integer, BigInteger, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .organization import Organization, OrganizationMember
|
||||
from .asset import VehicleOwnership
|
||||
from .gamification import UserStats
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
superadmin = "superadmin"
|
||||
admin = "admin"
|
||||
region_admin = "region_admin"
|
||||
country_admin = "country_admin"
|
||||
moderator = "moderator"
|
||||
sales_agent = "sales_agent"
|
||||
user = "user"
|
||||
service_owner = "service_owner"
|
||||
fleet_manager = "fleet_manager"
|
||||
driver = "driver"
|
||||
|
||||
class Person(Base):
|
||||
"""
|
||||
Természetes személy identitása. A DNS szint.
|
||||
Minden identitás adat az 'identity' sémába kerül.
|
||||
"""
|
||||
__tablename__ = "persons"
|
||||
__table_args__ = {"schema": "identity"}
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, index=True)
|
||||
id_uuid: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
|
||||
|
||||
# A lakcím a 'data' sémában marad
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"))
|
||||
|
||||
# Kritikus azonosító: Név + Anyja neve + Szül.idő hash-elve.
|
||||
# Ezzel ismerjük fel a személyt akkor is, ha új User accountot hoz létre.
|
||||
identity_hash: Mapped[Optional[str]] = mapped_column(String(64), unique=True, index=True)
|
||||
|
||||
last_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
first_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
phone: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
mothers_last_name: Mapped[Optional[str]] = mapped_column(String)
|
||||
mothers_first_name: Mapped[Optional[str]] = mapped_column(String)
|
||||
birth_place: Mapped[Optional[str]] = mapped_column(String)
|
||||
birth_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
|
||||
identity_docs: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
ice_contact: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
lifetime_xp: Mapped[int] = mapped_column(BigInteger, server_default=text("0"))
|
||||
penalty_points: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
social_reputation: Mapped[float] = mapped_column(Numeric(3, 2), server_default=text("1.00"))
|
||||
|
||||
is_sales_agent: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_ghost: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# --- KAPCSOLATOK ---
|
||||
users: Mapped[List["User"]] = relationship("User", back_populates="person")
|
||||
memberships: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="person")
|
||||
|
||||
# MB 2.0 KIEGÉSZÍTÉS: A személy által birtokolt üzleti entitások (Cégek/Szolgáltatók)
|
||||
# Ez a lista megmarad akkor is, ha az Organization deaktiválódik.
|
||||
owned_business_entities: Mapped[List["Organization"]] = relationship("Organization", back_populates="legal_owner")
|
||||
|
||||
class User(Base):
|
||||
""" Login entitás. Bármikor törölhető (GDPR), de Person-höz kötött. """
|
||||
__tablename__ = "users"
|
||||
__table_args__ = {"schema": "identity"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
PG_ENUM(UserRole, name="userrole", schema="identity"),
|
||||
default=UserRole.user
|
||||
)
|
||||
|
||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
|
||||
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"))
|
||||
subscription_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
is_vip: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
|
||||
|
||||
referral_code: Mapped[Optional[str]] = mapped_column(String(20), unique=True)
|
||||
|
||||
referred_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
current_sales_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
|
||||
|
||||
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
|
||||
region_code: Mapped[str] = mapped_column(String(5), server_default="HU")
|
||||
preferred_currency: Mapped[str] = mapped_column(String(3), server_default="HUF")
|
||||
|
||||
scope_level: Mapped[str] = mapped_column(String(30), server_default="individual")
|
||||
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Kapcsolatok
|
||||
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="users")
|
||||
wallet: Mapped[Optional["Wallet"]] = relationship("Wallet", back_populates="user", uselist=False)
|
||||
social_accounts: Mapped[List["SocialAccount"]] = relationship("SocialAccount", back_populates="user", cascade="all, delete-orphan")
|
||||
owned_organizations: Mapped[List["Organization"]] = relationship("Organization", back_populates="owner")
|
||||
stats: Mapped[Optional["UserStats"]] = relationship("UserStats", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="user")
|
||||
|
||||
@property
|
||||
def tier_name(self) -> str:
|
||||
"""Kompatibilitási mező a keresőhöz: a 'FREE' -> 'free' konverzióhoz"""
|
||||
return (self.subscription_plan or "free").lower()
|
||||
|
||||
class Wallet(Base):
|
||||
__tablename__ = "wallets"
|
||||
__table_args__ = {"schema": "identity"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), unique=True)
|
||||
|
||||
earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
|
||||
purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
|
||||
service_coins: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
|
||||
|
||||
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
user: Mapped["User"] = relationship("User", back_populates="wallet")
|
||||
|
||||
class VerificationToken(Base):
|
||||
__tablename__ = "verification_tokens"
|
||||
__table_args__ = {"schema": "identity"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
token: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False)
|
||||
token_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
is_used: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class SocialAccount(Base):
|
||||
__tablename__ = "social_accounts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('provider', 'social_id', name='uix_social_provider_id'),
|
||||
{"schema": "identity"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
social_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
extra_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="social_accounts")
|
||||
31
backend/app/models/legal.py
Executable file
31
backend/app/models/legal.py
Executable file
@@ -0,0 +1,31 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/legal.py
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base_class import Base
|
||||
|
||||
class LegalDocument(Base):
|
||||
__tablename__ = "legal_documents"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
title: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
version: Mapped[str] = mapped_column(String(20))
|
||||
|
||||
region_code: Mapped[str] = mapped_column(String(5), default="HU")
|
||||
language: Mapped[str] = mapped_column(String(5), default="hu")
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class LegalAcceptance(Base):
|
||||
__tablename__ = "legal_acceptances"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.legal_documents.id"))
|
||||
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(Text)
|
||||
26
backend/app/models/logistics.py
Executable file
26
backend/app/models/logistics.py
Executable file
@@ -0,0 +1,26 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/logistics.py
|
||||
import enum
|
||||
from typing import Optional
|
||||
from sqlalchemy import Integer, String, Enum
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.db.base_class import Base
|
||||
|
||||
class LocationType(str, enum.Enum):
|
||||
stop = "stop"
|
||||
warehouse = "warehouse"
|
||||
client = "client"
|
||||
|
||||
class Location(Base):
|
||||
__tablename__ = "locations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
type: Mapped[LocationType] = mapped_column(
|
||||
PG_ENUM(LocationType, name="location_type", inherit_schema=True),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
coordinates: Mapped[Optional[str]] = mapped_column(String)
|
||||
address_full: Mapped[Optional[str]] = mapped_column(String)
|
||||
capacity: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
217
backend/app/models/organization.py
Executable file
217
backend/app/models/organization.py
Executable file
@@ -0,0 +1,217 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/organization.py
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, text, Numeric, BigInteger, Float
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, foreign
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
|
||||
from app.database import Base
|
||||
|
||||
class OrgType(str, enum.Enum):
|
||||
individual = "individual"
|
||||
service = "service"
|
||||
service_provider = "service_provider"
|
||||
fleet_owner = "fleet_owner"
|
||||
club = "club"
|
||||
business = "business"
|
||||
|
||||
class OrgUserRole(str, enum.Enum):
|
||||
OWNER = "OWNER"
|
||||
ADMIN = "ADMIN"
|
||||
FLEET_MANAGER = "FLEET_MANAGER"
|
||||
DRIVER = "DRIVER"
|
||||
MECHANIC = "MECHANIC"
|
||||
RECEPTIONIST = "RECEPTIONIST"
|
||||
|
||||
class Organization(Base):
|
||||
"""
|
||||
Szervezet entitás (MB 2.0).
|
||||
Támogatja a 'Digital Twin' logikát: a cég törölhető, de a statisztika és
|
||||
a jármű-életút adatok megmaradnak az eredeti Person-höz kötve.
|
||||
"""
|
||||
__tablename__ = "organizations"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
# --- 🛡️ BIZTONSÁGI ÉS ÉLETÚT KIEGÉSZÍTÉSEK ---
|
||||
|
||||
# A Jogi képviselő/Tulajdonos (A Person örök DNS-e az identity sémában)
|
||||
# Ez segít felismerni, ha ugyanaz az ember új céggel akar 'tiszta lapot' nyitni.
|
||||
legal_owner_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"), index=True)
|
||||
|
||||
# ÉLETÚT DÁTUMOK (A kért logika alapján)
|
||||
# 1. A legelső regisztráció dátuma (Soha nem változik)
|
||||
first_registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 2. Az AKTUÁLIS életciklus kezdete. Újraregisztrációkor ez frissül.
|
||||
# Az API ezt használja szűrőnek: a cég csak az ezutáni adatokat látja a Dashboardon.
|
||||
current_lifecycle_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 3. Az utolsó deaktiválás/törlés időpontja
|
||||
last_deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Hányszor regisztrált újra ez a cég/adószám (Reinkarnációs index)
|
||||
lifecycle_index: Mapped[int] = mapped_column(Integer, default=1, server_default=text("1"))
|
||||
|
||||
# --- 🏢 ALAPADATOK (MEGŐRIZVE) ---
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"))
|
||||
|
||||
is_anonymized: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
|
||||
anonymized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
full_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
display_name: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
folder_slug: Mapped[str] = mapped_column(String(12), unique=True, index=True)
|
||||
|
||||
default_currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
country_code: Mapped[str] = mapped_column(String(2), default="HU")
|
||||
language: Mapped[str] = mapped_column(String(5), default="hu")
|
||||
|
||||
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
address_city: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
|
||||
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
org_type: Mapped[OrgType] = mapped_column(
|
||||
PG_ENUM(OrgType, name="orgtype", schema="data"),
|
||||
default=OrgType.individual
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(30), default="pending_verification")
|
||||
|
||||
# Soft delete: is_active=False és is_deleted=True esetén a cég 'törölt'
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
|
||||
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
||||
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||
|
||||
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
||||
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
# A technikai tulajdonos (User fiók - törölhető)
|
||||
owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Időbélyegek az aktuális állapothoz
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
is_ownership_transferable: Mapped[bool] = mapped_column(Boolean, server_default=text("true"))
|
||||
|
||||
# --- 🔗 KAPCSOLATOK (RELATIONSHIPS) ---
|
||||
assets: Mapped[List["AssetAssignment"]] = relationship("AssetAssignment", back_populates="organization", cascade="all, delete-orphan")
|
||||
members: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
|
||||
owner: Mapped[Optional["User"]] = relationship("User", back_populates="owned_organizations")
|
||||
financials: Mapped[List["OrganizationFinancials"]] = relationship("OrganizationFinancials", back_populates="organization", cascade="all, delete-orphan")
|
||||
|
||||
# JAVÍTVA: Ha az Organization törlődik, a ServiceProfile megmarad 'Ghost'-ként (ondelete="SET NULL")
|
||||
service_profile: Mapped[Optional["ServiceProfile"]] = relationship("ServiceProfile", back_populates="organization", uselist=False)
|
||||
|
||||
branches: Mapped[List["Branch"]] = relationship("Branch", back_populates="organization", cascade="all, delete-orphan")
|
||||
|
||||
# Kapcsolat az örök személy rekordhoz
|
||||
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
|
||||
|
||||
class OrganizationFinancials(Base):
|
||||
__tablename__ = "organization_financials"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
|
||||
year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
turnover: Mapped[Optional[float]] = mapped_column(Numeric(18, 2))
|
||||
profit: Mapped[Optional[float]] = mapped_column(Numeric(18, 2))
|
||||
employee_count: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
source: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="financials")
|
||||
|
||||
class OrganizationMember(Base):
|
||||
__tablename__ = "organization_members"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
|
||||
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
|
||||
role: Mapped[OrgUserRole] = mapped_column(
|
||||
PG_ENUM(OrgUserRole, name="orguserrole", schema="data"),
|
||||
default=OrgUserRole.DRIVER
|
||||
)
|
||||
permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
is_permanent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="members")
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="memberships")
|
||||
|
||||
class OrganizationSalesAssignment(Base):
|
||||
__tablename__ = "org_sales_assignments"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
|
||||
agent_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class Branch(Base):
|
||||
"""
|
||||
Telephely entitás. A fizikai helyszín, ahol a szolgáltatás vagy flotta-kezelés zajlik.
|
||||
"""
|
||||
__tablename__ = "branches"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"))
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Denormalizált adatok a gyors lekérdezéshez
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
floor: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
door: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
opening_hours: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
branch_rating: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(30), default="active")
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Kapcsolatok
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="branches")
|
||||
address: Mapped[Optional["Address"]] = relationship("Address")
|
||||
|
||||
# Kapcsolatok (Primaryjoin tartva a rating rendszerhez)
|
||||
reviews: Mapped[List["Rating"]] = relationship(
|
||||
"Rating",
|
||||
primaryjoin="and_(Branch.id==foreign(Rating.target_branch_id))"
|
||||
)
|
||||
51
backend/app/models/security.py
Executable file
51
backend/app/models/security.py
Executable file
@@ -0,0 +1,51 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/security.py
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, Integer, ForeignKey, DateTime, text, Enum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# MB 2.0: Központi aszinkron adatbázis motorból származó Base
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .identity import User
|
||||
|
||||
class ActionStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
rejected = "rejected"
|
||||
expired = "expired"
|
||||
|
||||
class PendingAction(Base):
|
||||
""" Sentinel: Kritikus műveletek jóváhagyási lánca. """
|
||||
__tablename__ = "pending_actions"
|
||||
__table_args__ = {"schema": "system"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
# JAVÍTÁS: A User az identity sémában van, nem a data-ban!
|
||||
requester_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
approver_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=True)
|
||||
|
||||
status: Mapped[ActionStatus] = mapped_column(
|
||||
Enum(ActionStatus, name="actionstatus", schema="system"),
|
||||
default=ActionStatus.pending
|
||||
)
|
||||
|
||||
action_type: Mapped[str] = mapped_column(String(50)) # pl. "WALLET_ADJUST"
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text("now() + interval '24 hours'")
|
||||
)
|
||||
processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Kapcsolatok meghatározása (String hivatkozással a körkörös import ellen)
|
||||
requester: Mapped["User"] = relationship("User", foreign_keys=[requester_id])
|
||||
approver: Mapped[Optional["User"]] = relationship("User", foreign_keys=[approver_id])
|
||||
159
backend/app/models/service.py
Executable file
159
backend/app/models/service.py
Executable file
@@ -0,0 +1,159 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/service.py
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, ForeignKey, text, Text, Float, Index, Numeric, BigInteger
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
|
||||
from app.database import Base
|
||||
|
||||
class ServiceProfile(Base):
|
||||
""" Szerviz szolgáltató adatai (v1.3.1). """
|
||||
__tablename__ = "service_profiles"
|
||||
__table_args__ = (
|
||||
Index('idx_service_fingerprint', 'fingerprint', unique=True),
|
||||
{"schema": "data"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"), unique=True)
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.service_profiles.id"))
|
||||
|
||||
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
location: Mapped[Any] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), index=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(20), server_default=text("'ghost'"), index=True)
|
||||
last_audit_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
google_place_id: Mapped[Optional[str]] = mapped_column(String(100), unique=True)
|
||||
rating: Mapped[Optional[float]] = mapped_column(Float)
|
||||
user_ratings_total: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
vibe_analysis: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
social_links: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
specialization_tags: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
|
||||
trust_score: Mapped[int] = mapped_column(Integer, default=30)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
verification_log: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
|
||||
opening_hours: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String)
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String)
|
||||
website: Mapped[Optional[str]] = mapped_column(String)
|
||||
bio: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
# Kapcsolatok
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="service_profile")
|
||||
expertises: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="service")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
class ExpertiseTag(Base):
|
||||
"""
|
||||
Szakmai címkék mesterlistája (MB 2.0).
|
||||
Ez a tábla vezérli a robotok keresését és a Gamification pontozást is.
|
||||
"""
|
||||
__tablename__ = "expertise_tags"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Egyedi azonosító kulcs (pl. 'ENGINE_REBUILD')
|
||||
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
|
||||
# Megjelenítendő nevek
|
||||
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
name_en: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
|
||||
# Főcsoport (pl. 'MECHANICS', 'ELECTRICAL', 'EMERGENCY')
|
||||
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
||||
|
||||
# --- 🎮 GAMIFICATION ÉS DISCOVERY ---
|
||||
|
||||
# Hivatalos címke (True) vagy júzer/robot által javasolt (False)
|
||||
is_official: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true"))
|
||||
|
||||
# Ha júzer javasolta, itt tároljuk, ki volt az (XP jóváíráshoz)
|
||||
suggested_by_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
|
||||
# ÁLLÍTHATÓ PONTÉRTÉK: Az adatbázisból jön, így bármikor módosítható.
|
||||
# Ritka szakmáknál magasabb, gyakoriaknál alacsonyabb érték állítható be.
|
||||
discovery_points: Mapped[int] = mapped_column(Integer, default=10, server_default=text("10"))
|
||||
|
||||
# Robot kulcsszavak (JSONB): ["fék", "betét", "tárcsa", "fékfolyadék"]
|
||||
# A Scout robot ez alapján azonosítja be a szervizt a weboldala alapján.
|
||||
search_keywords: Mapped[Any] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
|
||||
|
||||
# Népszerűségi mutató (hányszor lett felhasználva a rendszerben)
|
||||
usage_count: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
|
||||
# UI ikon azonosító (pl. 'wrench', 'tire-flat', 'car-electric')
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
# Leírás a szakmáról (Adminisztratív célokra)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
# Időbélyegek
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# --- KAPCSOLATOK ---
|
||||
services: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="tag")
|
||||
# Visszamutatás a beküldőre (ha van)
|
||||
suggested_by: Mapped[Optional["Person"]] = relationship("Person")
|
||||
|
||||
class ServiceExpertise(Base):
|
||||
"""
|
||||
KAPCSOLÓTÁBLA: Ez köti össze a szervizt a szakmáival.
|
||||
Itt tároljuk, hogy az adott szerviznél mennyire validált egy szakma.
|
||||
"""
|
||||
__tablename__ = "service_expertises"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
service_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.service_profiles.id", ondelete="CASCADE"))
|
||||
expertise_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.expertise_tags.id", ondelete="CASCADE"))
|
||||
|
||||
# Mennyire biztos ez a tudás? (0: robot találta, 1: júzer mondta, 2: igazolt szakma)
|
||||
confidence_level: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()"))
|
||||
|
||||
# Kapcsolatok visszafelé
|
||||
service = relationship("ServiceProfile", back_populates="expertises")
|
||||
tag = relationship("ExpertiseTag", back_populates="services")
|
||||
|
||||
class ServiceStaging(Base):
|
||||
""" Hunter (robot) adatok tárolója. """
|
||||
__tablename__ = "service_staging"
|
||||
__table_args__ = (
|
||||
Index('idx_staging_fingerprint', 'fingerprint', unique=True),
|
||||
{"schema": "data"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
full_address: Mapped[Optional[str]] = mapped_column(String)
|
||||
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class DiscoveryParameter(Base):
|
||||
""" Robot vezérlési paraméterek adminból. """
|
||||
__tablename__ = "discovery_parameters"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
city: Mapped[str] = mapped_column(String(100))
|
||||
keyword: Mapped[str] = mapped_column(String(100))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
78
backend/app/models/social.py
Executable file
78
backend/app/models/social.py
Executable file
@@ -0,0 +1,78 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/social.py
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base_class import Base
|
||||
|
||||
class ModerationStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
rejected = "rejected"
|
||||
|
||||
class SourceType(str, enum.Enum):
|
||||
manual = "manual"
|
||||
ocr = "ocr"
|
||||
api_import = "import"
|
||||
|
||||
class ServiceProvider(Base):
|
||||
""" Közösség által beküldött szolgáltatók (v1.3.1). """
|
||||
__tablename__ = "service_providers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
address: Mapped[str] = mapped_column(String, nullable=False)
|
||||
category: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
status: Mapped[ModerationStatus] = mapped_column(
|
||||
PG_ENUM(ModerationStatus, name="moderation_status", inherit_schema=True),
|
||||
default=ModerationStatus.pending
|
||||
)
|
||||
source: Mapped[SourceType] = mapped_column(
|
||||
PG_ENUM(SourceType, name="source_type", inherit_schema=True),
|
||||
default=SourceType.manual
|
||||
)
|
||||
|
||||
validation_score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
evidence_image_path: Mapped[Optional[str]] = mapped_column(String)
|
||||
added_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Vote(Base):
|
||||
""" Közösségi validációs szavazatok. """
|
||||
__tablename__ = "votes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'provider_id', name='uq_user_provider_vote'),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.service_providers.id"), nullable=False)
|
||||
vote_value: Mapped[int] = mapped_column(Integer, nullable=False) # +1 vagy -1
|
||||
|
||||
class Competition(Base):
|
||||
""" Gamifikált versenyek (pl. Januári Feltöltő Verseny). """
|
||||
__tablename__ = "competitions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
start_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
end_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class UserScore(Base):
|
||||
""" Versenyenkénti ranglista pontszámok. """
|
||||
__tablename__ = "user_scores"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'competition_id', name='uq_user_competition_score'),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
competition_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.competitions.id"))
|
||||
points: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_updated: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
56
backend/app/models/staged_data.py
Executable file
56
backend/app/models/staged_data.py
Executable file
@@ -0,0 +1,56 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/staged_data.py
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base_class import Base
|
||||
|
||||
class StagedVehicleData(Base):
|
||||
""" Robot 2.1 (Researcher) nyers adatgyűjtője. """
|
||||
__tablename__ = "staged_vehicle_data"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_url: Mapped[Optional[str]] = mapped_column(String)
|
||||
raw_data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
|
||||
status: Mapped[str] = mapped_column(String(20), default="PENDING", index=True)
|
||||
error_log: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class ServiceStaging(Base):
|
||||
""" Robot 1.3 (Scout) által talált nyers szerviz adatok. """
|
||||
__tablename__ = "service_staging"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
source: Mapped[str] = mapped_column(String(50))
|
||||
external_id: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
|
||||
city: Mapped[str] = mapped_column(String(100), index=True)
|
||||
full_address: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
website: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
raw_data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||
trust_score: Mapped[int] = mapped_column(Integer, default=30)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
class DiscoveryParameter(Base):
|
||||
""" Felderítési paraméterek (Városok, ahol a Scout keres). """
|
||||
__tablename__ = "discovery_parameters"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
city: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
country_code: Mapped[str] = mapped_column(String(5), server_default=text("'HU'"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
53
backend/app/models/system.py
Executable file
53
backend/app/models/system.py
Executable file
@@ -0,0 +1,53 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/system.py
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Integer, Boolean, DateTime, text, UniqueConstraint, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base_class import Base
|
||||
|
||||
class SystemParameter(Base):
|
||||
""" Dinamikus konfigurációs motor (Global -> Org -> User). """
|
||||
__tablename__ = "system_parameters"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('key', 'scope_level', 'scope_id', name='uix_param_scope'),
|
||||
{"extend_existing": True}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String, index=True)
|
||||
category: Mapped[str] = mapped_column(String, server_default="general", index=True)
|
||||
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
|
||||
scope_level: Mapped[str] = mapped_column(String(30), server_default=text("'global'"), index=True)
|
||||
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(String)
|
||||
last_modified_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
class InternalNotification(Base):
|
||||
"""
|
||||
Belső értesítési központ.
|
||||
Ezek az üzenetek várják a felhasználót belépéskor.
|
||||
"""
|
||||
__tablename__ = "internal_notifications"
|
||||
__table_args__ = ({"schema": "data", "extend_existing": True})
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(50), server_default="info") # insurance, mot, service, legal
|
||||
priority: Mapped[str] = mapped_column(String(20), server_default="medium") # low, medium, high, critical
|
||||
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Metaadatok a gyors eléréshez (melyik autó, melyik VIN)
|
||||
data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
27
backend/app/models/translation.py
Executable file
27
backend/app/models/translation.py
Executable file
@@ -0,0 +1,27 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/translation.py
|
||||
from sqlalchemy import String, Integer, Text, Boolean, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
|
||||
from app.database import Base
|
||||
|
||||
class Translation(Base):
|
||||
"""
|
||||
Többnyelvűséget támogató tábla a felületi elemekhez és dinamikus tartalmakhoz.
|
||||
"""
|
||||
__tablename__ = "translations"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
# A fordítandó kulcs (pl. 'NAV_DASHBOARD' vagy 'ERR_USER_NOT_FOUND')
|
||||
key: Mapped[str] = mapped_column(String(255), index=True)
|
||||
|
||||
# Nyelvi kód (pl: 'hu', 'en', 'de')
|
||||
lang: Mapped[str] = mapped_column(String(5), index=True)
|
||||
|
||||
# A tényleges fordított szöveg
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
|
||||
# --- JAVÍTÁS: A diagnosztika által hiányolt publikációs állapot ---
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true"))
|
||||
155
backend/app/models/vehicle_definitions.py
Executable file
155
backend/app/models/vehicle_definitions.py
Executable file
@@ -0,0 +1,155 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/vehicle_definitions.py
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, text, JSON, Index, UniqueConstraint, Text, ARRAY, func, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# MB 2.0: Egységesített Base import a központi adatbázis motorból
|
||||
from app.database import Base
|
||||
|
||||
class VehicleType(Base):
|
||||
""" Jármű kategóriák (pl. Személyautó, Motorkerékpár, Teherautó, Hajó) """
|
||||
__tablename__ = "vehicle_types"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(30), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(50))
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
units: Mapped[dict] = mapped_column(JSONB, server_default=text("'{\"power\": \"kW\", \"weight\": \"kg\"}'::jsonb"))
|
||||
|
||||
# Kapcsolatok
|
||||
features: Mapped[List["FeatureDefinition"]] = relationship("FeatureDefinition", back_populates="vehicle_type")
|
||||
definitions: Mapped[List["VehicleModelDefinition"]] = relationship("VehicleModelDefinition", back_populates="v_type_rel")
|
||||
|
||||
|
||||
class FeatureDefinition(Base):
|
||||
""" Felszereltségi elemek definíciója (pl. ABS, Klíma, LED fényszóró) """
|
||||
__tablename__ = "feature_definitions"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
vehicle_type_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.vehicle_types.id"))
|
||||
code: Mapped[str] = mapped_column(String(50), index=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
category: Mapped[str] = mapped_column(String(50), index=True)
|
||||
|
||||
vehicle_type: Mapped["VehicleType"] = relationship("VehicleType", back_populates="features")
|
||||
model_maps: Mapped[List["ModelFeatureMap"]] = relationship("ModelFeatureMap", back_populates="feature")
|
||||
|
||||
|
||||
class VehicleModelDefinition(Base):
|
||||
"""
|
||||
Robot v1.1.0 Multi-Tier MDM Master Adattábla.
|
||||
Az ökoszisztéma technikai igazságforrása.
|
||||
"""
|
||||
__tablename__ = "vehicle_model_definitions"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
make: Mapped[str] = mapped_column(String(100), index=True)
|
||||
marketing_name: Mapped[str] = mapped_column(String(255), index=True) # Nyers név az RDW-ből
|
||||
official_marketing_name: Mapped[Optional[str]] = mapped_column(String(255)) # Dúsított, validált név (Robot 2.2)
|
||||
|
||||
# --- ROBOT LOGIKAI MEZŐK (JAVÍTVA 2.0 STÍLUSBAN) ---
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
priority_score: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
|
||||
# --- PRECISION LOGIC MEZŐK ---
|
||||
normalized_name: Mapped[Optional[str]] = mapped_column(String(255), index=True, nullable=True)
|
||||
marketing_name_aliases: Mapped[list] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
|
||||
engine_code: Mapped[Optional[str]] = mapped_column(String(50), index=True) # A GLOBÁLIS KAPOCS
|
||||
|
||||
# --- TECHNIKAI AZONOSÍTÓK ---
|
||||
technical_code: Mapped[str] = mapped_column(String(100), index=True) # Holland rendszám (kulcs)
|
||||
variant_code: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
version_code: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
|
||||
# --- ÚJ PRÉMIUM MŰSZAKI MEZŐK ---
|
||||
type_approval_number: Mapped[Optional[str]] = mapped_column(String(100), index=True) # e1*2001/...
|
||||
seats: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
width: Mapped[Optional[int]] = mapped_column(Integer) # cm
|
||||
wheelbase: Mapped[Optional[int]] = mapped_column(Integer) # cm
|
||||
list_price: Mapped[Optional[int]] = mapped_column(Integer) # EUR (catalogusprijs)
|
||||
max_speed: Mapped[Optional[int]] = mapped_column(Integer) # km/h
|
||||
|
||||
# Vontatási adatok
|
||||
towing_weight_unbraked: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
towing_weight_braked: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
# Környezetvédelmi adatok
|
||||
fuel_consumption_combined: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
co2_emissions_combined: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
|
||||
# --- SPECIFIKÁCIÓK ---
|
||||
vehicle_type_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.vehicle_types.id"))
|
||||
vehicle_class: Mapped[Optional[str]] = mapped_column(String(50), index=True)
|
||||
body_type: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
fuel_type: Mapped[Optional[str]] = mapped_column(String(50), index=True)
|
||||
|
||||
engine_capacity: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
power_kw: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
torque_nm: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
cylinders: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
cylinder_layout: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
curb_weight: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
max_weight: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
euro_classification: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
doors: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
transmission_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
drive_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
# --- ÉLETCIKLUS ÉS STÁTUSZ ---
|
||||
year_from: Mapped[Optional[int]] = mapped_column(Integer, index=True)
|
||||
year_to: Mapped[Optional[int]] = mapped_column(Integer, index=True)
|
||||
production_status: Mapped[Optional[str]] = mapped_column(String(50)) # active / discontinued
|
||||
|
||||
# Státusz szintek: unverified, research_in_progress, awaiting_ai_synthesis, gold_enriched
|
||||
status: Mapped[str] = mapped_column(String(50), server_default=text("'unverified'"), index=True)
|
||||
is_manual: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
source: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
|
||||
# --- ADAT-KONTÉNEREK ---
|
||||
raw_search_context: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
research_metadata: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
specifications: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # Robot 2.2/2.5 Arany adatai
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
last_research_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# --- BEÁLLÍTÁSOK ---
|
||||
__table_args__ = (
|
||||
UniqueConstraint('make', 'normalized_name', 'variant_code', 'version_code', 'fuel_type', name='uix_vmd_precision'),
|
||||
Index('idx_vmd_lookup_fast', 'make', 'normalized_name'),
|
||||
Index('idx_vmd_engine_bridge', 'make', 'engine_code'),
|
||||
{"schema": "data"}
|
||||
)
|
||||
|
||||
# KAPCSOLATOK
|
||||
v_type_rel: Mapped["VehicleType"] = relationship("VehicleType", back_populates="definitions")
|
||||
feature_maps: Mapped[List["ModelFeatureMap"]] = relationship("ModelFeatureMap", back_populates="model_definition")
|
||||
|
||||
# Hivatkozás az asset.py-ban lévő osztályra
|
||||
# Megjegyzés: Ha az AssetCatalog nincs itt importálva, húzzal adjuk meg a nevet
|
||||
variants: Mapped[List["AssetCatalog"]] = relationship("AssetCatalog", back_populates="master_definition")
|
||||
|
||||
|
||||
class ModelFeatureMap(Base):
|
||||
""" Kapcsolótábla a modellek és az alapfelszereltség között """
|
||||
__tablename__ = "model_feature_maps"
|
||||
__table_args__ = {"schema": "data"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
model_definition_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.vehicle_model_definitions.id"))
|
||||
feature_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.feature_definitions.id"))
|
||||
is_standard: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
model_definition: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="feature_maps")
|
||||
feature: Mapped["FeatureDefinition"] = relationship("FeatureDefinition", back_populates="model_maps")
|
||||
123
backend/app/schemas/admin.py
Executable file
123
backend/app/schemas/admin.py
Executable file
@@ -0,0 +1,123 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, text, delete
|
||||
from typing import List, Any, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.api import deps
|
||||
from app.models.identity import User, UserRole
|
||||
from app.models.system import SystemParameter
|
||||
from app.models.audit import SecurityAuditLog, OperationalLog
|
||||
from app.models.security import PendingAction, ActionStatus
|
||||
from app.services.security_service import security_service
|
||||
from app.services.translation_service import TranslationService
|
||||
from app.schemas.admin import PointRuleResponse, LevelConfigResponse, ConfigUpdate
|
||||
from app.schemas.admin_security import PendingActionResponse, SecurityStatusResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
|
||||
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
|
||||
""" Csak Admin vagy Superadmin léphet be a Sentinel központba. """
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Sentinel jogosultság szükséges a művelethez!"
|
||||
)
|
||||
return current_user
|
||||
|
||||
# --- 🛰️ 1. SENTINEL: RENDSZERÁLLAPOT ÉS MONITORING ---
|
||||
|
||||
@router.get("/health-monitor", response_model=Dict[str, Any], tags=["Sentinel Monitoring"])
|
||||
async def get_system_health(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
""" Részletes rendszerstatisztikák (Felhasználók, Eszközök, Biztonság). """
|
||||
stats = {}
|
||||
|
||||
# Felhasználói eloszlás (Nyers SQL a sebességért)
|
||||
user_res = await db.execute(text("SELECT subscription_plan, count(*) FROM data.users GROUP BY subscription_plan"))
|
||||
stats["user_distribution"] = {row[0]: row[1] for row in user_res}
|
||||
|
||||
# Eszköz és Szervezet számlálók
|
||||
stats["total_assets"] = (await db.execute(text("SELECT count(*) FROM data.assets"))).scalar()
|
||||
stats["total_organizations"] = (await db.execute(text("SELECT count(*) FROM data.organizations"))).scalar()
|
||||
|
||||
# Biztonsági riasztások (Kritikus logok az elmúlt 24 órában)
|
||||
day_ago = datetime.now() - timedelta(days=1)
|
||||
crit_logs = await db.execute(
|
||||
select(func.count(SecurityAuditLog.id))
|
||||
.where(SecurityAuditLog.is_critical == True, SecurityAuditLog.created_at >= day_ago)
|
||||
)
|
||||
stats["critical_alerts_24h"] = crit_logs.scalar() or 0
|
||||
|
||||
return stats
|
||||
|
||||
# --- ⚖️ 2. SENTINEL: NÉGY SZEM ELV (Approval System) ---
|
||||
|
||||
@router.get("/pending-actions", response_model=List[PendingActionResponse], tags=["Sentinel Security"])
|
||||
async def list_pending_actions(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
""" Jóváhagyásra váró kritikus műveletek listázása. """
|
||||
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/approve/{action_id}", tags=["Sentinel Security"])
|
||||
async def approve_action(
|
||||
action_id: int,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
""" Művelet véglegesítése egy második admin által. """
|
||||
try:
|
||||
await security_service.approve_action(db, admin.id, action_id)
|
||||
return {"status": "success", "message": "Művelet végrehajtva."}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
# --- ⚙️ 3. DINAMIKUS KONFIGURÁCIÓ (System Parameters) ---
|
||||
|
||||
@router.get("/parameters", tags=["Dynamic Configuration"])
|
||||
async def list_all_parameters(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
""" Globális és lokális paraméterek (Limitek, XP szorzók) lekérése. """
|
||||
result = await db.execute(select(SystemParameter))
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/parameters", tags=["Dynamic Configuration"])
|
||||
async def set_parameter(
|
||||
config: ConfigUpdate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_admin_access)
|
||||
):
|
||||
""" Paraméter beállítása vagy frissítése hierarchikus scope-al. """
|
||||
query = text("""
|
||||
INSERT INTO data.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
|
||||
VALUES (:key, :val, :sl, :sid, :cat, :user)
|
||||
ON CONFLICT (key, scope_level, scope_id)
|
||||
DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
category = EXCLUDED.category,
|
||||
last_modified_by = EXCLUDED.last_modified_by,
|
||||
updated_at = now()
|
||||
""")
|
||||
|
||||
await db.execute(query, {
|
||||
"key": config.key, "val": config.value, "sl": config.scope_level,
|
||||
"sid": config.scope_id, "cat": config.category, "user": admin.email
|
||||
})
|
||||
await db.commit()
|
||||
return {"status": "success", "message": f"'{config.key}' frissítve."}
|
||||
|
||||
@router.post("/translations/sync", tags=["System Utilities"])
|
||||
async def sync_translations(db: AsyncSession = Depends(deps.get_db), admin: User = Depends(check_admin_access)):
|
||||
""" DB fordítások exportálása JSON fájlokba a frontendnek. """
|
||||
await TranslationService.export_to_json(db)
|
||||
return {"message": "Nyelvi fájlok frissítve."}
|
||||
23
backend/app/schemas/admin_security.py
Executable file
23
backend/app/schemas/admin_security.py
Executable file
@@ -0,0 +1,23 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/admin_security.py
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any, Dict
|
||||
from app.models.security import ActionStatus
|
||||
|
||||
class PendingActionResponse(BaseModel):
|
||||
id: int
|
||||
requester_id: int
|
||||
action_type: str
|
||||
payload: Dict[str, Any]
|
||||
reason: str
|
||||
status: ActionStatus
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class SecurityStatusResponse(BaseModel):
|
||||
total_pending: int
|
||||
critical_logs_last_24h: int
|
||||
emergency_locks_active: int
|
||||
|
||||
56
backend/app/schemas/asset.py
Executable file
56
backend/app/schemas/asset.py
Executable file
@@ -0,0 +1,56 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset.py
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
class AssetCatalogResponse(BaseModel):
|
||||
""" A technikai katalógus (Master Data) teljes adattartalma. """
|
||||
id: int
|
||||
make: str
|
||||
model: str
|
||||
generation: Optional[str] = None
|
||||
engine_variant: Optional[str] = None
|
||||
year_from: Optional[int] = None
|
||||
year_to: Optional[int] = None
|
||||
vehicle_class: Optional[str] = None
|
||||
fuel_type: Optional[str] = None
|
||||
|
||||
# Technikai paraméterek az automatizáláshoz
|
||||
power_kw: Optional[int] = None
|
||||
engine_capacity: Optional[int] = None
|
||||
max_weight_kg: Optional[int] = None
|
||||
axle_count: Optional[int] = None
|
||||
euro_class: Optional[str] = None
|
||||
body_type: Optional[str] = None
|
||||
engine_code: Optional[str] = None
|
||||
|
||||
factory_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
""" A konkrét járműpéldány (Asset) teljes válaszmodellje. """
|
||||
id: UUID
|
||||
vin: str = Field(..., min_length=17, max_length=17)
|
||||
license_plate: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
year_of_manufacture: Optional[int] = None
|
||||
|
||||
# Státusz és ellenőrzés
|
||||
status: str
|
||||
is_verified: bool
|
||||
verification_method: Optional[str] = None
|
||||
catalog_match_score: Optional[float] = None
|
||||
|
||||
# Kapcsolt adatok
|
||||
catalog_id: Optional[int] = None
|
||||
catalog: Optional[AssetCatalogResponse] = None # Itt jön a dúsítás!
|
||||
|
||||
owner_organization_id: Optional[int] = None
|
||||
operator_person_id: Optional[int] = None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
35
backend/app/schemas/asset_cost.py
Executable file
35
backend/app/schemas/asset_cost.py
Executable file
@@ -0,0 +1,35 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
class AssetCostBase(BaseModel):
|
||||
cost_type: str # fuel, service, tax, insurance
|
||||
amount_local: Decimal
|
||||
currency_local: str = "HUF"
|
||||
net_amount_local: Optional[Decimal] = None
|
||||
vat_rate: Optional[Decimal] = Field(default=27.0)
|
||||
|
||||
date: datetime = Field(default_factory=datetime.now)
|
||||
mileage_at_cost: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
|
||||
|
||||
class AssetCostCreate(AssetCostBase):
|
||||
asset_id: UUID
|
||||
organization_id: int
|
||||
|
||||
class AssetCostResponse(AssetCostBase):
|
||||
id: UUID
|
||||
asset_id: UUID
|
||||
organization_id: int
|
||||
driver_id: Optional[int] = None
|
||||
|
||||
# Pénzügyi dúsítás (Backend számolja)
|
||||
amount_eur: Optional[Decimal] = None
|
||||
exchange_rate_used: Optional[Decimal] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
54
backend/app/schemas/auth.py
Executable file
54
backend/app/schemas/auth.py
Executable file
@@ -0,0 +1,54 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/auth.py
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import date, datetime
|
||||
|
||||
class DocumentDetail(BaseModel):
|
||||
number: str
|
||||
expiry_date: date
|
||||
|
||||
class ICEContact(BaseModel):
|
||||
name: str
|
||||
phone: str
|
||||
relationship: str
|
||||
|
||||
class UserLiteRegister(BaseModel):
|
||||
""" Step 1: Gyors regisztráció (Alap azonosítás). """
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, description="Minimum 8 karakter hosszú jelszó")
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class UserKYCComplete(BaseModel):
|
||||
""" Step 2: Teljes körű személyazonosítás és címadatok. """
|
||||
phone_number: str = Field(..., pattern=r"^\+?[0-9]{7,15}$")
|
||||
birth_place: str
|
||||
birth_date: date
|
||||
mothers_last_name: str
|
||||
mothers_first_name: str
|
||||
|
||||
# Atomizált címadatok a pontos GPS-hez és Robot-munkához
|
||||
address_zip: str
|
||||
address_city: str
|
||||
address_street_name: str
|
||||
address_street_type: str # utca, út, tér...
|
||||
address_house_number: str
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None # Külterület/Helyrajzi szám
|
||||
|
||||
# Okmányok és Vészhelyzet
|
||||
identity_docs: Dict[str, DocumentDetail] # pl: {"ID_CARD": {...}, "LICENSE": {...}}
|
||||
ice_contact: ICEContact
|
||||
|
||||
preferred_language: str = "hu"
|
||||
preferred_currency: str = "HUF"
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
token_type: str = "bearer"
|
||||
is_active: bool
|
||||
47
backend/app/schemas/evidence.py
Executable file
47
backend/app/schemas/evidence.py
Executable file
@@ -0,0 +1,47 @@
|
||||
# app/schemas/evidence.py
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
class RegistrationDocumentExtracted(BaseModel):
|
||||
"""A magyar forgalmi engedély teljes adattartalma."""
|
||||
# A - Okmány adatok
|
||||
license_plate: Optional[str] = Field(None, alias="A", description="Rendszám")
|
||||
first_registration_date: Optional[str] = Field(None, alias="B", description="Első nyilvántartásba vétel")
|
||||
doc_serial_number: Optional[str] = Field(None, description="Okmány sorszáma (jobb felső sarok)")
|
||||
|
||||
# C - Tulajdonos/Üzembentartó adatok
|
||||
owner_last_name: Optional[str] = Field(None, alias="C.1.1", description="Családi név vagy cégnév")
|
||||
owner_first_name: Optional[str] = Field(None, alias="C.1.2", description="Utónév")
|
||||
owner_address: Optional[str] = Field(None, alias="C.1.3", description="Lakcím/Székhely")
|
||||
owner_status: Optional[str] = Field(None, alias="C.4", description="Jogosultság státusza (a=tulaj, b=nem tulaj)")
|
||||
|
||||
# D - Jármű technikai adatai
|
||||
make: Optional[str] = Field(None, alias="D.1", description="Gyártmány")
|
||||
vehicle_type: Optional[str] = Field(None, alias="D.2", description="Típus")
|
||||
commercial_description: Optional[str] = Field(None, alias="D.3", description="Kereskedelmi leírás")
|
||||
vin: Optional[str] = Field(None, alias="E", description="Alvázszám (17 karakter)")
|
||||
|
||||
# G, F - Tömeg adatok
|
||||
weight_kg: Optional[int] = Field(None, alias="G", description="Saját tömeg")
|
||||
max_weight_kg: Optional[int] = Field(None, alias="F.1", description="Együttes tömeg")
|
||||
|
||||
# P, V - Motor és Környezetvédelem
|
||||
engine_capacity: Optional[int] = Field(None, alias="P.1", description="Hengerűrtartalom (cm3)")
|
||||
engine_power: Optional[float] = Field(None, alias="P.2", description="Teljesítmény (kW)")
|
||||
fuel_type: Optional[str] = Field(None, alias="P.3", description="Hajtóanyag")
|
||||
engine_code: Optional[str] = Field(None, alias="P.5", description="Motorkód")
|
||||
env_category: Optional[str] = Field(None, alias="V.9", description="Környezetvédelmi osztály")
|
||||
|
||||
# R, S, H - Egyéb
|
||||
color: Optional[str] = Field(None, alias="R", description="Szín")
|
||||
seats: Optional[int] = Field(None, alias="S.1", description="Ülések száma")
|
||||
expiry_date: Optional[str] = Field(None, alias="H", description="Műszaki érvényesség")
|
||||
transmission_type: Optional[str] = Field(None, description="Sebességváltó fajtája")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
class OcrResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[RegistrationDocumentExtracted] = None
|
||||
20
backend/app/schemas/fleet.py
Executable file
20
backend/app/schemas/fleet.py
Executable file
@@ -0,0 +1,20 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/fleet.py
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
asset_id: UUID
|
||||
event_type: str # 'SERVICE', 'FUEL', 'MOT'
|
||||
date: date
|
||||
odometer_value: int
|
||||
cost_amount: float
|
||||
description: Optional[str] = None
|
||||
provider_id: Optional[int] = None
|
||||
|
||||
class TCOStats(BaseModel):
|
||||
asset_id: UUID
|
||||
total_cost_huf: float
|
||||
cost_per_km: float
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
38
backend/app/schemas/organization.py
Executable file
38
backend/app/schemas/organization.py
Executable file
@@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
full_name: str
|
||||
email: str
|
||||
phone: Optional[str] = None
|
||||
contact_type: str = "primary"
|
||||
|
||||
class CorpOnboardIn(BaseModel):
|
||||
""" Teljes onboarding adatcsomag atomizált címekkel. """
|
||||
full_name: str = Field(..., description="Hivatalos cégnév")
|
||||
name: str = Field(..., description="Rövid név")
|
||||
display_name: str
|
||||
|
||||
tax_number: str
|
||||
reg_number: Optional[str] = None
|
||||
country_code: str = "HU"
|
||||
language: str = "hu"
|
||||
default_currency: str = "HUF"
|
||||
|
||||
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
|
||||
address_zip: str
|
||||
address_city: str
|
||||
address_street_name: str
|
||||
address_street_type: str
|
||||
address_house_number: str
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
|
||||
contacts: List[ContactCreate] = []
|
||||
|
||||
class CorpOnboardResponse(BaseModel):
|
||||
organization_id: int
|
||||
status: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
38
backend/app/schemas/service.py
Executable file
38
backend/app/schemas/service.py
Executable file
@@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
full_name: str
|
||||
email: str
|
||||
phone: Optional[str] = None
|
||||
contact_type: str = "primary"
|
||||
|
||||
class CorpOnboardIn(BaseModel):
|
||||
""" Teljes onboarding adatcsomag atomizált címekkel. """
|
||||
full_name: str = Field(..., description="Hivatalos cégnév")
|
||||
name: str = Field(..., description="Rövid név")
|
||||
display_name: str
|
||||
|
||||
tax_number: str
|
||||
reg_number: Optional[str] = None
|
||||
country_code: str = "HU"
|
||||
language: str = "hu"
|
||||
default_currency: str = "HUF"
|
||||
|
||||
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
|
||||
address_zip: str
|
||||
address_city: str
|
||||
address_street_name: str
|
||||
address_street_type: str
|
||||
address_house_number: str
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
|
||||
contacts: List[ContactCreate] = []
|
||||
|
||||
class CorpOnboardResponse(BaseModel):
|
||||
organization_id: int
|
||||
status: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
9
backend/app/schemas/service_hunt.py
Executable file
9
backend/app/schemas/service_hunt.py
Executable file
@@ -0,0 +1,9 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/service_hunt.py
|
||||
class ServiceHuntRequest(BaseModel):
|
||||
name: str
|
||||
category_id: int
|
||||
address: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
user_latitude: float
|
||||
user_longitude: float
|
||||
58
backend/app/schemas/social.py
Executable file
58
backend/app/schemas/social.py
Executable file
@@ -0,0 +1,58 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/social.py
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.social import ModerationStatus, SourceType
|
||||
|
||||
# --- Alap Sémák (Szolgáltatók) ---
|
||||
|
||||
class ServiceProviderBase(BaseModel):
|
||||
name: str
|
||||
address: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
source: SourceType = SourceType.manual
|
||||
|
||||
class ServiceProviderCreate(BaseModel):
|
||||
name: str
|
||||
address: str
|
||||
category: Optional[str] = None
|
||||
|
||||
class ServiceProviderResponse(ServiceProviderBase):
|
||||
id: int
|
||||
status: ModerationStatus
|
||||
validation_score: int
|
||||
evidence_image_path: Optional[str] = None
|
||||
added_by_user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# --- Gamifikáció és Szavazás (Voting & Gamification) ---
|
||||
|
||||
class VoteCreate(BaseModel):
|
||||
vote_value: int
|
||||
|
||||
class LeaderboardEntry(BaseModel):
|
||||
username: str
|
||||
points: int
|
||||
rank: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class BadgeSchema(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
icon_url: Optional[str] = None # JAVÍTVA: icon_url a modell szerint
|
||||
|
||||
model_config = ConfigDict(from_attributes=True) # Pydantic V2 kompatibilis
|
||||
|
||||
class UserStatSchema(BaseModel):
|
||||
user_id: int
|
||||
total_xp: int # JAVÍTVA: total_xp a modell szerint
|
||||
current_level: int
|
||||
penalty_points: int # JAVÍTVA: új mező
|
||||
rank_title: Optional[str] = None
|
||||
badges: List[BadgeSchema] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
10
backend/app/schemas/token.py
Executable file
10
backend/app/schemas/token.py
Executable file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
25
backend/app/schemas/user.py
Executable file
25
backend/app/schemas/user.py
Executable file
@@ -0,0 +1,25 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
|
||||
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
is_active: bool = True
|
||||
region_code: str = "HU"
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
person_id: Optional[int] = None
|
||||
role: str
|
||||
subscription_plan: str
|
||||
scope_level: str
|
||||
scope_id: Optional[str] = None
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
preferred_language: Optional[str] = None
|
||||
30
backend/app/schemas/vehicle.py.old
Executable file
30
backend/app/schemas/vehicle.py.old
Executable file
@@ -0,0 +1,30 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, List, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
class EngineSpecBase(BaseModel):
|
||||
engine_code: str
|
||||
fuel_type: str
|
||||
power_kw: int
|
||||
default_service_interval_km: int = 15000
|
||||
|
||||
class VehicleBase(BaseModel):
|
||||
brand_id: int
|
||||
model_name: str
|
||||
identification_number: str
|
||||
license_plate: Optional[str] = None
|
||||
tracking_mode: str = "km"
|
||||
|
||||
class VehicleCreate(VehicleBase):
|
||||
current_company_id: int
|
||||
engine_spec_id: int
|
||||
|
||||
class VehicleRead(VehicleBase):
|
||||
id: UUID
|
||||
current_rating_pct: int
|
||||
total_real_usage: float
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
20
backend/app/schemas/vehicle_categories.py
Executable file
20
backend/app/schemas/vehicle_categories.py
Executable file
@@ -0,0 +1,20 @@
|
||||
# app/core/schemas/vehicle_categories.py
|
||||
|
||||
VEHICLE_SCHEMAS = {
|
||||
"motorcycle": {
|
||||
"features": ["ABS", "Markolatfűtés", "Szélvédő", "Bukócső/gomba", "Automata váltó", "Gyári dobozok", "Zárható doboz", "Veterán"],
|
||||
"service_items": ["motorolaj", "olajszűrő", "levegőszűrő", "lánc_szett", "fékfolyadék", "gyújtógyertya", "szelephézag_ellenőrzés"]
|
||||
},
|
||||
"car": {
|
||||
"features": ["Automata", "Tempomat", "Összkerékhajtás", "Alufelni", "Elektromos ablak", "Vonóhorog", "ISOFIX rendszer", "ESP", "Szervizkönyv", "Veterán"],
|
||||
"service_items": ["motorolaj", "olajszűrő", "levegőszűrő", "pollenszűrő", "vezérlés_szett", "hosszbordásszíj", "váltóolaj", "fagyálló"]
|
||||
},
|
||||
"truck": {
|
||||
"features": ["Légrugó", "Hálófülke", "Retarder/Intarder", "Emelőhátfal", "Tengelysúly-mérő", "AdBlue", "Állóhelyzeti klíma"],
|
||||
"service_items": ["motorolaj", "légfék_szárító_patron", "üzemanyagszűrő", "érintésvédelmi_vizsga", "tengely_zsírozás"]
|
||||
},
|
||||
"boat": {
|
||||
"features": ["Utánfutó", "Takaróponyva", "Orrsugárkormány", "Halradar", "Kormányállás", "Üzemanyagtartály", "Sólyakocsi", "Zárható tároló", "Elektromos horgonycsörlő"],
|
||||
"service_items": ["motorolaj", "hajómotor_anód", "vízpumpa_lapát", "téliesítés", "algagátlózás"]
|
||||
}
|
||||
}
|
||||
38
backend/app/scripts/discovery_bot.py.veryold
Executable file
38
backend/app/scripts/discovery_bot.py.veryold
Executable file
@@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from app.db.session import engine
|
||||
from datetime import datetime
|
||||
|
||||
async def log_discovery(conn, category, brand, model, action):
|
||||
await conn.execute(text("""
|
||||
INSERT INTO data.bot_discovery_logs (category, brand_name, model_name, action_taken)
|
||||
VALUES (:c, :b, :m, :a)
|
||||
"""), {"c": category, "b": brand, "m": model, "a": action})
|
||||
|
||||
async def run_discovery():
|
||||
async with engine.begin() as conn:
|
||||
print(f"🚀 Jármű felfedezés indul: {datetime.now()}")
|
||||
|
||||
# Jelenleg a CAR kategóriára fókuszálunk egy külső API segítségével (pl. NHTSA - Ingyenes)
|
||||
# Itt egy példa, hogyan bővül dinamikusan a rendszer
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Autók lekérése
|
||||
response = await client.get("https://vpic.nhtsa.dot.gov/api/vehicles/getallmakes?format=json")
|
||||
if response.status_code == 200:
|
||||
makes = response.json().get('Results', [])[:100] # Tesztként az első 100
|
||||
|
||||
for make in makes:
|
||||
brand_name = make['Make_Name'].strip()
|
||||
# Megnézzük, megvan-e már
|
||||
res = await conn.execute(text("SELECT id FROM data.vehicle_brands WHERE name = :n"), {"n": brand_name})
|
||||
if not res.scalar():
|
||||
await conn.execute(text("INSERT INTO data.vehicle_brands (category_id, name) VALUES (1, :n)"), {"n": brand_name})
|
||||
await log_discovery(conn, "CAR", brand_name, "ALL", "NEW_BRAND")
|
||||
print(f"✨ Új márka találva: {brand_name}")
|
||||
|
||||
await conn.commit()
|
||||
print("✅ Bot futása befejeződött.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_discovery())
|
||||
63
backend/app/scripts/link_catalog_to_mdm.py
Executable file
63
backend/app/scripts/link_catalog_to_mdm.py
Executable file
@@ -0,0 +1,63 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/link_catalog_to_mdm.py
|
||||
import asyncio
|
||||
from sqlalchemy import select, update
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.asset import AssetCatalog
|
||||
from app.models.vehicle_definitions import VehicleModelDefinition, VehicleType
|
||||
|
||||
async def link_catalog_to_mdm():
|
||||
""" Összefűzi a technikai katalógust a központi Master Definíciókkal. """
|
||||
async with SessionLocal() as db:
|
||||
try:
|
||||
print("🔍 Master-Híd építése indul...")
|
||||
|
||||
# 1. Típusok betöltése
|
||||
type_res = await db.execute(select(VehicleType))
|
||||
types = {t.code: t.id for t in type_res.scalars().all()}
|
||||
|
||||
# 2. Egyedi variánsok lekérése
|
||||
stmt = select(AssetCatalog.make, AssetCatalog.model, AssetCatalog.vehicle_class).distinct()
|
||||
raw_data = await db.execute(stmt)
|
||||
unique_models = raw_data.all()
|
||||
|
||||
linked_count = 0
|
||||
for make, model, v_class in unique_models:
|
||||
t_code = v_class if v_class in types else "car"
|
||||
t_id = types.get(t_code)
|
||||
|
||||
# Master rekord keresése vagy létrehozása
|
||||
master_stmt = select(VehicleModelDefinition).where(
|
||||
VehicleModelDefinition.make == make,
|
||||
VehicleModelDefinition.marketing_name == model
|
||||
)
|
||||
master = (await db.execute(master_stmt)).scalar_one_or_none()
|
||||
|
||||
if not master:
|
||||
master = VehicleModelDefinition(
|
||||
make=make,
|
||||
technical_code=model.replace(" ", "-").lower(),
|
||||
marketing_name=model,
|
||||
vehicle_type=t_code,
|
||||
vehicle_type_id=t_id,
|
||||
status="unverified",
|
||||
source="linking_process"
|
||||
)
|
||||
db.add(master)
|
||||
await db.flush()
|
||||
|
||||
# Összekötés
|
||||
await db.execute(
|
||||
update(AssetCatalog)
|
||||
.where(AssetCatalog.make == make, AssetCatalog.model == model)
|
||||
.values(master_definition_id=master.id)
|
||||
)
|
||||
linked_count += 1
|
||||
|
||||
await db.commit()
|
||||
print(f"✅ Sikeresen összekötve: {linked_count} modell.")
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"❌ Hiba: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(link_catalog_to_mdm())
|
||||
35
backend/app/scripts/morning_report.py
Executable file
35
backend/app/scripts/morning_report.py
Executable file
@@ -0,0 +1,35 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/morning_report.py
|
||||
import asyncio
|
||||
from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.audit import ProcessLog
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
async def generate_morning_report():
|
||||
""" Összesíti a háttérfolyamatok (robotok) elmúlt 24 órás teljesítményét. """
|
||||
async with SessionLocal() as db:
|
||||
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
stmt = select(ProcessLog).where(ProcessLog.start_time >= yesterday)
|
||||
res = await db.execute(stmt)
|
||||
logs = res.scalars().all()
|
||||
|
||||
report = f"📊 REGGELI ROBOT JELENTÉS - {datetime.now().date()}\n"
|
||||
report += "="*40 + "\n"
|
||||
|
||||
total_proc = sum(log.items_processed for log in logs)
|
||||
total_fail = sum(log.items_failed for log in logs)
|
||||
|
||||
report += f"✅ Feldolgozott egységek: {total_proc}\n"
|
||||
report += f"❌ Sikertelen műveletek: {total_fail}\n"
|
||||
|
||||
if logs:
|
||||
report += "\nAktív robotok állapota:\n"
|
||||
for log in logs:
|
||||
status = "🟢 OK" if log.items_failed == 0 else "🔴 HIBA"
|
||||
report += f" - {log.process_name}: {log.items_processed} feldolgozva ({status})\n"
|
||||
|
||||
print(report)
|
||||
return report
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(generate_morning_report())
|
||||
428
backend/app/scripts/seed_system_params.py
Executable file
428
backend/app/scripts/seed_system_params.py
Executable file
@@ -0,0 +1,428 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/seed_system_params.py
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.system import SystemParameter
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
|
||||
logger = logging.getLogger("Seed-System-Params-2.0")
|
||||
|
||||
async def seed_params():
|
||||
async with AsyncSessionLocal() as db:
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# GONDOLATMENET A STRUKTÚRÁHOZ:
|
||||
# Ez a lista tartalmazza a rendszer összes "alapértelmezett" (global) beállítását.
|
||||
# Minden modul innen kapja meg az indulási értékeit. Ha az admin felületen
|
||||
# egy értéket módosítanak, ez a script a következő futáskor NEM írja felül azt,
|
||||
# csak a leírásokat (description) és a kategóriákat frissíti.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
params = [
|
||||
# --- 1. KOMMUNIKÁCIÓ (Az EmailManager 2.0 motorja) ---
|
||||
{
|
||||
"key": "email_provider",
|
||||
"value": "smtp", # Lehetőségek: "smtp", "sendgrid", "disabled"
|
||||
"category": "communication",
|
||||
"description": "Aktív e-mail küldő szolgáltató",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "emails_from_email",
|
||||
"value": "noreply@profibot.hu",
|
||||
"category": "communication",
|
||||
"description": "A rendszer által küldött levelek feladó címe",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "emails_from_name",
|
||||
"value": "Sentinel Master",
|
||||
"category": "communication",
|
||||
"description": "A rendszer által küldött levelek feladó neve",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "sendgrid_api_key",
|
||||
"value": "",
|
||||
"category": "communication",
|
||||
"description": "SendGrid API kulcs (ha a provider 'sendgrid')",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "smtp_config",
|
||||
"value": {
|
||||
"host": "localhost",
|
||||
"port": 587,
|
||||
"user": "smtp_user",
|
||||
"pass": "smtp_password",
|
||||
"tls": True
|
||||
},
|
||||
"category": "communication",
|
||||
"description": "SMTP szerver konfiguráció JSON formátumban",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 2. AUTH & SECURITY (Kapuőr modul) ---
|
||||
{"key": "auth_min_password_length", "value": 8, "category": "security", "description": "Minimum jelszóhossz", "scope_level": "global"},
|
||||
{"key": "auth_default_role", "value": "user", "category": "auth", "description": "Új regisztrálók alapértelmezett rangja", "scope_level": "global"},
|
||||
{"key": "auth_registration_hours", "value": 48, "category": "auth", "description": "Regisztrációs link érvényessége", "scope_level": "global"},
|
||||
{"key": "auth_password_reset_hours", "value": 2, "category": "security", "description": "Jelszóvisszaállító link érvényessége", "scope_level": "global"},
|
||||
{"key": "asset_auto_transfer_enabled", "value": False, "category": "security", "description": "Autonóm tulajdonosváltás engedélyezése", "scope_level": "global"},
|
||||
|
||||
# --- 3. LIMITS & CSOMAGOK (A Billing és Asset korlátok) ---
|
||||
{
|
||||
"key": "VEHICLE_LIMIT",
|
||||
"value": {"free": 1, "premium": 5, "vip": 50, "service_pro": 10},
|
||||
"category": "limits",
|
||||
"description": "Járműszám korlátok előfizetés szerint",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "subscription_packages_matrix",
|
||||
"value": {
|
||||
"free": {"price": 0, "rank": 1, "type": "credit"},
|
||||
"premium": {"price": 1990, "rank": 5, "type": "credit"},
|
||||
"vip": {"price": 4990, "rank": 50, "type": "credit"},
|
||||
"service_pro": {"price": 9990, "rank": 30, "type": "coin", "initial_coin_bonus": 500}
|
||||
},
|
||||
"category": "billing",
|
||||
"description": "Csomagok, árak és bónuszok központi mátrixa",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 4. FINANCE (Költségek és Devizák) ---
|
||||
{"key": "finance_default_currency", "value": "HUF", "category": "finance", "description": "Helyi alap deviza", "scope_level": "global"},
|
||||
{"key": "finance_base_currency", "value": "EUR", "category": "finance", "description": "Központi elszámoló deviza (Statisztikákhoz)", "scope_level": "global"},
|
||||
{"key": "org_naming_template", "value": "{last_name} Flotta", "category": "system", "description": "Szervezet név sablon", "scope_level": "global"},
|
||||
|
||||
# --- 5. DOCUMENT & OCR (Robot 1 vezérlése) ---
|
||||
{
|
||||
"key": "ocr_monthly_limit",
|
||||
"value": {"free": 1, "premium": 10, "vip": 100},
|
||||
"category": "limits",
|
||||
"description": "Havi ingyenes OCR szkennelések száma",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "ocr_auto_trigger_types",
|
||||
"value": ["invoice", "registration_card", "sale_contract"],
|
||||
"category": "robots",
|
||||
"description": "Azonnali OCR feldolgozásra kijelölt típusok",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 6. GAMIFICATION (A Játékmester) ---
|
||||
{"key": "gamification_kyc_bonus", "value": 500, "category": "gamification", "description": "XP jutalom a KYC után", "scope_level": "global"},
|
||||
{"key": "xp_multiplier_ocr_cost", "value": 1.5, "category": "gamification", "description": "Bónusz szorzó digitális (OCR) adatrögzítésre", "scope_level": "global"},
|
||||
{
|
||||
"key": "GAMIFICATION_MASTER_CONFIG",
|
||||
"value": {
|
||||
"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}
|
||||
},
|
||||
"category": "gamification",
|
||||
"description": "Szintek, büntetések és jutalmak mátrixa",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 7. ÉRTESÍTÉSEK ÉS KARBANTARTÁS ---
|
||||
{
|
||||
"key": "notif_expiry_threshold_days",
|
||||
"value": 30,
|
||||
"category": "notifications",
|
||||
"description": "Hány nappal az okmányok lejárata előtt küldjön a rendszer automata figyelmeztetést?",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "notif_expiry_steps",
|
||||
"value": [30, 7, 1, 0],
|
||||
"category": "notifications",
|
||||
"description": "Hány nappal a lejárat előtt küldjön a rendszer riasztást? (Lista formátum)",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "maint_km_alert_threshold",
|
||||
"value": 1000,
|
||||
"category": "maintenance",
|
||||
"description": "Hány kilométerrel a tervezett szerviz előtt küldjön figyelmeztetést a rendszer?",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "storage_retention_days",
|
||||
"value": 365,
|
||||
"category": "storage",
|
||||
"description": "Fájlok megőrzési ideje a NAS-on (napokban)",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "storage_delete_after_validation",
|
||||
"value": False,
|
||||
"category": "storage",
|
||||
"description": "Törölje-e a rendszer az OCR bizonyítékokat a sikeres hitelesítés után?",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 8. GEO & LOCALIZATION ---
|
||||
{
|
||||
"key": "geo_default_country_code",
|
||||
"value": "HU",
|
||||
"category": "geo",
|
||||
"description": "Alapértelmezett országkód a címek normalizálásához",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "GEO_ADDRESS_FORMAT_TEMPLATE",
|
||||
"value": "{zip} {city}, {street} {type} {number}.",
|
||||
"category": "geo",
|
||||
"description": "Címformázási sablon (Python string format)",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "GEO_SUGGESTION_LIMIT",
|
||||
"value": 15,
|
||||
"category": "geo",
|
||||
"description": "Hány találatot adjon vissza az utca-kiegészítő?",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 9. ÉRTESÍTÉSI MÁTRIXOK (A granuláris szabályozáshoz) ---
|
||||
{
|
||||
"key": "notification_categories_config",
|
||||
"value": {
|
||||
"insurance": {"mandatory": True, "channels": ["email", "internal"], "label": "Biztosítás lejárat"},
|
||||
"mot_expiry": {"mandatory": True, "channels": ["email", "internal"], "label": "Műszaki vizsga"},
|
||||
"personal_id": {"mandatory": True, "channels": ["email", "internal"], "label": "Személyi okmányok"},
|
||||
"service_due": {"mandatory": False, "channels": ["internal"], "label": "Karbantartási emlékeztető"},
|
||||
"system_alert": {"mandatory": True, "channels": ["email", "internal"], "label": "Rendszerüzenetek"}
|
||||
},
|
||||
"category": "notifications",
|
||||
"description": "Értesítési típusok és biztonsági szintek mátrixa",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "NOTIFICATION_TYPE_MATRIX",
|
||||
"value": {
|
||||
"insurance": [45, 30, 15, 7, 1, 0], # A kötelező biztosítás kiemelt kezelése!
|
||||
"mot": [30, 14, 7, 1, 0],
|
||||
"personal_id": [60, 30, 15, 0],
|
||||
"default": [30, 7, 1]
|
||||
},
|
||||
"category": "notifications",
|
||||
"description": "Dokumentum alapú riasztási naptár mátrix",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 10. FLEET & TELEMETRY ---
|
||||
{
|
||||
"key": "FLEET_EVENT_REWARDS",
|
||||
"value": {
|
||||
"refuel": {"xp": 30, "social": 5}, # Tankolás rögzítése
|
||||
"service": {"xp": 150, "social": 30}, # Szerviz látogatás (értékesebb adat!)
|
||||
"repair": {"xp": 100, "social": 20}, # Javítás
|
||||
"tire_change": {"xp": 40, "social": 5}, # Gumicsere
|
||||
"accident": {"xp": 10, "social": 0}, # Baleset
|
||||
"default": {"xp": 20, "social": 2}
|
||||
},
|
||||
"category": "fleet",
|
||||
"description": "Eseményenkénti gamifikációs jutalom mátrix",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "FLEET_ANOMALY_LOGIC",
|
||||
"value": {
|
||||
"odometer_drop_severity": "critical",
|
||||
"excessive_daily_km": 1500,
|
||||
"flag_unverified_providers": True
|
||||
},
|
||||
"category": "fleet",
|
||||
"description": "Flotta anomália detekciós küszöbértékek",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 11. KÜLSŐ API-K (DVLA, UK) ---
|
||||
{
|
||||
"key": "dvla_api_enabled",
|
||||
"value": True,
|
||||
"category": "api_keys",
|
||||
"description": "Engedélyezze-e a brit DVLA lekérdezéseket?",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "dvla_api_url",
|
||||
"value": "https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles",
|
||||
"category": "api_keys",
|
||||
"description": "Hivatalos DVLA Vehicle Enquiry API végpont",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "dvla_api_key",
|
||||
"value": "IDE_JÖN_A_VALÓDI_KULCS",
|
||||
"category": "api_keys",
|
||||
"description": "Bizalmas DVLA API kulcs (X-API-KEY)",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 12. AI & ROBOTOK (Ollama integráció) ---
|
||||
{
|
||||
"key": "ai_model_text",
|
||||
"value": "qwen2.5-coder:32b",
|
||||
"category": "ai",
|
||||
"description": "Fő technikai elemző modell (Ollama)",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "ai_model_vision",
|
||||
"value": "llava:7b",
|
||||
"category": "ai",
|
||||
"description": "Látó modell az OCR folyamatokhoz",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "ai_temperature",
|
||||
"value": 0.1,
|
||||
"category": "ai",
|
||||
"description": "AI válasz kreativitása (0.1 = precíz, 0.9 = kreatív)",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "ai_prompt_ocr_invoice",
|
||||
"value": "FELADAT: Olvasd ki a számla adatait. JSON válasz: {amount, currency, date, vendor, vat}.",
|
||||
"category": "ai",
|
||||
"description": "Robot 1 - Számla OCR prompt",
|
||||
"scope_level": "global"
|
||||
},
|
||||
{
|
||||
"key": "ai_prompt_gold_data",
|
||||
"value": "Készíts technikai adatlapot a(z) {make} {model} típushoz a megadott adatok alapján: {context}. Csak hiteles JSON-t adj!",
|
||||
"category": "ai",
|
||||
"description": "Robot 3 - Technikai dúsító prompt",
|
||||
"scope_level": "global"
|
||||
}
|
||||
] # <-- ITT HIÁNYZOTT A ZÁRÓJEL!
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# HIERARCHIKUS KERESÉSI MÁTRIXOK (A SearchService 2.4-hez)
|
||||
# Ezek az értékek felülbírálják az alapértelmezéseket a megfelelő "scope" esetén.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
# 1. GLOBÁLIS ALAP (Free usereknek)
|
||||
params.append({
|
||||
"key": "RANKING_RULES",
|
||||
"scope_level": "global",
|
||||
"scope_id": None,
|
||||
"value": {
|
||||
"ad_weight": 8000,
|
||||
"partner_weight": 1000,
|
||||
"trust_weight": 5,
|
||||
"dist_penalty": 40,
|
||||
"can_use_prefs": False,
|
||||
"search_radius_km": 25
|
||||
},
|
||||
"category": "search",
|
||||
"description": "Alapértelmezett (Free) rangsorolási szabályok"
|
||||
})
|
||||
|
||||
# 2. PREMIUM CSOMAG SZINTŰ BEÁLLÍTÁS (Közepes szint)
|
||||
params.append({
|
||||
"key": "RANKING_RULES",
|
||||
"scope_level": "package",
|
||||
"scope_id": "premium",
|
||||
"value": {
|
||||
"pref_weight": 10000,
|
||||
"partner_weight": 2000,
|
||||
"trust_weight": 50,
|
||||
"ad_weight": 500,
|
||||
"dist_penalty": 20,
|
||||
"can_use_prefs": True,
|
||||
"search_radius_km": 50
|
||||
},
|
||||
"category": "search",
|
||||
"description": "Prémium csomag rangsorolási szabályai"
|
||||
})
|
||||
|
||||
# 3. VIP CSOMAG SZINTŰ BEÁLLÍTÁS
|
||||
params.append({
|
||||
"key": "RANKING_RULES",
|
||||
"scope_level": "package",
|
||||
"scope_id": "vip",
|
||||
"value": {
|
||||
"pref_weight": 20000, # A kedvenc mindent visz
|
||||
"partner_weight": 5000,
|
||||
"trust_weight": 100, # A minőség számít
|
||||
"ad_weight": 0, # VIP-nek nem tolunk hirdetést az élre
|
||||
"dist_penalty": 5, # Alig büntetjük a távolságot
|
||||
"can_use_prefs": True,
|
||||
"search_radius_km": 150
|
||||
},
|
||||
"category": "search",
|
||||
"description": "VIP csomag rangsorolási szabályai"
|
||||
})
|
||||
|
||||
# 4. EGYÉNI CÉGES FELÜLBÍRÁLÁS (Pl. ProfiBot Flotta Co.)
|
||||
params.append({
|
||||
"key": "RANKING_RULES",
|
||||
"scope_level": "user",
|
||||
"scope_id": "99",
|
||||
"value": {
|
||||
"pref_weight": 50000, # Nekik csak a saját szerződött partnereik kellenek
|
||||
"can_use_prefs": True,
|
||||
"search_radius_km": 500 # Az egész országot látják
|
||||
},
|
||||
"category": "search",
|
||||
"description": "Egyedi flotta-ügyfél keresési szabályai"
|
||||
})
|
||||
|
||||
logger.info("🚀 Rendszerparaméterek szinkronizálása a 2.0-ás modell szerint...")
|
||||
added_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for p in params:
|
||||
# GONDOLATMENET A JAVÍTÁSHOZ:
|
||||
# Muszáj a scope_level-t és scope_id-t is vizsgálni, különben az SQLAlchemy
|
||||
# összeomlik (MultipleResultsFound), mert ugyanaz a 'key' (pl. RANKING_RULES)
|
||||
# több sorban is szerepel a hierarchia miatt!
|
||||
|
||||
s_level = p.get("scope_level", "global")
|
||||
s_id = p.get("scope_id", None)
|
||||
|
||||
stmt = select(SystemParameter).where(
|
||||
SystemParameter.key == p["key"],
|
||||
SystemParameter.scope_level == s_level,
|
||||
SystemParameter.scope_id == s_id
|
||||
)
|
||||
res = await db.execute(stmt)
|
||||
existing = res.scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
# Új rekord létrehozása
|
||||
new_param = SystemParameter(
|
||||
key=p["key"],
|
||||
value=p["value"],
|
||||
category=p["category"],
|
||||
description=p["description"],
|
||||
scope_level=s_level,
|
||||
scope_id=s_id,
|
||||
last_modified_by=None
|
||||
)
|
||||
db.add(new_param)
|
||||
added_count += 1
|
||||
# Azonnali commit, hogy a következő körben már lássa a DB!
|
||||
await db.commit()
|
||||
else:
|
||||
# Csak frissítés, ha szükséges
|
||||
existing.description = p["description"]
|
||||
existing.category = p["category"]
|
||||
updated_count += 1
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Kész! Új: {added_count}, Frissített meta: {updated_count}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_params())
|
||||
31
backend/app/scripts/seed_v1_9_system.py
Executable file
31
backend/app/scripts/seed_v1_9_system.py
Executable file
@@ -0,0 +1,31 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/seed_v1_9_system.py
|
||||
import asyncio
|
||||
from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.vehicle_definitions import VehicleType, FeatureDefinition
|
||||
|
||||
async def seed_system_data():
|
||||
""" Alapvető típusok és extrák (Features) feltöltése. """
|
||||
async with SessionLocal() as db:
|
||||
try:
|
||||
print("🚀 Rendszer-blueprint betöltése...")
|
||||
|
||||
types_data = [
|
||||
{"code": "car", "name": "Személyautó", "icon": "directions_car"},
|
||||
{"code": "motorcycle", "name": "Motorkerékpár", "icon": "moped"},
|
||||
{"code": "truck", "name": "Teherautó", "icon": "local_shipping"},
|
||||
{"code": "boat", "name": "Hajó", "icon": "sailing"}
|
||||
]
|
||||
|
||||
for t_info in types_data:
|
||||
stmt = select(VehicleType).where(VehicleType.code == t_info["code"])
|
||||
if not (await db.execute(stmt)).scalar_one_or_none():
|
||||
db.add(VehicleType(**t_info))
|
||||
|
||||
await db.commit()
|
||||
print("✅ Blueprint kész.")
|
||||
except Exception as e:
|
||||
print(f"❌ Hiba: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_system_data())
|
||||
64
backend/app/services/ai_ocr_service.py
Executable file
64
backend/app/services/ai_ocr_service.py
Executable 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)}")
|
||||
104
backend/app/services/ai_service.py
Executable file
104
backend/app/services/ai_service.py
Executable 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])
|
||||
141
backend/app/services/ai_service1.1.0.py
Executable file
141
backend/app/services/ai_service1.1.0.py
Executable 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
|
||||
111
backend/app/services/ai_service_googleApi_old.py
Executable file
111
backend/app/services/ai_service_googleApi_old.py
Executable 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
|
||||
153
backend/app/services/asset_service.py
Executable file
153
backend/app/services/asset_service.py
Executable 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
|
||||
258
backend/app/services/auth_service.py
Executable file
258
backend/app/services/auth_service.py
Executable 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
|
||||
281
backend/app/services/auth_service.py.old_1
Executable file
281
backend/app/services/auth_service.py.old_1
Executable 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
|
||||
83
backend/app/services/config_service.py
Executable file
83
backend/app/services/config_service.py
Executable 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()
|
||||
144
backend/app/services/cost_service.py
Executable file
144
backend/app/services/cost_service.py
Executable 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()
|
||||
135
backend/app/services/document_service.py
Executable file
135
backend/app/services/document_service.py
Executable 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
|
||||
71
backend/app/services/dvla_service.py
Executable file
71
backend/app/services/dvla_service.py
Executable 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
|
||||
132
backend/app/services/email_manager.py
Executable file
132
backend/app/services/email_manager.py
Executable 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()
|
||||
135
backend/app/services/fleet_service.py
Executable file
135
backend/app/services/fleet_service.py
Executable 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
|
||||
)
|
||||
153
backend/app/services/gamification_service.py
Executable file
153
backend/app/services/gamification_service.py
Executable 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()
|
||||
155
backend/app/services/geo_service.py
Executable file
155
backend/app/services/geo_service.py
Executable 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.")
|
||||
38
backend/app/services/image_processor.py
Executable file
38
backend/app/services/image_processor.py
Executable 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
|
||||
106
backend/app/services/maintenance_service.py
Executable file
106
backend/app/services/maintenance_service.py
Executable 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
|
||||
35
backend/app/services/matching_service.py
Executable file
35
backend/app/services/matching_service.py
Executable 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()
|
||||
45
backend/app/services/media_service.py
Executable file
45
backend/app/services/media_service.py
Executable 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
|
||||
150
backend/app/services/notification_service.py
Executable file
150
backend/app/services/notification_service.py
Executable 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
|
||||
49
backend/app/services/recon_bot.py
Executable file
49
backend/app/services/recon_bot.py
Executable 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user