1435 lines
51 KiB
Python
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,
|
|
} |