feat: Asset Catalog system, PostGIS integration and RobotScout V1

This commit is contained in:
2026-02-11 22:47:38 +00:00
parent a63e6c8fac
commit 09a0430384
53 changed files with 2756 additions and 426 deletions

View File

@@ -1,35 +1,99 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.asset import Asset, AssetTelemetry, AssetFinancials
from sqlalchemy import select, distinct
from app.models.asset import Asset, AssetCatalog, AssetTelemetry, AssetFinancials, AssetAssignment
from app.models.gamification import UserStats, PointRule
import uuid
import logging
async def create_new_vehicle(db: AsyncSession, user_id: int, vin: str, license_plate: str):
# 1. Alap Asset létrehozása
new_asset = Asset(
vin=vin,
license_plate=license_plate,
name=f"Teszt Autó ({license_plate})"
)
db.add(new_asset)
await db.flush() # Hogy legyen ID-ja
logger = logging.getLogger(__name__)
# 2. Modulok inicializálása (Digital Twin alapozás)
db.add(AssetTelemetry(asset_id=new_asset.id, current_mileage=0))
db.add(AssetFinancials(asset_id=new_asset.id))
class AssetService:
@staticmethod
async def get_makes(db: AsyncSession):
"""1. Szint: Márkák lekérdezése (pl. Audi, BMW)."""
stmt = select(distinct(AssetCatalog.make)).order_by(AssetCatalog.make)
result = await db.execute(stmt)
return result.scalars().all()
# 3. GAMIFICATION: Pontszerzés (ASSET_REGISTER = 100 XP)
# Megkeressük a szabályt
rule_stmt = select(PointRule).where(PointRule.action_key == "ASSET_REGISTER")
rule = (await db.execute(rule_stmt)).scalar_one_or_none()
if rule:
# Frissítjük a felhasználó XP-jét
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
if stats:
stats.total_xp += rule.points
# Itt később jöhet a szintlépés ellenőrzése is!
@staticmethod
async def get_models(db: AsyncSession, make: str):
"""2. Szint: Típusok szűrése márka alapján (pl. A4, A6)."""
stmt = select(distinct(AssetCatalog.model)).where(AssetCatalog.make == make).order_by(AssetCatalog.model)
result = await db.execute(stmt)
return result.scalars().all()
await db.commit()
return new_asset
@staticmethod
async def get_generations(db: AsyncSession, make: str, model: str):
"""3. Szint: Generációk/Évjáratok (pl. B8 (2008-2015))."""
stmt = select(distinct(AssetCatalog.generation)).where(
AssetCatalog.make == make,
AssetCatalog.model == model
).order_by(AssetCatalog.generation)
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def get_engines(db: AsyncSession, make: str, model: str, generation: str):
"""4. Szint: Motorváltozatok (pl. 2.0 TDI)."""
stmt = select(AssetCatalog).where(
AssetCatalog.make == make,
AssetCatalog.model == model,
AssetCatalog.generation == generation
).order_by(AssetCatalog.engine_variant)
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def create_and_assign_vehicle(
db: AsyncSession,
user_id: int,
org_id: int,
vin: str,
license_plate: str,
catalog_id: int = None
):
"""Jármű rögzítése, flottához rendelése és XP jóváírás (Atomic)."""
try:
# 1. Asset létrehozása közvetlen flotta-kötéssel
new_asset = Asset(
vin=vin,
license_plate=license_plate,
catalog_id=catalog_id,
current_organization_id=org_id, # Izolációs pointer
status="active",
is_verified=False
)
db.add(new_asset)
await db.flush()
# 2. Digitális Iker történetiség (Assignment)
assignment = AssetAssignment(
asset_id=new_asset.id,
organization_id=org_id,
status="active"
)
db.add(assignment)
# 3. Digitális Iker modulok indítása
db.add(AssetTelemetry(asset_id=new_asset.id))
db.add(AssetFinancials(asset_id=new_asset.id))
# 4. GAMIFICATION: XP jóváírás
rule_stmt = select(PointRule).where(PointRule.action_key == "ASSET_REGISTER")
rule = (await db.execute(rule_stmt)).scalar_one_or_none()
if rule:
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
if stats:
stats.total_xp += rule.points
logger.info(f"User {user_id} awarded {rule.points} XP for asset registration.")
# 5. Robot Scout Trigger (későbbi implementáció)
# await RobotScout.trigger_vin_lookup(db, new_asset.id)
await db.commit()
return new_asset
except Exception as e:
await db.rollback()
logger.error(f"Asset Creation Error: {str(e)}")
raise e

View File

