feat: Asset Catalog system, PostGIS integration and RobotScout V1
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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,
|
||||
|
||||
61
backend/app/services/search_service.py
Normal file
61
backend/app/services/search_service.py
Normal 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']))
|
||||
92
backend/app/services/social_auth_service.py
Normal file
92
backend/app/services/social_auth_service.py
Normal 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
|
||||
Reference in New Issue
Block a user