Fix backend imports and clip pinning flow
This commit is contained in:
@@ -7,6 +7,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs &
|
||||
COPY pyproject.toml /app/pyproject.toml
|
||||
COPY requirements.txt requirements-dev.txt /app/
|
||||
COPY app /app/app
|
||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
RUN pip install --no-cache-dir -e .[dev]
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -55,6 +55,7 @@ class AudioClip(Base):
|
||||
label: Mapped[str] = mapped_column(String(255))
|
||||
start_ms: Mapped[int] = mapped_column(Integer)
|
||||
end_ms: Mapped[int] = mapped_column(Integer)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
normalization_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
normalized_path: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
@@ -65,7 +66,7 @@ class AudioClip(Base):
|
||||
class GameAssignment(Base):
|
||||
__tablename__ = "game_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("external_game_id", "external_player_id", "clip_id", name="uq_game_assignment_player_clip"),
|
||||
UniqueConstraint("external_game_id", "clip_id", name="uq_game_assignment_game_clip"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import require_session
|
||||
@@ -40,6 +40,24 @@ def assignment_to_response(assignment: GameAssignment) -> GameAssignmentResponse
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pins", response_model=list[GameAssignmentResponse])
|
||||
def list_pins(
|
||||
external_player_id: str | None = Query(default=None),
|
||||
session: UserSession = Depends(require_session),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[GameAssignmentResponse]:
|
||||
player_id = external_player_id or session.external_player_id
|
||||
if not player_id or not session.external_team_id:
|
||||
raise HTTPException(status_code=422, detail="Provide a player to list pins")
|
||||
|
||||
query = select(GameAssignment).join(GameAssignment.clip).where(
|
||||
GameAssignment.external_team_id == session.external_team_id,
|
||||
GameAssignment.external_player_id == player_id,
|
||||
)
|
||||
pins = db.scalars(query.order_by(GameAssignment.external_game_id.asc(), AudioClip.sort_order.asc())).all()
|
||||
return [assignment_to_response(assignment) for assignment in pins]
|
||||
|
||||
|
||||
@router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse])
|
||||
def list_assignments(
|
||||
external_game_id: str,
|
||||
@@ -47,10 +65,10 @@ def list_assignments(
|
||||
_: UserSession = Depends(require_session),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[GameAssignmentResponse]:
|
||||
query = select(GameAssignment).where(GameAssignment.external_game_id == external_game_id)
|
||||
query = select(GameAssignment).join(GameAssignment.clip).where(GameAssignment.external_game_id == external_game_id)
|
||||
if external_player_id:
|
||||
query = query.where(GameAssignment.external_player_id == external_player_id)
|
||||
assignments = db.scalars(query.order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc())).all()
|
||||
assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all()
|
||||
return [assignment_to_response(assignment) for assignment in assignments]
|
||||
|
||||
|
||||
@@ -67,12 +85,11 @@ def create_assignment(
|
||||
if clip.asset.external_team_id != payload.external_team_id:
|
||||
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
|
||||
if clip.asset.owner_external_player_id != payload.external_player_id:
|
||||
raise HTTPException(status_code=403, detail="You can only attach clips owned by that player")
|
||||
raise HTTPException(status_code=403, detail="You can only pin clips owned by that player")
|
||||
|
||||
assignment = db.scalar(
|
||||
select(GameAssignment).where(
|
||||
GameAssignment.external_game_id == external_game_id,
|
||||
GameAssignment.external_player_id == payload.external_player_id,
|
||||
GameAssignment.clip_id == payload.clip_id,
|
||||
)
|
||||
)
|
||||
@@ -88,6 +105,7 @@ def create_assignment(
|
||||
db.add(assignment)
|
||||
else:
|
||||
assignment.external_team_id = payload.external_team_id
|
||||
assignment.external_player_id = payload.external_player_id
|
||||
assignment.clip_id = payload.clip_id
|
||||
assignment.batting_slot = payload.batting_slot
|
||||
assignment.status = payload.status
|
||||
@@ -96,6 +114,25 @@ def create_assignment(
|
||||
return assignment_to_response(assignment)
|
||||
|
||||
|
||||
@router.delete("/{external_game_id}/assignments/{assignment_id}", status_code=204)
|
||||
def delete_assignment(
|
||||
external_game_id: str,
|
||||
assignment_id: int,
|
||||
external_player_id: str | None = Query(default=None),
|
||||
_: UserSession = Depends(require_session),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
assignment = db.get(GameAssignment, assignment_id)
|
||||
if assignment is None or assignment.external_game_id != external_game_id:
|
||||
raise HTTPException(status_code=404, detail="Pin not found")
|
||||
if external_player_id is not None and assignment.external_player_id != external_player_id:
|
||||
raise HTTPException(status_code=403, detail="Pin does not belong to that player")
|
||||
|
||||
db.execute(update(PlaybackSession).where(PlaybackSession.current_assignment_id == assignment.id).values(current_assignment_id=None))
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{external_game_id}/prep", response_model=GamePrepResponse)
|
||||
def prepare_game(
|
||||
external_game_id: str,
|
||||
@@ -104,8 +141,9 @@ def prepare_game(
|
||||
) -> GamePrepResponse:
|
||||
assignments = db.scalars(
|
||||
select(GameAssignment)
|
||||
.join(GameAssignment.clip)
|
||||
.where(GameAssignment.external_game_id == external_game_id)
|
||||
.order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc())
|
||||
.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())
|
||||
).all()
|
||||
external_team_id = assignments[0].external_team_id if assignments else ""
|
||||
return GamePrepResponse(
|
||||
@@ -148,12 +186,12 @@ def trigger_playback(
|
||||
raise HTTPException(status_code=404, detail="Playback session not found")
|
||||
|
||||
if payload.assignment_id is None and payload.clip_id is None:
|
||||
raise HTTPException(status_code=422, detail="Provide an assignment or clip to trigger")
|
||||
raise HTTPException(status_code=422, detail="Provide a pin or clip to trigger")
|
||||
|
||||
if payload.assignment_id is not None:
|
||||
assignment = db.get(GameAssignment, payload.assignment_id)
|
||||
if assignment is None or assignment.external_game_id != external_game_id:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
raise HTTPException(status_code=404, detail="Pin not found")
|
||||
playback.current_assignment_id = assignment.id
|
||||
else:
|
||||
clip = db.get(AudioClip, payload.clip_id)
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import require_session
|
||||
@@ -19,6 +19,7 @@ from ..schemas import (
|
||||
AudioClipCreate,
|
||||
AudioClipResponse,
|
||||
AudioClipUpdate,
|
||||
AudioClipReorder,
|
||||
)
|
||||
from ..storage import storage
|
||||
|
||||
@@ -39,6 +40,7 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse:
|
||||
label=clip.label,
|
||||
start_ms=clip.start_ms,
|
||||
end_ms=clip.end_ms,
|
||||
sort_order=clip.sort_order,
|
||||
normalization_status=clip.normalization_status,
|
||||
normalized_url=normalized_url,
|
||||
waveform_duration_ms=waveform["duration_ms"] if waveform else None,
|
||||
@@ -53,6 +55,18 @@ def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_pla
|
||||
return owner_external_player_id is not None and asset.owner_external_player_id == owner_external_player_id
|
||||
|
||||
|
||||
def next_clip_sort_order(db: Session, *, external_team_id: str, owner_external_player_id: str) -> int:
|
||||
highest_sort_order = db.scalar(
|
||||
select(func.max(AudioClip.sort_order))
|
||||
.join(AudioClip.asset)
|
||||
.where(
|
||||
AudioAsset.external_team_id == external_team_id,
|
||||
AudioAsset.owner_external_player_id == owner_external_player_id,
|
||||
)
|
||||
)
|
||||
return highest_sort_order + 1 if highest_sort_order is not None else 0
|
||||
|
||||
|
||||
def create_asset_with_default_clip(
|
||||
*,
|
||||
db: Session,
|
||||
@@ -83,6 +97,11 @@ def create_asset_with_default_clip(
|
||||
label=asset.title,
|
||||
start_ms=0,
|
||||
end_ms=DEFAULT_CLIP_LENGTH_MS,
|
||||
sort_order=next_clip_sort_order(
|
||||
db,
|
||||
external_team_id=external_team_id,
|
||||
owner_external_player_id=owner_external_player_id,
|
||||
),
|
||||
normalization_status="processing",
|
||||
)
|
||||
db.add(clip)
|
||||
@@ -282,6 +301,11 @@ def create_clip(
|
||||
label=payload.label,
|
||||
start_ms=payload.start_ms,
|
||||
end_ms=payload.end_ms,
|
||||
sort_order=next_clip_sort_order(
|
||||
db,
|
||||
external_team_id=payload.external_team_id,
|
||||
owner_external_player_id=payload.owner_external_player_id,
|
||||
),
|
||||
normalization_status="processing",
|
||||
)
|
||||
db.add(clip)
|
||||
@@ -314,6 +338,8 @@ def update_clip(
|
||||
clip.label = payload.label or clip.label
|
||||
clip.start_ms = payload.start_ms
|
||||
clip.end_ms = payload.end_ms
|
||||
if payload.sort_order is not None:
|
||||
clip.sort_order = payload.sort_order
|
||||
db.commit()
|
||||
db.refresh(clip)
|
||||
return clip_to_response(clip)
|
||||
@@ -357,7 +383,7 @@ def list_clips(
|
||||
select(AudioClip)
|
||||
.join(AudioClip.asset)
|
||||
.where(AudioAsset.external_team_id == external_team_id)
|
||||
.order_by(AudioClip.created_at.desc())
|
||||
.order_by(AudioClip.sort_order.asc(), AudioClip.created_at.desc())
|
||||
)
|
||||
if owner_external_player_id:
|
||||
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
|
||||
@@ -365,6 +391,33 @@ def list_clips(
|
||||
return [clip_to_response(clip) for clip in clips]
|
||||
|
||||
|
||||
@router.post("/clips/reorder", status_code=204)
|
||||
def reorder_clips(
|
||||
payload: AudioClipReorder,
|
||||
session: UserSession = Depends(require_session),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
if not session.is_admin and session.external_team_id != payload.external_team_id:
|
||||
raise HTTPException(status_code=403, detail="You can only reorder clips for your selected team")
|
||||
|
||||
clips = db.scalars(
|
||||
select(AudioClip)
|
||||
.join(AudioClip.asset)
|
||||
.where(
|
||||
AudioAsset.external_team_id == payload.external_team_id,
|
||||
AudioAsset.owner_external_player_id == payload.owner_external_player_id,
|
||||
)
|
||||
).all()
|
||||
clips_by_id = {clip.id: clip for clip in clips}
|
||||
if len(clips_by_id) != len(clips) or set(clips_by_id) != set(payload.clip_ids):
|
||||
raise HTTPException(status_code=422, detail="Clip order must include every clip for that player")
|
||||
|
||||
for sort_order, clip_id in enumerate(payload.clip_ids):
|
||||
clips_by_id[clip_id].sort_order = sort_order
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/files/{relative_path:path}")
|
||||
def media_file(relative_path: str) -> FileResponse:
|
||||
path = storage.absolute_path(relative_path)
|
||||
|
||||
@@ -67,6 +67,7 @@ class AudioClipUpdate(BaseModel):
|
||||
label: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
start_ms: int = Field(ge=0)
|
||||
end_ms: int = Field(gt=0)
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class AudioClipResponse(BaseModel):
|
||||
@@ -78,6 +79,7 @@ class AudioClipResponse(BaseModel):
|
||||
label: str
|
||||
start_ms: int
|
||||
end_ms: int
|
||||
sort_order: int
|
||||
normalization_status: str
|
||||
normalized_url: str | None
|
||||
waveform_duration_ms: int | None = None
|
||||
@@ -116,6 +118,12 @@ class GamePrepResponse(BaseModel):
|
||||
assignments: list[GameAssignmentResponse]
|
||||
|
||||
|
||||
class AudioClipReorder(BaseModel):
|
||||
external_team_id: str
|
||||
owner_external_player_id: str
|
||||
clip_ids: list[int] = Field(min_length=1)
|
||||
|
||||
|
||||
class PlaybackSessionCreate(BaseModel):
|
||||
external_team_id: str
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ def test_walkup_session_selection_is_persisted_in_session() -> None:
|
||||
assert response.json()["external_player_id"] == "player-1002"
|
||||
|
||||
|
||||
def test_player_can_attach_multiple_clips_to_same_game() -> None:
|
||||
def test_player_can_pin_a_clip_to_multiple_games_independently() -> None:
|
||||
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
|
||||
assert login.status_code == 200
|
||||
|
||||
@@ -159,18 +159,9 @@ def test_player_can_attach_multiple_clips_to_same_game() -> None:
|
||||
normalization_status="ready",
|
||||
normalized_path="clips/intro.mp3",
|
||||
)
|
||||
second_clip = AudioClip(
|
||||
asset_id=asset.id,
|
||||
label="Chorus",
|
||||
start_ms=12000,
|
||||
end_ms=22000,
|
||||
normalization_status="ready",
|
||||
normalized_path="clips/chorus.mp3",
|
||||
)
|
||||
db.add_all([first_clip, second_clip])
|
||||
db.add(first_clip)
|
||||
db.commit()
|
||||
db.refresh(first_clip)
|
||||
db.refresh(second_clip)
|
||||
db.close()
|
||||
|
||||
first_response = client.post(
|
||||
@@ -183,28 +174,68 @@ def test_player_can_attach_multiple_clips_to_same_game() -> None:
|
||||
"status": "ready",
|
||||
},
|
||||
)
|
||||
second_response = client.post(
|
||||
"/games/game-1/assignments",
|
||||
second_game_response = client.post(
|
||||
"/games/game-2/assignments",
|
||||
json={
|
||||
"external_team_id": "team-1",
|
||||
"external_player_id": "player-1",
|
||||
"clip_id": second_clip.id,
|
||||
"clip_id": first_clip.id,
|
||||
"batting_slot": 1,
|
||||
"status": "ready",
|
||||
},
|
||||
)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
assert second_response.status_code == 200
|
||||
assert second_game_response.status_code == 200
|
||||
assert first_response.json()["start_ms"] == 0
|
||||
assert first_response.json()["end_ms"] == 10000
|
||||
assert second_response.json()["start_ms"] == 12000
|
||||
assert second_response.json()["end_ms"] == 22000
|
||||
|
||||
assignments = client.get("/games/game-1/assignments")
|
||||
assert assignments.status_code == 200
|
||||
assignment_ids = [item["clip_id"] for item in assignments.json()]
|
||||
assert assignment_ids == [second_clip.id, first_clip.id]
|
||||
game_one_assignments = client.get("/games/game-1/assignments")
|
||||
game_two_assignments = client.get("/games/game-2/assignments")
|
||||
assert game_one_assignments.status_code == 200
|
||||
assert game_two_assignments.status_code == 200
|
||||
assert [item["clip_id"] for item in game_one_assignments.json()] == [first_clip.id]
|
||||
assert [item["clip_id"] for item in game_two_assignments.json()] == [first_clip.id]
|
||||
|
||||
db = SessionLocal()
|
||||
session = db.query(UserSession).filter_by(session_token="admin-session").one_or_none()
|
||||
if session is None:
|
||||
session = UserSession(
|
||||
session_token="admin-session",
|
||||
provider="teamsnap",
|
||||
external_team_id="team-1",
|
||||
external_player_id="player-1",
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
else:
|
||||
session.external_team_id = "team-1"
|
||||
session.external_player_id = "player-1"
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
client.cookies.set(settings.session_cookie_name, "admin-session")
|
||||
pins_before_delete = client.get("/games/pins", params={"external_player_id": "player-1"})
|
||||
assert pins_before_delete.status_code == 200
|
||||
assert [item["clip_id"] for item in pins_before_delete.json()] == [first_clip.id, first_clip.id]
|
||||
|
||||
delete_response = client.delete(
|
||||
f"/games/game-1/assignments/{first_response.json()['id']}",
|
||||
params={"external_player_id": "player-1"},
|
||||
)
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
game_one_after_delete = client.get("/games/game-1/assignments")
|
||||
game_two_after_delete = client.get("/games/game-2/assignments")
|
||||
assert game_one_after_delete.status_code == 200
|
||||
assert game_two_after_delete.status_code == 200
|
||||
assert game_one_after_delete.json() == []
|
||||
assert [item["clip_id"] for item in game_two_after_delete.json()] == [first_clip.id]
|
||||
|
||||
client.cookies.set(settings.session_cookie_name, "admin-session")
|
||||
pins = client.get("/games/pins", params={"external_player_id": "player-1"})
|
||||
assert pins.status_code == 200
|
||||
assert [item["clip_id"] for item in pins.json()] == [first_clip.id]
|
||||
|
||||
|
||||
def test_upload_creates_default_clip_and_clip_ranges_can_be_updated() -> None:
|
||||
@@ -248,6 +279,73 @@ def test_upload_creates_default_clip_and_clip_ranges_can_be_updated() -> None:
|
||||
assert updated_clip["label"] == "Fresh track"
|
||||
|
||||
|
||||
def test_player_can_reorder_clips_in_their_library() -> None:
|
||||
db = SessionLocal()
|
||||
session = UserSession(
|
||||
session_token="player-session",
|
||||
provider="teamsnap",
|
||||
external_team_id="team-9",
|
||||
external_player_id="player-9",
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
client.cookies.set(settings.session_cookie_name, "player-session")
|
||||
|
||||
asset = AudioAsset(
|
||||
external_team_id="team-9",
|
||||
owner_external_player_id="player-9",
|
||||
title="Song",
|
||||
original_filename="song.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
size_bytes=123,
|
||||
storage_path="uploads/song.mp3",
|
||||
)
|
||||
db = SessionLocal()
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
first_clip = AudioClip(
|
||||
asset_id=asset.id,
|
||||
label="Intro",
|
||||
start_ms=0,
|
||||
end_ms=10000,
|
||||
sort_order=0,
|
||||
normalization_status="ready",
|
||||
normalized_path="clips/intro.mp3",
|
||||
)
|
||||
second_clip = AudioClip(
|
||||
asset_id=asset.id,
|
||||
label="Chorus",
|
||||
start_ms=12000,
|
||||
end_ms=22000,
|
||||
sort_order=1,
|
||||
normalization_status="ready",
|
||||
normalized_path="clips/chorus.mp3",
|
||||
)
|
||||
db.add_all([first_clip, second_clip])
|
||||
db.commit()
|
||||
db.refresh(first_clip)
|
||||
db.refresh(second_clip)
|
||||
db.close()
|
||||
|
||||
reorder = client.post(
|
||||
"/media/clips/reorder",
|
||||
json={
|
||||
"external_team_id": "team-9",
|
||||
"owner_external_player_id": "player-9",
|
||||
"clip_ids": [second_clip.id, first_clip.id],
|
||||
},
|
||||
)
|
||||
|
||||
assert reorder.status_code == 204
|
||||
|
||||
clips = client.get("/media/clips", params={"external_team_id": "team-9", "owner_external_player_id": "player-9"})
|
||||
assert clips.status_code == 200
|
||||
assert [item["id"] for item in clips.json()] == [second_clip.id, first_clip.id]
|
||||
assert [item["sort_order"] for item in clips.json()] == [0, 1]
|
||||
|
||||
|
||||
def test_clip_updates_can_use_player_scoped_authorization() -> None:
|
||||
uploader_session = UserSession(session_token="uploader-session", provider="teamsnap")
|
||||
editor_session = UserSession(session_token="editor-session", provider="teamsnap")
|
||||
|
||||
Reference in New Issue
Block a user