Rettelsaer

This commit is contained in:
2026-04-13 07:23:37 +02:00
parent 45dcedaed4
commit bbd5690d72
22 changed files with 2026 additions and 538 deletions

View File

@@ -61,11 +61,12 @@ LineDance Player
port=settings.MAIL_PORT,
username=settings.MAIL_USERNAME or None,
password=settings.MAIL_PASSWORD or None,
use_tls=settings.MAIL_TLS,
start_tls=settings.MAIL_TLS, # STARTTLS på port 587
use_tls=False,
)
except Exception as e:
# Log fejl men lad registrering gennemføre
print(f"Mail-fejl: {e}")
raise # Vis fejlen i serverlogs
async def send_share_invitation(email: str, owner_name: str,

View File

@@ -1,21 +1,20 @@
import bcrypt
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
return bcrypt.hashpw(password[:72].encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
return bcrypt.checkpw(plain[:72].encode(), hashed.encode())
def create_access_token(data: dict) -> str:
expire = datetime.now(timezone.utc) + timedelta(

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.database import engine, Base
from app.routers import auth, projects, songs, alternatives, dances
from app.routers import auth, projects, songs, alternatives, dances, sync, sharing
from app.websocket.manager import router as ws_router
# Opret tabeller hvis de ikke findes (til udvikling — brug Alembic i produktion)
@@ -26,6 +26,8 @@ app.include_router(projects.router)
app.include_router(songs.router)
app.include_router(alternatives.router)
app.include_router(dances.router)
app.include_router(sync.router)
app.include_router(sharing.router)
@app.on_event("startup")

View File

@@ -1,235 +1,3 @@
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
"""alternatives.py — Placeholder (håndteres via /sync)."""
from fastapi import APIRouter
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()

View File

@@ -24,7 +24,7 @@ def _assert_role(project: Project, user: User, db: Session, min_role: str = "vie
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":
if project.visibility == "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):

View File

@@ -0,0 +1,394 @@
"""
sharing.py — Del playlister med andre brugere.
"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User, Project, PlaylistShare
router = APIRouter(prefix="/sharing", tags=["sharing"])
class ShareRequest(BaseModel):
email: EmailStr
permission: str = "view" # view | copy | edit
class ShareOut(BaseModel):
id: str
project_id: str
invited_email: str
permission: str
accepted_at: Optional[str] = None
class Config:
from_attributes = True
# ── Del en playliste ──────────────────────────────────────────────────────────
@router.post("/playlists/{project_id}/share", status_code=201)
async def share_playlist(
project_id: str,
data: ShareRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet eller du er ikke ejer")
if data.permission not in ("view", "copy", "edit"):
raise HTTPException(400, "Ugyldig rettighed — brug view, copy eller edit")
# Find bruger via email
target = db.query(User).filter_by(email=data.email).first()
# Tjek om deling allerede eksisterer
existing = db.query(PlaylistShare).filter_by(
project_id=project_id,
invited_email=data.email,
).first()
if existing:
existing.permission = data.permission
db.commit()
return {"detail": "Rettigheder opdateret", "share_id": existing.id}
share = PlaylistShare(
project_id=project_id,
shared_with_id=target.id if target else None,
invited_email=data.email,
permission=data.permission,
)
db.add(share)
db.commit()
db.refresh(share)
# Send invitation-mail
try:
from app.core.mail import send_share_invitation
from app.core.config import settings
background_tasks.add_task(
send_share_invitation,
email=data.email,
owner_name=me.username,
playlist_name=project.name,
permission=data.permission,
accept_url=f"{settings.BASE_URL}/sharing/accept/{share.id}",
)
except Exception:
pass
return {"detail": "Invitation sendt", "share_id": share.id}
@router.patch("/playlists/{project_id}/share/{share_id}")
def update_share(
project_id: str,
share_id: str,
data: ShareRequest,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first()
if not share:
raise HTTPException(404, "Deling ikke fundet")
share.permission = data.permission
db.commit()
return {"detail": "Rettigheder opdateret"}
@router.delete("/playlists/{project_id}/share/{share_id}", status_code=204)
def remove_share(
project_id: str,
share_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first()
if not share:
raise HTTPException(404, "Deling ikke fundet")
db.delete(share)
db.commit()
@router.get("/playlists/{project_id}/shares")
def list_shares(
project_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
shares = db.query(PlaylistShare).filter_by(project_id=project_id).all()
return [
{
"id": s.id,
"email": s.invited_email,
"permission": s.permission,
"accepted": s.accepted_at is not None,
}
for s in shares
]
# ── Visibility ────────────────────────────────────────────────────────────────
@router.patch("/playlists/{project_id}/visibility")
def set_visibility(
project_id: str,
visibility: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
if visibility not in ("private", "shared", "public"):
raise HTTPException(400, "Ugyldig synlighed — brug private, shared eller public")
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
project.visibility = visibility
db.commit()
return {"detail": f"Synlighed sat til {visibility}"}
# ── Hent delte lister ─────────────────────────────────────────────────────────
@router.get("/playlists/shared-with-me")
def shared_with_me(
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent alle playlister der er delt med mig."""
# Via direkte deling
shares = db.query(PlaylistShare).filter_by(
shared_with_id=me.id
).all()
project_ids = {s.project_id for s in shares}
# Via email-invitation
email_shares = db.query(PlaylistShare).filter_by(
invited_email=me.email
).all()
project_ids.update(s.project_id for s in email_shares)
# Public playlister
public = db.query(Project).filter_by(visibility="public").all()
project_ids.update(p.id for p in public)
result = []
for pid in project_ids:
p = db.query(Project).filter_by(id=pid).first()
if not p or p.owner_id == me.id:
continue
# Find min rettighed
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == pid,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
permission = share.permission if share else "view"
owner = db.query(User).filter_by(id=p.owner_id).first()
result.append({
"project_id": p.id,
"name": p.name,
"owner": owner.username if owner else "?",
"visibility": p.visibility,
"permission": permission,
"song_count": len(p.project_songs),
})
return result
# ── Hent en delt playliste ────────────────────────────────────────────────────
@router.get("/playlists/{project_id}")
def get_shared_playlist(
project_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent indholdet af en delt playliste."""
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
# Tjek adgang
if p.owner_id != me.id:
if p.visibility != "public":
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == project_id,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
if not share:
raise HTTPException(403, "Du har ikke adgang til denne playliste")
from app.models import Song
songs = []
for ps in p.project_songs:
song = db.query(Song).filter_by(id=ps.song_id).first()
if not song:
continue
songs.append({
"title": song.title,
"artist": song.artist,
"album": song.album,
"bpm": song.bpm,
"duration_sec": song.duration_sec,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override,
})
return {
"id": p.id,
"name": p.name,
"description": p.description,
"visibility": p.visibility,
"songs": sorted(songs, key=lambda x: x["position"]),
}
# ── Opdater sange i en linket liste ──────────────────────────────────────────
class LinkedSongData(BaseModel):
title: str
artist: str = ""
position: int = 1
status: str = "pending"
is_workshop: bool = False
dance_override: str = ""
class LinkedSongsUpdate(BaseModel):
songs: list[LinkedSongData]
@router.put("/playlists/{project_id}/songs")
def update_linked_songs(
project_id: str,
data: LinkedSongsUpdate,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Opdater sange i en linket playliste — kræver edit-rettighed."""
from app.models import Song, ProjectSong
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
# Tjek edit-rettighed
if p.owner_id != me.id:
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == project_id,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
if not share or share.permission != "edit":
raise HTTPException(403, "Du har ikke redigerings-rettighed")
# Slet eksisterende sange og geninsert
db.query(ProjectSong).filter_by(project_id=project_id).delete()
for song_data in data.songs:
song = db.query(Song).filter_by(
title=song_data.title, artist=song_data.artist
).first()
if not song:
continue
ps = ProjectSong(
project_id=project_id,
song_id=song.id,
position=song_data.position,
status=song_data.status,
is_workshop=song_data.is_workshop,
dance_override=song_data.dance_override,
)
db.add(ps)
db.commit()
return {"detail": "Liste opdateret", "songs": len(data.songs)}
# ── Opdater sange på en delt playliste ───────────────────────────────────────
class LinkedSongData(BaseModel):
title: str
artist: str
position: int
status: str = "pending"
is_workshop: bool = False
dance_override: str = ""
class LinkedPlaylistUpdate(BaseModel):
songs: list[LinkedSongData]
@router.put("/playlists/{project_id}/songs")
def update_linked_playlist_songs(
project_id: str,
data: LinkedPlaylistUpdate,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Opdater sange på en delt playliste — kræver edit-rettighed."""
from app.models import Song
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
# Tjek rettighed
if p.owner_id != me.id:
from app.models import PlaylistShare
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == project_id,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
if not share or share.permission != "edit":
raise HTTPException(403, "Du har ikke rettighed til at redigere denne liste")
# Slet eksisterende sange og indsæt nye
from app.models import ProjectSong
db.query(ProjectSong).filter_by(project_id=project_id).delete()
for song_data in data.songs:
# Match sang globalt på titel+artist
song = db.query(Song).filter_by(
title=song_data.title, artist=song_data.artist
).first()
if not song:
song = Song(
owner_id=me.id,
title=song_data.title,
artist=song_data.artist,
)
db.add(song)
db.flush()
ps = ProjectSong(
project_id=project_id,
song_id=song.id,
position=song_data.position,
status=song_data.status,
is_workshop=song_data.is_workshop,
dance_override=song_data.dance_override,
)
db.add(ps)
db.commit()
return {"detail": "Playliste opdateret", "songs": len(data.songs)}

View File

@@ -1,41 +1,25 @@
"""songs.py — Simpel sang-router (basis CRUD)."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
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,
)
from app.models import User, Song
router = APIRouter(prefix="/songs", tags=["songs"])
# ── Sange ─────────────────────────────────────────────────────────────────────
class SongOut(BaseModel):
id: str; title: str; artist: str; album: str
bpm: int; duration_sec: int; file_format: str
class Config: from_attributes = True
@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()
@@ -43,67 +27,3 @@ def delete_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(
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()

View File

@@ -0,0 +1,274 @@
"""
sync.py — Push/pull synkronisering mellem lokal app og server.
POST /sync/push — send lokal data op til server
GET /sync/pull — hent server-data ned til app
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import (
User, Song, Dance, DanceLevel, Project, ProjectSong,
PlaylistShare, CommunityDance, CommunityDanceAlt,
)
router = APIRouter(prefix="/sync", tags=["sync"])
# ── Schemas ───────────────────────────────────────────────────────────────────
class SongData(BaseModel):
local_id: str
title: str
artist: str = ""
album: str = ""
bpm: int = 0
duration_sec: int = 0
file_format: str = ""
class DanceData(BaseModel):
name: str
level_name: str = ""
choreographer: str = ""
video_url: str = ""
stepsheet_url: str = ""
notes: str = ""
class SongDanceData(BaseModel):
song_local_id: str
dance_name: str
level_name: str = ""
dance_order: int = 1
class SongAltDanceData(BaseModel):
song_local_id: str
dance_name: str
level_name: str = ""
note: str = ""
class PlaylistSongData(BaseModel):
song_local_id: str
position: int
status: str = "pending"
is_workshop: bool = False
dance_override: str = ""
class PlaylistData(BaseModel):
local_id: str
name: str
description: str = ""
tags: str = ""
visibility: str = "private"
songs: list[PlaylistSongData] = []
class PushPayload(BaseModel):
songs: list[SongData] = []
dances: list[DanceData] = []
song_dances: list[SongDanceData] = []
song_alts: list[SongAltDanceData] = []
playlists: list[PlaylistData] = []
# ── Push ──────────────────────────────────────────────────────────────────────
@router.post("/push")
def push(
payload: PushPayload,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Upload lokal data til server. Returnerer server-IDs."""
song_id_map = {} # local_id → server Song.id
dance_id_map = {} # "name|level" → server Dance.id
level_map = {} # level_name → DanceLevel.id
# ── Dans-niveauer ─────────────────────────────────────────────────────────
for lvl in db.query(DanceLevel).all():
level_map[lvl.name.lower()] = lvl.id
# ── Sange ─────────────────────────────────────────────────────────────────
for s in payload.songs:
if not s.title:
continue
# Match globalt på titel+artist — samme sang deles på tværs af brugere
existing = db.query(Song).filter(
Song.title == s.title,
Song.artist == s.artist,
).first()
if existing:
song_id_map[s.local_id] = existing.id
# Opdater BPM hvis det mangler
if s.bpm and not existing.bpm:
existing.bpm = s.bpm
else:
song = Song(
owner_id=me.id,
title=s.title, artist=s.artist, album=s.album,
bpm=s.bpm, duration_sec=s.duration_sec,
file_format=s.file_format,
)
db.add(song)
db.flush()
song_id_map[s.local_id] = song.id
# ── Danse ──────────────────────────────────────────────────────────────────
for d in payload.dances:
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
key = f"{d.name.lower()}|{level_id}"
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
if existing:
# Opdater info hvis den har ny data
if d.choreographer: existing.choreographer = d.choreographer
if d.video_url: existing.video_url = d.video_url
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
if d.notes: existing.notes = d.notes
dance_id_map[key] = existing.id
else:
dance = Dance(
name=d.name, level_id=level_id,
choreographer=d.choreographer, video_url=d.video_url,
stepsheet_url=d.stepsheet_url, notes=d.notes,
)
db.add(dance)
db.flush()
dance_id_map[key] = dance.id
# ── Community dans-tags ────────────────────────────────────────────────────
for sd in payload.song_dances:
song_id = song_id_map.get(sd.song_local_id)
if not song_id:
continue
song = db.query(Song).filter_by(id=song_id).first()
level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None
key = f"{sd.dance_name.lower()}|{level_id}"
dance_id = dance_id_map.get(key)
if not dance_id:
continue
# Indsend som community dans-tag
existing = db.query(CommunityDance).filter_by(
song_title=song.title, song_artist=song.artist, dance_id=dance_id
).first()
if not existing:
cd = CommunityDance(
song_title=song.title, song_artist=song.artist,
dance_id=dance_id, submitted_by=me.id,
)
db.add(cd)
# ── Playlister ────────────────────────────────────────────────────────────
playlist_id_map = {}
for pl in payload.playlists:
existing = db.query(Project).filter_by(
owner_id=me.id, name=pl.name
).first()
if existing:
existing.description = pl.description
existing.visibility = pl.visibility
# Slet og geninsert sange
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
project = existing
else:
project = Project(
owner_id=me.id, name=pl.name,
description=pl.description, visibility=pl.visibility,
)
db.add(project)
db.flush()
playlist_id_map[pl.local_id] = project.id
for ps in pl.songs:
song_id = song_id_map.get(ps.song_local_id)
if not song_id:
continue
proj_song = ProjectSong(
project_id=project.id, song_id=song_id,
position=ps.position, status=ps.status,
is_workshop=ps.is_workshop,
dance_override=ps.dance_override,
)
db.add(proj_song)
db.commit()
return {
"status": "ok",
"songs_synced": len(song_id_map),
"playlists_synced": len(playlist_id_map),
"song_id_map": song_id_map,
"playlist_id_map": playlist_id_map,
}
# ── Pull ──────────────────────────────────────────────────────────────────────
@router.get("/pull")
def pull(
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent server-data til lokal app."""
# Dans-niveauer
levels = [
{"id": l.id, "name": l.name, "sort_order": l.sort_order}
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
]
# Danse med info
dances = [
{
"name": d.name,
"level_id": d.level_id,
"choreographer": d.choreographer,
"video_url": d.video_url,
"stepsheet_url": d.stepsheet_url,
"notes": d.notes,
"use_count": d.use_count,
}
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
]
# Community dans-tags (populære)
community = []
for cd in db.query(CommunityDance).limit(1000).all():
community.append({
"song_title": cd.song_title,
"song_artist": cd.song_artist,
"dance_id": cd.dance_id,
})
# Delte playlister
shared_ids = [
s.project_id for s in
db.query(PlaylistShare).filter_by(shared_with_id=me.id).all()
]
shared = []
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
shared.append({
"id": p.id,
"name": p.name,
"owner_id": p.owner_id,
"visibility": p.visibility,
"songs": [
{
"song_id": ps.song_id,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override,
}
for ps in p.project_songs
]
})
return {
"levels": levels,
"dances": dances,
"community": community,
"shared": shared,
}