Rettelsaer
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
394
linedance-api/app/routers/sharing.py
Normal file
394
linedance-api/app/routers/sharing.py
Normal 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)}
|
||||
@@ -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()
|
||||
|
||||
274
linedance-api/app/routers/sync.py
Normal file
274
linedance-api/app/routers/sync.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user