feat: SuperAdmin bootstrap, i18n sync fix and AssetAssignment ORM fix

- Fixed AttributeError in User model (added region_code, preferred_language)
- Fixed InvalidRequestError in AssetAssignment (added organization relationship)
- Configured STATIC_DIR for translation sync
- Applied Alembic migrations for user schema updates
This commit is contained in:
2026-02-10 21:01:58 +00:00
parent e255fea3a5
commit 425f598fa3
51 changed files with 1753 additions and 204 deletions

View File

@@ -1,6 +1,7 @@
import os
import logging
import uuid
import json
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -18,18 +19,15 @@ 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ó (Master Book 1.1)
Új User és ideiglenes Person rekord létrehozása nyelvi és időzóna adatokkal.
"""
"""Step 1: Lite Regisztráció."""
try:
# Ideiglenes Person rekord a KYC-ig
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
@@ -46,14 +44,12 @@ class AuthService:
is_active=False,
is_deleted=False,
region_code=user_in.region_code,
# --- NYELVI ÉS ADMIN BEÁLLÍTÁSOK MENTÉSE ---
preferred_language=user_in.lang,
timezone=user_in.timezone
)
db.add(new_user)
await db.flush()
# Regisztrációs token generálása
reg_hours = await config.get_setting("auth_registration_hours", region_code=user_in.region_code, default=48)
token_val = uuid.uuid4()
db.add(VerificationToken(
@@ -63,14 +59,12 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
))
# --- EMAIL KÜLDÉSE A VÁLASZTOTT NYELVEN ---
# Master Book 3.2: Nincs manuális subject, a nyelvi kulcs alapján töltődik be
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
await email_manager.send_email(
recipient=user_in.email,
template_key="reg", # hu.json: email.reg_subject, reg_greeting stb.
template_key="reg",
variables={"first_name": user_in.first_name, "link": verification_link},
lang=user_in.lang # Dinamikus nyelvválasztás
lang=user_in.lang
)
await db.commit()
@@ -83,23 +77,16 @@ class AuthService:
@staticmethod
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
"""
1.3. Fázis: Atomi Tranzakció & Shadow Identity
Felismeri a visszatérő Person-t, de új User-ként, izolált flottával indít.
Frissíti a nyelvi és pénzügyi beállításokat.
"""
"""1.3. Fázis: Atomi Tranzakció & Shadow Identity."""
try:
# 1. Aktuális technikai User lekérése
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
# --- PÉNZNEM PREFERENCIA FRISSÍTÉSE ---
if hasattr(kyc_in, 'preferred_currency') and kyc_in.preferred_currency:
user.preferred_currency = kyc_in.preferred_currency
# 2. Shadow Identity Ellenőrzése
identity_stmt = select(Person).where(and_(
Person.mothers_last_name == kyc_in.mothers_last_name,
Person.mothers_first_name == kyc_in.mothers_first_name,
@@ -115,7 +102,6 @@ class AuthService:
else:
active_person = user.person
# 3. Címkezelés
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=kyc_in.address_zip,
@@ -126,7 +112,6 @@ class AuthService:
parcel_id=kyc_in.address_hrsz
)
# 4. Person 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
@@ -137,7 +122,6 @@ class AuthService:
active_person.ice_contact = jsonable_encoder(kyc_in.ice_contact)
active_person.is_active = True
# 5. Új, izolált INDIVIDUAL szervezet (4.2.3) i18n beállításokkal
new_org = Organization(
full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta",
name=f"{active_person.last_name} Flotta",
@@ -146,7 +130,6 @@ class AuthService:
is_transferable=False,
is_active=True,
status="verified",
# Megörökölt adminisztrációs adatok
language=user.preferred_language,
default_currency=user.preferred_currency,
country_code=user.region_code
@@ -154,7 +137,6 @@ class AuthService:
db.add(new_org)
await db.flush()
# 6. Tagság és Jogosultságok
db.add(OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
@@ -162,7 +144,6 @@ class AuthService:
permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True}
))
# 7. Wallet & Stats
db.add(Wallet(
user_id=user.id,
coin_balance=0,
@@ -171,7 +152,6 @@ class AuthService:
))
db.add(UserStats(user_id=user.id, total_xp=0, current_level=1))
# 8. Aktiválás
user.is_active = True
await db.commit()
@@ -182,6 +162,39 @@ class AuthService:
logger.error(f"KYC Atomi Tranzakció Hiba: {str(e)}")
raise e
@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'.
"""
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 felszabadítása: deleted_ID_TIMESTAMP_EMAIL formátumban
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,
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:
@@ -227,13 +240,12 @@ class AuthService:
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_hours))
))
# --- EMAIL KÜLDÉSE A FELHASZNÁLÓ SAJÁT NYELVÉN ---
reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email(
recipient=email,
template_key="pwd_reset", # hu.json: email.pwd_reset_subject stb.
template_key="pwd_reset",
variables={"link": reset_link},
lang=user.preferred_language # Adatbázisból kinyert nyelv
lang=user.preferred_language
)
await db.commit()
return "success"

