fixed some bugs
This commit is contained in:
@@ -12,6 +12,7 @@ FastAPI service for the Android oral-training SDK. It provides:
|
||||
- video catalog and HTTP Range playback;
|
||||
- sentence-boundary lookup compatible with the existing Android SDK;
|
||||
- student recording upload and `asr-fluency-v1` assessment;
|
||||
- signed-in user history for test results and dubbing shares, including deletion;
|
||||
- AI sentence explanation for signed-in mobile users;
|
||||
- the legacy JSON boundary index as a read-only fallback.
|
||||
|
||||
|
||||
@@ -346,6 +346,30 @@ def create_app(
|
||||
def my_dub_shares(user: AuthenticatedUser = Depends(current_user)) -> List[UserDubShareSummary]:
|
||||
return [UserDubShareSummary(**row) for row in video_repository.list_user_dub_shares(user.id)]
|
||||
|
||||
@application.delete("/api/v1/me/results/{attempt_id}", status_code=204)
|
||||
def delete_my_result(
|
||||
attempt_id: str,
|
||||
user: AuthenticatedUser = Depends(current_user),
|
||||
) -> Response:
|
||||
deleted_result = video_repository.delete_user_result(user.id, attempt_id)
|
||||
if deleted_result is None:
|
||||
raise HTTPException(status_code=404, detail="Test result was not found.")
|
||||
if deleted_result["audio_filename"]:
|
||||
(service_settings.attempts_dir / Path(deleted_result["audio_filename"]).name).unlink(missing_ok=True)
|
||||
return Response(status_code=204)
|
||||
|
||||
@application.delete("/api/v1/me/dub-shares/{share_id}", status_code=204)
|
||||
def delete_my_dub_share(
|
||||
share_id: str,
|
||||
user: AuthenticatedUser = Depends(current_user),
|
||||
) -> Response:
|
||||
audio_filenames = video_repository.delete_user_dub_share(user.id, share_id)
|
||||
if audio_filenames is None:
|
||||
raise HTTPException(status_code=404, detail="Dubbing share was not found.")
|
||||
for filename in audio_filenames:
|
||||
(service_settings.dub_shares_dir / Path(filename).name).unlink(missing_ok=True)
|
||||
return Response(status_code=204)
|
||||
|
||||
@application.get("/admin", include_in_schema=False)
|
||||
def admin_page() -> FileResponse:
|
||||
page = static_dir / "admin.html"
|
||||
|
||||
@@ -1118,6 +1118,47 @@ class VideoRepository:
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def delete_user_result(self, user_id: str, attempt_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Delete one attempt owned by the user and return its stored audio filename."""
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT audio_filename FROM attempts
|
||||
WHERE attempt_id = ? AND user_id = ?
|
||||
""",
|
||||
(attempt_id, user_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
connection.execute(
|
||||
"DELETE FROM attempts WHERE attempt_id = ? AND user_id = ?",
|
||||
(attempt_id, user_id),
|
||||
)
|
||||
return {"audio_filename": row["audio_filename"]}
|
||||
|
||||
def delete_user_dub_share(self, user_id: str, share_id: str) -> Optional[List[str]]:
|
||||
"""Delete one dub share owned by the user and return stored audio filenames."""
|
||||
with self._connect() as connection:
|
||||
share = connection.execute(
|
||||
"SELECT share_id FROM dub_shares WHERE share_id = ? AND user_id = ?",
|
||||
(share_id, user_id),
|
||||
).fetchone()
|
||||
if share is None:
|
||||
return None
|
||||
filenames = [
|
||||
row["audio_filename"]
|
||||
for row in connection.execute(
|
||||
"SELECT audio_filename FROM dub_share_segments WHERE share_id = ?",
|
||||
(share_id,),
|
||||
).fetchall()
|
||||
]
|
||||
# Segments are removed by ON DELETE CASCADE.
|
||||
connection.execute(
|
||||
"DELETE FROM dub_shares WHERE share_id = ? AND user_id = ?",
|
||||
(share_id, user_id),
|
||||
)
|
||||
return filenames
|
||||
|
||||
def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]:
|
||||
video = self.get_video(video_hash)
|
||||
if video is None:
|
||||
|
||||
@@ -239,3 +239,68 @@ def test_user_enrollment_results_and_dub_shares_are_scoped(tmp_path):
|
||||
assert len(shares) == 1
|
||||
assert shares[0]["segment_count"] == 1
|
||||
assert shares[0]["average_score"] == 88
|
||||
|
||||
|
||||
def test_user_can_delete_owned_result_and_dub_share(tmp_path):
|
||||
client = make_client(tmp_path)
|
||||
assessed = client.post(
|
||||
f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments",
|
||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
attempt_id = client.get("/api/v1/me/results").json()[0]["attempt_id"]
|
||||
shared = client.post(
|
||||
"/api/v1/dub-shares",
|
||||
data={
|
||||
"video_hash": VIDEO_HASH,
|
||||
"segments": json.dumps([{"sentence_index": 0}]),
|
||||
},
|
||||
files={"files": ("dub.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
share_id = shared.json()["share_id"]
|
||||
|
||||
deleted_result = client.delete(f"/api/v1/me/results/{attempt_id}")
|
||||
deleted_share = client.delete(f"/api/v1/me/dub-shares/{share_id}")
|
||||
|
||||
assert assessed.status_code == 200
|
||||
assert shared.status_code == 201
|
||||
assert deleted_result.status_code == 204
|
||||
assert deleted_share.status_code == 204
|
||||
assert client.get("/api/v1/me/results").json() == []
|
||||
assert client.get("/api/v1/me/dub-shares").json() == []
|
||||
assert client.get(f"/api/v1/dub-shares/{share_id}").status_code == 404
|
||||
|
||||
|
||||
def test_user_cannot_delete_another_users_history(tmp_path):
|
||||
client = make_client(tmp_path)
|
||||
client.post(
|
||||
f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments",
|
||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
attempt_id = client.get("/api/v1/me/results").json()[0]["attempt_id"]
|
||||
shared = client.post(
|
||||
"/api/v1/dub-shares",
|
||||
data={
|
||||
"video_hash": VIDEO_HASH,
|
||||
"segments": json.dumps([{"sentence_index": 0}]),
|
||||
},
|
||||
files={"files": ("dub.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
share_id = shared.json()["share_id"]
|
||||
|
||||
other = TestClient(create_app(
|
||||
BoundaryStore(tmp_path / "legacy.json"),
|
||||
settings=replace(
|
||||
Settings.from_env(),
|
||||
data_dir=tmp_path / "data",
|
||||
legacy_boundaries_file=tmp_path / "legacy.json",
|
||||
),
|
||||
))
|
||||
registered = other.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "other", "password": "password-123"},
|
||||
)
|
||||
other.headers.update({"Authorization": f"Bearer {registered.json()['token']}"})
|
||||
|
||||
assert registered.status_code == 201
|
||||
assert other.delete(f"/api/v1/me/results/{attempt_id}").status_code == 404
|
||||
assert other.delete(f"/api/v1/me/dub-shares/{share_id}").status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user