Add clip pinning test coverage

This commit is contained in:
Codex
2026-04-22 08:33:47 -05:00
parent 0a13aedbef
commit 58efca9a9f
9 changed files with 124 additions and 6 deletions

View File

@@ -56,6 +56,7 @@ class AudioClip(Base):
start_ms: Mapped[int] = mapped_column(Integer) start_ms: Mapped[int] = mapped_column(Integer)
end_ms: Mapped[int] = mapped_column(Integer) end_ms: Mapped[int] = mapped_column(Integer)
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True) sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
hidden: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
normalization_status: Mapped[str] = mapped_column(String(32), default="pending") normalization_status: Mapped[str] = mapped_column(String(32), default="pending")
normalized_path: Mapped[str | None] = mapped_column(String(512)) normalized_path: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)

View File

@@ -65,7 +65,10 @@ def list_assignments(
_: UserSession = Depends(require_session), _: UserSession = Depends(require_session),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[GameAssignmentResponse]: ) -> list[GameAssignmentResponse]:
query = select(GameAssignment).join(GameAssignment.clip).where(GameAssignment.external_game_id == external_game_id) query = select(GameAssignment).join(GameAssignment.clip).where(
GameAssignment.external_game_id == external_game_id,
AudioClip.hidden.is_(False),
)
if external_player_id: if external_player_id:
query = query.where(GameAssignment.external_player_id == external_player_id) query = query.where(GameAssignment.external_player_id == external_player_id)
assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all() assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all()
@@ -82,6 +85,8 @@ def create_assignment(
clip = db.get(AudioClip, payload.clip_id) clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.normalization_status != "ready": if clip is None or clip.normalization_status != "ready":
raise HTTPException(status_code=422, detail="Clip is not ready") raise HTTPException(status_code=422, detail="Clip is not ready")
if clip.hidden:
raise HTTPException(status_code=404, detail="Clip not found")
if clip.asset.external_team_id != payload.external_team_id: if clip.asset.external_team_id != payload.external_team_id:
raise HTTPException(status_code=422, detail="Clip does not belong to this team") raise HTTPException(status_code=422, detail="Clip does not belong to this team")
if clip.asset.owner_external_player_id != payload.external_player_id: if clip.asset.owner_external_player_id != payload.external_player_id:
@@ -142,7 +147,7 @@ def prepare_game(
assignments = db.scalars( assignments = db.scalars(
select(GameAssignment) select(GameAssignment)
.join(GameAssignment.clip) .join(GameAssignment.clip)
.where(GameAssignment.external_game_id == external_game_id) .where(GameAssignment.external_game_id == external_game_id, AudioClip.hidden.is_(False))
.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc()) .order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())
).all() ).all()
external_team_id = assignments[0].external_team_id if assignments else "" external_team_id = assignments[0].external_team_id if assignments else ""
@@ -192,11 +197,15 @@ def trigger_playback(
assignment = db.get(GameAssignment, payload.assignment_id) assignment = db.get(GameAssignment, payload.assignment_id)
if assignment is None or assignment.external_game_id != external_game_id: if assignment is None or assignment.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Pin not found") raise HTTPException(status_code=404, detail="Pin not found")
if assignment.clip.hidden:
raise HTTPException(status_code=404, detail="Pin not found")
playback.current_assignment_id = assignment.id playback.current_assignment_id = assignment.id
else: else:
clip = db.get(AudioClip, payload.clip_id) clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.asset.external_team_id != playback.external_team_id: if clip is None or clip.asset.external_team_id != playback.external_team_id:
raise HTTPException(status_code=404, detail="Clip not found") raise HTTPException(status_code=404, detail="Clip not found")
if clip.hidden:
raise HTTPException(status_code=404, detail="Clip not found")
if payload.external_player_id and clip.asset.owner_external_player_id != payload.external_player_id: if payload.external_player_id and clip.asset.owner_external_player_id != payload.external_player_id:
raise HTTPException(status_code=403, detail="Clip does not belong to that player") raise HTTPException(status_code=403, detail="Clip does not belong to that player")
playback.current_assignment_id = None playback.current_assignment_id = None

View File

@@ -41,6 +41,7 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse:
start_ms=clip.start_ms, start_ms=clip.start_ms,
end_ms=clip.end_ms, end_ms=clip.end_ms,
sort_order=clip.sort_order, sort_order=clip.sort_order,
hidden=clip.hidden,
normalization_status=clip.normalization_status, normalization_status=clip.normalization_status,
normalized_url=normalized_url, normalized_url=normalized_url,
waveform_duration_ms=waveform["duration_ms"] if waveform else None, waveform_duration_ms=waveform["duration_ms"] if waveform else None,
@@ -340,6 +341,8 @@ def update_clip(
clip.end_ms = payload.end_ms clip.end_ms = payload.end_ms
if payload.sort_order is not None: if payload.sort_order is not None:
clip.sort_order = payload.sort_order clip.sort_order = payload.sort_order
if payload.hidden is not None:
clip.hidden = payload.hidden
db.commit() db.commit()
db.refresh(clip) db.refresh(clip)
return clip_to_response(clip) return clip_to_response(clip)
@@ -376,6 +379,7 @@ def delete_clip(
def list_clips( def list_clips(
external_team_id: str, external_team_id: str,
owner_external_player_id: str | None = None, owner_external_player_id: str | None = None,
include_hidden: bool = False,
_: UserSession = Depends(require_session), _: UserSession = Depends(require_session),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[AudioClipResponse]: ) -> list[AudioClipResponse]:
@@ -387,6 +391,8 @@ def list_clips(
) )
if owner_external_player_id: if owner_external_player_id:
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id) query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
if not include_hidden:
query = query.where(AudioClip.hidden.is_(False))
clips = db.scalars(query).all() clips = db.scalars(query).all()
return [clip_to_response(clip) for clip in clips] return [clip_to_response(clip) for clip in clips]

View File

@@ -68,6 +68,7 @@ class AudioClipUpdate(BaseModel):
start_ms: int = Field(ge=0) start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0) end_ms: int = Field(gt=0)
sort_order: int | None = None sort_order: int | None = None
hidden: bool | None = None
class AudioClipResponse(BaseModel): class AudioClipResponse(BaseModel):
@@ -80,6 +81,7 @@ class AudioClipResponse(BaseModel):
start_ms: int start_ms: int
end_ms: int end_ms: int
sort_order: int sort_order: int
hidden: bool
normalization_status: str normalization_status: str
normalized_url: str | None normalized_url: str | None
waveform_duration_ms: int | None = None waveform_duration_ms: int | None = None

View File

@@ -346,6 +346,104 @@ def test_player_can_reorder_clips_in_their_library() -> None:
assert [item["sort_order"] for item in clips.json()] == [0, 1] assert [item["sort_order"] for item in clips.json()] == [0, 1]
def test_hidden_clips_are_removed_from_gameday_views_but_remain_pinnable() -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
db = SessionLocal()
session = UserSession(
session_token="player-hidden-session",
provider="teamsnap",
external_team_id="team-hidden",
external_player_id="player-hidden",
)
db.add(session)
db.flush()
asset = AudioAsset(
external_team_id="team-hidden",
owner_external_player_id="player-hidden",
uploaded_by_session_id=session.id,
title="Hidden song",
original_filename="hidden.mp3",
mime_type="audio/mpeg",
size_bytes=123,
storage_path="uploads/hidden.mp3",
)
db.add(asset)
db.flush()
clip = AudioClip(
asset_id=asset.id,
label="Hidden clip",
start_ms=0,
end_ms=10000,
normalization_status="ready",
normalized_path="clips/hidden.mp3",
)
db.add(clip)
db.commit()
db.refresh(clip)
db.close()
client.cookies.set(settings.session_cookie_name, "player-hidden-session")
pin_response = client.post(
"/games/game-hidden/assignments",
json={
"external_team_id": "team-hidden",
"external_player_id": "player-hidden",
"clip_id": clip.id,
"batting_slot": 1,
"status": "ready",
},
)
assert pin_response.status_code == 200
visible_before_hide = client.get(
"/media/clips",
params={"external_team_id": "team-hidden", "owner_external_player_id": "player-hidden"},
)
assert visible_before_hide.status_code == 200
assert [item["id"] for item in visible_before_hide.json()] == [clip.id]
assert visible_before_hide.json()[0]["hidden"] is False
hide_response = client.patch(
f"/media/clips/{clip.id}",
params={"owner_external_player_id": "player-hidden"},
json={"start_ms": 0, "end_ms": 10000, "hidden": True},
)
assert hide_response.status_code == 200
assert hide_response.json()["hidden"] is True
visible_after_hide = client.get(
"/media/clips",
params={"external_team_id": "team-hidden", "owner_external_player_id": "player-hidden"},
)
assert visible_after_hide.status_code == 200
assert visible_after_hide.json() == []
hidden_in_library = client.get(
"/media/clips",
params={
"external_team_id": "team-hidden",
"owner_external_player_id": "player-hidden",
"include_hidden": True,
},
)
assert hidden_in_library.status_code == 200
assert [item["id"] for item in hidden_in_library.json()] == [clip.id]
assert hidden_in_library.json()[0]["hidden"] is True
game_assignments = client.get("/games/game-hidden/assignments")
prep = client.get("/games/game-hidden/prep")
pins = client.get("/games/pins", params={"external_player_id": "player-hidden"})
assert game_assignments.status_code == 200
assert prep.status_code == 200
assert pins.status_code == 200
assert game_assignments.json() == []
assert prep.json()["assignments"] == []
assert [item["clip_id"] for item in pins.json()] == [clip.id]
def test_clip_updates_can_use_player_scoped_authorization() -> None: def test_clip_updates_can_use_player_scoped_authorization() -> None:
uploader_session = UserSession(session_token="uploader-session", provider="teamsnap") uploader_session = UserSession(session_token="uploader-session", provider="teamsnap")
editor_session = UserSession(session_token="editor-session", provider="teamsnap") editor_session = UserSession(session_token="editor-session", provider="teamsnap")

View File

@@ -131,11 +131,11 @@ export const api = {
throw new Error(await response.text()); throw new Error(await response.text());
} }
}, },
listClips: (teamId: string, playerId?: string) => listClips: (teamId: string, playerId?: string, includeHidden = false) =>
request<AudioClip[]>( request<AudioClip[]>(
`/media/clips?external_team_id=${encodeURIComponent(teamId)}${ `/media/clips?external_team_id=${encodeURIComponent(teamId)}${
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : "" playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
}`, }${includeHidden ? "&include_hidden=true" : ""}`,
), ),
createClip: (payload: { createClip: (payload: {
asset_id: number; asset_id: number;

View File

@@ -47,6 +47,7 @@ export interface AudioClip {
start_ms: number; start_ms: number;
end_ms: number; end_ms: number;
sort_order: number; sort_order: number;
hidden: boolean;
normalization_status: string; normalization_status: string;
normalized_url?: string | null; normalized_url?: string | null;
waveform_duration_ms?: number | null; waveform_duration_ms?: number | null;
@@ -59,6 +60,7 @@ export interface AudioClipUpdate {
end_ms: number; end_ms: number;
label?: string; label?: string;
sort_order?: number | null; sort_order?: number | null;
hidden?: boolean | null;
} }
export interface GameAssignment { export interface GameAssignment {

View File

@@ -30,7 +30,7 @@ export function GamePage() {
}, [searchParams, selectedGameId, walkup.nextGame]); }, [searchParams, selectedGameId, walkup.nextGame]);
const clipsQuery = useQuery({ const clipsQuery = useQuery({
queryKey: ["clips", teamId, playerId], queryKey: ["clips", teamId, playerId, "visible"],
queryFn: () => api.listClips(teamId, playerId), queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId), enabled: Boolean(teamId && playerId),
}); });

View File

@@ -653,7 +653,7 @@ function LibraryClips({
pinnedAssignmentsByClipId: Map<string, GameAssignment>; pinnedAssignmentsByClipId: Map<string, GameAssignment>;
}) { }) {
const fallbackClipsQuery = useQuery({ const fallbackClipsQuery = useQuery({
queryKey: ["clips", teamId, playerId], queryKey: ["clips", teamId, playerId, "visible"],
queryFn: () => api.listClips(teamId, playerId), queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId), enabled: Boolean(teamId && playerId),
}); });