feat: implement hybrid address system and premium search logic

- Added centralized, self-learning GeoService (ZIP, City, Street)
- Implemented Hybrid Address Management (Centralized table + Denormalized fields)
- Fixed Gamification logic (PointsLedger field names & filtering)
- Added address autocomplete and two-tier (Free/Premium) search API
- Synchronized UserStats and PointsLedger schemas
This commit is contained in:
2026-02-08 16:26:39 +00:00
parent 4e14d57bf6
commit 451900ae1a
41 changed files with 764 additions and 515 deletions

View File

@@ -1,4 +1,4 @@
from typing import AsyncGenerator
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,46 +6,50 @@ from sqlalchemy import select
from app.db.session import get_db
from app.core.security import decode_token
from app.models.identity import User # Javítva identity-re
from app.models.identity import User
# Javítva v1-re
# Az OAuth2 séma definiálása, ami a tokent keresi a Headerben
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
db: AsyncSession = Depends(get_db),
token: str = Depends(reusable_oauth2),
) -> User:
"""
Dependency, amely visszaadja az aktuálisan bejelentkezett felhasználót.
Ha a token érvénytelen vagy a felhasználó nem létezik, hibát dob.
"""
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen vagy lejárt token."
detail="Érvénytelen vagy lejárt munkamenet."
)
user_id = payload.get("sub")
user_id: str = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token azonosítási hiba."
)
# Felhasználó keresése az adatbázisban
res = await db.execute(select(User).where(User.id == int(user_id)))
user = res.scalar_one_or_none()
# Felhasználó lekérése az adatbázisból
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Felhasználó nem található."
detail="A felhasználó nem található."
)
if user.is_deleted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Ez a fiók törölve lett."
detail="Ez a fiók korábban törlésre került."
)
# FONTOS: Itt NEM dobunk hibát, ha user.is_active == False,
# mert a Step 2 (KYC) kitöltéséhez be kell tudnia lépni inaktívként is!
# Megjegyzés: is_active ellenőrzést szándékosan nem teszünk itt,
# hogy a KYC folyamatot (Step 2) be tudja fejezni a még nem aktív user is.
return user

View File

@@ -1,11 +1,14 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, catalog, assets, organizations, documents # <--- Ide bekerült a documents!
from app.api.v1.endpoints import auth, catalog, assets, organizations, documents, services
api_router = APIRouter()
# Hitelesítés
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
# Szolgáltatások és Vadászat (Ez az új rész!)
api_router.include_router(services.router, prefix="/services", tags=["Service Hunt & Discovery"])
# Katalógus
api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"])
@@ -15,5 +18,5 @@ api_router.include_router(assets.router, prefix="/assets", tags=["Assets"])
# Szervezetek
api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"])
# DOKUMENTUMOK (Ez az új rész, ami hiányzik neked)
# Dokumentumok
api_router.include_router(documents.router, prefix="/documents", tags=["Documents"])

View File

