From 740548dc3da3610819a4fcd183902568979d4fe4 Mon Sep 17 00:00:00 2001 From: Katy Baulch <46493669+katybaulch@users.noreply.github.com> Date: Wed, 18 Oct 2023 17:46:58 +0100 Subject: [PATCH] Pdct 312 Added endpoint for creating events (#24) --- app/api/api_v1/routers/event.py | 29 +++- app/model/document.py | 2 + app/model/event.py | 30 ++++- app/repository/event.py | 68 +++++++++- app/service/event.py | 29 +++- integration_tests/conftest.py | 8 ++ integration_tests/event/test_create.py | 126 ++++++++++++++++++ integration_tests/mocks/bad_event_repo.py | 8 +- .../mocks/rollback_event_repo.py | 17 +++ integration_tests/setup_db.py | 2 +- unit_tests/helpers/event.py | 20 ++- unit_tests/mocks/repos/event_repo.py | 11 +- unit_tests/mocks/services/event_service.py | 14 +- unit_tests/routers/test_event.py | 31 +++++ unit_tests/service/test_event_service.py | 32 ++++- 15 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 integration_tests/event/test_create.py create mode 100644 integration_tests/mocks/rollback_event_repo.py diff --git a/app/api/api_v1/routers/event.py b/app/api/api_v1/routers/event.py index 8e69aac4..77fd2937 100644 --- a/app/api/api_v1/routers/event.py +++ b/app/api/api_v1/routers/event.py @@ -5,7 +5,7 @@ import app.service.event as event_service from app.errors import RepositoryError, ValidationError -from app.model.event import EventReadDTO +from app.model.event import EventCreateDTO, EventReadDTO event_router = r = APIRouter() @@ -89,3 +89,30 @@ async def get_event(import_id: str) -> EventReadDTO: ) return event + + +@r.post("/events", response_model=str, status_code=status.HTTP_201_CREATED) +async def create_document( + new_event: EventCreateDTO, +) -> str: + """ + Creates a specific event given the values in EventCreateDTO. + + :raises HTTPException: If a validation error occurs, a 400 is + returned. + :raises HTTPException: If an SQL alchemy database error occurs, a + 503 is returned. + :return str: returns a the import_id of the event created. + """ + try: + return event_service.create(new_event) + + except ValidationError as e: + _LOGGER.error(e.message) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.message) + + except RepositoryError as e: + _LOGGER.error(e.message) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=e.message + ) diff --git a/app/model/document.py b/app/model/document.py index c8addf6d..9e31954b 100644 --- a/app/model/document.py +++ b/app/model/document.py @@ -51,6 +51,8 @@ class DocumentCreateDTO(BaseModel): variant_name: str role: str type: str + + # From PhysicalDocument title: str source_url: str user_language_name: str diff --git a/app/model/event.py b/app/model/event.py index 3bfd7f6b..dad1ea60 100644 --- a/app/model/event.py +++ b/app/model/event.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel from typing import Optional +from pydantic import BaseModel from app.clients.db.models.law_policy.family import ( EventStatus, @@ -8,13 +8,39 @@ class EventReadDTO(BaseModel): - """JSON Representation of a Event for reading.""" + """ + JSON Representation of the DTO for reading an event. + + TODO: Add family document fields here including family title, + family document title, maybe geography etc.? + """ # From FamilyEvent import_id: str event_title: str date: datetime event_type_value: str + event_status: EventStatus + + # From FamilyDocument family_import_id: str family_document_import_id: Optional[str] = None + + +class EventCreateDTO(BaseModel): + """ + JSON Representation of the DTO for creating an event. + + We don't need to specify the import_id because this will be auto- + generated. + """ + + # From FamilyEvent + event_title: str + date: datetime + event_type_value: str event_status: EventStatus + + # From FamilyDocument + family_import_id: str + family_document_import_id: Optional[str] = None diff --git a/app/repository/event.py b/app/repository/event.py index ac2afa45..daa2a5bf 100644 --- a/app/repository/event.py +++ b/app/repository/event.py @@ -4,18 +4,22 @@ from datetime import datetime from typing import Optional, Tuple, cast -from sqlalchemy import or_ +from sqlalchemy import or_, Column from sqlalchemy.orm import Query, Session from sqlalchemy.exc import NoResultFound from sqlalchemy_utils import escape_like +from app.clients.db.models.app.counters import CountedEntity from app.clients.db.models.law_policy import ( EventStatus, FamilyEvent, Family, FamilyDocument, ) -from app.model.event import EventReadDTO +from app.errors import ValidationError +from app.model.event import EventCreateDTO, EventReadDTO +from app.repository import family as family_repo +from app.repository.helpers import generate_import_id _LOGGER = logging.getLogger(__name__) @@ -38,9 +42,8 @@ def _get_query(db: Session) -> Query: ) -def _event_to_dto(db: Session, family_event_meta: FamilyEventTuple) -> EventReadDTO: +def _event_to_dto(family_event_meta: FamilyEventTuple) -> EventReadDTO: family_event = family_event_meta[0] - family_document_import_id = ( None if family_event.family_document_import_id is None @@ -58,6 +61,22 @@ def _event_to_dto(db: Session, family_event_meta: FamilyEventTuple) -> EventRead ) +def _dto_to_event_dict(dto: EventCreateDTO) -> dict: + return { + "family_import_id": dto.family_import_id, + "family_document_import_id": dto.family_document_import_id, + "date": dto.date, + "title": dto.event_title, + "event_type_name": dto.event_type_value, + "status": EventStatus.OK, + } + + +def _event_from_dto(db: Session, dto: EventCreateDTO): + family_event = FamilyEvent(**_dto_to_event_dict(dto)) + return family_event + + def all(db: Session) -> list[EventReadDTO]: """ Returns all family events. @@ -70,7 +89,7 @@ def all(db: Session) -> list[EventReadDTO]: if not family_event_metas: return [] - result = [_event_to_dto(db, event_meta) for event_meta in family_event_metas] + result = [_event_to_dto(event_meta) for event_meta in family_event_metas] return result @@ -90,7 +109,7 @@ def get(db: Session, import_id: str) -> Optional[EventReadDTO]: _LOGGER.error(e) return - return _event_to_dto(db, family_event_meta) + return _event_to_dto(family_event_meta) def search(db: Session, search_term: str) -> Optional[list[EventReadDTO]]: @@ -115,7 +134,42 @@ def search(db: Session, search_term: str) -> Optional[list[EventReadDTO]]: if not found: return [] - return [_event_to_dto(db, f) for f in found] + return [_event_to_dto(f) for f in found] + + +def create(db: Session, event: EventCreateDTO) -> str: + """ + Creates a new family event. + + :param db Session: The database connection. + :param EventCreateDTO event: The values for the new event. + :return str: The import id of the newly created family event. + """ + + try: + new_family_event = _event_from_dto(db, event) + + family_import_id = new_family_event.family_import_id + + # Generate the import_id for the new event + org = family_repo.get_organisation(db, cast(str, family_import_id)) + if org is None: + raise ValidationError( + f"Cannot find counter to generate id for {family_import_id}" + ) + + org_name = cast(str, org.name) + new_family_event.import_id = cast( + Column, generate_import_id(db, CountedEntity.Event, org_name) + ) + + db.add(new_family_event) + db.flush() + except Exception: + _LOGGER.exception("Error trying to create Event") + raise + + return cast(str, new_family_event.import_id) def count(db: Session) -> Optional[int]: diff --git a/app/service/event.py b/app/service/event.py index e313f0c5..f5414073 100644 --- a/app/service/event.py +++ b/app/service/event.py @@ -3,11 +3,13 @@ from pydantic import ConfigDict, validate_call from sqlalchemy import exc +from sqlalchemy.orm import Session import app.clients.db.session as db_session import app.repository.event as event_repo -from app.errors import RepositoryError -from app.model.event import EventReadDTO +import app.service.family as family_service +from app.errors import RepositoryError, ValidationError +from app.model.event import EventCreateDTO, EventReadDTO from app.service import id @@ -73,6 +75,29 @@ def validate_import_id(import_id: str) -> None: id.validate(import_id) +@db_session.with_transaction(__name__) +@validate_call(config=ConfigDict(arbitrary_types_allowed=True)) +def create(event: EventCreateDTO, db: Session = db_session.get_db()) -> str: + """ + Creates a new document with the values passed. + + :param documentDTO document: The values for the new document. + :raises RepositoryError: raised on a database error + :raises ValidationError: raised should the import_id be invalid. + :return Optional[documentDTO]: The new created document or + None if unsuccessful. + """ + id.validate(event.family_import_id) + + family = family_service.get(event.family_import_id) + if family is None: + raise ValidationError( + f"Could not find family when creating event for {event.family_import_id}" + ) + + return event_repo.create(db, event) + + @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def count() -> Optional[int]: """ diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py index 4692fd00..7ae2bdd3 100644 --- a/integration_tests/conftest.py +++ b/integration_tests/conftest.py @@ -35,6 +35,7 @@ mock_rollback_document_repo, ) from integration_tests.mocks.rollback_family_repo import mock_rollback_family_repo +from integration_tests.mocks.rollback_event_repo import mock_rollback_event_repo def get_test_db_url() -> str: @@ -160,6 +161,13 @@ def rollback_document_repo(monkeypatch, mocker): yield document_repo +@pytest.fixture +def rollback_event_repo(monkeypatch, mocker): + """Mocks the repository for a single test.""" + mock_rollback_event_repo(event_repo, monkeypatch, mocker) + yield event_repo + + @pytest.fixture def superuser_header_token() -> Dict[str, str]: a_token = token_service.encode("test@cpr.org", True, {}) diff --git a/integration_tests/event/test_create.py b/integration_tests/event/test_create.py new file mode 100644 index 00000000..e7e52ffa --- /dev/null +++ b/integration_tests/event/test_create.py @@ -0,0 +1,126 @@ +from fastapi.encoders import jsonable_encoder +from fastapi.testclient import TestClient +from fastapi import status +from sqlalchemy.orm import Session + +from app.clients.db.models.law_policy import FamilyEvent, Family + +from integration_tests.setup_db import setup_db +from unit_tests.helpers.event import create_event_create_dto + + +def test_create_event(client: TestClient, test_db: Session, user_header_token): + setup_db(test_db) + new_event = create_event_create_dto( + title="some event title", family_import_id="A.0.0.1" + ) + response = client.post( + "/api/v1/events", + json=jsonable_encoder(new_event), + headers=user_header_token, + ) + assert response.status_code == status.HTTP_201_CREATED + created_import_id = response.json() + actual_family_event = ( + test_db.query(FamilyEvent) + .filter(FamilyEvent.import_id == created_import_id) + .one() + ) + + assert actual_family_event is not None + assert actual_family_event.title == "some event title" + + actual_family = ( + test_db.query(Family) + .filter(Family.import_id == actual_family_event.family_import_id) + .one() + ) + assert actual_family is not None + assert actual_family.title == "apple" + + +def test_create_event_when_not_authenticated(client: TestClient, test_db: Session): + setup_db(test_db) + new_event = create_event_create_dto( + title="some event title", family_import_id="A.0.0.3" + ) + response = client.post( + "/api/v1/events", + json=jsonable_encoder(new_event), + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_create_event_rollback( + client: TestClient, test_db: Session, rollback_event_repo, user_header_token +): + setup_db(test_db) + new_event = create_event_create_dto( + title="some event title", family_import_id="A.0.0.3" + ) + response = client.post( + "/api/v1/events", + json=jsonable_encoder(new_event), + headers=user_header_token, + ) + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + actual_fd = ( + test_db.query(FamilyEvent) + .filter(FamilyEvent.import_id == "A.0.0.9") + .one_or_none() + ) + assert actual_fd is None + assert rollback_event_repo.create.call_count == 1 + + +def test_create_event_when_db_error( + client: TestClient, test_db: Session, bad_event_repo, user_header_token +): + setup_db(test_db) + new_event = create_event_create_dto(title="Title", family_import_id="A.0.0.3") + response = client.post( + "/api/v1/events", + json=jsonable_encoder(new_event), + headers=user_header_token, + ) + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + data = response.json() + assert data["detail"] == "Bad Repo" + assert bad_event_repo.create.call_count == 1 + + +def test_create_event_when_family_invalid( + client: TestClient, test_db: Session, user_header_token +): + setup_db(test_db) + new_event = create_event_create_dto( + title="some event title", family_import_id="invalid" + ) + response = client.post( + "/api/v1/events", + json=jsonable_encoder(new_event), + headers=user_header_token, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = response.json() + assert data["detail"] == "The import id invalid is invalid!" + + +def test_create_event_when_family_missing( + client: TestClient, test_db: Session, user_header_token +): + setup_db(test_db) + new_event = create_event_create_dto( + title="some event title", + ) + response = client.post( + "/api/v1/events", + json=jsonable_encoder(new_event), + headers=user_header_token, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = response.json() + assert ( + data["detail"] + == f"Could not find family when creating event for {new_event.family_import_id}" + ) diff --git a/integration_tests/mocks/bad_event_repo.py b/integration_tests/mocks/bad_event_repo.py index d6783f26..c6188250 100644 --- a/integration_tests/mocks/bad_event_repo.py +++ b/integration_tests/mocks/bad_event_repo.py @@ -2,7 +2,7 @@ from pytest import MonkeyPatch from app.errors import RepositoryError -from app.model.event import EventReadDTO +from app.model.event import EventCreateDTO, EventReadDTO def mock_bad_event_repo(repo, monkeypatch: MonkeyPatch, mocker): @@ -15,6 +15,9 @@ def mock_get(_, import_id: str) -> Optional[EventReadDTO]: def mock_search(_, q: str) -> list[EventReadDTO]: raise RepositoryError("Bad Repo") + def mock_create(_, data: EventCreateDTO) -> Optional[EventReadDTO]: + raise RepositoryError("Bad Repo") + def mock_get_count(_) -> Optional[int]: raise RepositoryError("Bad Repo") @@ -27,6 +30,9 @@ def mock_get_count(_) -> Optional[int]: monkeypatch.setattr(repo, "search", mock_search) mocker.spy(repo, "search") + monkeypatch.setattr(repo, "create", mock_create) + mocker.spy(repo, "create") + 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 new file mode 100644 index 00000000..4041995b --- /dev/null +++ b/integration_tests/mocks/rollback_event_repo.py @@ -0,0 +1,17 @@ +from typing import Optional +from pytest import MonkeyPatch + +from sqlalchemy.exc import NoResultFound + +from app.model.event import EventCreateDTO, EventReadDTO + + +def mock_rollback_event_repo(event_repo, monkeypatch: MonkeyPatch, mocker): + actual_create = event_repo.create + + def mock_create_document(db, data: EventCreateDTO) -> Optional[EventReadDTO]: + actual_create(db, data) + raise NoResultFound() + + monkeypatch.setattr(event_repo, "create", mock_create_document) + mocker.spy(event_repo, "create") diff --git a/integration_tests/setup_db.py b/integration_tests/setup_db.py index d8d7f42d..acfea88d 100644 --- a/integration_tests/setup_db.py +++ b/integration_tests/setup_db.py @@ -347,7 +347,7 @@ def _setup_event_data( configure_empty: bool = False, ) -> None: """ - TODO: Need to test events associated with a family document. + TODO: Need to test events associated with family documents. family_document_import_id=data["family_document_import_id"], """ diff --git a/unit_tests/helpers/event.py b/unit_tests/helpers/event.py index 8223941a..e0598788 100644 --- a/unit_tests/helpers/event.py +++ b/unit_tests/helpers/event.py @@ -1,16 +1,30 @@ from app.clients.db.models.law_policy.family import EventStatus -from app.model.event import EventReadDTO +from app.model.event import EventCreateDTO, EventReadDTO + from datetime import datetime, timezone def create_event_read_dto( - import_id: str, family_import_id: str = "family_import_id", title: str = "title" + import_id: str, family_import_id: str = "test.family.1.0", title: str = "title" ) -> EventReadDTO: return EventReadDTO( import_id=import_id, event_title=title, date=datetime.now(timezone.utc), - event_type_value="Some Event", + event_type_value="Amended", + family_import_id=family_import_id, + family_document_import_id=None, + event_status=EventStatus.OK, + ) + + +def create_event_create_dto( + family_import_id: str = "test.family.1.0", title: str = "title" +) -> EventCreateDTO: + return EventCreateDTO( + event_title=title, + date=datetime.now(timezone.utc), + event_type_value="Passed/Approved", family_import_id=family_import_id, family_document_import_id=None, event_status=EventStatus.OK, diff --git a/unit_tests/mocks/repos/event_repo.py b/unit_tests/mocks/repos/event_repo.py index 6db49276..e778ed03 100644 --- a/unit_tests/mocks/repos/event_repo.py +++ b/unit_tests/mocks/repos/event_repo.py @@ -2,7 +2,7 @@ from pytest import MonkeyPatch from sqlalchemy import exc -from app.model.event import EventReadDTO +from app.model.event import EventCreateDTO, EventReadDTO from unit_tests.helpers.event import create_event_read_dto @@ -31,6 +31,12 @@ def mock_search(_, q: str) -> list[EventReadDTO]: return [create_event_read_dto("search1")] return [] + def mock_create(_, data: EventCreateDTO) -> str: + maybe_throw() + if event_repo.return_empty: + raise exc.NoResultFound() + return "test.new.event.0" + def mock_get_count(_) -> Optional[int]: maybe_throw() if not event_repo.return_empty: @@ -46,5 +52,8 @@ def mock_get_count(_) -> Optional[int]: monkeypatch.setattr(event_repo, "search", mock_search) mocker.spy(event_repo, "search") + monkeypatch.setattr(event_repo, "create", mock_create) + mocker.spy(event_repo, "create") + 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 be024e09..ffaad008 100644 --- a/unit_tests/mocks/services/event_service.py +++ b/unit_tests/mocks/services/event_service.py @@ -1,8 +1,8 @@ from typing import Optional from pytest import MonkeyPatch -from app.errors import RepositoryError +from app.errors import RepositoryError, ValidationError -from app.model.event import EventReadDTO +from app.model.event import EventCreateDTO, EventReadDTO from unit_tests.helpers.event import create_event_read_dto @@ -30,6 +30,13 @@ def mock_search_documents(q: str) -> list[EventReadDTO]: else: return [create_event_read_dto("search1")] + def mock_create_document(data: EventCreateDTO) -> str: + maybe_throw() + if not event_service.missing: + return "new.event.id.0" + + raise ValidationError(f"Could not find family for {data.family_import_id}") + def mock_count_collection() -> Optional[int]: maybe_throw() if event_service.missing: @@ -45,5 +52,8 @@ def mock_count_collection() -> Optional[int]: monkeypatch.setattr(event_service, "search", mock_search_documents) mocker.spy(event_service, "search") + monkeypatch.setattr(event_service, "create", mock_create_document) + mocker.spy(event_service, "create") + monkeypatch.setattr(event_service, "count", mock_count_collection) mocker.spy(event_service, "count") diff --git a/unit_tests/routers/test_event.py b/unit_tests/routers/test_event.py index 89d30dc0..4bd664a8 100644 --- a/unit_tests/routers/test_event.py +++ b/unit_tests/routers/test_event.py @@ -1,7 +1,13 @@ from fastapi import status +from fastapi.encoders import jsonable_encoder from fastapi.testclient import TestClient import app.service.event as event_service +from unit_tests.helpers.event import ( + create_event_create_dto, + # create_event_write_dto, +) + def test_get_all_when_ok(client: TestClient, user_header_token, event_service_mock): response = client.get("/api/v1/events", headers=user_header_token) @@ -49,3 +55,28 @@ def test_search_when_not_found( data = response.json() assert data["detail"] == "Events not found for term: stuff" assert event_service_mock.search.call_count == 1 + + +def test_create_when_ok(client: TestClient, event_service_mock, user_header_token): + new_data = create_event_create_dto("event1").model_dump() + response = client.post( + "/api/v1/events", json=jsonable_encoder(new_data), headers=user_header_token + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data == "new.event.id.0" + assert event_service_mock.create.call_count == 1 + + +def test_create_when_family_not_found( + client: TestClient, event_service_mock, user_header_token +): + event_service_mock.missing = True + new_data = create_event_create_dto("this_family") + response = client.post( + "/api/v1/events", json=jsonable_encoder(new_data), headers=user_header_token + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = response.json() + assert data["detail"] == "Could not find family for this_family" + assert event_service_mock.create.call_count == 1 diff --git a/unit_tests/service/test_event_service.py b/unit_tests/service/test_event_service.py index 221e1dc1..cbde552c 100644 --- a/unit_tests/service/test_event_service.py +++ b/unit_tests/service/test_event_service.py @@ -1,7 +1,7 @@ import pytest import app.service.event as event_service from app.errors import RepositoryError, ValidationError - +from unit_tests.helpers.event import create_event_create_dto # --- GET @@ -46,6 +46,36 @@ def test_search_when_missing(event_repo_mock): assert event_repo_mock.search.call_count == 1 +# --- CREATE + + +def test_create(event_repo_mock, family_repo_mock): + new_event = create_event_create_dto() + event = event_service.create(new_event) + assert event is not None + assert event_repo_mock.create.call_count == 1 + assert family_repo_mock.get.call_count == 1 + + +def test_create_when_db_fails(event_repo_mock, family_repo_mock): + new_event = create_event_create_dto() + event_repo_mock.return_empty = True + + with pytest.raises(RepositoryError): + event_service.create(new_event) + assert event_repo_mock.create.call_count == 1 + assert family_repo_mock.get.call_count == 1 + + +def test_create_raises_when_invalid_family_id(event_repo_mock): + new_event = create_event_create_dto(family_import_id="invalid family") + with pytest.raises(ValidationError) as e: + event_service.create(new_event) + expected_msg = f"The import id {new_event.family_import_id} is invalid!" + assert e.value.message == expected_msg + assert event_repo_mock.create.call_count == 0 + + # --- COUNT