diff --git a/frontend/src/lib/api/services/pointing.service.ts b/frontend/src/lib/api/services/pointing.service.ts index 278ce471..7a28ab81 100644 --- a/frontend/src/lib/api/services/pointing.service.ts +++ b/frontend/src/lib/api/services/pointing.service.ts @@ -5,7 +5,9 @@ import type { PointingResponse, PointingUpdate, CancelAllRequest, - PointingFilters + PointingFilters, + PointingDeleteRequest, + PointingUpdateBody } from '../types/pointing.types'; import type { DOIRequest, DOIRequestResponse } from '../types/doi.types'; @@ -30,6 +32,18 @@ export const pointingService = { return response.data; }, + updatePointing: async (id: number, update: PointingUpdateBody): Promise<{ message: string }> => { + const response = await client.put<{ message: string }>(`/api/v1/pointings/${id}`, update); + return response.data; + }, + + deletePointings: async (request: PointingDeleteRequest): Promise<{ message: string }> => { + const response = await client.delete<{ message: string }>('/api/v1/pointings', { + data: request + }); + return response.data; + }, + requestDOI: async (request: DOIRequest): Promise => { const response = await client.post('/api/v1/request_doi', request); return response.data; diff --git a/frontend/src/lib/api/types/pointing.types.ts b/frontend/src/lib/api/types/pointing.types.ts index 6fea53f7..529cfde1 100644 --- a/frontend/src/lib/api/types/pointing.types.ts +++ b/frontend/src/lib/api/types/pointing.types.ts @@ -64,6 +64,25 @@ export interface CancelAllRequest { instrumentid: number; } +export interface PointingDeleteRequest { + ids: number[]; +} + +export interface PointingUpdateBody { + status?: string | number; + time?: string; + depth?: number; + depth_err?: number; + depth_unit?: string | number; + band?: string | number; + pos_angle?: number; + central_wave?: number; + bandwidth?: number; + ra?: number; + dec?: number; + position?: string; +} + export interface PointingFilters { graceid?: string; graceids?: string; diff --git a/frontend/src/lib/components/search/PointingsTable.svelte b/frontend/src/lib/components/search/PointingsTable.svelte index 04d675d9..7da23de0 100644 --- a/frontend/src/lib/components/search/PointingsTable.svelte +++ b/frontend/src/lib/components/search/PointingsTable.svelte @@ -30,11 +30,7 @@ ]; function canSelectPointing(pointing: any): boolean { - // Only allow selection if: - // 1. Selection is allowed (allowSelection is true, which means myPointsOnly is true) - // 2. Status is 'completed' - // 3. No DOI URL already exists - return allowSelection && pointing.status === 'completed' && !pointing.doi_url; + return allowSelection; } function togglePointingSelection(pointingId: number) { diff --git a/frontend/src/routes/search/pointings/+page.svelte b/frontend/src/routes/search/pointings/+page.svelte index afc5011b..5d8509c1 100644 --- a/frontend/src/routes/search/pointings/+page.svelte +++ b/frontend/src/routes/search/pointings/+page.svelte @@ -113,6 +113,42 @@ function handleSelectionChange(event: CustomEvent) { selectedPointings = event.detail.selectedPointings; } + + // DOI requests are only valid for completed pointings without an existing DOI. + // Compute this subset so DoiRequestPanel never receives ineligible IDs. + $: doiEligibleSelected = new Set( + searchResults + .filter((p) => selectedPointings.has(p.id) && p.status === 'completed' && !p.doi_url) + .map((p) => p.id) + ); + + // Handle delete selected pointings + let isDeleting = false; + let deleteError = ''; + + async function handleDeleteSelected() { + if (selectedPointings.size === 0) return; + + const count = selectedPointings.size; + const confirmed = confirm( + `Delete ${count} pointing${count === 1 ? '' : 's'}? This cannot be undone.` + ); + if (!confirmed) return; + + isDeleting = true; + deleteError = ''; + + try { + await api.pointings.deletePointings({ ids: [...selectedPointings] }); + searchResults = searchResults.filter((p) => !selectedPointings.has(p.id)); + selectedPointings.clear(); + selectedPointings = selectedPointings; + } catch (err) { + deleteError = err instanceof Error ? err.message : 'Delete failed. Please try again.'; + } finally { + isDeleting = false; + } + } @@ -137,13 +173,31 @@ {#if hasSearched && lastSearchParams?.my_points_only} {/if} + + {#if hasSearched && lastSearchParams?.my_points_only && selectedPointings.size > 0} +
+ + {#if deleteError} + {deleteError} + {/if} +
+ {/if} + {#if isSearching}
@@ -215,3 +269,26 @@ {/if}
+ + diff --git a/server/auth/auth.py b/server/auth/auth.py index e59b1ab0..11d9715d 100644 --- a/server/auth/auth.py +++ b/server/auth/auth.py @@ -109,31 +109,30 @@ def get_current_user( ) +def is_admin_user(user: Users, db: Session) -> bool: + """Return True if the user belongs to the admin group.""" + return ( + db.query(UserGroups) + .join(Groups, UserGroups.groupid == Groups.id) + .filter( + UserGroups.userid == user.id, + func.lower(Groups.name) == "admin", + ) + .first() + ) is not None + + def verify_admin( user: Users = Depends(get_current_user), db: Session = Depends(get_db) ) -> Users: """ Check if the user belongs to the admin group (case-insensitive). """ - admin_group = db.query(Groups).filter(func.lower(Groups.name) == "admin").first() - if not admin_group: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Admin group does not exist", - ) - - user_group = ( - db.query(UserGroups) - .filter(UserGroups.userid == user.id, UserGroups.groupid == admin_group.id) - .first() - ) - - if not user_group: + if not is_admin_user(user, db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can access this endpoint", ) - return user diff --git a/server/routes/event/delete_candidate_event.py b/server/routes/event/delete_candidate_event.py index d9b94182..a0077035 100644 --- a/server/routes/event/delete_candidate_event.py +++ b/server/routes/event/delete_candidate_event.py @@ -6,8 +6,7 @@ from server.db.database import get_db from server.db.models.candidate import GWCandidate from server.utils.error_handling import not_found_exception, permission_exception -from server.auth.auth import get_current_user -from .utils import is_admin +from server.auth.auth import get_current_user, is_admin_user router = APIRouter(tags=["Events"]) @@ -25,7 +24,7 @@ async def delete_candidate_event( raise not_found_exception("Candidate not found") # Check if user is the owner or an admin - if db_candidate.submitterid != current_user.id and not is_admin(current_user, db): + if db_candidate.submitterid != current_user.id and not is_admin_user(current_user, db): raise permission_exception("Not authorized to delete this candidate") db.delete(db_candidate) diff --git a/server/routes/event/update_candidate_event.py b/server/routes/event/update_candidate_event.py index aba0cd7f..8d6c2fc8 100644 --- a/server/routes/event/update_candidate_event.py +++ b/server/routes/event/update_candidate_event.py @@ -7,8 +7,7 @@ from server.db.models.candidate import GWCandidate from server.schemas.candidate import GWCandidateSchema from server.utils.error_handling import not_found_exception, permission_exception -from server.auth.auth import get_current_user -from .utils import is_admin +from server.auth.auth import get_current_user, is_admin_user router = APIRouter(tags=["Events"]) @@ -27,7 +26,7 @@ async def update_candidate_event( raise not_found_exception("Candidate not found") # Check if user is the owner or an admin - if db_candidate.submitterid != current_user.id and not is_admin(current_user, db): + if db_candidate.submitterid != current_user.id and not is_admin_user(current_user, db): raise permission_exception("Not authorized to update this candidate") # Update fields diff --git a/server/routes/event/utils.py b/server/routes/event/utils.py deleted file mode 100644 index 51c88ebd..00000000 --- a/server/routes/event/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Utility functions for event routes.""" - -from sqlalchemy.orm import Session -from server.db.models.users import UserGroups, Groups - - -def is_admin(user, db: Session) -> bool: - """Check if the user is an admin.""" - admin_group = db.query(Groups).filter(Groups.name == "admin").first() - if not admin_group: - return False - - user_group = ( - db.query(UserGroups) - .filter(UserGroups.userid == user.id, UserGroups.groupid == admin_group.id) - .first() - ) - - return user_group is not None diff --git a/server/routes/pointing/delete_pointings.py b/server/routes/pointing/delete_pointings.py new file mode 100644 index 00000000..4e2fa231 --- /dev/null +++ b/server/routes/pointing/delete_pointings.py @@ -0,0 +1,59 @@ +"""Delete pointings endpoint.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from server.db.database import get_db +from server.db.models.pointing import Pointing +from server.db.models.pointing_event import PointingEvent +from server.schemas.pointing import PointingDeleteRequest +from server.auth.auth import get_current_user, is_admin_user +from server.utils.error_handling import not_found_exception, validation_exception + +router = APIRouter(tags=["pointings"]) + + +@router.delete("/pointings") +async def delete_pointings( + request: PointingDeleteRequest, + db: Session = Depends(get_db), + user=Depends(get_current_user), +): + """ + Delete pointings by ID. + + Users may only delete their own pointings. Admins may delete any pointing. + Associated pointing_event rows are also removed. + """ + try: + admin = is_admin_user(user, db) + + query = db.query(Pointing.id).filter(Pointing.id.in_(request.ids)) + if not admin: + query = query.filter(Pointing.submitterid == user.id) + + deleted_ids = sorted(row.id for row in query.all()) + if not deleted_ids: + raise not_found_exception("No matching pointings found for the given IDs.") + + db.query(PointingEvent).filter( + PointingEvent.pointingid.in_(deleted_ids) + ).delete(synchronize_session=False) + + db.query(Pointing).filter( + Pointing.id.in_(deleted_ids) + ).delete(synchronize_session=False) + + db.commit() + + failed_ids = sorted(set(request.ids) - set(deleted_ids)) + return { + "message": f"Deleted {len(deleted_ids)} of {len(request.ids)} pointing(s).", + "deleted_ids": deleted_ids, + "failed_ids": failed_ids, + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise validation_exception(message="Invalid request", errors=[str(e)]) diff --git a/server/routes/pointing/put_pointing.py b/server/routes/pointing/put_pointing.py new file mode 100644 index 00000000..5954ae5a --- /dev/null +++ b/server/routes/pointing/put_pointing.py @@ -0,0 +1,75 @@ +"""PUT pointing endpoint — update mutable fields on an existing pointing.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime + +from server.db.database import get_db +from server.db.models.pointing import Pointing +from server.schemas.pointing import PointingBase +from server.auth.auth import get_current_user, is_admin_user +from server.utils.error_handling import not_found_exception, validation_exception + +router = APIRouter(tags=["pointings"]) + +# Fields from PointingBase that are allowed to be updated on an existing pointing. +# instrumentid is intentionally excluded — it is fixed at submission time. +# position/ra/dec are handled separately below. +_UPDATABLE_FIELDS = frozenset({ + "status", "time", "depth", "depth_err", "depth_unit", + "band", "pos_angle", "central_wave", "bandwidth", +}) + + +@router.put("/pointings/{pointing_id}") +async def put_pointing( + pointing_id: int, + update: PointingBase, + db: Session = Depends(get_db), + user=Depends(get_current_user), +): + """ + Update fields on an existing pointing. + + Users may only update their own pointings. Admins may update any pointing. + Only fields present in the request body are changed. + """ + try: + admin = is_admin_user(user, db) + + query = db.query(Pointing).filter(Pointing.id == pointing_id) + if not admin: + query = query.filter(Pointing.submitterid == user.id) + + pointing = query.first() + if pointing is None: + raise not_found_exception( + f"Pointing {pointing_id} not found or not owned by user." + ) + + if (update.ra is None) != (update.dec is None): + raise validation_exception("ra and dec must be provided together.") + + fields_to_update = update.model_dump(exclude_unset=True).keys() & _UPDATABLE_FIELDS + has_position = (update.ra is not None and update.dec is not None) or update.position is not None + + if not fields_to_update and not has_position: + raise validation_exception("No updatable fields provided.") + + for field in fields_to_update: + setattr(pointing, field, getattr(update, field)) + + if update.ra is not None and update.dec is not None: + pointing.position = f"POINT({update.ra} {update.dec})" + elif update.position is not None: + pointing.position = update.position + + pointing.dateupdated = datetime.now() + db.commit() + + return {"message": f"Updated pointing {pointing_id} successfully."} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise validation_exception(message="Invalid request", errors=[str(e)]) diff --git a/server/routes/pointing/router.py b/server/routes/pointing/router.py index 97754236..71a8868c 100644 --- a/server/routes/pointing/router.py +++ b/server/routes/pointing/router.py @@ -8,6 +8,8 @@ from .update_pointings import router as update_pointings_router from .cancel_all import router as cancel_all_router from .request_doi import router as request_doi_router +from .delete_pointings import router as delete_pointings_router +from .put_pointing import router as put_pointing_router # Create the main router that includes all pointing routes router = APIRouter(tags=["pointings"]) @@ -18,3 +20,5 @@ router.include_router(update_pointings_router) router.include_router(cancel_all_router) router.include_router(request_doi_router) +router.include_router(delete_pointings_router) +router.include_router(put_pointing_router) diff --git a/server/schemas/pointing.py b/server/schemas/pointing.py index b4db2ac6..1303820d 100644 --- a/server/schemas/pointing.py +++ b/server/schemas/pointing.py @@ -74,8 +74,13 @@ def validate_status(cls, value): try: return pointing_status_enum[value] except KeyError: - # Return the string value if it's not a valid enum - return value + valid = [s.name for s in pointing_status_enum] + raise ValueError(f"Invalid status '{value}'. Valid values: {valid}") + elif isinstance(value, int): + try: + return pointing_status_enum(value) + except ValueError: + raise ValueError(f"Invalid status value: {value}") return value @field_validator("depth_unit", mode="before") @@ -85,14 +90,13 @@ def validate_depth_unit(cls, value): try: return depth_unit_enum[value] except KeyError: - # Return the string value if it's not a valid enum - return value + valid = [d.name for d in depth_unit_enum] + raise ValueError(f"Invalid depth_unit '{value}'. Valid values: {valid}") elif isinstance(value, int): try: return depth_unit_enum(value) except ValueError: - # Return the int value if it's not a valid enum - return value + raise ValueError(f"Invalid depth_unit value: {value}") return value @field_validator("band", mode="before") @@ -102,14 +106,28 @@ def validate_band(cls, value): try: return bandpass_enum[value] except KeyError: - # Return the string value if it's not a valid enum - return value + valid = [b.name for b in bandpass_enum] + raise ValueError(f"Invalid band '{value}'. Valid values: {valid}") elif isinstance(value, int): try: return bandpass_enum(value) except ValueError: - # Return the int value if it's not a valid enum - return value + raise ValueError(f"Invalid band value: {value}") + return value + + @field_validator("position", mode="before") + @classmethod + def validate_position(cls, value): + # Pass through None and non-strings (e.g. WKBElement from the DB). + if value is None or not isinstance(value, str): + return value + if not ( + all(token in value for token in ("POINT", "(", ")", " ")) + and "," not in value + ): + raise ValueError( + f"Invalid position '{value}'. Must be WKT format like 'POINT(ra dec)'." + ) return value model_config = ConfigDict( @@ -298,6 +316,11 @@ def validate_status(cls, value): raise ValueError( f"Invalid status: {value}. Valid values are: {[s.name for s in pointing_status_enum]}" ) + elif isinstance(value, int): + try: + return pointing_status_enum(value) + except ValueError: + raise ValueError(f"Invalid status value: {value}") return value @model_validator(mode="after") @@ -320,6 +343,18 @@ class CancelAllRequest(BaseModel): instrumentid: int = Field(..., description="Instrument ID to cancel pointings for") +class PointingDeleteRequest(BaseModel): + """Schema for deleting pointings by ID.""" + + ids: List[int] = Field(..., description="List of pointing IDs to delete") + + @model_validator(mode="after") + def validate_ids(self): + if not self.ids: + raise ValueError("At least one pointing ID must be provided") + return self + + class DOIRequest(BaseModel): """Schema for requesting a DOI.""" diff --git a/tests/fastapi/test_pointing.py b/tests/fastapi/test_pointing.py index 35dc5174..8c9fbe5f 100644 --- a/tests/fastapi/test_pointing.py +++ b/tests/fastapi/test_pointing.py @@ -805,3 +805,240 @@ def test_create_pointing_for_existing_graceid(self): @classmethod def teardown_class(cls): cls.session.close() + + +class TestPointingDeleteAndPut: + """Tests for the RESTful DELETE and PUT pointing endpoints.""" + + admin_token = "test_token_admin_001" + user_token = "test_token_user_002" + + def get_url(self, endpoint): + return f"{API_BASE_URL}{API_V1_PREFIX}{endpoint}" + + def _create_pointing(self, token, graceid="S190425z", ra=50.0, dec=-5.0): + """Helper to create a pointing and return its ID.""" + data = { + "graceid": graceid, + "pointing": { + "ra": ra, + "dec": dec, + "instrumentid": 1, + "depth": 21.0, + "depth_unit": "ab_mag", + "time": "2019-04-25T10:00:00.000000", + "status": "planned", + "band": "r", + }, + } + response = requests.post( + self.get_url("/pointings"), + json=data, + headers={"api_token": token}, + ) + assert response.status_code == status.HTTP_200_OK + return response.json()["pointing_ids"][0] + + def test_delete_own_pointing(self): + """User can delete their own pointing.""" + pid = self._create_pointing(self.admin_token, ra=51.0, dec=-6.0) + + response = requests.delete( + self.get_url("/pointings"), + json={"ids": [pid]}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_200_OK + assert "Deleted 1" in response.json()["message"] + + # Verify it's gone + get_resp = requests.get(self.get_url("/pointings"), params={"id": pid}) + assert get_resp.status_code == status.HTTP_200_OK + assert get_resp.json() == [] + + def test_delete_other_users_pointing_denied(self): + """Non-admin cannot delete another user's pointing.""" + pid = self._create_pointing(self.admin_token, ra=52.0, dec=-7.0) + + response = requests.delete( + self.get_url("/pointings"), + json={"ids": [pid]}, + headers={"api_token": self.user_token}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_nonexistent_pointing(self): + """Deleting a non-existent ID returns 404.""" + response = requests.delete( + self.get_url("/pointings"), + json={"ids": [999999]}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_multiple_pointings(self): + """User can delete multiple of their own pointings in one call.""" + pid1 = self._create_pointing(self.admin_token, ra=53.0, dec=-8.0) + pid2 = self._create_pointing(self.admin_token, ra=54.0, dec=-9.0) + + response = requests.delete( + self.get_url("/pointings"), + json={"ids": [pid1, pid2]}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_200_OK + assert "Deleted 2" in response.json()["message"] + + def test_put_pointing_update_status(self): + """User can update their own pointing's status.""" + pid = self._create_pointing(self.admin_token, ra=55.0, dec=-10.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={"status": "cancelled"}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify the status changed + get_resp = requests.get(self.get_url("/pointings"), params={"id": pid}) + assert get_resp.json()[0]["status"] == "cancelled" + + def test_put_pointing_update_depth_and_band(self): + """User can update depth and band on their pointing.""" + pid = self._create_pointing(self.admin_token, ra=56.0, dec=-11.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={"depth": 23.5, "band": "g", "depth_unit": "ab_mag"}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_200_OK + + get_resp = requests.get(self.get_url("/pointings"), params={"id": pid}) + p = get_resp.json()[0] + assert p["depth"] == 23.5 + assert p["band"] == "g" + + def test_put_pointing_other_users_denied(self): + """Non-admin cannot update another user's pointing.""" + pid = self._create_pointing(self.admin_token, ra=57.0, dec=-12.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={"status": "cancelled"}, + headers={"api_token": self.user_token}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_put_pointing_nonexistent(self): + """PUT on a non-existent pointing ID returns 404.""" + response = requests.put( + self.get_url("/pointings/999999"), + json={"status": "cancelled"}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_put_pointing_invalid_status(self): + """PUT with an invalid status value returns 422.""" + pid = self._create_pointing(self.admin_token, ra=58.0, dec=-13.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={"status": "invalid_status"}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY) + + def test_put_pointing_integer_status(self): + """PUT accepts a numeric status value and converts it to the enum.""" + pid = self._create_pointing(self.admin_token, ra=59.0, dec=-14.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={"status": 3}, # cancelled + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_200_OK + get_resp = requests.get(self.get_url("/pointings"), params={"id": pid}) + assert get_resp.json()[0]["status"] == "cancelled" + + def test_put_pointing_empty_body_rejected(self): + """PUT with no updatable fields returns 400.""" + pid = self._create_pointing(self.admin_token, ra=60.0, dec=-15.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "No updatable fields" in str(response.json()) + + def test_put_pointing_position_string(self): + """PUT accepts a WKT POINT(...) string for position.""" + pid = self._create_pointing(self.admin_token, ra=61.0, dec=-16.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={"position": "POINT(70.5 -25.5)"}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_200_OK + get_resp = requests.get(self.get_url("/pointings"), params={"id": pid}) + assert "70.5" in get_resp.json()[0]["position"] + assert "-25.5" in get_resp.json()[0]["position"] + + def test_put_pointing_invalid_position_string(self): + """PUT with a malformed position string is rejected.""" + pid = self._create_pointing(self.admin_token, ra=62.0, dec=-17.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={"position": "garbage"}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY) + + def test_put_pointing_partial_ra_dec_rejected(self): + """PUT with only ra (or only dec) is rejected.""" + pid = self._create_pointing(self.admin_token, ra=63.0, dec=-18.0) + + response = requests.put( + self.get_url(f"/pointings/{pid}"), + json={"ra": 75.0}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "ra and dec" in str(response.json()) + + def test_delete_partial_success_returns_failed_ids(self): + """Mixed valid/invalid IDs return 200 with deleted_ids and failed_ids.""" + pid = self._create_pointing(self.admin_token, ra=64.0, dec=-19.0) + + response = requests.delete( + self.get_url("/pointings"), + json={"ids": [pid, 999999]}, + headers={"api_token": self.admin_token}, + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["deleted_ids"] == [pid] + assert body["failed_ids"] == [999999] + assert "Deleted 1 of 2" in body["message"]