diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index 6fc3fb4d..d7621dea 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -153,7 +153,7 @@ def update_last_image(self, media: bytes) -> Response: return requests.patch( urljoin(self._route_prefix, ClientRoute.CAMERAS_IMAGE), headers=self.headers, - files={"file": ("logo.png", media, "image/png")}, + files={"file": ("logo.jpg", media, "image/jpeg")}, timeout=self.timeout, ) @@ -245,7 +245,7 @@ def update_pose_image(self, pose_id: int, media: bytes) -> Response: return requests.patch( urljoin(self._route_prefix, ClientRoute.POSES_IMAGE.format(pose_id=pose_id)), headers=self.headers, - files={"file": ("image.png", media, "image/png")}, + files={"file": ("image.jpg", media, "image/jpeg")}, timeout=self.timeout, ) @@ -344,6 +344,7 @@ def create_detection( media: bytes, bboxes: List[Tuple[float, float, float, float, float]], pose_id: int, + crop: bytes | None = None, ) -> Response: """Notify the detection of a wildfire on the picture taken by a camera. @@ -356,6 +357,7 @@ def create_detection( media: byte data of the picture bboxes: list of tuples where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf pose_id: pose_id of the detection + crop: optional byte data of a cropped picture associated with the detection Returns: HTTP response @@ -366,12 +368,15 @@ def create_detection( "bboxes": _dump_bbox_to_json(bboxes), } data["pose_id"] = str(pose_id) + files: Dict[str, Tuple[str, bytes, str]] = {"file": ("frame.jpg", media, "image/jpeg")} + if crop is not None: + files["crop"] = ("crop.jpg", crop, "image/jpeg") return requests.post( urljoin(self._route_prefix, ClientRoute.DETECTIONS_CREATE), headers=self.headers, data=data, timeout=self.timeout, - files={"file": ("logo.png", media, "image/png")}, + files=files, ) def get_detection_url(self, detection_id: int) -> Response: @@ -469,7 +474,13 @@ def fetch_latest_sequences(self) -> Response: timeout=self.timeout, ) - def fetch_sequences_detections(self, sequence_id: int, limit: int = 10, desc: bool = True) -> Response: + def fetch_sequences_detections( + self, + sequence_id: int, + limit: int = 10, + desc: bool = True, + with_crop: bool = True, + ) -> Response: """List the detections of a sequence >>> from pyroclient import client @@ -480,6 +491,7 @@ def fetch_sequences_detections(self, sequence_id: int, limit: int = 10, desc: bo sequence_id: ID of the associated sequence entry limit: maximum number of detections to fetch desc: whether to order the detections by created_at in descending order + with_crop: whether to include the crop_url for detections that have a crop Returns: HTTP response @@ -487,7 +499,7 @@ def fetch_sequences_detections(self, sequence_id: int, limit: int = 10, desc: bo return requests.get( urljoin(self._route_prefix, ClientRoute.SEQUENCES_FETCH_DETECTIONS.format(seq_id=sequence_id)), headers=self.headers, - params={"limit": limit, "desc": desc}, + params={"limit": limit, "desc": desc, "with_crop": with_crop}, timeout=self.timeout, ) diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index c6345c64..0649fa75 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -353,6 +353,7 @@ async def create_detection( ), pose_id: int = Form(..., gt=0, description="pose id of the detection"), file: UploadFile = File(..., alias="file"), + crop_file: Optional[UploadFile] = File(None, alias="crop"), detections: DetectionCRUD = Depends(get_detection_crud), webhooks: WebhookCRUD = Depends(get_webhook_crud), organizations: OrganizationCRUD = Depends(get_organization_crud), @@ -371,8 +372,7 @@ async def create_detection( detail="xmin & ymin are expected to be respectively smaller than xmax & ymax", ) - # Upload media - bucket_key = await upload_file(file, token_payload.organization_id, token_payload.sub) + # Authorize before any S3 upload to avoid orphan objects on 403 pose = cast(Pose, await poses.get(pose_id, strict=True)) if pose.camera_id != token_payload.sub: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") @@ -381,6 +381,14 @@ async def create_detection( if not bbox_strings: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid bbox format.") + # Upload media + bucket_key = await upload_file(file, token_payload.organization_id, token_payload.sub) + crop_bucket_key: Optional[str] = None + if crop_file is not None: + crop_bucket_key = await upload_file( + crop_file, token_payload.organization_id, token_payload.sub, key_prefix="crop_" + ) + created: List[Detection] = [] camera = cast(Camera, await cameras.get(token_payload.sub, strict=True)) @@ -393,6 +401,7 @@ async def create_detection( camera_id=token_payload.sub, pose_id=pose_id, bucket_key=bucket_key, + crop_bucket_key=crop_bucket_key, bbox=single_bboxes, others_bboxes=others_bboxes, ) @@ -529,17 +538,12 @@ async def get_detection_url( # Check in DB detection = cast(Detection, await detections.get(detection_id, strict=True)) - if UserRole.ADMIN in token_payload.scopes: - camera = cast(Camera, await cameras.get(detection.camera_id, strict=True)) - bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id)) - return DetectionUrl(url=bucket.get_public_url(detection.bucket_key)) - camera = cast(Camera, await cameras.get(detection.camera_id, strict=True)) - if token_payload.organization_id != camera.organization_id: + if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") - # Check in bucket bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id)) - return DetectionUrl(url=bucket.get_public_url(detection.bucket_key)) + crop_url = bucket.get_public_url(detection.crop_bucket_key) if detection.crop_bucket_key else None + return DetectionUrl(url=bucket.get_public_url(detection.bucket_key), crop_url=crop_url) @router.get("/", status_code=status.HTTP_200_OK, summary="Fetch all the detections") diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index be1c87ba..7358174a 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -105,6 +105,10 @@ async def fetch_sequence_detections( sequence_id: int = Path(..., gt=0), limit: int = Query(10, description="Maximum number of detections to fetch", ge=1, le=100), desc: bool = Query(True, description="Whether to order the detections by created_at in descending order"), + with_crop: bool = Query( + True, + description="If true, presign and include crop_url for detections that have a crop. Set to false to skip the extra S3 head requests when crops are not needed.", + ), cameras: CameraCRUD = Depends(get_camera_crud), detections: DetectionCRUD = Depends(get_detection_crud), sequences: SequenceCRUD = Depends(get_sequence_crud), @@ -118,17 +122,19 @@ async def fetch_sequence_detections( # Get the bucket of the camera's organization bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id)) + fetched = await detections.fetch_all( + filters=("sequence_id", sequence_id), + order_by="created_at", + order_desc=desc, + limit=limit, + ) return [ DetectionWithUrl( **DetectionRead(**elt.model_dump()).model_dump(), url=bucket.get_public_url(elt.bucket_key), + crop_url=(bucket.get_public_url(elt.crop_bucket_key) if with_crop and elt.crop_bucket_key else None), ) - for elt in await detections.fetch_all( - filters=("sequence_id", sequence_id), - order_by="created_at", - order_desc=desc, - limit=limit, - ) + for elt in fetched ] diff --git a/src/app/models.py b/src/app/models.py index c3b30b3c..6dbc07e4 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -86,6 +86,7 @@ class Detection(SQLModel, table=True): pose_id: int = Field(..., foreign_key="poses.id", nullable=False) sequence_id: Union[int, None] = Field(None, foreign_key="sequences.id", nullable=True) bucket_key: str + crop_bucket_key: Union[str, None] = Field(default=None, nullable=True) bbox: str = Field(..., min_length=2, max_length=settings.MAX_BBOX_STR_LENGTH_SINGLE, nullable=False) others_bboxes: Union[str, None] = Field(default=None, max_length=settings.MAX_BBOX_STR_LENGTH_OTHERS, nullable=True) created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) diff --git a/src/app/schemas/detections.py b/src/app/schemas/detections.py index f85f7c71..3c58dc5e 100644 --- a/src/app/schemas/detections.py +++ b/src/app/schemas/detections.py @@ -29,6 +29,7 @@ class DetectionCreate(BaseModel): camera_id: int = Field(..., gt=0) pose_id: int = Field(..., gt=0) bucket_key: str + crop_bucket_key: Optional[str] = None bbox: str = Field( ..., min_length=2, @@ -41,6 +42,7 @@ class DetectionCreate(BaseModel): class DetectionUrl(BaseModel): url: str = Field(..., description="temporary URL to access the media content") + crop_url: Optional[str] = Field(None, description="temporary URL to access the cropped media content, if any") class DetectionRead(Detection): @@ -49,6 +51,7 @@ class DetectionRead(Detection): class DetectionWithUrl(Detection): url: str = Field(..., description="temporary URL to access the media content") + crop_url: Optional[str] = Field(None, description="temporary URL to access the cropped media content, if any") class DetectionSequence(BaseModel): diff --git a/src/app/services/storage.py b/src/app/services/storage.py index 8ffa4945..f16b40af 100644 --- a/src/app/services/storage.py +++ b/src/app/services/storage.py @@ -155,7 +155,7 @@ def resolve_bucket_name(organization_id: int) -> str: return f"{settings.SERVER_NAME}-alert-api-{organization_id!s}" -async def upload_file(file: UploadFile, organization_id: int, camera_id: int) -> str: +async def upload_file(file: UploadFile, organization_id: int, camera_id: int, key_prefix: str = "") -> str: """Upload a file to S3 storage and return the public URL""" # Concatenate the first 8 chars (to avoid system interactions issues) of SHA256 hash with file extension sha_hash = hashlib.sha256(file.file.read()).hexdigest() @@ -165,8 +165,9 @@ async def upload_file(file: UploadFile, organization_id: int, camera_id: int) -> await file.seek(0) # guess_extension will return none if this fails extension = guess_extension(magic.from_buffer(file.file.read(), mime=True)) or "" - # Concatenate timestamp & hash - bucket_key = f"{camera_id}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{sha_hash[:8]}{extension}" + # Concatenate timestamp & hash; key_prefix lets callers segregate distinct uploads in the + # same request (e.g. frame vs crop) so identical bytes don't collide on the same key. + bucket_key = f"{key_prefix}{camera_id}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{sha_hash[:8]}{extension}" # Reset byte position of the file (cf. https://fastapi.tiangolo.com/tutorial/request-files/#uploadfile) await file.seek(0) bucket_name = s3_service.resolve_bucket_name(organization_id) diff --git a/src/migrations/versions/2026_04_26_1200-7f1c4d2a9b3e_add_crop_bucket_key_to_detections.py b/src/migrations/versions/2026_04_26_1200-7f1c4d2a9b3e_add_crop_bucket_key_to_detections.py new file mode 100644 index 00000000..1e480da2 --- /dev/null +++ b/src/migrations/versions/2026_04_26_1200-7f1c4d2a9b3e_add_crop_bucket_key_to_detections.py @@ -0,0 +1,27 @@ +"""Add crop_bucket_key to detections + +Revision ID: 7f1c4d2a9b3e +Revises: 9700bbccb2f1 +Create Date: 2026-04-26 12:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "7f1c4d2a9b3e" +down_revision: Union[str, None] = "9700bbccb2f1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("detections", sa.Column("crop_bucket_key", sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("detections", "crop_bucket_key") diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 6359915d..7763f041 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -149,6 +149,7 @@ "pose_id": 1, "sequence_id": 1, "bucket_key": "my_file", + "crop_bucket_key": None, "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format), @@ -159,6 +160,7 @@ "pose_id": 1, "sequence_id": 1, "bucket_key": "my_file", + "crop_bucket_key": None, "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:18:19.226673", dt_format), @@ -169,6 +171,7 @@ "pose_id": 1, "sequence_id": 1, "bucket_key": "my_file", + "crop_bucket_key": None, "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:28:19.226673", dt_format), @@ -179,6 +182,7 @@ "pose_id": 3, "sequence_id": 2, "bucket_key": "my_file", + "crop_bucket_key": None, "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T16:08:19.226673", dt_format), diff --git a/src/tests/endpoints/test_detections.py b/src/tests/endpoints/test_detections.py index e153b653..02c51e74 100644 --- a/src/tests/endpoints/test_detections.py +++ b/src/tests/endpoints/test_detections.py @@ -32,6 +32,7 @@ from app.models import Alert, AlertSequence, Camera, Detection, Organization, Pose, Role, Sequence, Webhook from app.schemas.login import TokenPayload from app.services.cones import resolve_cone +from app.services.storage import s3_service @pytest.mark.parametrize( @@ -877,6 +878,7 @@ async def test_create_detection_sequence_flow_direct(detection_session: AsyncSes bboxes="[(0.2,0.2,0.3,0.3,0.9)]", pose_id=pose_id, file=upload, + crop_file=None, detections=detections, webhooks=webhooks, organizations=organizations, @@ -914,6 +916,7 @@ async def fake_fetch_all(*args, **kwargs): bboxes="[(0.25,0.25,0.35,0.35,0.9)]", pose_id=pose_id, file=upload_again, + crop_file=None, detections=detections, webhooks=webhooks, organizations=organizations, @@ -1357,3 +1360,156 @@ async def test_attach_sequence_does_not_bridge_to_distant_alert(detection_sessio mappings_res = await detection_session.exec(select(AlertSequence).where(AlertSequence.alert_id == smoke_a_alert_id)) seqs_in_a = {m.sequence_id for m in mappings_res.all()} assert seq_cam2.id not in seqs_in_a + + +@pytest.mark.asyncio +async def test_create_detection_persists_crop_bucket_key( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[1]["id"], + ["camera"], + pytest.camera_table[1]["organization_id"], + ) + payload = {"pose_id": 3, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]"} + response = await async_client.post( + "/detections", + data=payload, + files={ + "file": ("frame.jpg", mock_img, "image/jpeg"), + "crop": ("crop.jpg", mock_img, "image/jpeg"), + }, + headers=auth, + ) + assert response.status_code == 201, response.text + data = response.json() + assert isinstance(data["crop_bucket_key"], str) + assert data["crop_bucket_key"].startswith("crop_") + assert data["crop_bucket_key"] != data["bucket_key"] + + det = await detection_session.get(Detection, data["id"]) + assert det is not None + assert det.crop_bucket_key == data["crop_bucket_key"] + + +@pytest.mark.asyncio +async def test_create_detection_without_crop_leaves_field_null( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[1]["id"], + ["camera"], + pytest.camera_table[1]["organization_id"], + ) + payload = {"pose_id": 3, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]"} + response = await async_client.post( + "/detections", + data=payload, + files={"file": ("frame.jpg", mock_img, "image/jpeg")}, + headers=auth, + ) + assert response.status_code == 201, response.text + assert response.json()["crop_bucket_key"] is None + + +@pytest.mark.asyncio +async def test_get_detection_url_returns_crop_url( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + detection = await detection_session.get(Detection, pytest.detection_table[0]["id"]) + assert detection is not None + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(pytest.camera_table[0]["organization_id"])) + crop_key = "crop_for_url_test.jpg" + bucket.upload_file(crop_key, io.BytesIO(mock_img)) + try: + detection.crop_bucket_key = crop_key + detection_session.add(detection) + await detection_session.commit() + + auth = pytest.get_token( + pytest.user_table[0]["id"], + pytest.user_table[0]["role"].split(), + pytest.user_table[0]["organization_id"], + ) + response = await async_client.get(f"/detections/{detection.id}/url", headers=auth) + assert response.status_code == 200, response.text + body = response.json() + assert isinstance(body["url"], str) + assert body["url"].startswith("http://") + assert isinstance(body["crop_url"], str) + assert body["crop_url"].startswith("http://") + assert body["crop_url"] != body["url"] + finally: + bucket.delete_file(crop_key) + + +@pytest.mark.asyncio +async def test_get_detection_url_crop_url_null_without_crop(async_client: AsyncClient, detection_session: AsyncSession): + auth = pytest.get_token( + pytest.user_table[0]["id"], + pytest.user_table[0]["role"].split(), + pytest.user_table[0]["organization_id"], + ) + detection_id = pytest.detection_table[0]["id"] + response = await async_client.get(f"/detections/{detection_id}/url", headers=auth) + assert response.status_code == 200, response.text + assert response.json()["crop_url"] is None + + +@pytest.mark.asyncio +async def test_create_detection_with_identical_crop_bytes_produces_distinct_keys( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[1]["id"], + ["camera"], + pytest.camera_table[1]["organization_id"], + ) + payload = {"pose_id": 3, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]"} + # Same bytes for frame and crop must not collide on the same bucket key. + response = await async_client.post( + "/detections", + data=payload, + files={ + "file": ("frame.jpg", mock_img, "image/jpeg"), + "crop": ("crop.jpg", mock_img, "image/jpeg"), + }, + headers=auth, + ) + assert response.status_code == 201, response.text + data = response.json() + assert data["bucket_key"] != data["crop_bucket_key"] + assert data["crop_bucket_key"].startswith("crop_") + + +@pytest.mark.asyncio +async def test_create_detection_authorizes_pose_before_uploading( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes, monkeypatch +): + upload_calls: List[str] = [] + + async def fake_upload_file(file: UploadFile, organization_id: int, camera_id: int) -> str: # noqa: RUF029 + upload_calls.append(file.filename or "") + return "should-never-persist" + + monkeypatch.setattr(detections_api, "upload_file", fake_upload_file) + + # Camera 1's token paired with pose 3 (owned by camera 2) must 403 before any upload. + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + payload = {"pose_id": 3, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]"} + response = await async_client.post( + "/detections", + data=payload, + files={ + "file": ("frame.jpg", mock_img, "image/jpeg"), + "crop": ("crop.jpg", mock_img, "image/jpeg"), + }, + headers=auth, + ) + assert response.status_code == 403, response.text + assert response.json()["detail"] == "Access forbidden." + assert upload_calls == [] diff --git a/src/tests/endpoints/test_sequences.py b/src/tests/endpoints/test_sequences.py index 7569f247..896fe993 100644 --- a/src/tests/endpoints/test_sequences.py +++ b/src/tests/endpoints/test_sequences.py @@ -1,3 +1,4 @@ +import io from datetime import datetime, timedelta from typing import Any, Dict, List, Union from unittest.mock import AsyncMock, MagicMock, patch @@ -12,6 +13,7 @@ from app.models import Alert, AlertSequence, AnnotationType, Camera, Detection, Pose, Sequence, UserRole from app.schemas.login import TokenPayload from app.schemas.sequences import SequenceLabel +from app.services.storage import s3_service @pytest.mark.parametrize( @@ -50,8 +52,11 @@ async def test_fetch_sequence_detections( if isinstance(status_detail, str): assert response.json()["detail"] == status_detail if response.status_code // 100 == 2 and expected_result is not None: - assert [{k: v for k, v in det.items() if k != "url"} for det in response.json()] == expected_result + assert [ + {k: v for k, v in det.items() if k not in {"url", "crop_url"}} for det in response.json() + ] == expected_result assert all(det["url"].startswith("http://") for det in response.json()) + assert all(det["crop_url"] is None for det in response.json()) @pytest.mark.parametrize( @@ -589,3 +594,65 @@ async def test_unit_label_sequence_forbidden_for_wrong_org(): ) assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_fetch_sequence_detections_includes_crop_url( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + detection = await detection_session.get(Detection, pytest.detection_table[0]["id"]) + assert detection is not None + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(pytest.camera_table[0]["organization_id"])) + crop_key = "crop_for_sequence_test.jpg" + bucket.upload_file(crop_key, io.BytesIO(mock_img)) + try: + detection.crop_bucket_key = crop_key + detection_session.add(detection) + await detection_session.commit() + + auth = pytest.get_token( + pytest.user_table[0]["id"], + pytest.user_table[0]["role"].split(), + pytest.user_table[0]["organization_id"], + ) + sequence_id = pytest.detection_table[0]["sequence_id"] + response = await async_client.get(f"/sequences/{sequence_id}/detections", headers=auth) + assert response.status_code == 200, response.text + payload = response.json() + enriched = next(det for det in payload if det["id"] == detection.id) + assert isinstance(enriched["crop_url"], str) + assert enriched["crop_url"].startswith("http://") + assert enriched["crop_url"] != enriched["url"] + other = [det for det in payload if det["id"] != detection.id] + assert all(det["crop_url"] is None for det in other) + finally: + bucket.delete_file(crop_key) + + +@pytest.mark.asyncio +async def test_fetch_sequence_detections_with_crop_false_skips_crop_url( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + detection = await detection_session.get(Detection, pytest.detection_table[0]["id"]) + assert detection is not None + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(pytest.camera_table[0]["organization_id"])) + crop_key = "crop_for_sequence_off_test.jpg" + bucket.upload_file(crop_key, io.BytesIO(mock_img)) + try: + detection.crop_bucket_key = crop_key + detection_session.add(detection) + await detection_session.commit() + + auth = pytest.get_token( + pytest.user_table[0]["id"], + pytest.user_table[0]["role"].split(), + pytest.user_table[0]["organization_id"], + ) + sequence_id = pytest.detection_table[0]["sequence_id"] + response = await async_client.get( + f"/sequences/{sequence_id}/detections", params={"with_crop": "false"}, headers=auth + ) + assert response.status_code == 200, response.text + assert all(det["crop_url"] is None for det in response.json()) + finally: + bucket.delete_file(crop_key)