View File

@@ -1,47 +1,106 @@
import logging
import math
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.gamification import UserStats, PointsLedger
import math
from app.models.identity import User, Wallet
from app.models.core_logic import CreditTransaction
from app.models.system_config import SystemParameter
logger = logging.getLogger(__name__)
class GamificationService:
@staticmethod
async def process_activity(db: AsyncSession, user_id: int, xp_amount: int, social_amount: int, reason: str):
"""
XP növelés, Szintlépés csekk és Automata Kredit váltás.
"""
# 1. User statisztika lekérése
stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stmt)).scalar_one_or_none()
async def get_config(db: AsyncSession):
"""Kiolvassa a GAMIFICATION_MASTER_CONFIG-ot a rendszerparaméterekből."""
stmt = select(SystemParameter).where(SystemParameter.key == "GAMIFICATION_MASTER_CONFIG")
res = await db.execute(stmt)
param = res.scalar_one_or_none()
return param.value if param else {
"xp_logic": {"base_xp": 500, "exponent": 1.5},
"penalty_logic": {
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
"multipliers": {"level_0": 1.0, "level_1": 0.5, "level_2": 0.1, "level_3": 0.0},
"recovery_rate": 0.5
},
"conversion_logic": {"social_to_credit_rate": 100},
"level_rewards": {"credits_per_10_levels": 50},
"blocked_roles": ["superadmin", "service_bot"]
}
async def process_activity(self, db: AsyncSession, user_id: int, xp_amount: int, social_amount: int, reason: str, is_penalty: bool = False):
"""A 'Bíró' logika: Ellenőriz, büntet, jutalmaz és szintez."""
config = await self.get_config(db)
# 1. Jogosultság ellenőrzése
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none()
if not user or user.is_deleted or user.role.value in config.get("blocked_roles", []):
return None
# 2. Stats lekéré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, social_points=0, current_level=1, credits=0)
stats = UserStats(user_id=user_id)
db.add(stats)
# 2. Részletes Logolás (PointsLedger) - A visszakövethetőség miatt
db.add(PointsLedger(
user_id=user_id,
xp_gain=xp_amount,
social_gain=social_amount,
reason=reason
))
# 3. XP és Szintlépés (Nehezedő görbe)
stats.total_xp += xp_amount
# Képlet: Level = (XP / 500)^(1/1.5)
new_level = int((stats.total_xp / 500) ** (1/1.5)) + 1
if new_level > stats.current_level:
stats.current_level = new_level
# 4. Automata Kredit váltás
# Példa: Minden 100 Social pont automatikusan 1 Kredit lesz
stats.social_points += social_amount
if stats.social_points >= 100:
new_credits = stats.social_points // 100
stats.credits += new_credits
stats.social_points %= 100 # A maradék megmarad a következő váltáshoz
# 3. Büntető logika (Penalty)
if is_penalty:
stats.penalty_points += xp_amount
th = config["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
# Külön log a váltásról
db.add(PointsLedger(user_id=user_id, reason=f"Auto-conversion: {new_credits} Credits", credits_change=new_credits))
db.add(PointsLedger(user_id=user_id, points=0, penalty_change=xp_amount, reason=f"PENALTY: {reason}"))
await db.commit()
return stats
# 4. Dinamikus szorzó alkalmazása
multipliers = config["penalty_logic"]["multipliers"]
multiplier = multipliers.get(f"level_{stats.restriction_level}", 1.0)
if multiplier <= 0:
logger.warning(f"User {user_id} activity blocked (Level {stats.restriction_level})")
return stats
# 5. XP, Ledolgozás és Szintlépés
final_xp = int(xp_amount * multiplier)
if final_xp > 0:
stats.total_xp += final_xp
if stats.penalty_points > 0:
rec_rate = config["penalty_logic"]["recovery_rate"]
stats.penalty_points = max(0, stats.penalty_points - int(final_xp * rec_rate))
xp_cfg = config["xp_logic"]
new_level = int((stats.total_xp / xp_cfg["base_xp"]) ** (1/xp_cfg["exponent"])) + 1
if new_level > stats.current_level:
if new_level % 10 == 0:
reward = config["level_rewards"]["credits_per_10_levels"]
await self._add_credits(db, user_id, reward, f"Level {new_level} Achievement Bonus")
stats.current_level = new_level
# 6. Social pont és váltás
final_social = int(social_amount * multiplier)
if final_social > 0:
stats.social_points += final_social
rate = config["conversion_logic"]["social_to_credit_rate"]
if stats.social_points >= rate:
new_credits = stats.social_points // rate
stats.social_points %= rate
await self._add_credits(db, user_id, new_credits, "Social conversion")
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason))
await db.commit()
return stats
return stats
async def _add_credits(self, db: AsyncSession, user_id: int, amount: int, reason: str):
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
wallet = (await db.execute(wallet_stmt)).scalar_one_or_none()
if wallet:
wallet.credit_balance += Decimal(amount)
db.add(CreditTransaction(org_id=None, amount=Decimal(amount), description=reason))
gamification_service = GamificationService()