@@ -1,48 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from sqlalchemy import select
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.core.security import create_access_token
from app.schemas.auth import UserLiteRegister, Token, PasswordResetRequest, UserKYCComplete
from app.api.deps import get_current_user # Ez kezeli a belépett felhasználót
from app.schemas.auth import (
UserLiteRegister, Token, PasswordResetRequest,
UserKYCComplete, PasswordResetConfirm
)
from app.api.deps import get_current_user
from app.models.identity import User
router = APIRouter()
@router.post("/register-lite", response_model=Token, status_code=201)
@router.post("/register-lite", response_model=Token, status_code=status.HTTP_201_CREATED)
async def register_lite(user_in: UserLiteRegister, db: AsyncSession = Depends(get_db)):
"""Step 1: Alapszintű regisztráció és aktiváló e-mail küldése."""
check = await db.execute(text("SELECT id FROM data.users WHERE email = :e"), {"e": user_in.email})
if check.fetchone():
raise HTTPException(status_code=400, detail="Ez az email cím már foglalt.")
"""Step 1: Alapszintű regisztráció (Email + Jelszó)."""
stmt = select(User).where(User.email == user_in.email)
result = await db.execute(stmt)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez az e-mail cím már regisztrálva van."
)
try:
user = await AuthService.register_lite(db, user_in)
token = create_access_token(data={"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer", "is_active": user.is_active}
return {
"access_token": token,
"token_type": "bearer",
"is_active": user.is_active
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Szerver hiba: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Sikertelen regisztráció: {str(e)}"
)
@router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Bejelentkezés az access_token megszerzéséhez."""
async def login(
db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
):
"""Bejelentkezés és Access Token generálása."""
user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=401, detail="Hibás e-mail vagy jelszó.")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Hibás e-mail cím vagy jelszó."
)
token = create_access_token(data={"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer", "is_active": user.is_active}
return {
"access_token": token,
"token_type": "bearer",
"is_active": user.is_active
}
@router.get("/verify-email")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
"""E-mail megerősítése a kiküldött token alapján."""
"""E-mail megerősítése a kiküldött link alapján."""
success = await AuthService.verify_email(db, token)
if not success:
raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt token.")
return {"message": "Email sikeresen megerősítve! Jöhet a Step 2 (KYC)."}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen vagy lejárt megerősítő token."
)
return {"message": "Email sikeresen megerősítve! Jöhet a profil kitöltése (KYC)."}
@router.post("/complete-kyc")
async def complete_kyc(
@@ -50,14 +77,38 @@ async def complete_kyc(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Step 2: Okmányok rögzítése, Privát Széf és Wallet aktiválása."""
"""Step 2: Személyes adatok és okmányok rögzítése."""
user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
if not user:
raise HTTPException(status_code=404, detail="Felhasználó nem található.")
return {"status": "success", "message": "Gratulálunk! A Privát Széf és a Pénztárca aktiválva lett."}
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Felhasználó nem található.")
return {"status": "success", "message": "A profil adatok rögzítve, a rendszer aktiválva."}
@router.post("/forgot-password")
async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
"""Jelszó-visszaállító link küldése."""
await AuthService.initiate_password_reset(db, req.email)
return {"message": "Ha a cím létezik, elküldtük a helyreállítási linket."}
"""Elfelejtett jelszó folyamat indítása biztonsági korlátokkal."""
result = await AuthService.initiate_password_reset(db, req.email)
if result == "cooldown":
raise HTTPException(status_code=429, detail="Kérjük várjon 2 percet az újabb kérés előtt.")
if result in ["hourly_limit", "daily_limit"]:
raise HTTPException(status_code=429, detail="Túllépte a napi/óránkénti próbálkozások számát.")
return {"message": "Amennyiben a megadott e-mail cím szerepel a rendszerünkben, kiküldtük a linket."}
@router.post("/reset-password")
async def reset_password(req: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
"""Új jelszó beállítása. Backend ellenőrzi az egyezőséget és a tokent."""
if req.password != req.password_confirm:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A két jelszó nem egyezik meg."
)
success = await AuthService.reset_password(db, req.email, req.token, req.password)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen adatok vagy lejárt token."
)
return {"message": "A jelszó sikeresen frissítve! Most már bejelentkezhet."}

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, Form, Query, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional, List
from app.db.session import get_db
from app.services.geo_service import GeoService
from app.services.gamification_service import GamificationService
from app.services.config_service import config
router = APIRouter()
@router.get("/suggest-street")
async def suggest_street(zip_code: str, q: str, db: AsyncSession = Depends(get_db)):
"""Azonnali utca javaslatok gépelés közben."""
return await GeoService.get_street_suggestions(db, zip_code, q)
@router.post("/hunt")
async def register_service_hunt(
name: str = Form(...),
zip_code: str = Form(...),
city: str = Form(...),
street_name: str = Form(...),
street_type: str = Form(...),
house_number: str = Form(...),
parcel_id: Optional[str] = Form(None),
latitude: float = Form(...),
longitude: float = Form(...),
user_latitude: float = Form(...),
user_longitude: float = Form(...),
current_user_id: int = 1,
db: AsyncSession = Depends(get_db)
):
# 1. Hibrid címrögzítés
addr_id = await GeoService.get_or_create_full_address(
db, zip_code, city, street_name, street_type, house_number, parcel_id
)
# 2. Távolságmérés
dist_query = text("""
SELECT ST_Distance(
ST_SetSRID(ST_MakePoint(:u_lon, :u_lat), 4326)::geography,
ST_SetSRID(ST_MakePoint(:s_lon, :s_lat), 4326)::geography
)
""")
distance = (await db.execute(dist_query, {
"u_lon": user_longitude, "u_lat": user_latitude,
"s_lon": longitude, "s_lat": latitude
})).scalar() or 0.0
# 3. Mentés (Denormalizált adatokkal a sebességért)
await db.execute(text("""
INSERT INTO data.organization_locations
(name, address_id, coordinates, proposed_by, zip_code, city, street, house_number, sources, confidence_score)
VALUES (:n, :aid, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, :uid, :z, :c, :s, :hn, jsonb_build_array(CAST('user_hunt' AS TEXT)), 1)
"""), {
"n": name, "aid": addr_id, "lon": longitude, "lat": latitude,
"uid": current_user_id, "z": zip_code, "c": city, "s": f"{street_name} {street_type}", "hn": house_number
})
# 4. Jutalmazás
await GamificationService.award_points(db, current_user_id, 50, f"Service Hunt: {city}")
await db.commit()
return {"status": "success", "address_id": str(addr_id), "distance_meters": round(distance, 2)}
@router.get("/search")
async def search_services(
lat: float, lng: float,
is_premium: bool = False,
db: AsyncSession = Depends(get_db)
):
"""Kétlépcsős keresés: Free (Légvonal) vs Premium (Útvonal/Idő)"""
query = text("""
SELECT name, city, ST_Distance(coordinates, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) as dist
FROM data.organization_locations WHERE is_verified = TRUE ORDER BY dist LIMIT 10
""")
res = (await db.execute(query, {"lat": lat, "lng": lng})).fetchall()
results = []
for row in res:
item = {"name": row[0], "city": row[1], "distance_km": round(row[2]/1000, 2)}
if is_premium:
# PRÉMIUM: Itt jönne az útvonaltervező API integráció
item["estimated_travel_time_min"] = round(row[2] / 700) # Becsült
results.append(item)
return results

