236 lines
8.0 KiB
Python
236 lines
8.0 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import func
|
||
from pydantic import BaseModel
|
||
from app.core.database import get_db
|
||
from app.core.security import get_current_user
|
||
from app.models import User, SongDance, DanceAlternative, DanceAlternativeRating
|
||
|
||
router = APIRouter(prefix="/alternatives", tags=["alternatives"])
|
||
|
||
# Bayesiansk minimum — alternativer med færre ratings trækkes mod gennemsnittet
|
||
BAYESIAN_MIN_VOTES = 5
|
||
|
||
|
||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||
|
||
class AlternativeCreate(BaseModel):
|
||
song_dance_id: str # dans der foreslås alternativ TIL
|
||
alt_song_dance_id: str # den alternative dans
|
||
note: str = ""
|
||
|
||
class AlternativeOut(BaseModel):
|
||
id: str
|
||
song_dance_id: str
|
||
alt_song_dance_id: str
|
||
alt_dance_name: str
|
||
alt_song_title: str
|
||
created_by_username: str
|
||
note: str
|
||
my_score: int | None # den indloggede brugers egen rating
|
||
avg_score: float | None # simpelt gennemsnit (til visning)
|
||
bayesian_score: float # bruges til sortering
|
||
rating_count: int
|
||
model_config = {"from_attributes": True}
|
||
|
||
class RatingUpsert(BaseModel):
|
||
score: int # 1-5
|
||
|
||
|
||
# ── Hjælpefunktion: genberegn bayesian score ─────────────────────────────────
|
||
|
||
def _recalculate_bayesian(alternative: DanceAlternative, db: Session):
|
||
"""
|
||
Bayesiansk score: vægter gennemsnittet mod et globalt gennemsnit
|
||
når der er få ratings, så nye alternativer ikke dominerer listen.
|
||
|
||
Formel: (n × avg + m × global_avg) / (n + m)
|
||
n = antal ratings på dette alternativ
|
||
avg = gennemsnit for dette alternativ
|
||
m = BAYESIAN_MIN_VOTES (tillid-konstant)
|
||
global_avg = gennemsnit på tværs af ALLE ratings
|
||
"""
|
||
# Beregn stats for dette alternativ
|
||
result = db.query(
|
||
func.count(DanceAlternativeRating.id),
|
||
func.avg(DanceAlternativeRating.score),
|
||
).filter(DanceAlternativeRating.alternative_id == alternative.id).one()
|
||
|
||
n = result[0] or 0
|
||
avg = float(result[1]) if result[1] else 0.0
|
||
|
||
# Globalt gennemsnit på tværs af alle ratings
|
||
global_avg_result = db.query(func.avg(DanceAlternativeRating.score)).scalar()
|
||
global_avg = float(global_avg_result) if global_avg_result else 3.0 # 3.0 som neutral fallback
|
||
|
||
m = BAYESIAN_MIN_VOTES
|
||
bayesian = (n * avg + m * global_avg) / (n + m) if (n + m) > 0 else global_avg
|
||
|
||
alternative.bayesian_score = round(bayesian, 4)
|
||
db.flush()
|
||
|
||
|
||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/", status_code=201)
|
||
def create_alternative(
|
||
data: AlternativeCreate,
|
||
db: Session = Depends(get_db),
|
||
me: User = Depends(get_current_user),
|
||
):
|
||
"""Opret et nyt alternativ-dans forslag. Alle registrerede brugere kan bidrage."""
|
||
dance = db.query(SongDance).filter(SongDance.id == data.song_dance_id).first()
|
||
if not dance:
|
||
raise HTTPException(404, "Dans ikke fundet")
|
||
|
||
alt_dance = db.query(SongDance).filter(SongDance.id == data.alt_song_dance_id).first()
|
||
if not alt_dance:
|
||
raise HTTPException(404, "Alternativ-dans ikke fundet")
|
||
|
||
if data.song_dance_id == data.alt_song_dance_id:
|
||
raise HTTPException(400, "En dans kan ikke være sit eget alternativ")
|
||
|
||
# Undgå dubletter fra samme bruger
|
||
existing = db.query(DanceAlternative).filter_by(
|
||
song_dance_id=data.song_dance_id,
|
||
alt_song_dance_id=data.alt_song_dance_id,
|
||
created_by=me.id,
|
||
).first()
|
||
if existing:
|
||
raise HTTPException(400, "Du har allerede foreslået dette alternativ")
|
||
|
||
alt = DanceAlternative(
|
||
song_dance_id=data.song_dance_id,
|
||
alt_song_dance_id=data.alt_song_dance_id,
|
||
created_by=me.id,
|
||
note=data.note,
|
||
bayesian_score=3.0, # starter på globalt neutral
|
||
)
|
||
db.add(alt)
|
||
db.commit()
|
||
db.refresh(alt)
|
||
return {"id": alt.id, "detail": "Alternativ oprettet"}
|
||
|
||
|
||
@router.get("/for-dance/{song_dance_id}", response_model=list[AlternativeOut])
|
||
def list_alternatives_for_dance(
|
||
song_dance_id: str,
|
||
db: Session = Depends(get_db),
|
||
me: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
Hent alle alternativer til en given dans, sorteret efter bayesiansk score.
|
||
Viser din egen rating og gennemsnittet.
|
||
"""
|
||
alternatives = (
|
||
db.query(DanceAlternative)
|
||
.filter(DanceAlternative.song_dance_id == song_dance_id)
|
||
.order_by(DanceAlternative.bayesian_score.desc())
|
||
.all()
|
||
)
|
||
|
||
result = []
|
||
for alt in alternatives:
|
||
# Din egen rating
|
||
my_rating = db.query(DanceAlternativeRating).filter_by(
|
||
alternative_id=alt.id, user_id=me.id
|
||
).first()
|
||
|
||
# Aggregeret stats
|
||
stats = db.query(
|
||
func.count(DanceAlternativeRating.id),
|
||
func.avg(DanceAlternativeRating.score),
|
||
).filter(DanceAlternativeRating.alternative_id == alt.id).one()
|
||
|
||
result.append(AlternativeOut(
|
||
id=alt.id,
|
||
song_dance_id=alt.song_dance_id,
|
||
alt_song_dance_id=alt.alt_song_dance_id,
|
||
alt_dance_name=alt.alt_song_dance.dance_name,
|
||
alt_song_title=alt.alt_song_dance.song.title,
|
||
created_by_username=alt.creator.username,
|
||
note=alt.note,
|
||
my_score=my_rating.score if my_rating else None,
|
||
avg_score=round(float(stats[1]), 1) if stats[1] else None,
|
||
bayesian_score=alt.bayesian_score,
|
||
rating_count=stats[0] or 0,
|
||
))
|
||
|
||
return result
|
||
|
||
|
||
@router.put("/{alternative_id}/rate")
|
||
def rate_alternative(
|
||
alternative_id: str,
|
||
data: RatingUpsert,
|
||
db: Session = Depends(get_db),
|
||
me: User = Depends(get_current_user),
|
||
):
|
||
"""Sæt eller opdater din rating (1-5) på et alternativ."""
|
||
if not 1 <= data.score <= 5:
|
||
raise HTTPException(400, "Score skal være mellem 1 og 5")
|
||
|
||
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
|
||
if not alt:
|
||
raise HTTPException(404, "Alternativ ikke fundet")
|
||
|
||
# Upsert — opdater eksisterende rating eller opret ny
|
||
existing = db.query(DanceAlternativeRating).filter_by(
|
||
alternative_id=alternative_id, user_id=me.id
|
||
).first()
|
||
|
||
if existing:
|
||
existing.score = data.score
|
||
else:
|
||
db.add(DanceAlternativeRating(
|
||
alternative_id=alternative_id,
|
||
user_id=me.id,
|
||
score=data.score,
|
||
))
|
||
|
||
db.flush()
|
||
_recalculate_bayesian(alt, db)
|
||
db.commit()
|
||
|
||
return {
|
||
"detail": "Rating gemt",
|
||
"my_score": data.score,
|
||
"bayesian_score": alt.bayesian_score,
|
||
}
|
||
|
||
|
||
@router.delete("/{alternative_id}/rate", status_code=204)
|
||
def remove_rating(
|
||
alternative_id: str,
|
||
db: Session = Depends(get_db),
|
||
me: User = Depends(get_current_user),
|
||
):
|
||
"""Fjern din rating fra et alternativ."""
|
||
rating = db.query(DanceAlternativeRating).filter_by(
|
||
alternative_id=alternative_id, user_id=me.id
|
||
).first()
|
||
if not rating:
|
||
raise HTTPException(404, "Du har ikke rated dette alternativ")
|
||
|
||
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
|
||
db.delete(rating)
|
||
db.flush()
|
||
_recalculate_bayesian(alt, db)
|
||
db.commit()
|
||
|
||
|
||
@router.delete("/{alternative_id}", status_code=204)
|
||
def delete_alternative(
|
||
alternative_id: str,
|
||
db: Session = Depends(get_db),
|
||
me: User = Depends(get_current_user),
|
||
):
|
||
"""Slet et alternativ — kun den der oprettede det."""
|
||
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
|
||
if not alt:
|
||
raise HTTPException(404, "Alternativ ikke fundet")
|
||
if alt.created_by != me.id:
|
||
raise HTTPException(403, "Du kan kun slette dine egne forslag")
|
||
db.delete(alt)
|
||
db.commit()
|