Fix backend imports and clip pinning flow

This commit is contained in:
Codex
2026-04-22 07:48:12 -05:00
parent 45c2b46304
commit ec73156966
12 changed files with 736 additions and 156 deletions

View File

@@ -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)

View File

@@ -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)