@@ -9,12 +9,13 @@ 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
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
@@ -26,8 +27,23 @@ logger = logging.getLogger(__name__)
class AuthService:
@staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
"""Step 1: Lite Regisztráció."""
"""
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,
@@ -46,11 +62,13 @@ class AuthService:
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()
reg_hours = await config.get_setting("auth_registration_hours", region_code=user_in.region_code, default=48)
# 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,
@@ -59,6 +77,7 @@ class AuthService:
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,
@@ -67,9 +86,23 @@ class AuthService:
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)}")
@@ -77,16 +110,27 @@ class AuthService:
@staticmethod
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
"""1.3. Fázis: Atomi Tranzakció & Shadow Identity."""
"""
Step 2: Atomi Tranzakció.
Itt dől el minden: Adatok rögzítése, Shadow Identity ellenőrzés,
Flotta és Wallet létrehozás, majd a fiók aktiválása.
"""
try:
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if not user: return None
# --- 1. BIZTONSÁG: User folder_slug generálása ---
# Ha Google-lel jött vagy még nincs slugja, most kap egyet.
if not user.folder_slug:
user.folder_slug = generate_secure_slug(length=12)
# Pénznem beállítása
if hasattr(kyc_in, 'preferred_currency') and kyc_in.preferred_currency:
user.preferred_currency = kyc_in.preferred_currency
# --- 2. Shadow Identity keresése (Már létezik-e ez a fizikai személy?) ---
identity_stmt = select(Person).where(and_(
Person.mothers_last_name == kyc_in.mothers_last_name,
Person.mothers_first_name == kyc_in.mothers_first_name,
@@ -96,12 +140,15 @@ class AuthService:
existing_person = (await db.execute(identity_stmt)).scalar_one_or_none()
if existing_person:
# Ha találtunk egyezést, összekötjük a User-t a meglévő Person-nel
user.person_id = existing_person.id
active_person = existing_person
logger.info(f"Shadow Identity linked: User {user_id} -> Person {existing_person.id}")
else:
# Ha nem, a saját (regisztrációkor létrehozott) Person-t töltjük fel
active_person = user.person
# --- 3. Cím rögzítése GeoService segítségével ---
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=kyc_in.address_zip,
@@ -112,31 +159,40 @@ class AuthService:
parcel_id=kyc_in.address_hrsz
)
# --- 4. Személyes adatok frissítése ---
active_person.mothers_last_name = kyc_in.mothers_last_name
active_person.mothers_first_name = kyc_in.mothers_first_name
active_person.birth_place = kyc_in.birth_place
active_person.birth_date = kyc_in.birth_date
active_person.phone = kyc_in.phone_number
active_person.address_id = addr_id
# Dokumentumok és ICE kontakt mentése JSON-ként
active_person.identity_docs = jsonable_encoder(kyc_in.identity_docs)
active_person.ice_contact = jsonable_encoder(kyc_in.ice_contact)
# A Person most válik aktívvá
active_person.is_active = True
# --- 5. EGYÉNI FLOTTA LÉTREHOZÁSA (A KYC szerves része) ---
# Itt generáljuk a flotta mappáját is (folder_slug)
new_org = Organization(
full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta",
name=f"{active_person.last_name} Flotta",
folder_slug=generate_secure_slug(length=12), # FLOTTA SLUG
org_type=OrgType.individual,
owner_id=user.id,
is_transferable=False,
is_active=True,
status="verified",
language=user.preferred_language,
default_currency=user.preferred_currency,
default_currency=user.preferred_currency or "HUF",
country_code=user.region_code
)
db.add(new_org)
await db.flush()
# Flotta tagság (Owner)
db.add(OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
@@ -144,15 +200,33 @@ class AuthService:
permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True}
))
# --- 6. PÉNZTÁRCA ÉS GAMIFICATION LÉTREHOZÁSA ---
db.add(Wallet(
user_id=user.id,
coin_balance=0,
credit_balance=0,
currency=user.preferred_currency
currency=user.preferred_currency or "HUF"
))
db.add(UserStats(user_id=user.id, total_xp=0, current_level=1))
# --- 7. AKTIVÁLÁS ÉS AUDIT ---
user.is_active = True
await security_service.log_event(
db,
user_id=user.id,
action="USER_KYC_COMPLETED",
severity="info",
target_type="User",
target_id=str(user.id),
new_data={
"status": "active",
"user_folder": user.folder_slug,
"organization_id": new_org.id,
"organization_folder": new_org.folder_slug,
"wallet_created": True
}
)
await db.commit()
await db.refresh(user)
@@ -165,8 +239,7 @@ class AuthService:
@staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
"""
Step 2 utáni Soft-Delete: Email felszabadítás és izoláció.
Az email átnevezésre kerül, így az eredeti cím újra regisztrálható 'tiszta lappal'.
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()
@@ -175,12 +248,11 @@ class AuthService:
return False
old_email = user.email
# Email felszabadítása: deleted_ID_TIMESTAMP_EMAIL formátumban
# 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
# Sentinel AuditLog bejegyzés
await security_service.log_event(
db,
user_id=actor_id,
@@ -231,7 +303,7 @@ class AuthService:
user = (await db.execute(stmt)).scalar_one_or_none()
if user:
reset_hours = await config.get_setting("auth_password_reset_hours", region_code=user.region_code, default=2)
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,

View File

@@ -0,0 +1,61 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.models.service import ServiceProfile, ExpertiseTag, ServiceExpertise
from app.models.organization import Organization
from geoalchemy2.functions import ST_Distance, ST_MakePoint
class SearchService:
@staticmethod
async def find_nearby_services(
db: AsyncSession,
lat: float,
lon: float,
expertise_key: str = None,
radius_km: int = 50,
is_premium: bool = False
):
"""
Keresés távolság és szakértelem alapján.
Premium: Trust Score + Valós távolság.
Free: Trust Score + Légvonal.
"""
user_point = ST_MakePoint(lon, lat) # PostGIS pont létrehozása
# Alap lekérdezés: ServiceProfile + Organization adatok
stmt = select(ServiceProfile, Organization).join(
Organization, ServiceProfile.organization_id == Organization.id
)
# 1. Sugár alapú szűrés (radius_km * 1000 méter)
stmt = stmt.where(
func.ST_DWithin(ServiceProfile.location, user_point, radius_km * 1000)
)
# 2. Szakterület szűrése
if expertise_key:
stmt = stmt.join(ServiceProfile.expertises).join(ExpertiseTag).where(
ExpertiseTag.key == expertise_key
)
# 3. Távolság és Trust Score alapú sorrend
# A ST_Distance méterben adja vissza az eredményt
stmt = stmt.order_by(ST_Distance(ServiceProfile.location, user_point))
result = await db.execute(stmt.limit(50))
rows = result.all()
# Rangsorolási logika alkalmazása
results = []
for s_prof, org in rows:
results.append({
"id": org.id,
"name": org.full_name,
"trust_score": s_prof.trust_score,
"is_verified": s_prof.is_verified,
"phone": s_prof.contact_phone,
"website": s_prof.website,
"is_premium_partner": s_prof.trust_score >= 90
})
# Súlyozott rendezés: Prémium partnerek és Trust Score előre
return sorted(results, key=lambda x: (not is_premium, -x['trust_score']))

View File

@@ -0,0 +1,92 @@
import uuid
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.identity import User, Person, SocialAccount, UserRole
from app.services.security_service import security_service
logger = logging.getLogger(__name__)
class SocialAuthService:
@staticmethod
async def get_or_create_social_user(
db: AsyncSession,
provider: str,
social_id: str,
email: str,
first_name: str,
last_name: str
):
"""
Social Step 1: Csak alapregisztráció.
Nincs slug generálás, nincs flotta. Megáll a KYC kapujában.
"""
# 1. Meglévő Social kapcsolat ellenőrzése
stmt = select(SocialAccount).where(
SocialAccount.provider == provider,
SocialAccount.social_id == social_id
)
result = await db.execute(stmt)
social_acc = result.scalar_one_or_none()
if social_acc:
stmt = select(User).where(User.id == social_acc.user_id)
user_result = await db.execute(stmt)
return user_result.scalar_one_or_none()
# 2. Felhasználó keresése email alapján
stmt = select(User).where(User.email == email)
user_result = await db.execute(stmt)
user = user_result.scalar_one_or_none()
if not user:
try:
# Person rekord létrehozása a Google-től kapott nevekkel
new_person = Person(
id_uuid=uuid.uuid4(),
first_name=first_name or "Google",
last_name=last_name or "User",
is_active=False
)
db.add(new_person)
await db.flush()
# User rekord (folder_slug nélkül!)
user = User(
email=email,
hashed_password=None,
person_id=new_person.id,
role=UserRole.user,
is_active=False,
is_deleted=False,
preferred_language="hu",
region_code="HU"
)
db.add(user)
await db.flush()
await security_service.log_event(
db,
user_id=user.id,
action="USER_REGISTER_SOCIAL",
severity="info",
target_type="User",
target_id=str(user.id),
new_data={"email": email, "provider": provider}
)
except Exception as e:
await db.rollback()
logger.error(f"Social Registration Error: {str(e)}")
raise e
# 3. Összekötés
new_social = SocialAccount(
user_id=user.id,
provider=provider,
social_id=social_id,
email=email
)
db.add(new_social)
await db.commit()
await db.refresh(user)
return user