Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2714 post new case #2737

Merged
merged 7 commits into from
Jun 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
23 changes: 16 additions & 7 deletions data-serving/reusable-data-service/reusable_data_service/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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 {}
Expand All @@ -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 }}
Expand Down
47 changes: 46 additions & 1 deletion data-serving/reusable-data-service/tests/test_case_controller.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from datetime import date

from reusable_data_service import Case, CaseController, app

Expand All @@ -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]

Expand All @@ -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


Expand Down Expand Up @@ -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
31 changes: 27 additions & 4 deletions data-serving/reusable-data-service/tests/test_case_end_to_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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"