Add OBS widgets for streamers
- Add widget token authentication system - Create leaderboard, current assignment, and progress widgets - Support dark, light, and neon themes - Add widget settings modal for URL generation - Fix avatar loading through backend API proxy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo, widgets
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -18,3 +18,4 @@ router.include_router(telegram.router)
|
||||
router.include_router(content.router)
|
||||
router.include_router(shop.router)
|
||||
router.include_router(promo.router)
|
||||
router.include_router(widgets.router)
|
||||
|
||||
423
backend/app/api/v1/widgets.py
Normal file
423
backend/app/api/v1/widgets.py
Normal file
@@ -0,0 +1,423 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, status, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_participant
|
||||
from app.models import (
|
||||
WidgetToken, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
BonusAssignment, BonusAssignmentStatus,
|
||||
)
|
||||
from app.schemas.widget import (
|
||||
WidgetTokenResponse,
|
||||
WidgetTokenListItem,
|
||||
WidgetLeaderboardEntry,
|
||||
WidgetLeaderboardResponse,
|
||||
WidgetCurrentResponse,
|
||||
WidgetProgressResponse,
|
||||
)
|
||||
from app.schemas.common import MessageResponse
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/widgets", tags=["widgets"])
|
||||
|
||||
|
||||
def get_avatar_url(user) -> str | None:
|
||||
"""Get avatar URL - through backend API if user has avatar, else telegram"""
|
||||
if user.avatar_path:
|
||||
return f"/api/v1/users/{user.id}/avatar"
|
||||
return user.telegram_avatar_url
|
||||
|
||||
|
||||
def generate_widget_token() -> str:
|
||||
"""Generate a secure widget token"""
|
||||
return f"wgt_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def build_widget_urls(marathon_id: int, token: str) -> dict[str, str]:
|
||||
"""Build widget URLs for the token"""
|
||||
base_url = settings.FRONTEND_URL or "http://localhost:5173"
|
||||
params = f"marathon={marathon_id}&token={token}"
|
||||
return {
|
||||
"leaderboard": f"{base_url}/widget/leaderboard?{params}",
|
||||
"current": f"{base_url}/widget/current?{params}",
|
||||
"progress": f"{base_url}/widget/progress?{params}",
|
||||
}
|
||||
|
||||
|
||||
# === Token management (authenticated) ===
|
||||
|
||||
@router.post("/marathons/{marathon_id}/token", response_model=WidgetTokenResponse)
|
||||
async def create_widget_token(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a widget token for the current user in a marathon"""
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check if user already has an active token
|
||||
existing = await db.scalar(
|
||||
select(WidgetToken).where(
|
||||
WidgetToken.participant_id == participant.id,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
WidgetToken.is_active == True,
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Return existing token
|
||||
return WidgetTokenResponse(
|
||||
id=existing.id,
|
||||
token=existing.token,
|
||||
created_at=existing.created_at,
|
||||
expires_at=existing.expires_at,
|
||||
is_active=existing.is_active,
|
||||
urls=build_widget_urls(marathon_id, existing.token),
|
||||
)
|
||||
|
||||
# Create new token
|
||||
token = generate_widget_token()
|
||||
widget_token = WidgetToken(
|
||||
token=token,
|
||||
participant_id=participant.id,
|
||||
marathon_id=marathon_id,
|
||||
)
|
||||
db.add(widget_token)
|
||||
await db.commit()
|
||||
await db.refresh(widget_token)
|
||||
|
||||
return WidgetTokenResponse(
|
||||
id=widget_token.id,
|
||||
token=widget_token.token,
|
||||
created_at=widget_token.created_at,
|
||||
expires_at=widget_token.expires_at,
|
||||
is_active=widget_token.is_active,
|
||||
urls=build_widget_urls(marathon_id, widget_token.token),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/tokens", response_model=list[WidgetTokenListItem])
|
||||
async def list_widget_tokens(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""List all widget tokens for the current user in a marathon"""
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.where(
|
||||
WidgetToken.participant_id == participant.id,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
)
|
||||
.order_by(WidgetToken.created_at.desc())
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
return [
|
||||
WidgetTokenListItem(
|
||||
id=t.id,
|
||||
token=t.token,
|
||||
created_at=t.created_at,
|
||||
is_active=t.is_active,
|
||||
)
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}", response_model=MessageResponse)
|
||||
async def revoke_widget_token(
|
||||
token_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Revoke a widget token"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(selectinload(WidgetToken.participant))
|
||||
.where(WidgetToken.id == token_id)
|
||||
)
|
||||
widget_token = result.scalar_one_or_none()
|
||||
|
||||
if not widget_token:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
if widget_token.participant.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to revoke this token")
|
||||
|
||||
widget_token.is_active = False
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Token revoked")
|
||||
|
||||
|
||||
@router.post("/tokens/{token_id}/regenerate", response_model=WidgetTokenResponse)
|
||||
async def regenerate_widget_token(
|
||||
token_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Regenerate a widget token (deactivates old, creates new)"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(selectinload(WidgetToken.participant))
|
||||
.where(WidgetToken.id == token_id)
|
||||
)
|
||||
old_token = result.scalar_one_or_none()
|
||||
|
||||
if not old_token:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
if old_token.participant.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# Deactivate old token
|
||||
old_token.is_active = False
|
||||
|
||||
# Create new token
|
||||
new_token = WidgetToken(
|
||||
token=generate_widget_token(),
|
||||
participant_id=old_token.participant_id,
|
||||
marathon_id=old_token.marathon_id,
|
||||
)
|
||||
db.add(new_token)
|
||||
await db.commit()
|
||||
await db.refresh(new_token)
|
||||
|
||||
return WidgetTokenResponse(
|
||||
id=new_token.id,
|
||||
token=new_token.token,
|
||||
created_at=new_token.created_at,
|
||||
expires_at=new_token.expires_at,
|
||||
is_active=new_token.is_active,
|
||||
urls=build_widget_urls(new_token.marathon_id, new_token.token),
|
||||
)
|
||||
|
||||
|
||||
# === Public widget endpoints (authenticated via widget token) ===
|
||||
|
||||
async def validate_widget_token(token: str, marathon_id: int, db) -> WidgetToken:
|
||||
"""Validate widget token and return it"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(
|
||||
selectinload(WidgetToken.participant).selectinload(Participant.user),
|
||||
selectinload(WidgetToken.marathon),
|
||||
)
|
||||
.where(
|
||||
WidgetToken.token == token,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
WidgetToken.is_active == True,
|
||||
)
|
||||
)
|
||||
widget_token = result.scalar_one_or_none()
|
||||
|
||||
if not widget_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid widget token")
|
||||
|
||||
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=401, detail="Widget token expired")
|
||||
|
||||
return widget_token
|
||||
|
||||
|
||||
@router.get("/data/leaderboard", response_model=WidgetLeaderboardResponse)
|
||||
async def widget_leaderboard(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
count: int = Query(5, ge=1, le=50, description="Number of participants"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get leaderboard data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
current_participant = widget_token.participant
|
||||
|
||||
# Get all participants ordered by points
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(Participant.marathon_id == marathon)
|
||||
.order_by(Participant.total_points.desc())
|
||||
)
|
||||
all_participants = result.scalars().all()
|
||||
|
||||
total_participants = len(all_participants)
|
||||
current_user_rank = None
|
||||
|
||||
# Find current user rank and build entries
|
||||
entries = []
|
||||
for rank, p in enumerate(all_participants, 1):
|
||||
if p.id == current_participant.id:
|
||||
current_user_rank = rank
|
||||
|
||||
if rank <= count:
|
||||
user = p.user
|
||||
entries.append(WidgetLeaderboardEntry(
|
||||
rank=rank,
|
||||
nickname=user.nickname,
|
||||
avatar_url=get_avatar_url(user),
|
||||
total_points=p.total_points,
|
||||
current_streak=p.current_streak,
|
||||
is_current_user=(p.id == current_participant.id),
|
||||
))
|
||||
|
||||
return WidgetLeaderboardResponse(
|
||||
entries=entries,
|
||||
current_user_rank=current_user_rank,
|
||||
total_participants=total_participants,
|
||||
marathon_title=widget_token.marathon.title,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/data/current", response_model=WidgetCurrentResponse)
|
||||
async def widget_current_assignment(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get current assignment data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
participant = widget_token.participant
|
||||
|
||||
# Get active assignment
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge),
|
||||
selectinload(Assignment.game),
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status.in_([
|
||||
AssignmentStatus.ACTIVE.value,
|
||||
AssignmentStatus.RETURNED.value,
|
||||
]),
|
||||
)
|
||||
.order_by(Assignment.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
|
||||
if not assignment:
|
||||
return WidgetCurrentResponse(has_assignment=False)
|
||||
|
||||
# Determine assignment type and details
|
||||
if assignment.is_playthrough:
|
||||
game = assignment.game
|
||||
assignment_type = "playthrough"
|
||||
challenge_title = "Прохождение"
|
||||
challenge_description = game.playthrough_description
|
||||
points = game.playthrough_points
|
||||
difficulty = None
|
||||
|
||||
# Count bonus challenges
|
||||
bonus_result = await db.execute(
|
||||
select(func.count()).select_from(BonusAssignment)
|
||||
.where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
bonus_total = bonus_result.scalar() or 0
|
||||
|
||||
completed_result = await db.execute(
|
||||
select(func.count()).select_from(BonusAssignment)
|
||||
.where(
|
||||
BonusAssignment.main_assignment_id == assignment.id,
|
||||
BonusAssignment.status == BonusAssignmentStatus.COMPLETED.value,
|
||||
)
|
||||
)
|
||||
bonus_completed = completed_result.scalar() or 0
|
||||
|
||||
game_title = game.title
|
||||
game_cover_url = f"/api/v1/games/{game.id}/cover" if game.cover_path else None
|
||||
else:
|
||||
challenge = assignment.challenge
|
||||
assignment_type = "challenge"
|
||||
challenge_title = challenge.title
|
||||
challenge_description = challenge.description
|
||||
points = challenge.points
|
||||
difficulty = challenge.difficulty
|
||||
bonus_completed = None
|
||||
bonus_total = None
|
||||
|
||||
game = challenge.game if hasattr(challenge, 'game') else None
|
||||
if not game:
|
||||
# Load game via challenge
|
||||
from app.models import Game
|
||||
game_result = await db.execute(
|
||||
select(Game).where(Game.id == challenge.game_id)
|
||||
)
|
||||
game = game_result.scalar_one_or_none()
|
||||
|
||||
game_title = game.title if game else None
|
||||
game_cover_url = f"/api/v1/games/{game.id}/cover" if game and game.cover_path else None
|
||||
|
||||
return WidgetCurrentResponse(
|
||||
has_assignment=True,
|
||||
game_title=game_title,
|
||||
game_cover_url=game_cover_url,
|
||||
assignment_type=assignment_type,
|
||||
challenge_title=challenge_title,
|
||||
challenge_description=challenge_description,
|
||||
points=points,
|
||||
difficulty=difficulty,
|
||||
bonus_completed=bonus_completed,
|
||||
bonus_total=bonus_total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/data/progress", response_model=WidgetProgressResponse)
|
||||
async def widget_progress(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get participant progress data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
participant = widget_token.participant
|
||||
user = participant.user
|
||||
|
||||
# Calculate rank
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Participant)
|
||||
.where(
|
||||
Participant.marathon_id == marathon,
|
||||
Participant.total_points > participant.total_points,
|
||||
)
|
||||
)
|
||||
higher_count = result.scalar() or 0
|
||||
rank = higher_count + 1
|
||||
|
||||
# Count completed and dropped assignments
|
||||
completed_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Assignment)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value,
|
||||
)
|
||||
)
|
||||
completed_count = completed_result.scalar() or 0
|
||||
|
||||
dropped_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Assignment)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.DROPPED.value,
|
||||
)
|
||||
)
|
||||
dropped_count = dropped_result.scalar() or 0
|
||||
|
||||
return WidgetProgressResponse(
|
||||
nickname=user.nickname,
|
||||
avatar_url=get_avatar_url(user),
|
||||
rank=rank,
|
||||
total_points=participant.total_points,
|
||||
current_streak=participant.current_streak,
|
||||
completed_count=completed_count,
|
||||
dropped_count=dropped_count,
|
||||
marathon_title=widget_token.marathon.title,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ from app.models.inventory import UserInventory
|
||||
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
||||
from app.models.consumable_usage import ConsumableUsage
|
||||
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||
from app.models.widget_token import WidgetToken
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -65,4 +66,5 @@ __all__ = [
|
||||
"ConsumableUsage",
|
||||
"PromoCode",
|
||||
"PromoCodeRedemption",
|
||||
"WidgetToken",
|
||||
]
|
||||
|
||||
22
backend/app/models/widget_token.py
Normal file
22
backend/app/models/widget_token.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class WidgetToken(Base):
|
||||
"""Токен для авторизации OBS виджетов"""
|
||||
__tablename__ = "widget_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"))
|
||||
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
participant: Mapped["Participant"] = relationship("Participant")
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon")
|
||||
@@ -134,6 +134,15 @@ from app.schemas.promo_code import (
|
||||
PromoCodeRedemptionUser,
|
||||
)
|
||||
from app.schemas.user import ShopItemPublic
|
||||
from app.schemas.widget import (
|
||||
WidgetTokenCreate,
|
||||
WidgetTokenResponse,
|
||||
WidgetTokenListItem,
|
||||
WidgetLeaderboardEntry,
|
||||
WidgetLeaderboardResponse,
|
||||
WidgetCurrentResponse,
|
||||
WidgetProgressResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
@@ -260,4 +269,12 @@ __all__ = [
|
||||
"PromoCodeRedeemResponse",
|
||||
"PromoCodeRedemptionResponse",
|
||||
"PromoCodeRedemptionUser",
|
||||
# Widget
|
||||
"WidgetTokenCreate",
|
||||
"WidgetTokenResponse",
|
||||
"WidgetTokenListItem",
|
||||
"WidgetLeaderboardEntry",
|
||||
"WidgetLeaderboardResponse",
|
||||
"WidgetCurrentResponse",
|
||||
"WidgetProgressResponse",
|
||||
]
|
||||
|
||||
79
backend/app/schemas/widget.py
Normal file
79
backend/app/schemas/widget.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# === Token schemas ===
|
||||
|
||||
class WidgetTokenCreate(BaseModel):
|
||||
"""Создание токена виджета"""
|
||||
pass # Не требует параметров
|
||||
|
||||
|
||||
class WidgetTokenResponse(BaseModel):
|
||||
"""Ответ с токеном виджета"""
|
||||
id: int
|
||||
token: str
|
||||
created_at: datetime
|
||||
expires_at: datetime | None
|
||||
is_active: bool
|
||||
urls: dict[str, str] # Готовые URL для виджетов
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WidgetTokenListItem(BaseModel):
|
||||
"""Элемент списка токенов"""
|
||||
id: int
|
||||
token: str
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Widget data schemas ===
|
||||
|
||||
class WidgetLeaderboardEntry(BaseModel):
|
||||
"""Запись в лидерборде виджета"""
|
||||
rank: int
|
||||
nickname: str
|
||||
avatar_url: str | None
|
||||
total_points: int
|
||||
current_streak: int
|
||||
is_current_user: bool # Для подсветки
|
||||
|
||||
|
||||
class WidgetLeaderboardResponse(BaseModel):
|
||||
"""Ответ лидерборда для виджета"""
|
||||
entries: list[WidgetLeaderboardEntry]
|
||||
current_user_rank: int | None
|
||||
total_participants: int
|
||||
marathon_title: str
|
||||
|
||||
|
||||
class WidgetCurrentResponse(BaseModel):
|
||||
"""Текущее задание для виджета"""
|
||||
has_assignment: bool
|
||||
game_title: str | None = None
|
||||
game_cover_url: str | None = None
|
||||
assignment_type: str | None = None # "challenge" | "playthrough"
|
||||
challenge_title: str | None = None
|
||||
challenge_description: str | None = None
|
||||
points: int | None = None
|
||||
difficulty: str | None = None # easy, medium, hard
|
||||
bonus_completed: int | None = None # Для прохождений
|
||||
bonus_total: int | None = None
|
||||
|
||||
|
||||
class WidgetProgressResponse(BaseModel):
|
||||
"""Прогресс участника для виджета"""
|
||||
nickname: str
|
||||
avatar_url: str | None
|
||||
rank: int
|
||||
total_points: int
|
||||
current_streak: int
|
||||
completed_count: int
|
||||
dropped_count: int
|
||||
marathon_title: str
|
||||
Reference in New Issue
Block a user