View File

@@ -0,0 +1,169 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, Any, Dict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from app.models.security import PendingAction, ActionStatus
from app.models.history import AuditLog, LogSeverity
from app.models.identity import User
from app.models.system_config import SystemParameter
logger = logging.getLogger(__name__)
class SecurityService:
@staticmethod
async def get_sec_config(db: AsyncSession) -> Dict[str, Any]:
"""Lekéri a biztonsági korlátokat a központi rendszerparaméterekből."""
keys = ["SECURITY_MAX_RECORDS_PER_HOUR", "SECURITY_DUAL_CONTROL_ENABLED"]
stmt = select(SystemParameter).where(SystemParameter.key.in_(keys))
res = await db.execute(stmt)
params = {p.key: p.value for p in res.scalars().all()}
return {
"max_records": int(params.get("SECURITY_MAX_RECORDS_PER_HOUR", 500)),
"dual_control": str(params.get("SECURITY_DUAL_CONTROL_ENABLED", "true")).lower() == "true"
}
# --- 1. SZINT: AUDIT & LOGGING (A Mindenlátó Szem) ---
async def log_event(
self,
db: AsyncSession,
user_id: Optional[int],
action: str,
severity: LogSeverity,
old_data: Optional[Dict] = None,
new_data: Optional[Dict] = None,
ip: Optional[str] = None,
ua: Optional[str] = None,
target_type: Optional[str] = None,
target_id: Optional[str] = None,
reason: Optional[str] = None
):
"""Minden rendszerművelet rögzítése és azonnali biztonsági elemzése."""
new_log = AuditLog(
user_id=user_id,
severity=severity,
action=action,
target_type=target_type,
target_id=target_id,
old_data=old_data,
new_data=new_data,
ip_address=ip,
user_agent=ua
)
db.add(new_log)
# Ha a szint EMERGENCY, azonnal lőjük le a júzert
if severity == LogSeverity.emergency:
await self._execute_emergency_lock(db, user_id, f"Auto-lock triggered by: {action}")
await db.commit()
# --- 2. SZINT: PENDING ACTIONS (Négy szem elv) ---
async def request_action(
self,
db: AsyncSession,
requester_id: int,
action_type: str,
payload: Dict,
reason: str
):
"""Kritikus művelet kezdeményezése jóváhagyásra (nem hajtódik végre azonnal)."""
new_action = PendingAction(
requester_id=requester_id,
action_type=action_type,
payload=payload,
reason=reason,
status=ActionStatus.pending
)
db.add(new_action)
await self.log_event(
db, requester_id,
action=f"REQUEST_{action_type}",
severity=LogSeverity.critical,
new_data=payload,
reason=f"Approval requested: {reason}"
)
await db.commit()
return new_action
async def approve_action(self, db: AsyncSession, approver_id: int, action_id: int):
"""Művelet végrehajtása egy második admin által."""
stmt = select(PendingAction).where(PendingAction.id == action_id)
action = (await db.execute(stmt)).scalar_one_or_none()
if not action or action.status != ActionStatus.pending:
raise Exception("A művelet nem található vagy már feldolgozták.")
if action.requester_id == approver_id:
raise Exception("Önmagad kérését nem hagyhatod jóvá! (Négy szem elv)")
# ITT TÖRTÉNIK A TÉNYLEGES ÜZLETI LOGIKA (Példa: Rangmódosítás)
if action.action_type == "CHANGE_ROLE":
user_id = action.payload.get("user_id")
new_role = action.payload.get("new_role")
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none()
if user:
user.role = new_role
logger.info(f"Role for user {user_id} changed to {new_role} via approved action {action_id}")
action.status = ActionStatus.approved
action.approver_id = approver_id
action.processed_at = func.now()
await self.log_event(
db, approver_id,
action=f"APPROVE_{action.action_type}",
severity=LogSeverity.info,
target_id=str(action.id),
reason=f"Approved action requested by {action.requester_id}"
)
await db.commit()
return True
# --- 3. SZINT: DATA THROTTLING & EMERGENCY LOCK ---
async def check_data_access_limit(self, db: AsyncSession, user_id: int):
"""Figyeli a tömeges adatlekérést (Adatlopás elleni védelem)."""
config = await self.get_sec_config(db)
one_hour_ago = datetime.now() - timedelta(hours=1)
# Megszámoljuk az utolsó egy óra GET (lekérési) logjait
stmt = select(func.count(AuditLog.id)).where(
and_(
AuditLog.user_id == user_id,
AuditLog.timestamp >= one_hour_ago,
AuditLog.action.like("GET_%")
)
)
count = (await db.execute(stmt)).scalar() or 0
if count > config["max_records"]:
await self.log_event(
db, user_id,
action="MASS_DATA_ACCESS_DETECTED",
severity=LogSeverity.emergency,
reason=f"Access count: {count} (Limit: {config['max_records']})"
)
# A log_event automatikusan hívja a _execute_emergency_lock-ot
return False
return True
async def _execute_emergency_lock(self, db: AsyncSession, user_id: int, reason: str):
"""Azonnali fiókfelfüggesztés vészhelyzet esetén."""
if not user_id: return
stmt = select(User).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if user:
user.is_active = False
logger.critical(f"🚨 SECURITY EMERGENCY LOCK: User {user_id} suspended. Reason: {reason}")
# Itt lehetne bekötni egy külső SMS/Slack/Email riasztást
security_service = SecurityService()

