diff --git a/data-serving/reusable-data-service/reusable_data_service/controller/case_controller.py b/data-serving/reusable-data-service/reusable_data_service/controller/case_controller.py index 51049ba30..35e3e4cce 100644 --- a/data-serving/reusable-data-service/reusable_data_service/controller/case_controller.py +++ b/data-serving/reusable-data-service/reusable_data_service/controller/case_controller.py @@ -1,5 +1,6 @@ from flask import jsonify from datetime import date +from reusable_data_service.model.case import Case from reusable_data_service.model.filter import ( Anything, Filter, @@ -9,14 +10,21 @@ ) +class PreconditionError(Exception): + pass + + class CaseController: """Implements CRUD operations on cases. Uses an external store class to actually work with the cases collection, so that different storage technology can be chosen. All methods return a tuple of (response, HTTP status code)""" - def __init__(self, store): + def __init__(self, store, outbreak_date: date): + """store is an adapter to the external storage technology. + outbreak_date is the earliest date on which this instance should accept cases.""" self.store = store + self.outbreak_date = outbreak_date def get_case(self, id: str): """Implements get /cases/:id. Interpretation of ID is dependent @@ -50,6 +58,27 @@ def list_cases(self, page: int = None, limit: int = None, filter: str = None): return jsonify(response), 200 + def create_case(self, maybe_case: dict, num_cases: int = 1): + """Implements post /cases.""" + if num_cases <= 0: + return "Must create a positive number of cases", 400 + try: + case = Case.from_dict(maybe_case) + self.check_case_preconditions(case) + for i in range(num_cases): + self.store.insert_case(case) + return "", 201 + except ValueError as ve: + # ValueError means we can't even turn this into a case + return ve.args[0], 400 + except PreconditionError as pe: + # PreconditionError means it's a case, but not one we can use + return pe.args[0], 422 + + def check_case_preconditions(self, case: Case): + if case.confirmation_date < self.outbreak_date: + raise PreconditionError("Confirmation date is before outbreak began") + @staticmethod def parse_filter(filter: str) -> Filter: """Interpret the filter query in the incoming request.""" @@ -79,6 +108,8 @@ def individual_filter(term: str) -> Filter: ) if keyword == "dateconfirmedafter": return PropertyFilter( - "confirmation_date", FilterOperator.GREATER_THAN, date.fromisoformat(value) + "confirmation_date", + FilterOperator.GREATER_THAN, + date.fromisoformat(value), ) # anything else (not supported yet) is equality diff --git a/data-serving/reusable-data-service/reusable_data_service/main.py b/data-serving/reusable-data-service/reusable_data_service/main.py index 554957308..33762c702 100644 --- a/data-serving/reusable-data-service/reusable_data_service/main.py +++ b/data-serving/reusable-data-service/reusable_data_service/main.py @@ -1,3 +1,4 @@ +from datetime import date from flask import Flask, request from . import CaseController, MongoStore from reusable_data_service.util.iso_json_encoder import ISOJSONEncoder @@ -16,13 +17,18 @@ def get_case(id): return case_controller.get_case(id) -@app.route("/api/cases") +@app.route("/api/cases", methods = ['POST', 'GET']) def list_cases(): - page = request.args.get("page", type=int) - limit = request.args.get("limit", type=int) - filter = request.args.get("q", type=str) - return case_controller.list_cases(page=page, limit=limit, filter=filter) - + if request.method == 'GET': + page = request.args.get("page", type=int) + limit = request.args.get("limit", type=int) + filter = request.args.get("q", type=str) + return case_controller.list_cases(page=page, limit=limit, filter=filter) + else: + count = request.args.get("num_cases", type=int) + if count is None: + count = 1 + return case_controller.create_case(request.get_json(), num_cases=count) def set_up_controllers(): global case_controller @@ -33,7 +39,10 @@ def set_up_controllers(): except KeyError: logging.exception(f"Cannot configure backend data store {store_choice}") raise - case_controller = CaseController(store) + outbreak_date = os.environ.get("OUTBREAK_DATE") + if outbreak_date is None: + raise ValueError("Define $OUTBREAK_DATE in the environment") + case_controller = CaseController(store, date.fromisoformat(outbreak_date)) def main(): diff --git a/data-serving/reusable-data-service/reusable_data_service/model/case.py b/data-serving/reusable-data-service/reusable_data_service/model/case.py index b562681db..447be5e3a 100644 --- a/data-serving/reusable-data-service/reusable_data_service/model/case.py +++ b/data-serving/reusable-data-service/reusable_data_service/model/case.py @@ -40,7 +40,7 @@ def from_dict(cls, dictionary: dict[str, Any]) -> type: elif isinstance(maybe_date, str): value = datetime.datetime.strptime( maybe_date, "%Y-%m-%dT%H:%M:%S.%fZ" - ) + ).date() elif isinstance(maybe_date, dict) and "$date" in maybe_date: value = datetime.datetime.strptime( maybe_date["$date"], "%Y-%m-%dT%H:%M:%SZ" @@ -59,6 +59,10 @@ def validate(self): raise ValueError("Confirmation Date is mandatory") elif self.confirmation_date is None: raise ValueError("Confirmation Date must have a value") + + def to_dict(self): + """Return myself as a dictionary.""" + return dataclasses.asdict(self) @classmethod def date_fields(cls) -> list[str]: diff --git a/data-serving/reusable-data-service/reusable_data_service/stores/mongo_store.py b/data-serving/reusable-data-service/reusable_data_service/stores/mongo_store.py index c920a8406..2f32a831f 100644 --- a/data-serving/reusable-data-service/reusable_data_service/stores/mongo_store.py +++ b/data-serving/reusable-data-service/reusable_data_service/stores/mongo_store.py @@ -50,6 +50,13 @@ def count_cases(self, filter: Filter) -> int: return self.get_case_collection().estimated_document_count() return self.get_case_collection().count_documents(filter.to_mongo_query()) + def insert_case(self, case: Case): + to_insert = case.to_dict() + for field in Case.date_fields(): + # BSON works with datetimes, not dates + to_insert[field] = date_to_datetime(to_insert[field]) + self.get_case_collection().insert_one(to_insert) + @staticmethod def setup(): """Configure a store instance from the environment.""" @@ -62,6 +69,10 @@ def setup(): return mongo_store +def date_to_datetime(dt: datetime.date) -> datetime.datetime: + """Convert datetime.date to datetime.datetime for encoding as BSON""" + return datetime.datetime(dt.year, dt.month, dt.day) + # Add methods to the Filter classes here to turn them into Mongo queries. def anything_query(self): return {} @@ -71,7 +82,7 @@ def anything_query(self): def property_query(self): # rewrite dates specified in the app to datetimes because pymongo # expects datetimes to represent BSON dates. - value = datetime.datetime(self.value.year, self.value.month, self.value.day) if isinstance(self.value, datetime.date) else self.value + value = date_to_datetime(self.value) if isinstance(self.value, datetime.date) else self.value match self.operation: case FilterOperator.LESS_THAN: return { self.property_name: { "$lt" : value }} diff --git a/data-serving/reusable-data-service/tests/test_case_controller.py b/data-serving/reusable-data-service/tests/test_case_controller.py index c3a576765..f09040d60 100644 --- a/data-serving/reusable-data-service/tests/test_case_controller.py +++ b/data-serving/reusable-data-service/tests/test_case_controller.py @@ -1,4 +1,5 @@ import pytest +from datetime import date from reusable_data_service import Case, CaseController, app @@ -8,13 +9,20 @@ class MemoryStore: def __init__(self): self.cases = dict() + self.next_id = 0 def case_by_id(self, id: str): return self.cases.get(id) def put_case(self, id: str, case: Case): + """Used in the tests to populate the store.""" self.cases[id] = case + def insert_case(self, case: Case): + """Used by the controller to insert a new case.""" + self.next_id += 1 + self.put_case(str(self.next_id), case) + def fetch_cases(self, page: int, limit: int, *args): return list(self.cases.values())[(page - 1) * limit : page * limit] @@ -26,7 +34,7 @@ def count_cases(self, *args): def case_controller(): with app.app_context(): store = MemoryStore() - controller = CaseController(store) + controller = CaseController(store, outbreak_date=date(2019, 11, 1)) yield controller @@ -95,3 +103,40 @@ def test_list_cases_nonexistent_page(case_controller): assert len(response.json["cases"]) == 0 assert response.json["total"] == 15 assert "nextPage" not in response.json + + +def test_create_case_with_missing_properties_400_error(case_controller): + (response, status) = case_controller.create_case({}) + assert status == 400 + + +def test_create_case_with_invalid_data_422_error(case_controller): + (response, status) = case_controller.create_case( + {"confirmation_date": date(2001, 3, 17)} + ) + assert status == 422 + + +def test_create_valid_case_adds_to_collection(case_controller): + (response, status) = case_controller.create_case( + {"confirmation_date": date(2021, 6, 3)} + ) + assert status == 201 + assert case_controller.store.count_cases() == 1 + + +def test_create_valid_case_with_negative_count_400_error(case_controller): + (response, status) = case_controller.create_case( + {"confirmation_date": date(2021, 6, 3)}, + num_cases = -7 + ) + assert status == 400 + + +def test_create_valid_case_with_positive_count_adds_to_collection(case_controller): + (response, status) = case_controller.create_case( + {"confirmation_date": date(2021, 6, 3)}, + num_cases = 7 + ) + assert status == 201 + assert case_controller.store.count_cases() == 7 diff --git a/data-serving/reusable-data-service/tests/test_case_end_to_end.py b/data-serving/reusable-data-service/tests/test_case_end_to_end.py index 8019c7a10..6184265dd 100644 --- a/data-serving/reusable-data-service/tests/test_case_end_to_end.py +++ b/data-serving/reusable-data-service/tests/test_case_end_to_end.py @@ -16,6 +16,7 @@ def client_with_patched_mongo(monkeypatch): ) # will be unused monkeypatch.setenv("MONGO_DB", "outbreak") monkeypatch.setenv("MONGO_CASE_COLLECTION", "cases") + monkeypatch.setenv("OUTBREAK_DATE", "2019-11-01") db = mongomock.MongoClient() def fake_mongo(connection_string): @@ -115,7 +116,9 @@ def test_list_cases_filter_confirmation_date_after(client_with_patched_mongo): assert "2022-05-11" in dates -def test_list_cases_filter_confirmation_date_before_and_after(client_with_patched_mongo): +def test_list_cases_filter_confirmation_date_before_and_after( + client_with_patched_mongo, +): db = pymongo.MongoClient("mongodb://localhost:27017/outbreak") db["outbreak"]["cases"].insert_many( [{"confirmation_date": datetime(2022, 5, i)} for i in range(1, 32)] @@ -147,7 +150,27 @@ def test_list_cases_no_matching_results(client_with_patched_mongo): def test_list_cases_with_bad_filter_rejected(client_with_patched_mongo): - response = client_with_patched_mongo.get( - f"/api/cases?q=country%3A" - ) + response = client_with_patched_mongo.get("/api/cases?q=country%3A") assert response.status_code == 422 + + +def test_post_case_list_cases_round_trip(client_with_patched_mongo): + post_response = client_with_patched_mongo.post("/api/cases", json = { + "confirmation_date": "2022-01-23T13:45:01.234Z" + }) + assert post_response.status_code == 201 + get_response = client_with_patched_mongo.get("/api/cases") + assert get_response.status_code == 200 + assert len(get_response.json["cases"]) == 1 + assert get_response.json["cases"][0]["confirmation_date"] == "2022-01-23" + + +def test_post_multiple_case_list_cases_round_trip(client_with_patched_mongo): + post_response = client_with_patched_mongo.post("/api/cases?num_cases=3", json = { + "confirmation_date": "2022-01-23T13:45:01.234Z" + }) + assert post_response.status_code == 201 + get_response = client_with_patched_mongo.get("/api/cases") + assert get_response.status_code == 200 + assert len(get_response.json["cases"]) == 3 + assert get_response.json["cases"][0]["confirmation_date"] == "2022-01-23"