895 lines
30 KiB
Python
Executable File
895 lines
30 KiB
Python
Executable File
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/gamification.py
|
|
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException, Body, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc, func, and_
|
|
from typing import List, Optional
|
|
from datetime import datetime, timedelta
|
|
|
|
logger = logging.getLogger("gamification-endpoints")
|
|
|
|
from app.db.session import get_db
|
|
from app.api.deps import get_current_user
|
|
from app.models.identity import User
|
|
from app.models import UserStats, PointsLedger, LevelConfig, UserContribution, Badge, UserBadge, Season
|
|
from app.models.system import SystemParameter, ParameterScope
|
|
from app.models.marketplace.service import ServiceStaging
|
|
from app.schemas.gamification import SeasonResponse, UserStatResponse, LeaderboardEntry
|
|
from app.core.i18n import t
|
|
|
|
router = APIRouter()
|
|
|
|
# -- SEGÉDFÜGGVÉNY A RENDSZERBEÁLLÍTÁSOKHOZ --
|
|
async def get_system_param(db: AsyncSession, key: str, default_value):
|
|
stmt = select(SystemParameter).where(SystemParameter.key == key)
|
|
res = (await db.execute(stmt)).scalar_one_or_none()
|
|
return res.value if res else default_value
|
|
|
|
@router.get("/my-stats")
|
|
async def get_my_stats(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
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, "services_submitted": 0}
|
|
return stats
|
|
|
|
@router.get("/leaderboard")
|
|
async def get_leaderboard(
|
|
limit: int = 10,
|
|
season_id: Optional[int] = None,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Vezetőlista - globális vagy szezonális"""
|
|
if season_id:
|
|
# Szezonális vezetőlista
|
|
stmt = (
|
|
select(
|
|
User.email,
|
|
func.sum(UserContribution.points_awarded).label("total_points"),
|
|
func.sum(UserContribution.xp_awarded).label("total_xp")
|
|
)
|
|
.join(UserContribution, User.id == UserContribution.user_id)
|
|
.where(UserContribution.season_id == season_id)
|
|
.group_by(User.id)
|
|
.order_by(desc("total_points"))
|
|
.limit(limit)
|
|
)
|
|
else:
|
|
# Globális vezetőlista
|
|
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)
|
|
|
|
if season_id:
|
|
return [
|
|
{"user": f"{r[0][:2]}***@{r[0].split('@')[1]}", "points": r[1], "xp": r[2]}
|
|
for r in result.all()
|
|
]
|
|
else:
|
|
return [
|
|
{"user": f"{r[0][:2]}***@{r[0].split('@')[1]}", "xp": r[1], "level": r[2]}
|
|
for r in result.all()
|
|
]
|
|
|
|
@router.get("/seasons")
|
|
async def get_seasons(
|
|
active_only: bool = True,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Szezonok listázása"""
|
|
stmt = select(Season)
|
|
if active_only:
|
|
stmt = stmt.where(Season.is_active == True)
|
|
|
|
result = await db.execute(stmt)
|
|
seasons = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": s.id,
|
|
"name": s.name,
|
|
"start_date": s.start_date,
|
|
"end_date": s.end_date,
|
|
"is_active": s.is_active
|
|
}
|
|
for s in seasons
|
|
]
|
|
|
|
@router.get("/my-contributions")
|
|
async def get_my_contributions(
|
|
season_id: Optional[int] = None,
|
|
limit: int = 50,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Felhasználó hozzájárulásainak listázása"""
|
|
stmt = select(UserContribution).where(UserContribution.user_id == current_user.id)
|
|
|
|
if season_id:
|
|
stmt = stmt.where(UserContribution.season_id == season_id)
|
|
|
|
stmt = stmt.order_by(desc(UserContribution.created_at)).limit(limit)
|
|
|
|
result = await db.execute(stmt)
|
|
contributions = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": c.id,
|
|
"contribution_type": c.contribution_type,
|
|
"entity_type": c.entity_type,
|
|
"entity_id": c.entity_id,
|
|
"points_awarded": c.points_awarded,
|
|
"xp_awarded": c.xp_awarded,
|
|
"status": c.status,
|
|
"created_at": c.created_at
|
|
}
|
|
for c in contributions
|
|
]
|
|
|
|
@router.get("/season-standings/{season_id}")
|
|
async def get_season_standings(
|
|
season_id: int,
|
|
limit: int = 20,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Szezon állása - top hozzájárulók"""
|
|
# Aktuális szezon ellenőrzése
|
|
season_stmt = select(Season).where(Season.id == season_id)
|
|
season = (await db.execute(season_stmt)).scalar_one_or_none()
|
|
|
|
if not season:
|
|
raise HTTPException(status_code=404, detail=t("GAMIFICATION.SEASON.NOT_FOUND"))
|
|
|
|
# Top hozzájárulók lekérdezése
|
|
stmt = (
|
|
select(
|
|
User.email,
|
|
func.sum(UserContribution.points_awarded).label("total_points"),
|
|
func.sum(UserContribution.xp_awarded).label("total_xp"),
|
|
func.count(UserContribution.id).label("contribution_count")
|
|
)
|
|
.join(UserContribution, User.id == UserContribution.user_id)
|
|
.where(
|
|
and_(
|
|
UserContribution.season_id == season_id,
|
|
UserContribution.status == "approved"
|
|
)
|
|
)
|
|
.group_by(User.id)
|
|
.order_by(desc("total_points"))
|
|
.limit(limit)
|
|
)
|
|
|
|
result = await db.execute(stmt)
|
|
standings = result.all()
|
|
|
|
# Szezonális jutalmak konfigurációja
|
|
season_config = await get_system_param(
|
|
db, "seasonal_competition_config",
|
|
{
|
|
"top_contributors_count": 10,
|
|
"rewards": {
|
|
"first_place": {"credits": 1000, "badge": "season_champion"},
|
|
"second_place": {"credits": 500, "badge": "season_runner_up"},
|
|
"third_place": {"credits": 250, "badge": "season_bronze"},
|
|
"top_10": {"credits": 100, "badge": "season_elite"}
|
|
}
|
|
}
|
|
)
|
|
|
|
return {
|
|
"season": {
|
|
"id": season.id,
|
|
"name": season.name,
|
|
"start_date": season.start_date,
|
|
"end_date": season.end_date
|
|
},
|
|
"standings": [
|
|
{
|
|
"rank": idx + 1,
|
|
"user": f"{r[0][:2]}***@{r[0].split('@')[1]}",
|
|
"points": r[1],
|
|
"xp": r[2],
|
|
"contributions": r[3],
|
|
"reward": get_season_reward(idx + 1, season_config)
|
|
}
|
|
for idx, r in enumerate(standings)
|
|
],
|
|
"config": season_config
|
|
}
|
|
|
|
def get_season_reward(rank: int, config: dict) -> dict:
|
|
"""Szezonális jutalom meghatározása a rang alapján"""
|
|
rewards = config.get("rewards", {})
|
|
|
|
if rank == 1:
|
|
return rewards.get("first_place", {})
|
|
elif rank == 2:
|
|
return rewards.get("second_place", {})
|
|
elif rank == 3:
|
|
return rewards.get("third_place", {})
|
|
elif rank <= config.get("top_contributors_count", 10):
|
|
return rewards.get("top_10", {})
|
|
else:
|
|
return {}
|
|
|
|
@router.get("/self-defense-status")
|
|
async def get_self_defense_status(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Önvédelmi rendszer státusz lekérdezése"""
|
|
stmt = select(UserStats).where(UserStats.user_id == current_user.id)
|
|
stats = (await db.execute(stmt)).scalar_one_or_none()
|
|
|
|
if not stats:
|
|
return {
|
|
"penalty_level": 0,
|
|
"restrictions": [],
|
|
"recovery_progress": 0,
|
|
"can_submit_services": True
|
|
}
|
|
|
|
# Önvédelmi büntetések konfigurációja
|
|
penalty_config = await get_system_param(
|
|
db, "self_defense_penalties",
|
|
{
|
|
"level_minus_1": {"restrictions": ["no_service_submissions"], "duration_days": 7},
|
|
"level_minus_2": {"restrictions": ["no_service_submissions", "no_reviews"], "duration_days": 30},
|
|
"level_minus_3": {"restrictions": ["no_service_submissions", "no_reviews", "no_messaging"], "duration_days": 365}
|
|
}
|
|
)
|
|
|
|
# Büntetési szint meghatározása (egyszerűsített logika)
|
|
penalty_level = 0
|
|
if stats.penalty_points >= 1000:
|
|
penalty_level = -3
|
|
elif stats.penalty_points >= 500:
|
|
penalty_level = -2
|
|
elif stats.penalty_points >= 100:
|
|
penalty_level = -1
|
|
|
|
restrictions = []
|
|
if penalty_level < 0:
|
|
level_key = f"level_minus_{abs(penalty_level)}"
|
|
restrictions = penalty_config.get(level_key, {}).get("restrictions", [])
|
|
|
|
return {
|
|
"penalty_level": penalty_level,
|
|
"penalty_points": stats.penalty_points,
|
|
"restrictions": restrictions,
|
|
"recovery_progress": min(stats.total_xp / 10000 * 100, 100) if penalty_level < 0 else 100,
|
|
"can_submit_services": "no_service_submissions" not in restrictions
|
|
}
|
|
|
|
# --- AZ ÚJ, DINAMIKUS BEKÜLDŐ VÉGPONT (Gamification 2.0 kompatibilis) ---
|
|
@router.post("/submit-service")
|
|
async def submit_new_service(
|
|
name: str = Body(...),
|
|
city: str = Body(...),
|
|
address: str = Body(...),
|
|
contact_phone: Optional[str] = Body(None),
|
|
website: Optional[str] = Body(None),
|
|
description: Optional[str] = Body(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
from app.services.gamification_service import gamification_service
|
|
|
|
# 1. Önvédelmi státusz ellenőrzése
|
|
defense_status = await get_self_defense_status(db, current_user)
|
|
if not defense_status["can_submit_services"]:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=t("GAMIFICATION.SUBMIT_SERVICE.SELF_DEFENSE_BLOCKED")
|
|
)
|
|
|
|
# 2. Beállítások lekérése az Admin által vezérelt táblából
|
|
submission_rewards = await get_system_param(
|
|
db, "service_submission_rewards",
|
|
{"points": 50, "xp": 100, "social_credits": 10}
|
|
)
|
|
|
|
# 3. Aktuális szezon lekérdezése
|
|
season_stmt = select(Season).where(
|
|
and_(
|
|
Season.is_active == True,
|
|
Season.start_date <= datetime.utcnow().date(),
|
|
Season.end_date >= datetime.utcnow().date()
|
|
)
|
|
).limit(1)
|
|
|
|
season_result = await db.execute(season_stmt)
|
|
current_season = season_result.scalar_one_or_none()
|
|
|
|
# 4. Felhasználó statisztikák
|
|
stmt = select(UserStats).where(UserStats.user_id == current_user.id)
|
|
stats = (await db.execute(stmt)).scalar_one_or_none()
|
|
user_lvl = stats.current_level if stats else 1
|
|
|
|
# 5. Trust score számítás a szint alapján
|
|
trust_weight = min(20 + (user_lvl * 6), 90)
|
|
|
|
# 6. Nyers adat beküldése a Robotoknak (Staging)
|
|
import hashlib
|
|
f_print = hashlib.md5(f"{name.lower()}{city.lower()}{address.lower()}".encode()).hexdigest()
|
|
|
|
new_staging = ServiceStaging(
|
|
name=name,
|
|
city=city,
|
|
address_line1=address,
|
|
contact_phone=contact_phone,
|
|
website=website,
|
|
description=description,
|
|
fingerprint=f_print,
|
|
status="pending",
|
|
trust_score=trust_weight,
|
|
submitted_by=current_user.id,
|
|
raw_data={
|
|
"submitted_by_user": current_user.id,
|
|
"user_level": user_lvl,
|
|
"submitted_at": datetime.utcnow().isoformat()
|
|
}
|
|
)
|
|
db.add(new_staging)
|
|
await db.flush() # Get the ID
|
|
|
|
# 7. UserContribution létrehozása
|
|
contribution = UserContribution(
|
|
user_id=current_user.id,
|
|
season_id=current_season.id if current_season else None,
|
|
contribution_type="service_submission",
|
|
entity_type="service_staging",
|
|
entity_id=new_staging.id,
|
|
points_awarded=submission_rewards.get("points", 50),
|
|
xp_awarded=submission_rewards.get("xp", 100),
|
|
status="pending", # Robot 5 jóváhagyására vár
|
|
provided_fields={
|
|
"service_name": name,
|
|
"city": city,
|
|
"staging_id": new_staging.id
|
|
},
|
|
action_type=1,
|
|
earned_xp=submission_rewards.get("xp", 100),
|
|
)
|
|
db.add(contribution)
|
|
|
|
# 8. GamificationService hívás a pontok jóváírásához (a direkt UserStats módosítás helyett)
|
|
# A GamificationService kezeli a szorzókat, szintlépést, recovery-t és naplózást
|
|
try:
|
|
await gamification_service.process_activity(
|
|
db=db,
|
|
user_id=current_user.id,
|
|
xp_amount=submission_rewards.get("xp", 100),
|
|
social_amount=submission_rewards.get("social_credits", 10),
|
|
reason=f"SERVICE_SUBMISSION: {name}",
|
|
is_penalty=False,
|
|
commit=False, # Ne commitoljon, mert a contribution-t is hozzáadjuk
|
|
action_key="service_submission",
|
|
source_type="service_submission",
|
|
source_id=new_staging.id,
|
|
)
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"GamificationService error during service submission for user {current_user.id}: {e}")
|
|
raise HTTPException(status_code=400, detail=t("GAMIFICATION.SUBMIT_SERVICE.POINTS_ERROR", error=str(e)))
|
|
|
|
try:
|
|
await db.commit()
|
|
return {
|
|
"status": "success",
|
|
"message": t("GAMIFICATION.SUBMIT_SERVICE.SUCCESS"),
|
|
"xp_earned": submission_rewards.get("xp", 100),
|
|
"points_earned": submission_rewards.get("points", 50),
|
|
"staging_id": new_staging.id,
|
|
"season_id": current_season.id if current_season else None
|
|
}
|
|
except Exception as e:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=400, detail=t("GAMIFICATION.SUBMIT_SERVICE.SUBMIT_ERROR", error=str(e)))
|
|
|
|
|
|
# --- Gamification 2.0 API végpontok (Frontend/Mobil) ---
|
|
|
|
@router.get("/me", response_model=UserStatResponse)
|
|
async def get_my_gamification_stats(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Visszaadja a bejelentkezett felhasználó aktuális statisztikáit.
|
|
Ha nincs rekord, alapértelmezett értékekkel tér vissza.
|
|
"""
|
|
stmt = select(UserStats).where(UserStats.user_id == current_user.id)
|
|
stats = (await db.execute(stmt)).scalar_one_or_none()
|
|
if not stats:
|
|
# Alapértelmezett statisztika
|
|
return UserStatResponse(
|
|
user_id=current_user.id,
|
|
total_xp=0,
|
|
current_level=1,
|
|
restriction_level=0,
|
|
penalty_quota_remaining=0,
|
|
banned_until=None
|
|
)
|
|
return UserStatResponse.from_orm(stats)
|
|
|
|
|
|
@router.get("/seasons/active", response_model=SeasonResponse)
|
|
async def get_active_season(
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Visszaadja az éppen aktív szezont.
|
|
"""
|
|
stmt = select(Season).where(Season.is_active == True)
|
|
season = (await db.execute(stmt)).scalar_one_or_none()
|
|
if not season:
|
|
raise HTTPException(status_code=404, detail=t("GAMIFICATION.SEASON.NO_ACTIVE"))
|
|
return SeasonResponse.from_orm(season)
|
|
|
|
|
|
@router.get("/leaderboard", response_model=List[LeaderboardEntry])
|
|
async def get_leaderboard_top10(
|
|
limit: int = Query(10, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Visszaadja a top felhasználókat total_xp alapján csökkenő sorrendben.
|
|
"""
|
|
stmt = (
|
|
select(UserStats, User.email)
|
|
.join(User, UserStats.user_id == User.id)
|
|
.order_by(desc(UserStats.total_xp))
|
|
.limit(limit)
|
|
)
|
|
result = await db.execute(stmt)
|
|
rows = result.all()
|
|
|
|
leaderboard = []
|
|
for stats, email in rows:
|
|
leaderboard.append(
|
|
LeaderboardEntry(
|
|
user_id=stats.user_id,
|
|
username=email, # email használata username helyett
|
|
total_xp=stats.total_xp,
|
|
current_level=stats.current_level
|
|
)
|
|
)
|
|
return leaderboard
|
|
|
|
|
|
# --- QUIZ ENDPOINTS FOR DAILY QUIZ GAMIFICATION ---
|
|
|
|
@router.get("/quiz/daily")
|
|
async def get_daily_quiz(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Returns daily quiz questions for the user.
|
|
Checks if user has already played today.
|
|
"""
|
|
# Check if user has already played today
|
|
today = datetime.now().date()
|
|
stmt = select(PointsLedger).where(
|
|
PointsLedger.user_id == current_user.id,
|
|
func.date(PointsLedger.created_at) == today,
|
|
PointsLedger.reason.ilike("%quiz%")
|
|
)
|
|
result = await db.execute(stmt)
|
|
already_played = result.scalar_one_or_none()
|
|
|
|
if already_played:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=t("GAMIFICATION.QUIZ.ALREADY_PLAYED")
|
|
)
|
|
|
|
# Load quiz questions from JSON locale files (i18n)
|
|
from app.core.i18n import get_quiz_data
|
|
quiz_data = get_quiz_data()
|
|
quiz_questions = quiz_data.get("questions", [])
|
|
|
|
return {
|
|
"questions": quiz_questions,
|
|
"total_questions": len(quiz_questions),
|
|
"date": today.isoformat()
|
|
}
|
|
|
|
|
|
@router.post("/quiz/answer")
|
|
async def submit_quiz_answer(
|
|
question_id: int = Body(...),
|
|
selected_option: int = Body(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Submit answer to a quiz question and award points if correct.
|
|
"""
|
|
# Check if user has already played today
|
|
today = datetime.now().date()
|
|
stmt = select(PointsLedger).where(
|
|
PointsLedger.user_id == current_user.id,
|
|
func.date(PointsLedger.created_at) == today,
|
|
PointsLedger.reason.ilike("%quiz%")
|
|
)
|
|
result = await db.execute(stmt)
|
|
already_played = result.scalar_one_or_none()
|
|
|
|
if already_played:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=t("GAMIFICATION.QUIZ.ALREADY_PLAYED")
|
|
)
|
|
|
|
# Load quiz data from JSON locale files (i18n)
|
|
from app.core.i18n import get_quiz_data
|
|
quiz_locale = get_quiz_data()
|
|
quiz_questions = quiz_locale.get("questions", [])
|
|
|
|
# Build lookup dict from questions list
|
|
quiz_lookup = {}
|
|
for q in quiz_questions:
|
|
quiz_lookup[q["id"]] = {
|
|
"correct_answer": q["correctAnswer"],
|
|
"points": 10,
|
|
"explanation": q["explanation"]
|
|
}
|
|
|
|
if question_id not in quiz_lookup:
|
|
raise HTTPException(status_code=404, detail=t("GAMIFICATION.QUIZ.QUESTION_NOT_FOUND"))
|
|
|
|
question_info = quiz_lookup[question_id]
|
|
is_correct = selected_option == question_info["correct_answer"]
|
|
|
|
# Award points if correct (GamificationService-en keresztül)
|
|
if is_correct:
|
|
from app.services.gamification_service import gamification_service
|
|
|
|
try:
|
|
await gamification_service.process_activity(
|
|
db=db,
|
|
user_id=current_user.id,
|
|
xp_amount=question_info["points"],
|
|
social_amount=0,
|
|
reason=f"DAILY_QUIZ: Question {question_id}",
|
|
is_penalty=False,
|
|
commit=False,
|
|
action_key="daily_quiz_correct",
|
|
source_type="daily_quiz",
|
|
source_id=question_id,
|
|
)
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"GamificationService error during quiz answer for user {current_user.id}: {e}")
|
|
raise HTTPException(status_code=500, detail=t("GAMIFICATION.QUIZ.POINTS_FAILED", error=str(e)))
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"is_correct": is_correct,
|
|
"correct_answer": question_info["correct_answer"],
|
|
"points_awarded": question_info["points"] if is_correct else 0,
|
|
"explanation": question_info["explanation"]
|
|
}
|
|
|
|
|
|
@router.post("/quiz/complete")
|
|
async def complete_daily_quiz(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Mark daily quiz as completed for today.
|
|
This prevents the user from playing again today.
|
|
"""
|
|
today = datetime.now().date()
|
|
|
|
# Check if already completed today
|
|
stmt = select(PointsLedger).where(
|
|
PointsLedger.user_id == current_user.id,
|
|
func.date(PointsLedger.created_at) == today,
|
|
PointsLedger.reason == "Daily quiz completed"
|
|
)
|
|
result = await db.execute(stmt)
|
|
already_completed = result.scalar_one_or_none()
|
|
|
|
if already_completed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=t("GAMIFICATION.QUIZ.ALREADY_COMPLETED")
|
|
)
|
|
|
|
# Add completion entry
|
|
completion_ledger = PointsLedger(
|
|
user_id=current_user.id,
|
|
points=0,
|
|
reason="Daily quiz completed",
|
|
created_at=datetime.now()
|
|
)
|
|
db.add(completion_ledger)
|
|
await db.commit()
|
|
|
|
return {"message": t("GAMIFICATION.QUIZ.COMPLETED_SUCCESS")}
|
|
|
|
|
|
@router.get("/quiz/stats")
|
|
async def get_quiz_stats(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get user's quiz statistics including points, streak, and last played date.
|
|
"""
|
|
# Get user stats
|
|
stats_stmt = select(UserStats).where(UserStats.user_id == current_user.id)
|
|
stats_result = await db.execute(stats_stmt)
|
|
user_stats = stats_result.scalar_one_or_none()
|
|
|
|
# Get quiz points from ledger
|
|
points_stmt = select(func.sum(PointsLedger.points)).where(
|
|
PointsLedger.user_id == current_user.id,
|
|
PointsLedger.reason.ilike("%quiz%")
|
|
)
|
|
points_result = await db.execute(points_stmt)
|
|
quiz_points = points_result.scalar() or 0
|
|
|
|
# Get last played date
|
|
last_played_stmt = select(PointsLedger.created_at).where(
|
|
PointsLedger.user_id == current_user.id,
|
|
PointsLedger.reason.ilike("%quiz%")
|
|
).order_by(desc(PointsLedger.created_at)).limit(1)
|
|
last_played_result = await db.execute(last_played_stmt)
|
|
last_played = last_played_result.scalar()
|
|
|
|
# Calculate streak (simplified - in production would be more sophisticated)
|
|
streak = 0
|
|
if last_played:
|
|
# Simple streak calculation - check last 7 days
|
|
streak = 1 # Placeholder
|
|
|
|
return {
|
|
"total_quiz_points": quiz_points,
|
|
"total_xp": user_stats.total_xp if user_stats else 0,
|
|
"current_level": user_stats.current_level if user_stats else 1,
|
|
"last_played": last_played.isoformat() if last_played else None,
|
|
"current_streak": streak,
|
|
"can_play_today": not await has_played_today(db, current_user.id)
|
|
}
|
|
|
|
|
|
async def has_played_today(db: AsyncSession, user_id: int) -> bool:
|
|
"""Check if user has already played quiz today."""
|
|
today = datetime.now().date()
|
|
stmt = select(PointsLedger).where(
|
|
PointsLedger.user_id == user_id,
|
|
func.date(PointsLedger.created_at) == today,
|
|
PointsLedger.reason.ilike("%quiz%")
|
|
)
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none() is not None
|
|
|
|
|
|
# --- BADGE/TROPHY ENDPOINTS ---
|
|
|
|
@router.get("/badges")
|
|
async def get_all_badges(
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Get all available badges in the system.
|
|
"""
|
|
stmt = select(Badge)
|
|
result = await db.execute(stmt)
|
|
badges = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": badge.id,
|
|
"name": badge.name,
|
|
"description": badge.description,
|
|
"icon_url": badge.icon_url
|
|
}
|
|
for badge in badges
|
|
]
|
|
|
|
|
|
@router.get("/my-badges")
|
|
async def get_my_badges(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get badges earned by the current user.
|
|
"""
|
|
stmt = (
|
|
select(UserBadge, Badge)
|
|
.join(Badge, UserBadge.badge_id == Badge.id)
|
|
.where(UserBadge.user_id == current_user.id)
|
|
.order_by(desc(UserBadge.earned_at))
|
|
)
|
|
result = await db.execute(stmt)
|
|
user_badges = result.all()
|
|
|
|
return [
|
|
{
|
|
"badge_id": badge.id,
|
|
"badge_name": badge.name,
|
|
"badge_description": badge.description,
|
|
"badge_icon_url": badge.icon_url,
|
|
"earned_at": user_badge.earned_at.isoformat() if user_badge.earned_at else None
|
|
}
|
|
for user_badge, badge in user_badges
|
|
]
|
|
|
|
|
|
@router.post("/badges/award/{badge_id}")
|
|
async def award_badge_to_user(
|
|
badge_id: int,
|
|
user_id: int = Body(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Award a badge to a user (admin only or automated system).
|
|
"""
|
|
# Check if badge exists
|
|
badge_stmt = select(Badge).where(Badge.id == badge_id)
|
|
badge_result = await db.execute(badge_stmt)
|
|
badge = badge_result.scalar_one_or_none()
|
|
|
|
if not badge:
|
|
raise HTTPException(status_code=404, detail=t("GAMIFICATION.BADGE.NOT_FOUND"))
|
|
|
|
# Determine target user (default to current user if not specified)
|
|
target_user_id = user_id if user_id else current_user.id
|
|
|
|
# Check if user already has this badge
|
|
existing_stmt = select(UserBadge).where(
|
|
UserBadge.user_id == target_user_id,
|
|
UserBadge.badge_id == badge_id
|
|
)
|
|
existing_result = await db.execute(existing_stmt)
|
|
existing = existing_result.scalar_one_or_none()
|
|
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail=t("GAMIFICATION.BADGE.ALREADY_AWARDED"))
|
|
|
|
# Award the badge
|
|
user_badge = UserBadge(
|
|
user_id=target_user_id,
|
|
badge_id=badge_id,
|
|
earned_at=datetime.now()
|
|
)
|
|
db.add(user_badge)
|
|
|
|
# Award points via GamificationService (a direkt UserStats módosítás helyett)
|
|
from app.services.gamification_service import gamification_service
|
|
|
|
try:
|
|
await gamification_service.process_activity(
|
|
db=db,
|
|
user_id=target_user_id,
|
|
xp_amount=50,
|
|
social_amount=0,
|
|
reason=f"BADGE_EARNED: {badge.name}",
|
|
is_penalty=False,
|
|
commit=False,
|
|
action_key="badge_earned",
|
|
source_type="badge",
|
|
source_id=badge_id,
|
|
)
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"GamificationService error during badge award for user {target_user_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=t("GAMIFICATION.BADGE.POINTS_FAILED", error=str(e)))
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"message": t("GAMIFICATION.BADGE.AWARDED_SUCCESS", badge_name=badge.name),
|
|
"badge_id": badge.id,
|
|
"badge_name": badge.name,
|
|
"points_awarded": 50
|
|
}
|
|
|
|
|
|
@router.get("/achievements")
|
|
async def get_achievements_progress(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Get user's progress on various achievements (combines badges and other metrics).
|
|
"""
|
|
# Get user badges
|
|
badges_stmt = select(UserBadge.badge_id).where(UserBadge.user_id == current_user.id)
|
|
badges_result = await db.execute(badges_stmt)
|
|
user_badge_ids = [row[0] for row in badges_result.all()]
|
|
|
|
# Get all badges
|
|
all_badges_stmt = select(Badge)
|
|
all_badges_result = await db.execute(all_badges_stmt)
|
|
all_badges = all_badges_result.scalars().all()
|
|
|
|
# Get user stats
|
|
stats_stmt = select(UserStats).where(UserStats.user_id == current_user.id)
|
|
stats_result = await db.execute(stats_stmt)
|
|
user_stats = stats_result.scalar_one_or_none()
|
|
|
|
# Define achievement categories
|
|
achievements = []
|
|
|
|
# Badge-based achievements
|
|
for badge in all_badges:
|
|
achievements.append({
|
|
"id": f"badge_{badge.id}",
|
|
"title": badge.name,
|
|
"description": badge.description,
|
|
"icon_url": badge.icon_url,
|
|
"is_earned": badge.id in user_badge_ids,
|
|
"category": "badge",
|
|
"progress": 100 if badge.id in user_badge_ids else 0
|
|
})
|
|
|
|
# XP-based achievements
|
|
xp_levels = [
|
|
{"title": t("GAMIFICATION.ACHIEVEMENTS.XP_NOVICE"), "xp_required": 100, "description": t("GAMIFICATION.ACHIEVEMENTS.XP_NOVICE_DESC")},
|
|
{"title": t("GAMIFICATION.ACHIEVEMENTS.XP_APPRENTICE"), "xp_required": 500, "description": t("GAMIFICATION.ACHIEVEMENTS.XP_APPRENTICE_DESC")},
|
|
{"title": t("GAMIFICATION.ACHIEVEMENTS.XP_EXPERT"), "xp_required": 2000, "description": t("GAMIFICATION.ACHIEVEMENTS.XP_EXPERT_DESC")},
|
|
{"title": t("GAMIFICATION.ACHIEVEMENTS.XP_MASTER"), "xp_required": 5000, "description": t("GAMIFICATION.ACHIEVEMENTS.XP_MASTER_DESC")},
|
|
]
|
|
|
|
current_xp = user_stats.total_xp if user_stats else 0
|
|
for level in xp_levels:
|
|
progress = min((current_xp / level["xp_required"]) * 100, 100)
|
|
achievements.append({
|
|
"id": f"xp_{level['xp_required']}",
|
|
"title": level["title"],
|
|
"description": level["description"],
|
|
"icon_url": None,
|
|
"is_earned": current_xp >= level["xp_required"],
|
|
"category": "xp",
|
|
"progress": progress
|
|
})
|
|
|
|
# Quiz-based achievements
|
|
quiz_points_stmt = select(func.sum(PointsLedger.points)).where(
|
|
PointsLedger.user_id == current_user.id,
|
|
PointsLedger.reason.ilike("%quiz%")
|
|
)
|
|
quiz_points_result = await db.execute(quiz_points_stmt)
|
|
quiz_points = quiz_points_result.scalar() or 0
|
|
|
|
quiz_achievements = [
|
|
{"title": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_BEGINNER"), "points_required": 50, "description": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_BEGINNER_DESC")},
|
|
{"title": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_ENTHUSIAST"), "points_required": 200, "description": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_ENTHUSIAST_DESC")},
|
|
{"title": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_MASTER"), "points_required": 500, "description": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_MASTER_DESC")},
|
|
]
|
|
|
|
for achievement in quiz_achievements:
|
|
progress = min((quiz_points / achievement["points_required"]) * 100, 100)
|
|
achievements.append({
|
|
"id": f"quiz_{achievement['points_required']}",
|
|
"title": achievement["title"],
|
|
"description": achievement["description"],
|
|
"icon_url": None,
|
|
"is_earned": quiz_points >= achievement["points_required"],
|
|
"category": "quiz",
|
|
"progress": progress
|
|
})
|
|
|
|
return {
|
|
"achievements": achievements,
|
|
"total_achievements": len(achievements),
|
|
"earned_count": sum(1 for a in achievements if a["is_earned"]),
|
|
"progress_percentage": round((sum(1 for a in achievements if a["is_earned"]) / len(achievements)) * 100, 1) if achievements else 0
|
|
} |