Files
service-finder/backend/app/api/v1/endpoints/admin_gamification.py
2026-07-08 08:03:57 +00:00

1435 lines
51 KiB
Python

"""
🎮 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,
}