View File

@@ -1,262 +0,0 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Depends, Request, status, Query
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text, select
from app.core.config import settings
from app.core.security import get_password_hash, verify_password, create_access_token
from app.api.deps import get_db
from app.models.user import User
from app.models.company import Company
from app.services.email_manager import email_manager
router = APIRouter(prefix="", tags=["Authentication V2"])
# -----------------------
# Pydantic request models
# -----------------------
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=200) # policy endpointben
first_name: str = Field(min_length=1, max_length=80)
last_name: str = Field(min_length=1, max_length=80)
class ResetPasswordIn(BaseModel):
token: str
new_password: str = Field(min_length=1, max_length=200)
# -----------------------
# Helpers
# -----------------------
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _enforce_password_policy(password: str) -> None:
# Most: teszt policy (min length), később bővítjük nagybetű/szám/special szabályokra
min_len = int(getattr(settings, "PASSWORD_MIN_LENGTH", 4) or 4)
if len(password) < min_len:
raise HTTPException(
status_code=400,
detail={
"code": "password_policy_failed",
"message": "A jelszó nem felel meg a biztonsági szabályoknak.",
"rules": [f"Minimum hossz: {min_len} karakter"],
},
)
async def _mark_token_used(db: AsyncSession, token_id: int) -> None:
await db.execute(
text(
"UPDATE data.verification_tokens "
"SET is_used = true, used_at = now() "
"WHERE id = :id"
),
{"id": token_id},
)
# -----------------------
# Endpoints
# -----------------------
@router.post("/register")
async def register(payload: RegisterIn, request: Request, db: AsyncSession = Depends(get_db)):
_enforce_password_policy(payload.password)
# email unique (később: soft-delete esetén engedjük az újra-reget a szabályaid szerint)
res = await db.execute(select(User).where(User.email == payload.email))
if res.scalars().first():
raise HTTPException(status_code=400, detail="Ez az e-mail cím már foglalt.")
# create inactive user
new_user = User(
email=payload.email,
hashed_password=get_password_hash(payload.password),
first_name=payload.first_name,
last_name=payload.last_name,
is_active=False,
)
db.add(new_user)
await db.flush()
# create default private company
new_company = Company(name=f"{payload.first_name} Privát Széfje", owner_id=new_user.id)
db.add(new_company)
await db.flush()
# membership (enum miatt raw SQL)
await db.execute(
text(
"INSERT INTO data.company_members (company_id, user_id, role, is_active) "
"VALUES (:c, :u, 'owner'::data.companyrole, true)"
),
{"c": new_company.id, "u": new_user.id},
)
# verification token (store hash only)
token = secrets.token_urlsafe(48)
token_hash = _hash_token(token)
expires_at = datetime.now(timezone.utc) + timedelta(hours=48)
await db.execute(
text(
"INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at, is_used) "
"VALUES (:u, :t, 'email_verify'::data.tokentype, :e, false)"
),
{"u": new_user.id, "t": token_hash, "e": expires_at},
)
await db.commit()
# Send email (best-effort)
try:
link = f"{settings.FRONTEND_BASE_URL}/verify?token={token}"
await email_manager.send_email(
payload.email,
"registration",
{"first_name": payload.first_name, "link": link},
user_id=new_user.id,
)
except Exception:
# tesztben nem állítjuk meg a regisztrációt email hiba miatt
pass
return {"message": "Sikeres regisztráció! Kérlek aktiváld az emailedet."}
@router.get("/verify")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
token_hash = _hash_token(token)
res = await db.execute(
text(
"SELECT id, user_id, expires_at, is_used "
"FROM data.verification_tokens "
"WHERE token_hash = :h "
" AND token_type = 'email_verify'::data.tokentype "
" AND is_used = false "
"LIMIT 1"
),
{"h": token_hash},
)
row = res.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Érvénytelen vagy már felhasznált token.")
# expired? -> mark used (audit) and reject
if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc):
await _mark_token_used(db, row.id)
await db.commit()
raise HTTPException(status_code=400, detail="A token lejárt. Kérj újat.")
# activate user
await db.execute(
text("UPDATE data.users SET is_active = true WHERE id = :u"),
{"u": row.user_id},
)
# mark used (one-time)
await _mark_token_used(db, row.id)
await db.commit()
return {"message": "Fiók aktiválva. Most már be tudsz jelentkezni."}
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
res = await db.execute(
text("SELECT id, hashed_password, is_active, is_superuser FROM data.users WHERE email = :e"),
{"e": form_data.username},
)
u = res.fetchone()
if not u or not verify_password(form_data.password, u.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Hibás hitelesítés.")
if not u.is_active:
raise HTTPException(status_code=400, detail="Fiók nem aktív.")
token = create_access_token({"sub": str(u.id), "is_admin": bool(u.is_superuser)})
return {"access_token": token, "token_type": "bearer"}
@router.post("/forgot-password")
async def forgot_password(email: EmailStr = Query(...), db: AsyncSession = Depends(get_db)):
# Anti-enumeration: mindig ugyanazt válaszoljuk
res = await db.execute(select(User).where(User.email == email))
user = res.scalars().first()
if user:
token = secrets.token_urlsafe(48)
token_hash = _hash_token(token)
expires_at = datetime.now(timezone.utc) + timedelta(hours=2)
await db.execute(
text(
"INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at, is_used) "
"VALUES (:u, :t, 'password_reset'::data.tokentype, :e, false)"
),
{"u": user.id, "t": token_hash, "e": expires_at},
)
await db.commit()
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token}"
try:
await email_manager.send_email(
email,
"password_reset",
{"first_name": user.first_name or "", "link": link},
user_id=user.id,
)
except Exception:
pass
return {"message": "Ha a cím létezik, a helyreállító levelet elküldtük."}
@router.post("/reset-password-confirm")
async def reset_password_confirm(payload: ResetPasswordIn, db: AsyncSession = Depends(get_db)):
_enforce_password_policy(payload.new_password)
token_hash = _hash_token(payload.token)
res = await db.execute(
text(
"SELECT id, user_id, expires_at, is_used "
"FROM data.verification_tokens "
"WHERE token_hash = :h "
" AND token_type = 'password_reset'::data.tokentype "
" AND is_used = false "
"LIMIT 1"
),
{"h": token_hash},
)
row = res.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Érvénytelen vagy már felhasznált token.")
if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc):
await _mark_token_used(db, row.id)
await db.commit()
raise HTTPException(status_code=400, detail="A token lejárt. Kérj újat.")
new_hash = get_password_hash(payload.new_password)
await db.execute(
text("UPDATE data.users SET hashed_password = :p WHERE id = :u"),
{"p": new_hash, "u": row.user_id},
)
await _mark_token_used(db, row.id)
await db.commit()
return {"message": "Jelszó sikeresen megváltoztatva."}