""" 🎮 Admin Gamification API Végpontok a gamification rendszer teljes adminisztrációjához. Minden végponthoz `gamification:manage` permission szükséges. Végpontok: # Point Rules GET /admin/gamification/point-rules POST /admin/gamification/point-rules PUT /admin/gamification/point-rules/{id} DELETE /admin/gamification/point-rules/{id} # Level Configs GET /admin/gamification/level-configs POST /admin/gamification/level-configs PUT /admin/gamification/level-configs/{id} # Badges GET /admin/gamification/badges POST /admin/gamification/badges PUT /admin/gamification/badges/{id} DELETE /admin/gamification/badges/{id} # Seasons GET /admin/gamification/seasons POST /admin/gamification/seasons PUT /admin/gamification/seasons/{id} POST /admin/gamification/seasons/{id}/activate # Competitions GET /admin/gamification/competitions POST /admin/gamification/competitions PUT /admin/gamification/competitions/{id} # User Stats GET /admin/gamification/user-stats GET /admin/gamification/user-stats/{user_id} PUT /admin/gamification/user-stats/{user_id} POST /admin/gamification/user-stats/{user_id}/penalty POST /admin/gamification/user-stats/{user_id}/reward # Master Config GET /admin/gamification/master-config PUT /admin/gamification/master-config # System Params (gamification scope) GET /admin/gamification/system-params PUT /admin/gamification/system-params/{key} # Points Ledger GET /admin/gamification/points-ledger # Contributions GET /admin/gamification/contributions PUT /admin/gamification/contributions/{id}/review """ from __future__ import annotations import logging from datetime import date, datetime, timedelta, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy import select, func, or_, update as sa_update, delete as sa_delete from sqlalchemy.ext.asyncio import AsyncSession from app.api import deps from app.db.session import get_db from app.models.identity import User from app.models.gamification.gamification import ( PointRule, LevelConfig, PointsLedger, UserStats, Badge, UserBadge, UserContribution, Season, SeasonalCompetitions, ) from app.models.identity.social import Competition, UserScore from app.models.system import SystemParameter, ParameterScope from app.services.gamification_service import gamification_service from app.services.system_service import system_service logger = logging.getLogger("admin-gamification") router = APIRouter() # ============================================================================= # Pydantic Schemas # ============================================================================= class PointRuleCreate(BaseModel): action_key: str = Field(..., min_length=1, max_length=100) points: int = Field(..., ge=-10000, le=10000) description: Optional[str] = None is_active: bool = True class PointRuleUpdate(BaseModel): action_key: Optional[str] = None points: Optional[int] = Field(None, ge=-10000, le=10000) description: Optional[str] = None is_active: Optional[bool] = None class LevelConfigCreate(BaseModel): level_number: int = Field(..., description="Pozitív: normál, Negatív: büntető") min_points: int = Field(..., ge=0) rank_name: str = Field(..., min_length=1, max_length=100) is_penalty: bool = False class LevelConfigUpdate(BaseModel): level_number: Optional[int] = None min_points: Optional[int] = Field(None, ge=0) rank_name: Optional[str] = None is_penalty: Optional[bool] = None class BadgeCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) description: str = Field(..., min_length=1, max_length=500) icon_url: Optional[str] = None class BadgeUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None icon_url: Optional[str] = None class SeasonCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) start_date: date end_date: date is_active: bool = False class SeasonUpdate(BaseModel): name: Optional[str] = None start_date: Optional[date] = None end_date: Optional[date] = None is_active: Optional[bool] = None class CompetitionCreate(BaseModel): name: str = Field(..., min_length=1, max_length=200) description: Optional[str] = None season_id: int start_date: date end_date: date rules: Optional[Dict[str, Any]] = None status: str = "draft" class CompetitionUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None season_id: Optional[int] = None start_date: Optional[date] = None end_date: Optional[date] = None rules: Optional[Dict[str, Any]] = None status: Optional[str] = None class UserStatsUpdate(BaseModel): total_xp: Optional[int] = None social_points: Optional[int] = None current_level: Optional[int] = None penalty_points: Optional[int] = None restriction_level: Optional[int] = None places_discovered: Optional[int] = None places_validated: Optional[int] = None providers_added_count: Optional[int] = None services_submitted: Optional[int] = None class PenaltyRequest(BaseModel): amount: int = Field(..., gt=0, le=10000, description="Büntetőpontok száma") reason: str = Field(..., min_length=1, max_length=500) class RewardRequest(BaseModel): xp_amount: int = Field(..., ge=0, le=100000, description="XP jutalom") social_amount: int = Field(0, ge=0, le=100000, description="Social pont jutalom") reason: str = Field(..., min_length=1, max_length=500) class ContributionReview(BaseModel): status: str = Field(..., pattern="^(approved|rejected)$") review_comment: Optional[str] = None class MasterConfigUpdate(BaseModel): config: Dict[str, Any] class SystemParamUpdate(BaseModel): value: Any category: Optional[str] = None # ============================================================================= # POINT RULES # ============================================================================= @router.get("/point-rules", response_model=List[Dict[str, Any]]) async def list_point_rules( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Összes pontszabály listázása.""" stmt = select(PointRule).order_by(PointRule.action_key) result = await db.execute(stmt) rules = result.scalars().all() return [ { "id": r.id, "action_key": r.action_key, "points": r.points, "description": r.description, "is_active": r.is_active, } for r in rules ] @router.post("/point-rules", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED) async def create_point_rule( data: PointRuleCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Új pontszabály létrehozása.""" existing = await db.execute( select(PointRule).where(PointRule.action_key == data.action_key) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Point rule with action_key '{data.action_key}' already exists.", ) rule = PointRule( action_key=data.action_key, points=data.points, description=data.description, is_active=data.is_active, ) db.add(rule) await db.commit() await db.refresh(rule) return { "id": rule.id, "action_key": rule.action_key, "points": rule.points, "description": rule.description, "is_active": rule.is_active, } @router.put("/point-rules/{rule_id}", response_model=Dict[str, Any]) async def update_point_rule( rule_id: int, data: PointRuleUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Pontszabály módosítása.""" stmt = select(PointRule).where(PointRule.id == rule_id) rule = (await db.execute(stmt)).scalar_one_or_none() if not rule: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Point rule not found.") update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(rule, key, value) await db.commit() await db.refresh(rule) return { "id": rule.id, "action_key": rule.action_key, "points": rule.points, "description": rule.description, "is_active": rule.is_active, } @router.delete("/point-rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_point_rule( rule_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Pontszabály törlése (soft-delete: is_active = False).""" stmt = select(PointRule).where(PointRule.id == rule_id) rule = (await db.execute(stmt)).scalar_one_or_none() if not rule: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Point rule not found.") rule.is_active = False await db.commit() # ============================================================================= # LEVEL CONFIGS # ============================================================================= @router.get("/level-configs", response_model=List[Dict[str, Any]]) async def list_level_configs( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Szintkonfigurációk listázása.""" stmt = select(LevelConfig).order_by(LevelConfig.level_number) result = await db.execute(stmt) configs = result.scalars().all() return [ { "id": c.id, "level_number": c.level_number, "min_points": c.min_points, "rank_name": c.rank_name, "is_penalty": c.is_penalty, } for c in configs ] @router.post("/level-configs", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED) async def create_level_config( data: LevelConfigCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Új szint létrehozása.""" existing = await db.execute( select(LevelConfig).where(LevelConfig.level_number == data.level_number) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Level config with level_number '{data.level_number}' already exists.", ) config = LevelConfig( level_number=data.level_number, min_points=data.min_points, rank_name=data.rank_name, is_penalty=data.is_penalty, ) db.add(config) await db.commit() await db.refresh(config) return { "id": config.id, "level_number": config.level_number, "min_points": config.min_points, "rank_name": config.rank_name, "is_penalty": config.is_penalty, } @router.put("/level-configs/{config_id}", response_model=Dict[str, Any]) async def update_level_config( config_id: int, data: LevelConfigUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Szint módosítása.""" stmt = select(LevelConfig).where(LevelConfig.id == config_id) config = (await db.execute(stmt)).scalar_one_or_none() if not config: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Level config not found.") update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(config, key, value) await db.commit() await db.refresh(config) return { "id": config.id, "level_number": config.level_number, "min_points": config.min_points, "rank_name": config.rank_name, "is_penalty": config.is_penalty, } # ============================================================================= # BADGES # ============================================================================= @router.get("/badges", response_model=List[Dict[str, Any]]) async def list_badges( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Összes badge listázása.""" stmt = select(Badge).order_by(Badge.name) result = await db.execute(stmt) badges = result.scalars().all() return [ { "id": b.id, "name": b.name, "description": b.description, "icon_url": b.icon_url, } for b in badges ] @router.post("/badges", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED) async def create_badge( data: BadgeCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Új badge létrehozása.""" existing = await db.execute(select(Badge).where(Badge.name == data.name)) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Badge with name '{data.name}' already exists.", ) badge = Badge( name=data.name, description=data.description, icon_url=data.icon_url, ) db.add(badge) await db.commit() await db.refresh(badge) return { "id": badge.id, "name": badge.name, "description": badge.description, "icon_url": badge.icon_url, } @router.put("/badges/{badge_id}", response_model=Dict[str, Any]) async def update_badge( badge_id: int, data: BadgeUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Badge módosítása.""" stmt = select(Badge).where(Badge.id == badge_id) badge = (await db.execute(stmt)).scalar_one_or_none() if not badge: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Badge not found.") update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(badge, key, value) await db.commit() await db.refresh(badge) return { "id": badge.id, "name": badge.name, "description": badge.description, "icon_url": badge.icon_url, } @router.delete("/badges/{badge_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_badge( badge_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Badge törlése.""" stmt = select(Badge).where(Badge.id == badge_id) badge = (await db.execute(stmt)).scalar_one_or_none() if not badge: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Badge not found.") await db.delete(badge) await db.commit() # ============================================================================= # SEASONS # ============================================================================= @router.get("/seasons", response_model=List[Dict[str, Any]]) async def list_seasons( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Szezonok listázása.""" stmt = select(Season).order_by(Season.start_date.desc()) result = await db.execute(stmt) seasons = result.scalars().all() return [ { "id": s.id, "name": s.name, "start_date": str(s.start_date), "end_date": str(s.end_date), "is_active": s.is_active, "created_at": s.created_at.isoformat() if s.created_at else None, } for s in seasons ] @router.post("/seasons", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED) async def create_season( data: SeasonCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Új szezon létrehozása.""" season = Season( name=data.name, start_date=data.start_date, end_date=data.end_date, is_active=data.is_active, ) db.add(season) await db.commit() await db.refresh(season) return { "id": season.id, "name": season.name, "start_date": str(season.start_date), "end_date": str(season.end_date), "is_active": season.is_active, "created_at": season.created_at.isoformat() if season.created_at else None, } @router.put("/seasons/{season_id}", response_model=Dict[str, Any]) async def update_season( season_id: int, data: SeasonUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Szezon módosítása.""" stmt = select(Season).where(Season.id == season_id) season = (await db.execute(stmt)).scalar_one_or_none() if not season: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season not found.") update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(season, key, value) await db.commit() await db.refresh(season) return { "id": season.id, "name": season.name, "start_date": str(season.start_date), "end_date": str(season.end_date), "is_active": season.is_active, "created_at": season.created_at.isoformat() if season.created_at else None, } @router.post("/seasons/{season_id}/activate", response_model=Dict[str, Any]) async def activate_season( season_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Szezon aktiválása (minden más szezon deaktiválása mellett).""" stmt = select(Season).where(Season.id == season_id) season = (await db.execute(stmt)).scalar_one_or_none() if not season: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season not found.") # Deactivate all other seasons await db.execute( sa_update(Season).where(Season.id != season_id).values(is_active=False) ) # Activate the requested season season.is_active = True await db.commit() await db.refresh(season) return { "id": season.id, "name": season.name, "start_date": str(season.start_date), "end_date": str(season.end_date), "is_active": season.is_active, "created_at": season.created_at.isoformat() if season.created_at else None, } # ============================================================================= # COMPETITIONS # ============================================================================= @router.get("/competitions", response_model=List[Dict[str, Any]]) async def list_competitions( season_id: Optional[int] = Query(None, description="Szűrés szezon ID alapján"), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Versenyek listázása.""" stmt = select(SeasonalCompetitions).order_by(SeasonalCompetitions.start_date.desc()) if season_id: stmt = stmt.where(SeasonalCompetitions.season_id == season_id) result = await db.execute(stmt) competitions = result.scalars().all() return [ { "id": c.id, "name": c.name, "description": c.description, "season_id": c.season_id, "start_date": str(c.start_date), "end_date": str(c.end_date), "rules": c.rules, "status": c.status, "created_at": c.created_at.isoformat() if c.created_at else None, "updated_at": c.updated_at.isoformat() if c.updated_at else None, } for c in competitions ] @router.post("/competitions", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED) async def create_competition( data: CompetitionCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Új verseny létrehozása.""" # Verify season exists season = await db.execute(select(Season).where(Season.id == data.season_id)) if not season.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season not found.") competition = SeasonalCompetitions( name=data.name, description=data.description, season_id=data.season_id, start_date=data.start_date, end_date=data.end_date, rules=data.rules, status=data.status, ) db.add(competition) await db.commit() await db.refresh(competition) return { "id": competition.id, "name": competition.name, "description": competition.description, "season_id": competition.season_id, "start_date": str(competition.start_date), "end_date": str(competition.end_date), "rules": competition.rules, "status": competition.status, "created_at": competition.created_at.isoformat() if competition.created_at else None, } @router.put("/competitions/{competition_id}", response_model=Dict[str, Any]) async def update_competition( competition_id: int, data: CompetitionUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Verseny módosítása.""" stmt = select(SeasonalCompetitions).where(SeasonalCompetitions.id == competition_id) competition = (await db.execute(stmt)).scalar_one_or_none() if not competition: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Competition not found.") update_data = data.model_dump(exclude_unset=True) # If season_id is being updated, verify the new season exists if "season_id" in update_data and update_data["season_id"] is not None: season = await db.execute(select(Season).where(Season.id == update_data["season_id"])) if not season.scalar_one_or_none(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season not found.") for key, value in update_data.items(): setattr(competition, key, value) await db.commit() await db.refresh(competition) return { "id": competition.id, "name": competition.name, "description": competition.description, "season_id": competition.season_id, "start_date": str(competition.start_date), "end_date": str(competition.end_date), "rules": competition.rules, "status": competition.status, "created_at": competition.created_at.isoformat() if competition.created_at else None, "updated_at": competition.updated_at.isoformat() if competition.updated_at else None, } # ============================================================================= # USER STATS ADMIN # ============================================================================= @router.get("/user-stats", response_model=List[Dict[str, Any]]) async def list_user_stats( level: Optional[int] = Query(None, description="Szűrés szint szerint"), min_xp: Optional[int] = Query(None, description="Minimum XP"), max_xp: Optional[int] = Query(None, description="Maximum XP"), has_penalty: Optional[bool] = Query(None, description="Csak büntetett felhasználók"), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Összes felhasználói statisztika listázása (szűrhető).""" stmt = select(UserStats).order_by(UserStats.total_xp.desc()) if level is not None: stmt = stmt.where(UserStats.current_level == level) if min_xp is not None: stmt = stmt.where(UserStats.total_xp >= min_xp) if max_xp is not None: stmt = stmt.where(UserStats.total_xp <= max_xp) if has_penalty is True: stmt = stmt.where(UserStats.penalty_points > 0) elif has_penalty is False: stmt = stmt.where(UserStats.penalty_points == 0) stmt = stmt.offset(offset).limit(limit) result = await db.execute(stmt) stats_list = result.scalars().all() return [ { "user_id": s.user_id, "total_xp": s.total_xp, "total_points": s.total_points, "social_points": s.social_points, "current_level": s.current_level, "penalty_points": s.penalty_points, "restriction_level": s.restriction_level, "penalty_quota_remaining": s.penalty_quota_remaining, "places_discovered": s.places_discovered, "places_validated": s.places_validated, "providers_added_count": s.providers_added_count, "services_submitted": s.services_submitted, "banned_until": s.banned_until.isoformat() if s.banned_until else None, "updated_at": s.updated_at.isoformat() if s.updated_at else None, } for s in stats_list ] @router.get("/user-stats/{user_id}", response_model=Dict[str, Any]) async def get_user_stats( user_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Egy felhasználó gamification statisztikái.""" stmt = select(UserStats).where(UserStats.user_id == user_id) stats = (await db.execute(stmt)).scalar_one_or_none() if not stats: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User stats not found.") return { "user_id": stats.user_id, "total_xp": stats.total_xp, "total_points": stats.total_points, "social_points": stats.social_points, "current_level": stats.current_level, "penalty_points": stats.penalty_points, "restriction_level": stats.restriction_level, "penalty_quota_remaining": stats.penalty_quota_remaining, "places_discovered": stats.places_discovered, "places_validated": stats.places_validated, "providers_added_count": stats.providers_added_count, "services_submitted": stats.services_submitted, "banned_until": stats.banned_until.isoformat() if stats.banned_until else None, "updated_at": stats.updated_at.isoformat() if stats.updated_at else None, } @router.put("/user-stats/{user_id}", response_model=Dict[str, Any]) async def update_user_stats( user_id: int, data: UserStatsUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Felhasználói statisztika manuális módosítása.""" stmt = select(UserStats).where(UserStats.user_id == user_id) stats = (await db.execute(stmt)).scalar_one_or_none() if not stats: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User stats not found.") update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(stats, key, value) await db.commit() await db.refresh(stats) return { "user_id": stats.user_id, "total_xp": stats.total_xp, "total_points": stats.total_points, "social_points": stats.social_points, "current_level": stats.current_level, "penalty_points": stats.penalty_points, "restriction_level": stats.restriction_level, "services_submitted": stats.services_submitted, } @router.post("/user-stats/{user_id}/penalty", response_model=Dict[str, Any]) async def apply_penalty( user_id: int, data: PenaltyRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Büntetés kiszabása a GamificationService-en keresztül.""" try: stats = await gamification_service.process_activity( db=db, user_id=user_id, xp_amount=data.amount, social_amount=0, reason=f"ADMIN_PENALTY: {data.reason}", is_penalty=True, commit=True, ) return { "user_id": stats.user_id, "penalty_points": stats.penalty_points, "restriction_level": stats.restriction_level, "message": f"Penalty of {data.amount} points applied. Reason: {data.reason}", } except Exception as e: logger.error(f"Failed to apply penalty to user {user_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to apply penalty: {str(e)}", ) @router.post("/user-stats/{user_id}/reward", response_model=Dict[str, Any]) async def apply_reward( user_id: int, data: RewardRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Jutalom kiosztása a GamificationService-en keresztül.""" try: stats = await gamification_service.process_activity( db=db, user_id=user_id, xp_amount=data.xp_amount, social_amount=data.social_amount, reason=f"ADMIN_REWARD: {data.reason}", is_penalty=False, commit=True, ) return { "user_id": stats.user_id, "total_xp": stats.total_xp, "social_points": stats.social_points, "current_level": stats.current_level, "message": f"Reward of {data.xp_amount} XP and {data.social_amount} social points applied. Reason: {data.reason}", } except Exception as e: logger.error(f"Failed to apply reward to user {user_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to apply reward: {str(e)}", ) # ============================================================================= # MASTER CONFIG # ============================================================================= # DASHBOARD SUMMARY # ============================================================================= @router.get("/dashboard-summary", response_model=Dict[str, Any]) async def get_dashboard_summary( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Gamification dashboard összesítő adatok (statok, aktív szezon, mai XP).""" # Total users with gamification stats total_users_result = await db.execute(select(func.count(UserStats.user_id))) total_users = total_users_result.scalar() or 0 # Active season active_season_stmt = select(Season).where(Season.is_active == True) active_season = (await db.execute(active_season_stmt)).scalar_one_or_none() active_season_name = active_season.name if active_season else None # Pending contributions pending_stmt = select(func.count(UserContribution.id)).where( UserContribution.status == "pending" ) pending_result = await db.execute(pending_stmt) pending_contributions = pending_result.scalar() or 0 # Today's XP today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today_xp_stmt = select(func.coalesce(func.sum(PointsLedger.xp), 0)).where( PointsLedger.created_at >= today_start ) today_xp_result = await db.execute(today_xp_stmt) today_xp = today_xp_result.scalar() or 0 return { "total": total_users, "active_season": active_season_name, "pending_contributions": pending_contributions, "today_xp": today_xp, } # ============================================================================= @router.get("/master-config", response_model=Dict[str, Any]) async def get_master_config( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """GAMIFICATION_MASTER_CONFIG lekérése.""" cfg = await system_service.get_scoped_parameter(db, "GAMIFICATION_MASTER_CONFIG", default={}) return {"key": "GAMIFICATION_MASTER_CONFIG", "value": cfg} @router.put("/master-config", response_model=Dict[str, Any]) async def update_master_config( data: MasterConfigUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """GAMIFICATION_MASTER_CONFIG módosítása.""" await system_service.set_scoped_parameter( db, "GAMIFICATION_MASTER_CONFIG", data.config, scope_level=ParameterScope.GLOBAL, category="gamification" ) return {"key": "GAMIFICATION_MASTER_CONFIG", "value": data.config, "message": "Master config updated successfully."} # ============================================================================= # SYSTEM PARAMETERS (gamification scope) # ============================================================================= @router.get("/system-params", response_model=List[Dict[str, Any]]) async def list_gamification_system_params( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Gamification kategóriájú rendszerparaméterek listázása.""" stmt = select(SystemParameter).where( SystemParameter.category == "gamification" ).order_by(SystemParameter.key) result = await db.execute(stmt) params = result.scalars().all() return [ { "key": p.key, "value": p.value, "category": p.category, "scope_level": p.scope_level.value if hasattr(p.scope_level, 'value') else str(p.scope_level), "scope_id": p.scope_id, } for p in params ] @router.put("/system-params/{param_key}", response_model=Dict[str, Any]) async def update_gamification_system_param( param_key: str, data: SystemParamUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Rendszerparaméter módosítása.""" stmt = select(SystemParameter).where(SystemParameter.key == param_key) param = (await db.execute(stmt)).scalar_one_or_none() if not param: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"System parameter '{param_key}' not found.") param.value = data.value if data.category: param.category = data.category await db.commit() return { "key": param.key, "value": param.value, "category": param.category, "message": f"System parameter '{param_key}' updated successfully.", } # ============================================================================= # POINTS LEDGER # ============================================================================= @router.get("/points-ledger", response_model=List[Dict[str, Any]]) async def list_points_ledger( user_id: Optional[int] = Query(None, description="Szűrés felhasználó ID alapján"), date_from: Optional[str] = Query(None, description="Kezdő dátum (YYYY-MM-DD)"), date_to: Optional[str] = Query(None, description="Záró dátum (YYYY-MM-DD)"), reason_search: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresés a reason mezőben (ILIKE)"), limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Pontnapló listázása (szűrhető user_id, date range, reason).""" stmt = ( select(PointsLedger, User.email) .join(User, PointsLedger.user_id == User.id, isouter=True) .order_by(PointsLedger.created_at.desc()) ) if user_id is not None: stmt = stmt.where(PointsLedger.user_id == user_id) if date_from: stmt = stmt.where(PointsLedger.created_at >= date_from) if date_to: stmt = stmt.where(PointsLedger.created_at <= f"{date_to} 23:59:59") if reason_search: stmt = stmt.where(PointsLedger.reason.ilike(f"%{reason_search}%")) stmt = stmt.offset(offset).limit(limit) result = await db.execute(stmt) rows = result.all() return [ { "id": e.id, "user_id": e.user_id, "user_name": email or f"User #{e.user_id}", "points": e.points, "penalty_change": e.penalty_change, "reason": e.reason, "xp": e.xp, "source_type": e.source_type, "source_id": e.source_id, "created_at": e.created_at.isoformat() if e.created_at else None, } for e, email in rows ] # ============================================================================= # CONTRIBUTIONS # ============================================================================= @router.get("/contributions", response_model=List[Dict[str, Any]]) async def list_contributions( status_filter: Optional[str] = Query(None, alias="status", description="Szűrés státusz szerint (pending/approved/rejected)"), user_id: Optional[int] = Query(None, description="Szűrés felhasználó ID alapján"), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Hozzájárulások listája (admin review).""" stmt = select(UserContribution).order_by(UserContribution.created_at.desc()) if status_filter: stmt = stmt.where(UserContribution.status == status_filter) if user_id is not None: stmt = stmt.where(UserContribution.user_id == user_id) stmt = stmt.offset(offset).limit(limit) result = await db.execute(stmt) contributions = result.scalars().all() return [ { "id": c.id, "user_id": c.user_id, "season_id": c.season_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, "reviewed_by": c.reviewed_by, "reviewed_at": c.reviewed_at.isoformat() if c.reviewed_at else None, "created_at": c.created_at.isoformat() if c.created_at else None, } for c in contributions ] @router.put("/contributions/{contribution_id}/review", response_model=Dict[str, Any]) async def review_contribution( contribution_id: int, data: ContributionReview, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Hozzájárulás jóváhagyása/elutasítása.""" stmt = select(UserContribution).where(UserContribution.id == contribution_id) contribution = (await db.execute(stmt)).scalar_one_or_none() if not contribution: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contribution not found.") contribution.status = data.status contribution.reviewed_by = current_user.id contribution.reviewed_at = datetime.utcnow() await db.commit() await db.refresh(contribution) # If approved, award points via GamificationService if data.status == "approved" and contribution.points_awarded > 0: try: await gamification_service.process_activity( db=db, user_id=contribution.user_id, xp_amount=contribution.points_awarded, social_amount=0, reason=f"CONTRIBUTION_APPROVED: {contribution.contribution_type} (ID: {contribution.id})", is_penalty=False, commit=True, ) except Exception as e: logger.error(f"Failed to award points for approved contribution {contribution_id}: {e}") return { "id": contribution.id, "status": contribution.status, "reviewed_by": contribution.reviewed_by, "reviewed_at": contribution.reviewed_at.isoformat() if contribution.reviewed_at else None, "message": f"Contribution {data.status} successfully.", } # ============================================================================= # VALIDATION RULES (Provider Validation Engine) # ============================================================================= class ValidationRuleUpdate(BaseModel): """Update payload for a single validation rule.""" rule_value: int = Field(..., ge=0, le=100, description="New integer value for this rule (0-100)") description: Optional[str] = Field(None, max_length=500, description="Updated description") class ValidationRuleBulkUpdate(BaseModel): """Bulk update payload — dict of rule_key → new_value.""" rules: Dict[str, int] = Field( ..., description="Mapping of rule_key → new integer value (0-100)" ) @router.get("/validation-rules", response_model=List[Dict[str, Any]]) async def list_validation_rules( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """ Fetch all ValidationRule records from the database. Returns a list of {id, rule_key, rule_value, description, updated_at}. These rules drive the ValidationEngine for provider scoring and auto-approval thresholds. """ from app.models.gamification.validation_rule import ValidationRule stmt = select(ValidationRule).order_by(ValidationRule.rule_key) result = await db.execute(stmt) rules = result.scalars().all() return [ { "id": r.id, "rule_key": r.rule_key, "rule_value": r.rule_value, "description": r.description, "updated_at": r.updated_at.isoformat() if r.updated_at else None, } for r in rules ] @router.put("/validation-rules", response_model=Dict[str, Any]) async def bulk_update_validation_rules( data: ValidationRuleBulkUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """ Bulk-update ValidationRule values. Accepts a dict of rule_key → new_value. Iterates through each entry and updates rule_value in the database. Unknown keys are silently skipped. Returns a summary of updated rules. """ from app.models.gamification.validation_rule import ValidationRule updated: List[Dict[str, Any]] = [] errors: List[Dict[str, Any]] = [] for rule_key, new_value in data.rules.items(): stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key) rule = (await db.execute(stmt)).scalar_one_or_none() if not rule: errors.append({"rule_key": rule_key, "error": "Rule not found"}) logger.warning(f"Validation rule '{rule_key}' not found, skipping.") continue if not (0 <= new_value <= 100): errors.append({"rule_key": rule_key, "error": "Value must be between 0 and 100"}) continue old_val = rule.rule_value rule.rule_value = new_value updated.append({ "rule_key": rule_key, "old_value": old_val, "new_value": new_value, }) await db.commit() return { "message": f"Updated {len(updated)} rule(s) successfully.", "updated": updated, "errors": errors if errors else None, } @router.put("/validation-rules/{rule_key}", response_model=Dict[str, Any]) async def update_validation_rule( rule_key: str, data: ValidationRuleUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """ Update a single ValidationRule by its rule_key. Allows updating both rule_value and description. """ from app.models.gamification.validation_rule import ValidationRule stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key) rule = (await db.execute(stmt)).scalar_one_or_none() if not rule: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Validation rule '{rule_key}' not found.", ) old_value = rule.rule_value rule.rule_value = data.rule_value if data.description is not None: rule.description = data.description await db.commit() await db.refresh(rule) logger.info( f"Validation rule '{rule_key}' updated: {old_value} → {data.rule_value}" ) return { "id": rule.id, "rule_key": rule.rule_key, "rule_value": rule.rule_value, "description": rule.description, "old_value": old_value, "updated_at": rule.updated_at.isoformat() if rule.updated_at else None, "message": f"Rule '{rule_key}' updated successfully.", } # ============================================================================= # LEADERBOARD (Admin) # ============================================================================= @router.get("/leaderboard", response_model=Dict[str, Any]) async def get_admin_leaderboard( limit: int = Query(50, ge=1, le=500, description="Sorok száma"), offset: int = Query(0, ge=0, description="Eltolás"), level: Optional[int] = Query(None, description="Szűrés szint szerint"), min_xp: Optional[int] = Query(None, description="Minimum XP"), search: Optional[str] = Query(None, min_length=2, max_length=100, description="Keresés email/név alapján"), sort_by: str = Query("total_xp", regex="^(total_xp|total_points|social_points|current_level)$"), sort_order: str = Query("desc", regex="^(asc|desc)$"), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _: User = Depends(deps.RequirePermission("gamification:manage")), ): """Admin ranglista — user nevekkel, szűréssel, lapozással.""" from sqlalchemy.orm import joinedload from app.models.identity import Person # Count query — user_id is the PK, not id count_stmt = select(func.count(UserStats.user_id)) count_stmt = count_stmt.select_from(UserStats) # Data query order_col = getattr(UserStats, sort_by, UserStats.total_xp) order_expr = order_col.desc() if sort_order == "desc" else order_col.asc() stmt = ( select(UserStats) .options(joinedload(UserStats.user).joinedload(User.person)) .order_by(order_expr) ) if level is not None: stmt = stmt.where(UserStats.current_level == level) count_stmt = count_stmt.where(UserStats.current_level == level) if min_xp is not None: stmt = stmt.where(UserStats.total_xp >= min_xp) count_stmt = count_stmt.where(UserStats.total_xp >= min_xp) if search: search_filter = or_( User.email.ilike(f"%{search}%"), Person.first_name.ilike(f"%{search}%"), Person.last_name.ilike(f"%{search}%"), ) # Explicit onclause for Person join to avoid AmbiguousForeignKeysError # (User has person_id -> Person.id, Person has user_id -> User.id) stmt = stmt.join(User).join(Person, User.person_id == Person.id).where(search_filter) count_stmt = count_stmt.join(User).join(Person, User.person_id == Person.id).where(search_filter) else: stmt = stmt.join(User) # Total count total_result = await db.execute(count_stmt) total = total_result.scalar() or 0 # Paginated data stmt = stmt.offset(offset).limit(limit) result = await db.execute(stmt) stats_list = result.unique().scalars().all() entries = [] for idx, s in enumerate(stats_list): user = s.user display_name = "N/A" if user: person = user.person if person and person.first_name and person.last_name: display_name = f"{person.first_name} {person.last_name}" elif user.email: display_name = user.email.split("@")[0] else: display_name = f"User #{s.user_id}" entries.append({ "rank": offset + idx + 1, "user_id": s.user_id, "user_name": display_name, "user_email": user.email if user else None, "total_xp": s.total_xp, "total_points": s.total_points or 0, "social_points": s.social_points or 0, "current_level": s.current_level, "penalty_points": s.penalty_points or 0, "restriction_level": s.restriction_level or 0, "places_discovered": s.places_discovered or 0, "services_submitted": s.services_submitted or 0, "updated_at": s.updated_at.isoformat() if s.updated_at else None, }) return { "total": total, "offset": offset, "limit": limit, "entries": entries, }