View File

@@ -1,15 +1,21 @@
import json
import os
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.models.translation import Translation
from typing import Dict
from app.core.config import settings
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
class TranslationService:
# Ez a memória-cache tárolja az élesített szövegeket
# Memória-cache a szerveroldali hibaüzenetekhez és emailekhez
_published_cache: Dict[str, Dict[str, str]] = {}
@classmethod
async def load_cache(cls, db: AsyncSession):
"""Betölti az összes PUBLIKÁLT fordítást az adatbázisból a memóriába."""
"""Betölti a publikált szövegeket a memóriába az adatbázisból."""
result = await db.execute(
select(Translation).where(Translation.is_published == True)
)
@@ -20,27 +26,80 @@ class TranslationService:
if t.lang_code not in cls._published_cache:
cls._published_cache[t.lang_code] = {}
cls._published_cache[t.lang_code][t.key] = t.value
print(f"🌍 i18n Cache: {len(translations)} szöveg élesítve.")
logger.info(f"🌍 i18n Cache: {len(translations)} szöveg betöltve.")
@classmethod
def get_text(cls, key: str, lang: str = "en") -> str:
"""Villámgyors lekérés a memóriából Fallback logikával."""
# 1. Kért nyelv
def get_text(cls, key: str, lang: str = "hu", variables: Optional[Dict[str, Any]] = None) -> str:
"""
Szerveroldali lekérés Fallback (EN) logikával és változó behelyettesítéssel.
Példa: get_text("AUTH.WELCOME", "hu", {"name": "Péter"})
"""
# 1. Kért nyelv lekérése
text = cls._published_cache.get(lang, {}).get(key)
if text: return text
# 2. Fallback: Angol
if lang != "en":
text = cls._published_cache.get("en", {}).get(key)
if text: return text
return f"[{key}]"
# 2. Fallback angolra, ha nincs meg a kért nyelven
if not text and lang != "en":
text = cls._published_cache.get("en", {}).get(key)
# 3. Ha sehol nincs meg, adjuk vissza a kulcsot
if not text:
return f"[{key}]"
# 4. Változók behelyettesítése (pl. {{name}})
if variables:
for k, v in variables.items():
text = text.replace(f"{{{{{k}}}}}", str(v))
return text
@classmethod
async def publish_all(cls, db: AsyncSession):
"""Élesíti a piszkozatokat és frissíti a szerver memóriáját."""
"""Minden piszkozatot élesít, frissíti a memóriát és legenerálja a JSON-öket."""
await db.execute(
update(Translation).where(Translation.is_published == False).values(is_published=True)
)
await db.commit()
await cls.load_cache(db)
await cls.load_cache(db)
await cls.export_to_json(db)
@staticmethod
async def export_to_json(db: AsyncSession):
"""
Adatbázis -> Hierarchikus JSON export.
'AUTH.LOGIN.TITLE' -> { "AUTH": { "LOGIN": { "TITLE": "..." } } }
"""
stmt = select(Translation).where(Translation.is_published == True)
result = await db.execute(stmt)
translations = result.scalars().all()
languages: Dict[str, Any] = {}
for t in translations:
if t.lang_code not in languages:
languages[t.lang_code] = {}
# Hierarchikus struktúra felépítése
parts = t.key.split('.')
current_level = languages[t.lang_code]
for part in parts[:-1]:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
current_level[parts[-1]] = t.value
# Fájlok mentése
locales_path = os.path.join(settings.STATIC_DIR, "locales")
os.makedirs(locales_path, exist_ok=True)
for lang, content in languages.items():
file_path = os.path.join(locales_path, f"{lang}.json")
try:
with open(file_path, "w", encoding="utf-8") as f:
json.dump(content, f, ensure_ascii=False, indent=2)
logger.info(f"🚀 JSON legenerálva: {file_path}")
except Exception as e:
logger.error(f"Fájl hiba ({lang}): {str(e)}")
return True
translation_service = TranslationService()