2
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,235 +0,0 @@
|
||||
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()
|
||||
@@ -1,39 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import hash_password, verify_password, create_access_token
|
||||
from app.models import User
|
||||
from app.schemas import UserCreate, UserOut, Token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=201)
|
||||
def register(data: UserCreate, db: Session = Depends(get_db)):
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(400, "Brugernavnet er allerede i brug")
|
||||
if db.query(User).filter(User.email == data.email).first():
|
||||
raise HTTPException(400, "E-mailen er allerede i brug")
|
||||
|
||||
user = User(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == form.username).first()
|
||||
if not user or not verify_password(form.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Forkert brugernavn eller kodeord",
|
||||
)
|
||||
token = create_access_token({"sub": user.id})
|
||||
return {"access_token": token}
|
||||
@@ -1,190 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User, Project, ProjectMember, ProjectSong, Song
|
||||
from app.schemas import (
|
||||
ProjectCreate, ProjectUpdate, ProjectOut,
|
||||
InviteMember, ProjectSongAdd, ProjectSongStatusUpdate, ProjectSongOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
def _get_project_or_404(project_id: str, db: Session) -> Project:
|
||||
p = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not p:
|
||||
raise HTTPException(404, "Projekt ikke fundet")
|
||||
return p
|
||||
|
||||
|
||||
def _assert_role(project: Project, user: User, db: Session, min_role: str = "viewer"):
|
||||
roles = ["viewer", "editor", "owner"]
|
||||
if project.owner_id == user.id:
|
||||
return # ejer har altid adgang
|
||||
member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first()
|
||||
if not member:
|
||||
if project.is_public and min_role == "viewer":
|
||||
return
|
||||
raise HTTPException(403, "Du har ikke adgang til dette projekt")
|
||||
if roles.index(member.role) < roles.index(min_role):
|
||||
raise HTTPException(403, "Din rolle giver ikke rettighed til dette")
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=list[ProjectOut])
|
||||
def list_projects(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
owned = db.query(Project).filter(Project.owner_id == me.id).all()
|
||||
member_ids = [m.project_id for m in db.query(ProjectMember).filter_by(user_id=me.id, status="accepted").all()]
|
||||
shared = db.query(Project).filter(Project.id.in_(member_ids)).all()
|
||||
return list({p.id: p for p in owned + shared}.values())
|
||||
|
||||
|
||||
@router.post("/", response_model=ProjectOut, status_code=201)
|
||||
def create_project(data: ProjectCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
project = Project(owner_id=me.id, **data.model_dump())
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectOut)
|
||||
def get_project(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "viewer")
|
||||
return p
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectOut)
|
||||
def update_project(project_id: str, data: ProjectUpdate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "editor")
|
||||
for field, val in data.model_dump(exclude_none=True).items():
|
||||
setattr(p, field, val)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=204)
|
||||
def delete_project(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
if p.owner_id != me.id:
|
||||
raise HTTPException(403, "Kun ejeren kan slette projektet")
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Invitationer ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/invite", status_code=201)
|
||||
def invite_member(project_id: str, data: InviteMember, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
if p.owner_id != me.id:
|
||||
raise HTTPException(403, "Kun ejeren kan invitere")
|
||||
|
||||
target = db.query(User).filter(User.username == data.username).first()
|
||||
if not target:
|
||||
raise HTTPException(404, f"Brugeren '{data.username}' findes ikke")
|
||||
if target.id == me.id:
|
||||
raise HTTPException(400, "Du kan ikke invitere dig selv")
|
||||
|
||||
existing = db.query(ProjectMember).filter_by(project_id=project_id, user_id=target.id).first()
|
||||
if existing:
|
||||
raise HTTPException(400, "Brugeren er allerede inviteret eller medlem")
|
||||
|
||||
member = ProjectMember(project_id=project_id, user_id=target.id, role=data.role, status="pending")
|
||||
db.add(member)
|
||||
db.commit()
|
||||
return {"detail": f"{data.username} er inviteret som {data.role}"}
|
||||
|
||||
|
||||
@router.get("/invitations/pending")
|
||||
def get_pending_invitations(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
invitations = db.query(ProjectMember).filter_by(user_id=me.id, status="pending").all()
|
||||
return [
|
||||
{"invitation_id": inv.id, "project_id": inv.project_id, "role": inv.role, "invited_at": inv.invited_at}
|
||||
for inv in invitations
|
||||
]
|
||||
|
||||
|
||||
@router.post("/invitations/{invitation_id}/accept")
|
||||
def accept_invitation(invitation_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
inv = db.query(ProjectMember).filter_by(id=invitation_id, user_id=me.id).first()
|
||||
if not inv:
|
||||
raise HTTPException(404, "Invitation ikke fundet")
|
||||
inv.status = "accepted"
|
||||
db.commit()
|
||||
return {"detail": "Invitation accepteret"}
|
||||
|
||||
|
||||
@router.delete("/invitations/{invitation_id}")
|
||||
def decline_invitation(invitation_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
inv = db.query(ProjectMember).filter_by(id=invitation_id, user_id=me.id).first()
|
||||
if not inv:
|
||||
raise HTTPException(404, "Invitation ikke fundet")
|
||||
db.delete(inv)
|
||||
db.commit()
|
||||
return {"detail": "Invitation afvist"}
|
||||
|
||||
|
||||
# ── Danseliste (ProjectSongs) ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{project_id}/songs", response_model=list[ProjectSongOut])
|
||||
def list_project_songs(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "viewer")
|
||||
return p.project_songs
|
||||
|
||||
|
||||
@router.post("/{project_id}/songs", response_model=ProjectSongOut, status_code=201)
|
||||
def add_song_to_project(project_id: str, data: ProjectSongAdd, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "editor")
|
||||
|
||||
song = db.query(Song).filter(Song.id == data.song_id).first()
|
||||
if not song:
|
||||
raise HTTPException(404, "Sang ikke fundet")
|
||||
|
||||
position = data.position
|
||||
if position is None:
|
||||
last = db.query(ProjectSong).filter_by(project_id=project_id).order_by(ProjectSong.position.desc()).first()
|
||||
position = (last.position + 1) if last else 1
|
||||
|
||||
ps = ProjectSong(project_id=project_id, song_id=data.song_id, position=position)
|
||||
db.add(ps)
|
||||
db.commit()
|
||||
db.refresh(ps)
|
||||
return ps
|
||||
|
||||
|
||||
@router.patch("/{project_id}/songs/{ps_id}/status", response_model=ProjectSongOut)
|
||||
def update_song_status(project_id: str, ps_id: str, data: ProjectSongStatusUpdate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "editor")
|
||||
|
||||
ps = db.query(ProjectSong).filter_by(id=ps_id, project_id=project_id).first()
|
||||
if not ps:
|
||||
raise HTTPException(404, "Sang ikke fundet i projektet")
|
||||
|
||||
valid = {"pending", "playing", "played", "skipped"}
|
||||
if data.status not in valid:
|
||||
raise HTTPException(400, f"Ugyldig status. Vælg én af: {valid}")
|
||||
|
||||
ps.status = data.status
|
||||
db.commit()
|
||||
db.refresh(ps)
|
||||
return ps
|
||||
|
||||
|
||||
@router.delete("/{project_id}/songs/{ps_id}", status_code=204)
|
||||
def remove_song_from_project(project_id: str, ps_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "editor")
|
||||
ps = db.query(ProjectSong).filter_by(id=ps_id, project_id=project_id).first()
|
||||
if not ps:
|
||||
raise HTTPException(404, "Sang ikke fundet i projektet")
|
||||
db.delete(ps)
|
||||
db.commit()
|
||||
@@ -1,109 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User, Song, SongDance, DanceAlternative
|
||||
from app.schemas import (
|
||||
SongCreate, SongOut,
|
||||
SongDanceCreate, SongDanceOut,
|
||||
DanceAlternativeCreate, DanceAlternativeOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/songs", tags=["songs"])
|
||||
|
||||
|
||||
# ── Sange ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=list[SongOut])
|
||||
def list_songs(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
return db.query(Song).filter(Song.owner_id == me.id).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=SongOut, status_code=201)
|
||||
def create_song(data: SongCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
song = Song(owner_id=me.id, **data.model_dump())
|
||||
db.add(song)
|
||||
db.commit()
|
||||
db.refresh(song)
|
||||
return song
|
||||
|
||||
|
||||
@router.get("/{song_id}", response_model=SongOut)
|
||||
def get_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
|
||||
if not song:
|
||||
raise HTTPException(404, "Sang ikke fundet")
|
||||
return song
|
||||
|
||||
|
||||
@router.delete("/{song_id}", status_code=204)
|
||||
def delete_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
|
||||
if not song:
|
||||
raise HTTPException(404, "Sang ikke fundet")
|
||||
db.delete(song)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Danse på en sang ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{song_id}/dances", response_model=SongDanceOut, status_code=201)
|
||||
def add_dance(song_id: str, data: SongDanceCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
|
||||
if not song:
|
||||
raise HTTPException(404, "Sang ikke fundet")
|
||||
dance = SongDance(song_id=song_id, **data.model_dump())
|
||||
db.add(dance)
|
||||
db.commit()
|
||||
db.refresh(dance)
|
||||
return dance
|
||||
|
||||
|
||||
@router.delete("/{song_id}/dances/{dance_id}", status_code=204)
|
||||
def remove_dance(song_id: str, dance_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
|
||||
if not song:
|
||||
raise HTTPException(404, "Sang ikke fundet")
|
||||
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first()
|
||||
if not dance:
|
||||
raise HTTPException(404, "Dans ikke fundet")
|
||||
db.delete(dance)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Alternativ-danse ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{song_id}/dances/{dance_id}/alternatives", response_model=DanceAlternativeOut, status_code=201)
|
||||
def add_alternative(song_id: str, dance_id: str, data: DanceAlternativeCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
|
||||
if not song:
|
||||
raise HTTPException(404, "Sang ikke fundet")
|
||||
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_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")
|
||||
|
||||
alt = DanceAlternative(song_dance_id=dance_id, **data.model_dump())
|
||||
db.add(alt)
|
||||
db.commit()
|
||||
db.refresh(alt)
|
||||
return alt
|
||||
|
||||
|
||||
@router.get("/{song_id}/dances/{dance_id}/alternatives", response_model=list[DanceAlternativeOut])
|
||||
def list_alternatives(song_id: str, dance_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first()
|
||||
if not dance:
|
||||
raise HTTPException(404, "Dans ikke fundet")
|
||||
return dance.alternatives
|
||||
|
||||
|
||||
@router.delete("/{song_id}/dances/{dance_id}/alternatives/{alt_id}", status_code=204)
|
||||
def remove_alternative(song_id: str, dance_id: str, alt_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alt_id, DanceAlternative.song_dance_id == dance_id).first()
|
||||
if not alt:
|
||||
raise HTTPException(404, "Alternativ ikke fundet")
|
||||
db.delete(alt)
|
||||
db.commit()
|
||||
@@ -1,115 +0,0 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: str
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
# ── Project ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
is_public: bool = False
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
is_public: bool | None = None
|
||||
|
||||
class ProjectOut(BaseModel):
|
||||
id: str
|
||||
owner_id: str
|
||||
name: str
|
||||
description: str
|
||||
is_public: bool
|
||||
updated_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class InviteMember(BaseModel):
|
||||
username: str
|
||||
role: str = "viewer" # editor | viewer
|
||||
|
||||
|
||||
# ── Song ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class SongCreate(BaseModel):
|
||||
title: str
|
||||
artist: str = ""
|
||||
local_path: str = ""
|
||||
bpm: int = 0
|
||||
duration_sec: int = 0
|
||||
|
||||
class SongOut(BaseModel):
|
||||
id: str
|
||||
owner_id: str
|
||||
title: str
|
||||
artist: str
|
||||
local_path: str
|
||||
bpm: int
|
||||
duration_sec: int
|
||||
synced_at: datetime
|
||||
dances: list[SongDanceOut] = []
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Dance ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class SongDanceCreate(BaseModel):
|
||||
dance_name: str
|
||||
dance_order: int = 1
|
||||
|
||||
class SongDanceOut(BaseModel):
|
||||
id: str
|
||||
dance_name: str
|
||||
dance_order: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class DanceAlternativeCreate(BaseModel):
|
||||
alt_song_dance_id: str
|
||||
note: str = ""
|
||||
|
||||
class DanceAlternativeOut(BaseModel):
|
||||
id: str
|
||||
song_dance_id: str
|
||||
alt_song_dance_id: str
|
||||
note: str
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── ProjectSong ───────────────────────────────────────────────────────────────
|
||||
|
||||
class ProjectSongAdd(BaseModel):
|
||||
song_id: str
|
||||
position: int | None = None # None = tilføj sidst
|
||||
|
||||
class ProjectSongStatusUpdate(BaseModel):
|
||||
status: str # pending | playing | played | skipped
|
||||
|
||||
class ProjectSongOut(BaseModel):
|
||||
id: str
|
||||
song_id: str
|
||||
position: int
|
||||
status: str
|
||||
song: SongOut
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
SongOut.model_rebuild()
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,78 +0,0 @@
|
||||
import json
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.models import Project, ProjectSong
|
||||
|
||||
router = APIRouter(prefix="/ws", tags=["websocket"])
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
# project_id -> liste af aktive forbindelser
|
||||
self.rooms: dict[str, list[WebSocket]] = {}
|
||||
|
||||
async def connect(self, project_id: str, ws: WebSocket):
|
||||
await ws.accept()
|
||||
self.rooms.setdefault(project_id, []).append(ws)
|
||||
|
||||
def disconnect(self, project_id: str, ws: WebSocket):
|
||||
if project_id in self.rooms:
|
||||
self.rooms[project_id].discard(ws) if hasattr(self.rooms[project_id], 'discard') else None
|
||||
try:
|
||||
self.rooms[project_id].remove(ws)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def broadcast(self, project_id: str, message: dict):
|
||||
dead = []
|
||||
for ws in self.rooms.get(project_id, []):
|
||||
try:
|
||||
await ws.send_text(json.dumps(message))
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(project_id, ws)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/{project_id}")
|
||||
async def project_live(
|
||||
project_id: str,
|
||||
websocket: WebSocket,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
await websocket.close(code=4004)
|
||||
return
|
||||
|
||||
await manager.connect(project_id, websocket)
|
||||
|
||||
# Send nuværende tilstand med det samme ved opkobling
|
||||
songs = db.query(ProjectSong).filter_by(project_id=project_id).order_by(ProjectSong.position).all()
|
||||
await websocket.send_text(json.dumps({
|
||||
"event": "state",
|
||||
"project_id": project_id,
|
||||
"songs": [
|
||||
{"id": ps.id, "position": ps.position, "status": ps.status, "song_id": ps.song_id}
|
||||
for ps in songs
|
||||
],
|
||||
}))
|
||||
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text() # hold forbindelsen åben
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(project_id, websocket)
|
||||
|
||||
|
||||
async def notify_status_change(project_id: str, project_song_id: str, new_status: str):
|
||||
"""Kaldes fra projects-router når en sangs status ændres."""
|
||||
await manager.broadcast(project_id, {
|
||||
"event": "status_update",
|
||||
"project_song_id": project_song_id,
|
||||
"status": new_status,
|
||||
})
|
||||
Reference in New Issue
Block a user