commit inicial
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request, Depends, Form, HTTPException
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import Playlist, Vote, VotingConfig
|
||||
from app.config import settings
|
||||
from app import spotify
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
_VALID_TYPES = {"playlist", "album", "artist", "track"}
|
||||
|
||||
_URI_RE = re.compile(r"spotify:(playlist|album|artist|track):([A-Za-z0-9]+)")
|
||||
_URL_RE = re.compile(r"open\.spotify\.com/(playlist|album|artist|track)/([A-Za-z0-9]+)")
|
||||
_BARE_ID_RE = re.compile(r"^[A-Za-z0-9]{22}$")
|
||||
|
||||
_TYPE_LABELS = {
|
||||
"playlist": ("📋", "Playlist"),
|
||||
"album": ("💿", "Álbum"),
|
||||
"artist": ("🎤", "Artista"),
|
||||
"track": ("🎵", "Canción"),
|
||||
}
|
||||
|
||||
|
||||
def _extract_spotify_item(value: str) -> tuple[str, str] | None:
|
||||
"""Retorna (spotify_id, spotify_type) o None si la URL no es válida."""
|
||||
value = value.strip().split("?")[0].rstrip("/")
|
||||
if m := _URI_RE.search(value):
|
||||
return m.group(2), m.group(1)
|
||||
if m := _URL_RE.search(value):
|
||||
return m.group(2), m.group(1)
|
||||
if _BARE_ID_RE.match(value):
|
||||
return value, "playlist"
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_metadata(sp, spotify_id: str, spotify_type: str) -> dict:
|
||||
if spotify_type == "playlist":
|
||||
data = sp.playlist(spotify_id, fields="name,description,images")
|
||||
return {
|
||||
"name": data["name"],
|
||||
"description": data.get("description") or "",
|
||||
"image_url": data["images"][0]["url"] if data.get("images") else "",
|
||||
}
|
||||
if spotify_type == "album":
|
||||
data = sp.album(spotify_id)
|
||||
artists = ", ".join(a["name"] for a in data.get("artists", []))
|
||||
return {
|
||||
"name": f"{data['name']}",
|
||||
"description": f"{artists} · {str(data.get('release_date', ''))[:4]}",
|
||||
"image_url": data["images"][0]["url"] if data.get("images") else "",
|
||||
}
|
||||
if spotify_type == "artist":
|
||||
data = sp.artist(spotify_id)
|
||||
return {
|
||||
"name": data["name"],
|
||||
"description": "Artista",
|
||||
"image_url": data["images"][0]["url"] if data.get("images") else "",
|
||||
}
|
||||
if spotify_type == "track":
|
||||
data = sp.track(spotify_id)
|
||||
artists = ", ".join(a["name"] for a in data.get("artists", []))
|
||||
return {
|
||||
"name": data["name"],
|
||||
"description": f"{artists} · {data['album']['name']}",
|
||||
"image_url": data["album"]["images"][0]["url"] if data["album"].get("images") else "",
|
||||
}
|
||||
return {"name": spotify_id, "description": "", "image_url": ""}
|
||||
|
||||
|
||||
def _require_admin(request: Request):
|
||||
if not request.session.get("admin_logged_in"):
|
||||
raise HTTPException(status_code=302, headers={"Location": "/admin/login"})
|
||||
|
||||
|
||||
# ── Login ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
return templates.TemplateResponse("admin/login.html", {"request": request, "error": None})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||
if username == settings.ADMIN_USERNAME and password == settings.ADMIN_PASSWORD:
|
||||
request.session["admin_logged_in"] = True
|
||||
return RedirectResponse(url="/admin/playlists", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"admin/login.html", {"request": request, "error": "Usuario o contraseña incorrectos"}, status_code=401
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/admin/login")
|
||||
|
||||
|
||||
# ── Playlists ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/playlists", response_class=HTMLResponse)
|
||||
def list_playlists(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
|
||||
return templates.TemplateResponse(
|
||||
"admin/playlists.html",
|
||||
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
|
||||
"error": None, "success": None},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/playlists")
|
||||
def add_playlist(
|
||||
request: Request,
|
||||
spotify_url: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_admin(request)
|
||||
|
||||
parsed = _extract_spotify_item(spotify_url)
|
||||
if not parsed:
|
||||
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
|
||||
return templates.TemplateResponse(
|
||||
"admin/playlists.html",
|
||||
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
|
||||
"error": "URL no reconocida. Acepta links de Spotify de canciones, álbumes, artistas o playlists.", "success": None},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
spotify_id, spotify_type = parsed
|
||||
|
||||
existing = db.query(Playlist).filter(Playlist.spotify_id == spotify_id).first()
|
||||
if existing:
|
||||
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
|
||||
return templates.TemplateResponse(
|
||||
"admin/playlists.html",
|
||||
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
|
||||
"error": "Este elemento ya está en el mantenedor", "success": None},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
sp = spotify.get_client()
|
||||
meta = _fetch_metadata(sp, spotify_id, spotify_type)
|
||||
except Exception:
|
||||
meta = {"name": spotify_id, "description": "", "image_url": ""}
|
||||
|
||||
item = Playlist(
|
||||
name=meta["name"],
|
||||
spotify_id=spotify_id,
|
||||
spotify_type=spotify_type,
|
||||
description=meta["description"],
|
||||
image_url=meta["image_url"],
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
|
||||
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
|
||||
label = _TYPE_LABELS.get(spotify_type, ("", spotify_type))[1]
|
||||
return templates.TemplateResponse(
|
||||
"admin/playlists.html",
|
||||
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
|
||||
"error": None, "success": f'{label} "{meta["name"]}" agregado correctamente'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/playlists/{playlist_id}/edit")
|
||||
def edit_playlist(
|
||||
playlist_id: int,
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
description: str = Form(""),
|
||||
emoji: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_admin(request)
|
||||
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Elemento no encontrado")
|
||||
playlist.name = name.strip() or playlist.name
|
||||
playlist.description = description.strip()
|
||||
playlist.emoji = emoji.strip()
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/playlists", status_code=303)
|
||||
|
||||
|
||||
@router.post("/playlists/{playlist_id}/delete")
|
||||
def delete_playlist(playlist_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist no encontrada")
|
||||
db.delete(playlist)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/playlists", status_code=303)
|
||||
|
||||
|
||||
# ── Votación ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_or_create_config(db: Session) -> VotingConfig:
|
||||
config = db.query(VotingConfig).first()
|
||||
if not config:
|
||||
config = VotingConfig(start_time="12:00", end_time="13:00", is_active=False)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
def _vote_results(db: Session) -> list[dict]:
|
||||
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
|
||||
results = []
|
||||
for pl in playlists:
|
||||
count = db.query(Vote).filter(Vote.playlist_id == pl.id).count()
|
||||
results.append({"playlist": pl, "votes": count})
|
||||
results.sort(key=lambda x: x["votes"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/voting", response_class=HTMLResponse)
|
||||
def voting_admin(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
config = _get_or_create_config(db)
|
||||
results = _vote_results(db)
|
||||
total_votes = sum(r["votes"] for r in results)
|
||||
return templates.TemplateResponse(
|
||||
"admin/voting.html",
|
||||
{
|
||||
"request": request,
|
||||
"config": config,
|
||||
"results": results,
|
||||
"total_votes": total_votes,
|
||||
"error": None,
|
||||
"success": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/voting")
|
||||
def update_voting_config(
|
||||
request: Request,
|
||||
start_time: str = Form(...),
|
||||
end_time: str = Form(...),
|
||||
is_active: str = Form(default="off"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_admin(request)
|
||||
config = _get_or_create_config(db)
|
||||
|
||||
try:
|
||||
datetime.strptime(start_time, "%H:%M")
|
||||
datetime.strptime(end_time, "%H:%M")
|
||||
except ValueError:
|
||||
results = _vote_results(db)
|
||||
return templates.TemplateResponse(
|
||||
"admin/voting.html",
|
||||
{
|
||||
"request": request,
|
||||
"config": config,
|
||||
"results": results,
|
||||
"total_votes": sum(r["votes"] for r in results),
|
||||
"error": "Formato de hora inválido. Use HH:MM",
|
||||
"success": None,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if start_time >= end_time:
|
||||
results = _vote_results(db)
|
||||
return templates.TemplateResponse(
|
||||
"admin/voting.html",
|
||||
{
|
||||
"request": request,
|
||||
"config": config,
|
||||
"results": results,
|
||||
"total_votes": sum(r["votes"] for r in results),
|
||||
"error": "La hora de inicio debe ser anterior a la hora de fin",
|
||||
"success": None,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
config.start_time = start_time
|
||||
config.end_time = end_time
|
||||
config.is_active = is_active == "on"
|
||||
db.commit()
|
||||
|
||||
results = _vote_results(db)
|
||||
return templates.TemplateResponse(
|
||||
"admin/voting.html",
|
||||
{
|
||||
"request": request,
|
||||
"config": config,
|
||||
"results": results,
|
||||
"total_votes": sum(r["votes"] for r in results),
|
||||
"error": None,
|
||||
"success": "Configuración guardada",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/voting/reset")
|
||||
def reset_votes(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
db.query(Vote).delete()
|
||||
db.commit()
|
||||
return RedirectResponse(url="/admin/voting", status_code=303)
|
||||
|
||||
|
||||
@router.post("/voting/play-winner")
|
||||
def play_winner(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
results = _vote_results(db)
|
||||
if not results or results[0]["votes"] == 0:
|
||||
raise HTTPException(status_code=400, detail="No hay votos registrados")
|
||||
|
||||
winner = results[0]["playlist"]
|
||||
try:
|
||||
sp = spotify.get_client()
|
||||
sp.start_playback(context_uri=f"spotify:playlist:{winner.spotify_id}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
return RedirectResponse(url="/admin/voting", status_code=303)
|
||||
|
||||
|
||||
# ── Sistema ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/system/restart")
|
||||
def system_restart(request: Request):
|
||||
_require_admin(request)
|
||||
|
||||
def _do():
|
||||
time.sleep(0.8)
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
return templates.TemplateResponse(
|
||||
"admin/system_action.html",
|
||||
{"request": request, "action": "reiniciando", "message": "El servidor se está reiniciando…"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/system/shutdown")
|
||||
def system_shutdown(request: Request):
|
||||
_require_admin(request)
|
||||
|
||||
def _do():
|
||||
time.sleep(0.8)
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
return templates.TemplateResponse(
|
||||
"admin/system_action.html",
|
||||
{"request": request, "action": "apagando", "message": "El servidor se está apagando…"},
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from app import spotify
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def spotify_login():
|
||||
return RedirectResponse(url=spotify.get_auth_url())
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
def spotify_callback(code: str):
|
||||
spotify.exchange_code(code)
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def spotify_logout():
|
||||
try:
|
||||
os.remove(".spotify_cache")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return RedirectResponse(url="/auth/login")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def auth_status():
|
||||
return {"authenticated": spotify.is_authenticated()}
|
||||
@@ -0,0 +1,160 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from app import spotify
|
||||
|
||||
router = APIRouter(prefix="/player", tags=["player"])
|
||||
|
||||
|
||||
class VolumeBody(BaseModel):
|
||||
volume: int
|
||||
|
||||
|
||||
class DeviceBody(BaseModel):
|
||||
device_id: str
|
||||
|
||||
|
||||
class PlayBody(BaseModel):
|
||||
context_uri: str | None = None
|
||||
uris: list[str] | None = None
|
||||
device_id: str | None = None
|
||||
|
||||
|
||||
def _sp():
|
||||
return spotify.get_client()
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
def current_track():
|
||||
sp = _sp()
|
||||
track = sp.current_playback()
|
||||
if not track or not track.get("item"):
|
||||
return {"playing": False}
|
||||
|
||||
item = track["item"]
|
||||
artists = ", ".join(a["name"] for a in item["artists"])
|
||||
album = item["album"]
|
||||
image = album["images"][0]["url"] if album["images"] else ""
|
||||
|
||||
return {
|
||||
"playing": track["is_playing"],
|
||||
"track": item["name"],
|
||||
"artists": artists,
|
||||
"album": album["name"],
|
||||
"image": image,
|
||||
"progress_ms": track["progress_ms"],
|
||||
"duration_ms": item["duration_ms"],
|
||||
"volume": track["device"]["volume_percent"] if track.get("device") else None,
|
||||
"device": track["device"]["name"] if track.get("device") else None,
|
||||
"context_uri": track["context"]["uri"] if track.get("context") else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
def get_devices():
|
||||
sp = _sp()
|
||||
result = sp.devices()
|
||||
return result.get("devices", [])
|
||||
|
||||
|
||||
@router.post("/play")
|
||||
def play(body: PlayBody):
|
||||
sp = _sp()
|
||||
kwargs = {}
|
||||
if body.uris:
|
||||
kwargs["uris"] = body.uris
|
||||
elif body.context_uri:
|
||||
kwargs["context_uri"] = body.context_uri
|
||||
if body.device_id:
|
||||
kwargs["device_id"] = body.device_id
|
||||
sp.start_playback(**kwargs)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
def pause():
|
||||
sp = _sp()
|
||||
sp.pause_playback()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/next")
|
||||
def next_track():
|
||||
sp = _sp()
|
||||
sp.next_track()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/previous")
|
||||
def previous_track():
|
||||
sp = _sp()
|
||||
sp.previous_track()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/volume")
|
||||
def set_volume(body: VolumeBody):
|
||||
if not 0 <= body.volume <= 100:
|
||||
raise HTTPException(status_code=400, detail="El volumen debe estar entre 0 y 100")
|
||||
sp = _sp()
|
||||
sp.volume(body.volume)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/device")
|
||||
def set_device(body: DeviceBody):
|
||||
sp = _sp()
|
||||
sp.transfer_playback(device_id=body.device_id, force_play=False)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/tracks")
|
||||
def get_tracks(id: str = Query(...), type: str = Query(...)):
|
||||
sp = _sp()
|
||||
tracks = []
|
||||
|
||||
if type == "playlist":
|
||||
results = sp.playlist_tracks(
|
||||
id, limit=50,
|
||||
fields="items(track(id,name,artists,duration_ms))",
|
||||
)
|
||||
for item in (results.get("items") or []):
|
||||
t = item.get("track")
|
||||
if t and t.get("id"):
|
||||
tracks.append({
|
||||
"id": t["id"],
|
||||
"name": t["name"],
|
||||
"artists": ", ".join(a["name"] for a in t["artists"]),
|
||||
"duration_ms": t["duration_ms"],
|
||||
})
|
||||
|
||||
elif type == "album":
|
||||
results = sp.album_tracks(id, limit=50)
|
||||
for t in (results.get("items") or []):
|
||||
tracks.append({
|
||||
"id": t["id"],
|
||||
"name": t["name"],
|
||||
"artists": ", ".join(a["name"] for a in t["artists"]),
|
||||
"duration_ms": t["duration_ms"],
|
||||
})
|
||||
|
||||
elif type == "artist":
|
||||
results = sp.artist_top_tracks(id)
|
||||
for t in (results.get("tracks") or []):
|
||||
tracks.append({
|
||||
"id": t["id"],
|
||||
"name": t["name"],
|
||||
"artists": ", ".join(a["name"] for a in t["artists"]),
|
||||
"duration_ms": t["duration_ms"],
|
||||
})
|
||||
|
||||
elif type == "track":
|
||||
t = sp.track(id)
|
||||
tracks.append({
|
||||
"id": t["id"],
|
||||
"name": t["name"],
|
||||
"artists": ", ".join(a["name"] for a in t["artists"]),
|
||||
"duration_ms": t["duration_ms"],
|
||||
})
|
||||
|
||||
return tracks
|
||||
@@ -0,0 +1,93 @@
|
||||
from collections import Counter
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import PlayHistory
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["stats"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
_TOP_N = 10
|
||||
|
||||
|
||||
def _stats_for_day(db: Session, selected: date) -> dict:
|
||||
records = (
|
||||
db.query(PlayHistory)
|
||||
.filter(func.date(PlayHistory.played_at) == selected.isoformat())
|
||||
.order_by(PlayHistory.played_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
track_counts: Counter = Counter()
|
||||
artist_counts: Counter = Counter()
|
||||
genre_counts: Counter = Counter()
|
||||
|
||||
for r in records:
|
||||
track_counts[(r.track_id, r.track_name)] += 1
|
||||
for artist in (a.strip() for a in r.artists.split(",") if a.strip()):
|
||||
artist_counts[artist] += 1
|
||||
for genre in (g.strip() for g in r.genres.split(",") if g.strip()):
|
||||
genre_counts[genre] += 1
|
||||
|
||||
def _ranked(counter: Counter) -> list[dict]:
|
||||
top = counter.most_common(_TOP_N)
|
||||
max_val = top[0][1] if top else 1
|
||||
return [
|
||||
{"label": k if isinstance(k, str) else k[1], "count": v, "pct": round(v / max_val * 100)}
|
||||
for k, v in top
|
||||
]
|
||||
|
||||
return {
|
||||
"total_plays": len(records),
|
||||
"top_tracks": _ranked(track_counts),
|
||||
"top_artists": _ranked(artist_counts),
|
||||
"top_genres": _ranked(genre_counts),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def stats_page(request: Request, day: str | None = None, db: Session = Depends(get_db)):
|
||||
try:
|
||||
selected_date = date.fromisoformat(day) if day else date.today()
|
||||
except ValueError:
|
||||
selected_date = date.today()
|
||||
|
||||
data = _stats_for_day(db, selected_date)
|
||||
|
||||
# Días con al menos una reproducción (para el selector)
|
||||
days_with_data = [
|
||||
str(r[0])
|
||||
for r in db.query(func.date(PlayHistory.played_at))
|
||||
.distinct()
|
||||
.order_by(func.date(PlayHistory.played_at).desc())
|
||||
.limit(30)
|
||||
.all()
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"admin/stats.html",
|
||||
{
|
||||
"request": request,
|
||||
"selected_date": selected_date.isoformat(),
|
||||
"prev_date": (selected_date - timedelta(days=1)).isoformat(),
|
||||
"next_date": (selected_date + timedelta(days=1)).isoformat(),
|
||||
"is_today": selected_date == date.today(),
|
||||
"days_with_data": days_with_data,
|
||||
**data,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def stats_api(day: str | None = None, db: Session = Depends(get_db)):
|
||||
try:
|
||||
selected_date = date.fromisoformat(day) if day else date.today()
|
||||
except ValueError:
|
||||
selected_date = date.today()
|
||||
return {"date": selected_date.isoformat(), **_stats_for_day(db, selected_date)}
|
||||
@@ -0,0 +1,117 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Playlist, Vote, VotingConfig
|
||||
|
||||
router = APIRouter(prefix="/voting", tags=["voting"])
|
||||
|
||||
COOLDOWN_SECONDS = 10
|
||||
|
||||
|
||||
def _voter_token(request: Request) -> str:
|
||||
token = request.session.get("voter_token")
|
||||
if not token:
|
||||
token = str(uuid.uuid4())
|
||||
request.session["voter_token"] = token
|
||||
return token
|
||||
|
||||
|
||||
def _is_open(config: VotingConfig | None) -> bool:
|
||||
if not config or not config.is_active:
|
||||
return False
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
return config.start_time <= now <= config.end_time
|
||||
|
||||
|
||||
def _cooldown_remaining(db: Session, voter_token: str) -> int:
|
||||
"""Segundos restantes de cooldown. 0 si puede votar."""
|
||||
last = (
|
||||
db.query(Vote)
|
||||
.filter(Vote.voter_token == voter_token)
|
||||
.order_by(Vote.voted_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not last:
|
||||
return 0
|
||||
voted_at = last.voted_at
|
||||
if voted_at.tzinfo is None:
|
||||
voted_at = voted_at.replace(tzinfo=timezone.utc)
|
||||
elapsed = (datetime.now(timezone.utc) - voted_at).total_seconds()
|
||||
return max(0, int(COOLDOWN_SECONDS - elapsed))
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def voting_status(request: Request, db: Session = Depends(get_db)):
|
||||
config = db.query(VotingConfig).first()
|
||||
voter_token = _voter_token(request)
|
||||
is_open = _is_open(config)
|
||||
|
||||
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
|
||||
|
||||
vote_counts: dict[int, int] = {
|
||||
pl.id: db.query(Vote).filter(Vote.playlist_id == pl.id).count()
|
||||
for pl in playlists
|
||||
}
|
||||
|
||||
last_vote = (
|
||||
db.query(Vote)
|
||||
.filter(Vote.voter_token == voter_token)
|
||||
.order_by(Vote.voted_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
return {
|
||||
"is_open": is_open,
|
||||
"cooldown_seconds": COOLDOWN_SECONDS,
|
||||
"cooldown_remaining": _cooldown_remaining(db, voter_token),
|
||||
"config": {
|
||||
"start_time": config.start_time if config else None,
|
||||
"end_time": config.end_time if config else None,
|
||||
"is_active": config.is_active if config else False,
|
||||
},
|
||||
"my_last_vote_playlist_id": last_vote.playlist_id if last_vote else None,
|
||||
"playlists": [
|
||||
{
|
||||
"id": pl.id,
|
||||
"spotify_id": pl.spotify_id,
|
||||
"spotify_type": pl.spotify_type,
|
||||
"name": pl.name,
|
||||
"image_url": pl.image_url,
|
||||
"emoji": pl.emoji or "",
|
||||
"description": pl.description or "",
|
||||
"votes": vote_counts.get(pl.id, 0),
|
||||
}
|
||||
for pl in playlists
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/vote/{playlist_id}")
|
||||
def cast_vote(playlist_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
config = db.query(VotingConfig).first()
|
||||
if not _is_open(config):
|
||||
raise HTTPException(status_code=403, detail="La votación no está abierta en este momento")
|
||||
|
||||
pl = db.query(Playlist).filter(Playlist.id == playlist_id).first()
|
||||
if not pl:
|
||||
raise HTTPException(status_code=404, detail="Playlist no encontrada")
|
||||
|
||||
voter_token = _voter_token(request)
|
||||
remaining = _cooldown_remaining(db, voter_token)
|
||||
if remaining > 0:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={"message": "Debes esperar antes de votar de nuevo", "remaining": remaining},
|
||||
)
|
||||
|
||||
db.add(Vote(
|
||||
playlist_id=playlist_id,
|
||||
voter_token=voter_token,
|
||||
voted_at=datetime.now(timezone.utc),
|
||||
))
|
||||
db.commit()
|
||||
return {"ok": True, "cooldown_seconds": COOLDOWN_SECONDS}
|
||||
Reference in New Issue
Block a user