mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-10 07:36:54 +00:00
feat: show trash for soft deleted transcripts and hard delete option (#942)
* feat: show trash for soft deleted transcripts and hard delete option * fix: test fixtures * docs: aws new permissions
This commit is contained in:
committed by
GitHub
parent
cc9c5cd4a5
commit
ec8b49738e
@@ -78,6 +78,14 @@ class RecordingController:
|
||||
)
|
||||
await get_database().execute(query)
|
||||
|
||||
async def restore_by_id(self, id: str) -> None:
|
||||
query = recordings.update().where(recordings.c.id == id).values(deleted_at=None)
|
||||
await get_database().execute(query)
|
||||
|
||||
async def hard_delete_by_id(self, id: str) -> None:
|
||||
query = recordings.delete().where(recordings.c.id == id)
|
||||
await get_database().execute(query)
|
||||
|
||||
async def set_meeting_id(
|
||||
self,
|
||||
recording_id: NonEmptyString,
|
||||
|
||||
@@ -138,6 +138,7 @@ class SearchParameters(BaseModel):
|
||||
source_kind: SourceKind | None = None
|
||||
from_datetime: datetime | None = None
|
||||
to_datetime: datetime | None = None
|
||||
include_deleted: bool = False
|
||||
|
||||
|
||||
class SearchResultDB(BaseModel):
|
||||
@@ -387,7 +388,10 @@ class SearchController:
|
||||
transcripts.join(rooms, transcripts.c.room_id == rooms.c.id, isouter=True)
|
||||
)
|
||||
|
||||
base_query = base_query.where(transcripts.c.deleted_at.is_(None))
|
||||
if params.include_deleted:
|
||||
base_query = base_query.where(transcripts.c.deleted_at.isnot(None))
|
||||
else:
|
||||
base_query = base_query.where(transcripts.c.deleted_at.is_(None))
|
||||
|
||||
if params.query_text is not None:
|
||||
# because already initialized based on params.query_text presence above
|
||||
@@ -396,7 +400,13 @@ class SearchController:
|
||||
transcripts.c.search_vector_en.op("@@")(search_query)
|
||||
)
|
||||
|
||||
if params.user_id:
|
||||
if params.include_deleted:
|
||||
# Trash view: only show user's own deleted transcripts.
|
||||
# Defense-in-depth: require user_id to prevent leaking all users' trash.
|
||||
if not params.user_id:
|
||||
return [], 0
|
||||
base_query = base_query.where(transcripts.c.user_id == params.user_id)
|
||||
elif params.user_id:
|
||||
base_query = base_query.where(
|
||||
sqlalchemy.or_(
|
||||
transcripts.c.user_id == params.user_id, rooms.c.is_shared
|
||||
@@ -421,6 +431,8 @@ class SearchController:
|
||||
|
||||
if params.query_text is not None:
|
||||
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
|
||||
elif params.include_deleted:
|
||||
order_by = sqlalchemy.desc(transcripts.c.deleted_at)
|
||||
else:
|
||||
order_by = sqlalchemy.desc(transcripts.c.created_at)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from reflector.db.utils import is_postgresql
|
||||
from reflector.logger import logger
|
||||
from reflector.processors.types import Word as ProcessorWord
|
||||
from reflector.settings import settings
|
||||
from reflector.storage import get_transcripts_storage
|
||||
from reflector.storage import get_source_storage, get_transcripts_storage
|
||||
from reflector.utils import generate_uuid4
|
||||
from reflector.utils.webvtt import topics_to_webvtt
|
||||
|
||||
@@ -676,6 +676,126 @@ class TranscriptController:
|
||||
)
|
||||
await get_database().execute(query)
|
||||
|
||||
async def restore_by_id(
|
||||
self,
|
||||
transcript_id: str,
|
||||
user_id: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Restore a soft-deleted transcript by clearing deleted_at.
|
||||
|
||||
Also restores the associated recording if present.
|
||||
Returns True if the transcript was restored, False otherwise.
|
||||
"""
|
||||
transcript = await self.get_by_id(transcript_id)
|
||||
if not transcript:
|
||||
return False
|
||||
if transcript.deleted_at is None:
|
||||
return False
|
||||
if user_id is not None and transcript.user_id != user_id:
|
||||
return False
|
||||
|
||||
query = (
|
||||
transcripts.update()
|
||||
.where(transcripts.c.id == transcript_id)
|
||||
.values(deleted_at=None)
|
||||
)
|
||||
await get_database().execute(query)
|
||||
|
||||
if transcript.recording_id:
|
||||
try:
|
||||
await recordings_controller.restore_by_id(transcript.recording_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to restore recording",
|
||||
exc_info=e,
|
||||
recording_id=transcript.recording_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def hard_delete(self, transcript_id: str) -> None:
|
||||
"""
|
||||
Permanently delete a transcript, its recording, and all associated files.
|
||||
|
||||
Only deletes transcript-owned resources:
|
||||
- Transcript row and recording row from DB (first, to make data inaccessible)
|
||||
- Transcript audio in S3 storage
|
||||
- Recording files in S3 (both object_key and track_keys, since a recording can have both)
|
||||
- Local files (data_path directory)
|
||||
|
||||
Does NOT delete: meetings, consent records, rooms, or any shared entity.
|
||||
Requires the transcript to be soft-deleted first (deleted_at must be set).
|
||||
"""
|
||||
transcript = await self.get_by_id(transcript_id)
|
||||
if not transcript:
|
||||
return
|
||||
if transcript.deleted_at is None:
|
||||
return
|
||||
|
||||
# Collect file references before deleting DB rows
|
||||
recording = None
|
||||
recording_storage = None
|
||||
if transcript.recording_id:
|
||||
recording = await recordings_controller.get_by_id(transcript.recording_id)
|
||||
# Determine the correct storage backend for recording files.
|
||||
# Recordings from different platforms (daily, whereby) live in
|
||||
# platform-specific buckets with separate credentials.
|
||||
if recording and recording.meeting_id:
|
||||
from reflector.db.meetings import meetings_controller # noqa: PLC0415
|
||||
|
||||
meeting = await meetings_controller.get_by_id(recording.meeting_id)
|
||||
if meeting:
|
||||
recording_storage = get_source_storage(meeting.platform)
|
||||
if recording_storage is None:
|
||||
recording_storage = get_transcripts_storage()
|
||||
|
||||
# 1. Hard-delete DB rows first (makes data inaccessible immediately)
|
||||
if recording:
|
||||
await recordings_controller.hard_delete_by_id(recording.id)
|
||||
await get_database().execute(
|
||||
transcripts.delete().where(transcripts.c.id == transcript_id)
|
||||
)
|
||||
|
||||
# 2. Delete transcript audio from S3 (always uses transcript storage)
|
||||
transcript_storage = get_transcripts_storage()
|
||||
if transcript.audio_location == "storage" and not transcript.audio_deleted:
|
||||
try:
|
||||
await transcript_storage.delete_file(transcript.storage_audio_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to delete transcript audio from storage",
|
||||
exc_info=e,
|
||||
transcript_id=transcript_id,
|
||||
path=transcript.storage_audio_path,
|
||||
)
|
||||
|
||||
# 3. Delete recording files from S3 (both object_key and track_keys —
|
||||
# a recording can have both, unlike consent cleanup which uses elif).
|
||||
# Uses platform-specific storage resolved above.
|
||||
if recording and recording.bucket_name and recording_storage:
|
||||
keys_to_delete = []
|
||||
if recording.track_keys:
|
||||
keys_to_delete = recording.track_keys
|
||||
if recording.object_key:
|
||||
keys_to_delete.append(recording.object_key)
|
||||
|
||||
for key in keys_to_delete:
|
||||
try:
|
||||
await recording_storage.delete_file(
|
||||
key, bucket=recording.bucket_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to delete recording file",
|
||||
exc_info=e,
|
||||
key=key,
|
||||
bucket=recording.bucket_name,
|
||||
)
|
||||
|
||||
# 4. Delete local files
|
||||
transcript.unlink()
|
||||
|
||||
async def remove_by_recording_id(self, recording_id: str):
|
||||
"""
|
||||
Soft-delete a transcript by recording_id
|
||||
|
||||
@@ -1277,6 +1277,7 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
|
||||
return ConsentResult()
|
||||
|
||||
consent_denied = False
|
||||
meeting = None
|
||||
if transcript.meeting_id:
|
||||
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
|
||||
if meeting:
|
||||
@@ -1339,6 +1340,22 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
|
||||
logger.error(error_msg, exc_info=True)
|
||||
deletion_errors.append(error_msg)
|
||||
|
||||
# Delete cloud video if present
|
||||
if meeting and meeting.daily_composed_video_s3_key:
|
||||
try:
|
||||
source_storage = get_source_storage("daily")
|
||||
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
|
||||
await meetings_controller.update_meeting(
|
||||
meeting.id,
|
||||
daily_composed_video_s3_key=None,
|
||||
daily_composed_video_duration=None,
|
||||
)
|
||||
ctx.log(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to delete cloud video: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
deletion_errors.append(error_msg)
|
||||
|
||||
if deletion_errors:
|
||||
logger.warning(
|
||||
"[Hatchet] cleanup_consent completed with errors",
|
||||
@@ -1349,7 +1366,7 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
|
||||
ctx.log(f"cleanup_consent completed with {len(deletion_errors)} errors")
|
||||
else:
|
||||
await transcripts_controller.update(transcript, {"audio_deleted": True})
|
||||
ctx.log("cleanup_consent: all audio deleted successfully")
|
||||
ctx.log("cleanup_consent: all audio and video deleted successfully")
|
||||
|
||||
return ConsentResult()
|
||||
|
||||
|
||||
@@ -688,7 +688,10 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu
|
||||
)
|
||||
from reflector.db.recordings import recordings_controller # noqa: PLC0415
|
||||
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
|
||||
from reflector.storage import get_transcripts_storage # noqa: PLC0415
|
||||
from reflector.storage import ( # noqa: PLC0415
|
||||
get_source_storage,
|
||||
get_transcripts_storage,
|
||||
)
|
||||
|
||||
transcript = await transcripts_controller.get_by_id(input.transcript_id)
|
||||
if not transcript:
|
||||
@@ -697,6 +700,7 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu
|
||||
|
||||
consent_denied = False
|
||||
recording = None
|
||||
meeting = None
|
||||
if transcript.recording_id:
|
||||
recording = await recordings_controller.get_by_id(transcript.recording_id)
|
||||
if recording and recording.meeting_id:
|
||||
@@ -756,6 +760,22 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu
|
||||
logger.error(error_msg, exc_info=True)
|
||||
deletion_errors.append(error_msg)
|
||||
|
||||
# Delete cloud video if present
|
||||
if meeting and meeting.daily_composed_video_s3_key:
|
||||
try:
|
||||
source_storage = get_source_storage("daily")
|
||||
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
|
||||
await meetings_controller.update_meeting(
|
||||
meeting.id,
|
||||
daily_composed_video_s3_key=None,
|
||||
daily_composed_video_duration=None,
|
||||
)
|
||||
ctx.log(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to delete cloud video: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
deletion_errors.append(error_msg)
|
||||
|
||||
if deletion_errors:
|
||||
logger.warning(
|
||||
"[Hatchet] cleanup_consent completed with errors",
|
||||
@@ -764,7 +784,7 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu
|
||||
)
|
||||
else:
|
||||
await transcripts_controller.update(transcript, {"audio_deleted": True})
|
||||
ctx.log("cleanup_consent: all audio deleted successfully")
|
||||
ctx.log("cleanup_consent: all audio and video deleted successfully")
|
||||
|
||||
return ConsentResult()
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ from reflector.processors.types import (
|
||||
)
|
||||
from reflector.processors.types import Transcript as TranscriptProcessorType
|
||||
from reflector.settings import settings
|
||||
from reflector.storage import get_transcripts_storage
|
||||
from reflector.storage import get_source_storage, get_transcripts_storage
|
||||
from reflector.views.transcripts import GetTranscriptTopic
|
||||
from reflector.ws_events import TranscriptEventName
|
||||
from reflector.ws_manager import WebsocketManager, get_ws_manager
|
||||
@@ -671,6 +671,22 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
|
||||
logger.error(error_msg, exc_info=e)
|
||||
deletion_errors.append(error_msg)
|
||||
|
||||
# Delete cloud video if present
|
||||
if meeting and meeting.daily_composed_video_s3_key:
|
||||
try:
|
||||
source_storage = get_source_storage("daily")
|
||||
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
|
||||
await meetings_controller.update_meeting(
|
||||
meeting.id,
|
||||
daily_composed_video_s3_key=None,
|
||||
daily_composed_video_duration=None,
|
||||
)
|
||||
logger.info(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to delete cloud video: {e}"
|
||||
logger.error(error_msg, exc_info=e)
|
||||
deletion_errors.append(error_msg)
|
||||
|
||||
if deletion_errors:
|
||||
logger.warning(
|
||||
f"Consent cleanup completed with {len(deletion_errors)} errors",
|
||||
@@ -678,7 +694,7 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
|
||||
)
|
||||
else:
|
||||
await transcripts_controller.update(transcript, {"audio_deleted": True})
|
||||
logger.info("Consent cleanup done - all audio deleted")
|
||||
logger.info("Consent cleanup done - all audio and video deleted")
|
||||
|
||||
|
||||
@get_transcript
|
||||
|
||||
@@ -309,6 +309,7 @@ async def transcripts_search(
|
||||
source_kind: Optional[SourceKind] = None,
|
||||
from_datetime: SearchFromDatetimeParam = None,
|
||||
to_datetime: SearchToDatetimeParam = None,
|
||||
include_deleted: bool = False,
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
] = None,
|
||||
@@ -316,6 +317,12 @@ async def transcripts_search(
|
||||
"""Full-text search across transcript titles and content."""
|
||||
user_id = user["sub"] if user else None
|
||||
|
||||
if include_deleted and not user_id:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required to view deleted transcripts",
|
||||
)
|
||||
|
||||
if from_datetime and to_datetime and from_datetime > to_datetime:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="'from' must be less than or equal to 'to'"
|
||||
@@ -330,6 +337,7 @@ async def transcripts_search(
|
||||
source_kind=source_kind,
|
||||
from_datetime=from_datetime,
|
||||
to_datetime=to_datetime,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
|
||||
results, total = await search_controller.search_transcripts(search_params)
|
||||
@@ -615,6 +623,54 @@ async def transcript_delete(
|
||||
return DeletionStatus(status="ok")
|
||||
|
||||
|
||||
@router.post("/transcripts/{transcript_id}/restore", response_model=DeletionStatus)
|
||||
async def transcript_restore(
|
||||
transcript_id: str,
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
"""Restore a soft-deleted transcript."""
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
if transcript.deleted_at is None:
|
||||
raise HTTPException(status_code=400, detail="Transcript is not deleted")
|
||||
if not transcripts_controller.user_can_mutate(transcript, user_id):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
await transcripts_controller.restore_by_id(transcript.id, user_id=user_id)
|
||||
await get_ws_manager().send_json(
|
||||
room_id=f"user:{user_id}",
|
||||
message={"event": "TRANSCRIPT_RESTORED", "data": {"id": transcript.id}},
|
||||
)
|
||||
return DeletionStatus(status="ok")
|
||||
|
||||
|
||||
@router.delete("/transcripts/{transcript_id}/destroy", response_model=DeletionStatus)
|
||||
async def transcript_destroy(
|
||||
transcript_id: str,
|
||||
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||
):
|
||||
"""Permanently delete a transcript and all associated files."""
|
||||
user_id = user["sub"]
|
||||
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
if transcript.deleted_at is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Transcript must be soft-deleted first"
|
||||
)
|
||||
if not transcripts_controller.user_can_mutate(transcript, user_id):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
await transcripts_controller.hard_delete(transcript.id)
|
||||
await get_ws_manager().send_json(
|
||||
room_id=f"user:{user_id}",
|
||||
message={"event": "TRANSCRIPT_DELETED", "data": {"id": transcript.id}},
|
||||
)
|
||||
return DeletionStatus(status="ok")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/transcripts/{transcript_id}/topics",
|
||||
response_model=list[GetTranscriptTopic],
|
||||
|
||||
@@ -113,6 +113,7 @@ TranscriptWsEvent = Annotated[
|
||||
UserEventName = Literal[
|
||||
"TRANSCRIPT_CREATED",
|
||||
"TRANSCRIPT_DELETED",
|
||||
"TRANSCRIPT_RESTORED",
|
||||
"TRANSCRIPT_STATUS",
|
||||
"TRANSCRIPT_FINAL_TITLE",
|
||||
"TRANSCRIPT_DURATION",
|
||||
@@ -161,6 +162,15 @@ class UserWsTranscriptDeleted(BaseModel):
|
||||
data: UserTranscriptDeletedData
|
||||
|
||||
|
||||
class UserTranscriptRestoredData(BaseModel):
|
||||
id: NonEmptyString
|
||||
|
||||
|
||||
class UserWsTranscriptRestored(BaseModel):
|
||||
event: Literal["TRANSCRIPT_RESTORED"] = "TRANSCRIPT_RESTORED"
|
||||
data: UserTranscriptRestoredData
|
||||
|
||||
|
||||
class UserWsTranscriptStatus(BaseModel):
|
||||
event: Literal["TRANSCRIPT_STATUS"] = "TRANSCRIPT_STATUS"
|
||||
data: UserTranscriptStatusData
|
||||
@@ -180,6 +190,7 @@ UserWsEvent = Annotated[
|
||||
Union[
|
||||
UserWsTranscriptCreated,
|
||||
UserWsTranscriptDeleted,
|
||||
UserWsTranscriptRestored,
|
||||
UserWsTranscriptStatus,
|
||||
UserWsTranscriptFinalTitle,
|
||||
UserWsTranscriptDuration,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from reflector.db.meetings import meetings_controller
|
||||
from reflector.db.recordings import Recording, recordings_controller
|
||||
from reflector.db.rooms import rooms_controller
|
||||
from reflector.db.transcripts import SourceKind, transcripts_controller
|
||||
@@ -390,3 +394,463 @@ async def test_transcripts_list_filtered_by_room_id(authenticated_client, client
|
||||
ids = [t["id"] for t in items]
|
||||
assert in_room.id in ids
|
||||
assert other.id not in ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Restore tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_restore(authenticated_client, client):
|
||||
"""Soft-delete then restore, verify accessible again."""
|
||||
response = await client.post("/transcripts", json={"name": "restore-me"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
|
||||
# Soft-delete
|
||||
response = await client.delete(f"/transcripts/{tid}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# 404 while deleted
|
||||
response = await client.get(f"/transcripts/{tid}")
|
||||
assert response.status_code == 404
|
||||
|
||||
# Restore
|
||||
response = await client.post(f"/transcripts/{tid}/restore")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
# Accessible again
|
||||
response = await client.get(f"/transcripts/{tid}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "restore-me"
|
||||
|
||||
# deleted_at is cleared
|
||||
transcript = await transcripts_controller.get_by_id(tid)
|
||||
assert transcript.deleted_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_restore_recording_also_restored(authenticated_client, client):
|
||||
"""Restoring a transcript also restores its recording."""
|
||||
recording = await recordings_controller.create(
|
||||
Recording(
|
||||
bucket_name="test-bucket",
|
||||
object_key="restore-test.mp4",
|
||||
recorded_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
transcript = await transcripts_controller.add(
|
||||
name="restore-with-recording",
|
||||
source_kind=SourceKind.ROOM,
|
||||
recording_id=recording.id,
|
||||
user_id="randomuserid",
|
||||
)
|
||||
|
||||
# Soft-delete
|
||||
response = await client.delete(f"/transcripts/{transcript.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Both should be soft-deleted
|
||||
rec = await recordings_controller.get_by_id(recording.id)
|
||||
assert rec.deleted_at is not None
|
||||
|
||||
# Restore
|
||||
response = await client.post(f"/transcripts/{transcript.id}/restore")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Recording also restored
|
||||
rec = await recordings_controller.get_by_id(recording.id)
|
||||
assert rec.deleted_at is None
|
||||
|
||||
tr = await transcripts_controller.get_by_id(transcript.id)
|
||||
assert tr.deleted_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_restore_not_deleted(authenticated_client, client):
|
||||
"""Restoring a non-deleted transcript returns 400."""
|
||||
response = await client.post("/transcripts", json={"name": "not-deleted"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
|
||||
response = await client.post(f"/transcripts/{tid}/restore")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_restore_not_found(authenticated_client, client):
|
||||
"""Restoring a nonexistent transcript returns 404."""
|
||||
response = await client.post("/transcripts/nonexistent-id/restore")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_restore_forbidden(authenticated_client, client):
|
||||
"""Cannot restore another user's deleted transcript."""
|
||||
# Create transcript owned by a different user
|
||||
transcript = await transcripts_controller.add(
|
||||
name="other-user-restore",
|
||||
source_kind=SourceKind.FILE,
|
||||
user_id="some-other-user",
|
||||
)
|
||||
# Soft-delete directly in DB
|
||||
await transcripts_controller.remove_by_id(transcript.id, user_id="some-other-user")
|
||||
|
||||
# Try to restore as randomuserid (authenticated_client)
|
||||
response = await client.post(f"/transcripts/{transcript.id}/restore")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Destroy tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_destroy_storage():
|
||||
"""Mock storage backends so hard_delete doesn't require S3 credentials."""
|
||||
with (
|
||||
patch(
|
||||
"reflector.db.transcripts.get_transcripts_storage",
|
||||
return_value=AsyncMock(delete_file=AsyncMock()),
|
||||
),
|
||||
patch(
|
||||
"reflector.db.transcripts.get_source_storage",
|
||||
return_value=AsyncMock(delete_file=AsyncMock()),
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_destroy(authenticated_client, client, mock_destroy_storage):
|
||||
"""Soft-delete then destroy, verify transcript gone from DB."""
|
||||
response = await client.post("/transcripts", json={"name": "destroy-me"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
|
||||
# Soft-delete first
|
||||
response = await client.delete(f"/transcripts/{tid}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Destroy
|
||||
response = await client.delete(f"/transcripts/{tid}/destroy")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
# Gone from DB entirely
|
||||
transcript = await transcripts_controller.get_by_id(tid)
|
||||
assert transcript is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_destroy_not_soft_deleted(authenticated_client, client):
|
||||
"""Cannot destroy a transcript that hasn't been soft-deleted."""
|
||||
response = await client.post("/transcripts", json={"name": "not-soft-deleted"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
|
||||
response = await client.delete(f"/transcripts/{tid}/destroy")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_destroy_with_recording(
|
||||
authenticated_client, client, mock_destroy_storage
|
||||
):
|
||||
"""Destroying a transcript also hard-deletes its recording from DB."""
|
||||
recording = await recordings_controller.create(
|
||||
Recording(
|
||||
bucket_name="test-bucket",
|
||||
object_key="destroy-test.mp4",
|
||||
recorded_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
transcript = await transcripts_controller.add(
|
||||
name="destroy-with-recording",
|
||||
source_kind=SourceKind.ROOM,
|
||||
recording_id=recording.id,
|
||||
user_id="randomuserid",
|
||||
)
|
||||
|
||||
# Soft-delete
|
||||
response = await client.delete(f"/transcripts/{transcript.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Destroy
|
||||
response = await client.delete(f"/transcripts/{transcript.id}/destroy")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Both gone from DB
|
||||
assert await transcripts_controller.get_by_id(transcript.id) is None
|
||||
assert await recordings_controller.get_by_id(recording.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_destroy_forbidden(authenticated_client, client):
|
||||
"""Cannot destroy another user's deleted transcript."""
|
||||
transcript = await transcripts_controller.add(
|
||||
name="other-user-destroy",
|
||||
source_kind=SourceKind.FILE,
|
||||
user_id="some-other-user",
|
||||
)
|
||||
await transcripts_controller.remove_by_id(transcript.id, user_id="some-other-user")
|
||||
|
||||
# Try to destroy as randomuserid (authenticated_client)
|
||||
response = await client.delete(f"/transcripts/{transcript.id}/destroy")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolation tests — verify unrelated data is NOT deleted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_destroy_does_not_delete_meeting(
|
||||
authenticated_client, client, mock_destroy_storage
|
||||
):
|
||||
"""Destroying a transcript must NOT delete its associated meeting."""
|
||||
room = await rooms_controller.add(
|
||||
name="room-for-meeting-isolation",
|
||||
user_id="randomuserid",
|
||||
zulip_auto_post=False,
|
||||
zulip_stream="",
|
||||
zulip_topic="",
|
||||
is_locked=False,
|
||||
room_mode="normal",
|
||||
recording_type="cloud",
|
||||
recording_trigger="automatic-2nd-participant",
|
||||
is_shared=False,
|
||||
webhook_url="",
|
||||
webhook_secret="",
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
meeting = await meetings_controller.create(
|
||||
id="meeting-isolation-test",
|
||||
room_name=room.name,
|
||||
room_url="https://example.com/room",
|
||||
host_room_url="https://example.com/room-host",
|
||||
start_date=now,
|
||||
end_date=now + timedelta(hours=1),
|
||||
room=room,
|
||||
)
|
||||
recording = await recordings_controller.create(
|
||||
Recording(
|
||||
bucket_name="test-bucket",
|
||||
object_key="meeting-iso.mp4",
|
||||
recorded_at=now,
|
||||
meeting_id=meeting.id,
|
||||
)
|
||||
)
|
||||
transcript = await transcripts_controller.add(
|
||||
name="transcript-with-meeting",
|
||||
source_kind=SourceKind.ROOM,
|
||||
recording_id=recording.id,
|
||||
meeting_id=meeting.id,
|
||||
room_id=room.id,
|
||||
user_id="randomuserid",
|
||||
)
|
||||
|
||||
# Soft-delete then destroy
|
||||
await transcripts_controller.remove_by_id(transcript.id, user_id="randomuserid")
|
||||
response = await client.delete(f"/transcripts/{transcript.id}/destroy")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Transcript and recording are gone
|
||||
assert await transcripts_controller.get_by_id(transcript.id) is None
|
||||
assert await recordings_controller.get_by_id(recording.id) is None
|
||||
|
||||
# Meeting still exists
|
||||
m = await meetings_controller.get_by_id(meeting.id)
|
||||
assert m is not None
|
||||
assert m.id == meeting.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_destroy_does_not_affect_other_transcripts(
|
||||
authenticated_client, client, mock_destroy_storage
|
||||
):
|
||||
"""Destroying one transcript must not affect another transcript or its recording."""
|
||||
user_id = "randomuserid"
|
||||
rec1 = await recordings_controller.create(
|
||||
Recording(
|
||||
bucket_name="test-bucket",
|
||||
object_key="sibling1.mp4",
|
||||
recorded_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
rec2 = await recordings_controller.create(
|
||||
Recording(
|
||||
bucket_name="test-bucket",
|
||||
object_key="sibling2.mp4",
|
||||
recorded_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
t1 = await transcripts_controller.add(
|
||||
name="sibling-1",
|
||||
source_kind=SourceKind.FILE,
|
||||
recording_id=rec1.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
t2 = await transcripts_controller.add(
|
||||
name="sibling-2",
|
||||
source_kind=SourceKind.FILE,
|
||||
recording_id=rec2.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Soft-delete and destroy t1
|
||||
await transcripts_controller.remove_by_id(t1.id, user_id=user_id)
|
||||
response = await client.delete(f"/transcripts/{t1.id}/destroy")
|
||||
assert response.status_code == 200
|
||||
|
||||
# t1 and rec1 gone
|
||||
assert await transcripts_controller.get_by_id(t1.id) is None
|
||||
assert await recordings_controller.get_by_id(rec1.id) is None
|
||||
|
||||
# t2 and rec2 untouched
|
||||
t2_after = await transcripts_controller.get_by_id(t2.id)
|
||||
assert t2_after is not None
|
||||
assert t2_after.deleted_at is None
|
||||
rec2_after = await recordings_controller.get_by_id(rec2.id)
|
||||
assert rec2_after is not None
|
||||
assert rec2_after.deleted_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_destroy_meeting_with_multiple_transcripts(
|
||||
authenticated_client, client, mock_destroy_storage
|
||||
):
|
||||
"""Destroying one transcript from a meeting must not affect the other
|
||||
transcript, its recording, or the shared meeting."""
|
||||
user_id = "randomuserid"
|
||||
room = await rooms_controller.add(
|
||||
name="room-multi-transcript",
|
||||
user_id=user_id,
|
||||
zulip_auto_post=False,
|
||||
zulip_stream="",
|
||||
zulip_topic="",
|
||||
is_locked=False,
|
||||
room_mode="normal",
|
||||
recording_type="cloud",
|
||||
recording_trigger="automatic-2nd-participant",
|
||||
is_shared=False,
|
||||
webhook_url="",
|
||||
webhook_secret="",
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
meeting = await meetings_controller.create(
|
||||
id="meeting-multi-transcript-test",
|
||||
room_name=room.name,
|
||||
room_url="https://example.com/room",
|
||||
host_room_url="https://example.com/room-host",
|
||||
start_date=now,
|
||||
end_date=now + timedelta(hours=1),
|
||||
room=room,
|
||||
)
|
||||
rec1 = await recordings_controller.create(
|
||||
Recording(
|
||||
bucket_name="test-bucket",
|
||||
object_key="multi1.mp4",
|
||||
recorded_at=now,
|
||||
meeting_id=meeting.id,
|
||||
)
|
||||
)
|
||||
rec2 = await recordings_controller.create(
|
||||
Recording(
|
||||
bucket_name="test-bucket",
|
||||
object_key="multi2.mp4",
|
||||
recorded_at=now,
|
||||
meeting_id=meeting.id,
|
||||
)
|
||||
)
|
||||
t1 = await transcripts_controller.add(
|
||||
name="multi-t1",
|
||||
source_kind=SourceKind.ROOM,
|
||||
recording_id=rec1.id,
|
||||
meeting_id=meeting.id,
|
||||
room_id=room.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
t2 = await transcripts_controller.add(
|
||||
name="multi-t2",
|
||||
source_kind=SourceKind.ROOM,
|
||||
recording_id=rec2.id,
|
||||
meeting_id=meeting.id,
|
||||
room_id=room.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Soft-delete and destroy t1
|
||||
await transcripts_controller.remove_by_id(t1.id, user_id=user_id)
|
||||
response = await client.delete(f"/transcripts/{t1.id}/destroy")
|
||||
assert response.status_code == 200
|
||||
|
||||
# t1 + rec1 gone
|
||||
assert await transcripts_controller.get_by_id(t1.id) is None
|
||||
assert await recordings_controller.get_by_id(rec1.id) is None
|
||||
|
||||
# t2 + rec2 + meeting all still exist
|
||||
assert (await transcripts_controller.get_by_id(t2.id)) is not None
|
||||
assert (await recordings_controller.get_by_id(rec2.id)) is not None
|
||||
assert (await meetings_controller.get_by_id(meeting.id)) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_include_deleted(authenticated_client, client):
|
||||
"""Search with include_deleted=true returns only deleted transcripts."""
|
||||
response = await client.post("/transcripts", json={"name": "search-deleted"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
|
||||
# Soft-delete
|
||||
response = await client.delete(f"/transcripts/{tid}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Normal search should not include it
|
||||
response = await client.get("/transcripts/search", params={"q": ""})
|
||||
assert response.status_code == 200
|
||||
ids = [r["id"] for r in response.json()["results"]]
|
||||
assert tid not in ids
|
||||
|
||||
# Search with include_deleted should include it
|
||||
response = await client.get(
|
||||
"/transcripts/search", params={"q": "", "include_deleted": True}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [r["id"] for r in response.json()["results"]]
|
||||
assert tid in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_exclude_deleted_by_default(authenticated_client, client):
|
||||
"""Normal search excludes deleted transcripts by default."""
|
||||
response = await client.post(
|
||||
"/transcripts", json={"name": "search-exclude-deleted"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
|
||||
# Verify it appears in search
|
||||
response = await client.get("/transcripts/search", params={"q": ""})
|
||||
assert response.status_code == 200
|
||||
ids = [r["id"] for r in response.json()["results"]]
|
||||
assert tid in ids
|
||||
|
||||
# Soft-delete
|
||||
response = await client.delete(f"/transcripts/{tid}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify it no longer appears in default search
|
||||
response = await client.get("/transcripts/search", params={"q": ""})
|
||||
assert response.status_code == 200
|
||||
ids = [r["id"] for r in response.json()["results"]]
|
||||
assert tid not in ids
|
||||
|
||||
Reference in New Issue
Block a user