diff --git a/app/api/api_v1/routers/event.py b/app/api/api_v1/routers/event.py index 9e633004..b1f2699e 100644 --- a/app/api/api_v1/routers/event.py +++ b/app/api/api_v1/routers/event.py @@ -147,3 +147,31 @@ async def update_event( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) return event + + +@r.delete( + "/events/{import_id}", +) +async def delete_event( + import_id: str, +) -> None: + """ + Deletes a specific event given the import id. + + :param str import_id: Specified import_id. + :raises HTTPException: If the event is not found a 404 is returned. + """ + try: + event_deleted = event_service.delete(import_id) + except ValidationError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.message) + except RepositoryError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=e.message + ) + + if not event_deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Event not deleted: {import_id}", + ) diff --git a/app/clients/db/models/law_policy/family.py b/app/clients/db/models/law_policy/family.py index f94984ba..e78a53be 100644 --- a/app/clients/db/models/law_policy/family.py +++ b/app/clients/db/models/law_policy/family.py @@ -205,6 +205,7 @@ class EventStatus(BaseModelEnum): # case we will need to validate, remove unnecessary duplicates & create new # events through a data cleaning exercise. DUPLICATED = "Duplicated" + DELETED = "Deleted" class FamilyEventType(Base): diff --git a/app/repository/event.py b/app/repository/event.py index cb490e97..063d6d21 100644 --- a/app/repository/event.py +++ b/app/repository/event.py @@ -177,7 +177,7 @@ def update(db: Session, import_id: str, event: EventWriteDTO) -> bool: :param db Session: the database connection :param str import_id: The event import id to change. - :param DocumentDTO event: The new values + :param EventDTO event: The new values :return bool: True if new values were set otherwise false. """ new_values = event.model_dump() @@ -208,6 +208,34 @@ def update(db: Session, import_id: str, event: EventWriteDTO) -> bool: return True +def delete(db: Session, import_id: str) -> bool: + """ + Deletes a single event by the import id. + + :param db Session: the database connection + :param str import_id: The event import id to delete. + :return bool: True if deleted False if not. + """ + + found = ( + db.query(FamilyEvent).filter(FamilyEvent.import_id == import_id).one_or_none() + ) + if found is None: + return False + + result = db.execute( + db_update(FamilyEvent) + .where(FamilyEvent.import_id == import_id) + .values(status=EventStatus.DELETED) + ) + if result.rowcount == 0: # type: ignore + msg = f"Could not delete event : {import_id}" + _LOGGER.error(msg) + raise RepositoryError(msg) + + return True + + def count(db: Session) -> Optional[int]: """ Counts the number of family events in the repository. diff --git a/app/service/event.py b/app/service/event.py index c4922405..331bb332 100644 --- a/app/service/event.py +++ b/app/service/event.py @@ -123,6 +123,21 @@ def update( raise RepositoryError(f"Error when updating event {import_id}") +@db_session.with_transaction(__name__) +@validate_call(config=ConfigDict(arbitrary_types_allowed=True)) +def delete(import_id: str, db: Session = db_session.get_db()) -> bool: + """ + Deletes the event specified by the import_id. + + :param str import_id: The import_id of the event to delete. + :raises RepositoryError: raised on a database error. + :raises ValidationError: raised should the import_id be invalid. + :return bool: True if deleted else False. + """ + id.validate(import_id) + return event_repo.delete(db, import_id) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def count() -> Optional[int]: """ diff --git a/integration_tests/event/test_delete.py b/integration_tests/event/test_delete.py new file mode 100644 index 00000000..d71fb0db --- /dev/null +++ b/integration_tests/event/test_delete.py @@ -0,0 +1,89 @@ +from fastapi.testclient import TestClient +from fastapi import status +from sqlalchemy.orm import Session +from app.clients.db.models.law_policy import FamilyEvent, EventStatus +from integration_tests.setup_db import setup_db +import app.repository.event as event_repo + + +def test_delete_event(client: TestClient, test_db: Session, admin_user_header_token): + setup_db(test_db) + response = client.delete("/api/v1/events/E.0.0.2", headers=admin_user_header_token) + assert response.status_code == status.HTTP_200_OK + assert test_db.query(FamilyEvent).count() == 3 + assert ( + test_db.query(FamilyEvent) + .filter(FamilyEvent.status == EventStatus.DELETED) + .count() + == 1 + ) + assert test_db.query(FamilyEvent).count() == 3 + + +def test_delete_event_when_not_authenticated( + client: TestClient, test_db: Session, mocker +): + setup_db(test_db) + mocker.spy(event_repo, "delete") + response = client.delete( + "/api/v1/events/E.0.0.2", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert test_db.query(FamilyEvent).count() == 3 + assert ( + test_db.query(FamilyEvent) + .filter(FamilyEvent.status == EventStatus.DELETED) + .count() + == 0 + ) + assert test_db.query(FamilyEvent).count() == 3 + assert event_repo.delete.call_count == 0 + + +def test_delete_event_rollback( + client: TestClient, + test_db: Session, + rollback_event_repo, + admin_user_header_token, +): + setup_db(test_db) + response = client.delete("/api/v1/events/E.0.0.2", headers=admin_user_header_token) + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert test_db.query(FamilyEvent).count() == 3 + assert ( + test_db.query(FamilyEvent) + .filter(FamilyEvent.status == EventStatus.DELETED) + .count() + == 0 + ) + assert test_db.query(FamilyEvent).count() == 3 + assert rollback_event_repo.delete.call_count == 1 + + +def test_delete_event_when_not_found( + client: TestClient, test_db: Session, admin_user_header_token +): + setup_db(test_db) + response = client.delete("/api/v1/events/E.0.0.22", headers=admin_user_header_token) + assert response.status_code == status.HTTP_404_NOT_FOUND + data = response.json() + assert data["detail"] == "Event not deleted: E.0.0.22" + assert test_db.query(FamilyEvent).count() == 3 + assert ( + test_db.query(FamilyEvent) + .filter(FamilyEvent.status == EventStatus.DELETED) + .count() + == 0 + ) + assert test_db.query(FamilyEvent).count() == 3 + + +def test_delete_event_when_db_error( + client: TestClient, test_db: Session, bad_event_repo, admin_user_header_token +): + setup_db(test_db) + response = client.delete("/api/v1/events/E.0.0.1", headers=admin_user_header_token) + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + data = response.json() + assert data["detail"] == "Bad Repo" + assert bad_event_repo.delete.call_count == 1 diff --git a/integration_tests/mocks/bad_event_repo.py b/integration_tests/mocks/bad_event_repo.py index d7452226..29c1317d 100644 --- a/integration_tests/mocks/bad_event_repo.py +++ b/integration_tests/mocks/bad_event_repo.py @@ -21,6 +21,9 @@ def mock_create(_, data: EventCreateDTO) -> Optional[EventReadDTO]: def mock_update(_, import_id, data: EventReadDTO) -> Optional[EventReadDTO]: raise RepositoryError("Bad Repo") + def mock_delete(_, import_id: str) -> bool: + raise RepositoryError("Bad Repo") + def mock_get_count(_) -> Optional[int]: raise RepositoryError("Bad Repo") @@ -39,6 +42,9 @@ def mock_get_count(_) -> Optional[int]: monkeypatch.setattr(repo, "update", mock_update) mocker.spy(repo, "update") + monkeypatch.setattr(repo, "delete", mock_delete) + mocker.spy(repo, "delete") + monkeypatch.setattr(repo, "count", mock_get_count) mocker.spy(repo, "count") diff --git a/integration_tests/mocks/rollback_event_repo.py b/integration_tests/mocks/rollback_event_repo.py index 9a14dd01..2a490ded 100644 --- a/integration_tests/mocks/rollback_event_repo.py +++ b/integration_tests/mocks/rollback_event_repo.py @@ -9,6 +9,7 @@ def mock_rollback_event_repo(event_repo, monkeypatch: MonkeyPatch, mocker): actual_create = event_repo.create actual_update = event_repo.update + actual_delete = event_repo.delete def mock_create_event(db, data: EventCreateDTO) -> Optional[EventReadDTO]: actual_create(db, data) @@ -18,8 +19,15 @@ def mock_update_event(db, import_id: str, data: EventWriteDTO) -> EventReadDTO: actual_update(db, import_id, data) raise NoResultFound() + def mock_delete_document(db, import_id: str) -> bool: + actual_delete(db, import_id) + raise NoResultFound() + monkeypatch.setattr(event_repo, "create", mock_create_event) mocker.spy(event_repo, "create") monkeypatch.setattr(event_repo, "update", mock_update_event) mocker.spy(event_repo, "update") + + monkeypatch.setattr(event_repo, "delete", mock_delete_document) + mocker.spy(event_repo, "delete") diff --git a/unit_tests/mocks/repos/event_repo.py b/unit_tests/mocks/repos/event_repo.py index 6bfa57bd..ffc2cabc 100644 --- a/unit_tests/mocks/repos/event_repo.py +++ b/unit_tests/mocks/repos/event_repo.py @@ -43,6 +43,10 @@ def mock_update(_, import_id: str, data: EventWriteDTO) -> EventReadDTO: raise exc.NoResultFound() return create_event_read_dto("a.b.c.d") + def mock_delete(_, import_id: str) -> bool: + maybe_throw() + return not event_repo.return_empty + def mock_get_count(_) -> Optional[int]: maybe_throw() if not event_repo.return_empty: @@ -64,5 +68,8 @@ def mock_get_count(_) -> Optional[int]: monkeypatch.setattr(event_repo, "update", mock_update) mocker.spy(event_repo, "update") + monkeypatch.setattr(event_repo, "delete", mock_delete) + mocker.spy(event_repo, "delete") + monkeypatch.setattr(event_repo, "count", mock_get_count) mocker.spy(event_repo, "count") diff --git a/unit_tests/mocks/services/event_service.py b/unit_tests/mocks/services/event_service.py index 127018fd..cd72fac9 100644 --- a/unit_tests/mocks/services/event_service.py +++ b/unit_tests/mocks/services/event_service.py @@ -46,6 +46,10 @@ def mock_update_event( import_id, "family_import_id", data.event_title ) + def mock_delete_event(_) -> bool: + maybe_throw() + return not event_service.missing + def mock_count_event() -> Optional[int]: maybe_throw() if event_service.missing: @@ -67,5 +71,8 @@ def mock_count_event() -> Optional[int]: monkeypatch.setattr(event_service, "update", mock_update_event) mocker.spy(event_service, "update") + monkeypatch.setattr(event_service, "delete", mock_delete_event) + mocker.spy(event_service, "delete") + monkeypatch.setattr(event_service, "count", mock_count_event) mocker.spy(event_service, "count") diff --git a/unit_tests/routers/test_event.py b/unit_tests/routers/test_event.py index 1884a2d4..f14d230b 100644 --- a/unit_tests/routers/test_event.py +++ b/unit_tests/routers/test_event.py @@ -109,3 +109,30 @@ def test_update_when_not_found( data = response.json() assert data["detail"] == "Event not updated: a.b.c.d" assert event_service_mock.update.call_count == 1 + + +def test_delete_when_ok( + client: TestClient, event_service_mock, admin_user_header_token +): + response = client.delete("/api/v1/events/event1", headers=admin_user_header_token) + assert response.status_code == status.HTTP_200_OK + assert event_service_mock.delete.call_count == 1 + + +def test_delete_event_fails_if_not_admin( + client: TestClient, event_service_mock, user_header_token +): + response = client.delete("/api/v1/events/event1", headers=user_header_token) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert event_service_mock.delete.call_count == 0 + + +def test_delete_when_not_found( + client: TestClient, event_service_mock, admin_user_header_token +): + event_service_mock.missing = True + response = client.delete("/api/v1/events/event1", headers=admin_user_header_token) + assert response.status_code == status.HTTP_404_NOT_FOUND + data = response.json() + assert data["detail"] == "Event not deleted: event1" + assert event_service_mock.delete.call_count == 1 diff --git a/unit_tests/service/test_event_service.py b/unit_tests/service/test_event_service.py index 32f1c770..7f8a9e32 100644 --- a/unit_tests/service/test_event_service.py +++ b/unit_tests/service/test_event_service.py @@ -127,6 +127,31 @@ def test_update_raises_when_invalid_id( assert event_repo_mock.update.call_count == 0 +# --- DELETE + + +def test_delete(event_repo_mock): + ok = event_service.delete("a.b.c.d") + assert ok + assert event_repo_mock.delete.call_count == 1 + + +def test_delete_when_missing(event_repo_mock): + event_repo_mock.return_empty = True + ok = event_service.delete("a.b.c.d") + assert not ok + assert event_repo_mock.delete.call_count == 1 + + +def test_delete_raises_when_invalid_id(event_repo_mock): + import_id = "invalid" + with pytest.raises(ValidationError) as e: + event_service.delete(import_id) + expected_msg = f"The import id {import_id} is invalid!" + assert e.value.message == expected_msg + assert event_repo_mock.delete.call_count == 0 + + # --- COUNT