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 filter by confirmation date #2736

Merged
merged 5 commits into from
Jun 22, 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,4 +1,12 @@
from flask import jsonify
from datetime import date
from reusable_data_service.model.filter import (
Anything,
Filter,
AndFilter,
PropertyFilter,
FilterOperator,
)


class CaseController:
Expand All @@ -19,7 +27,7 @@ def get_case(self, id: str):
return f"No case with ID {id}", 404
return jsonify(case), 200

def list_cases(self, page: int = None, limit: int = None):
def list_cases(self, page: int = None, limit: int = None, filter: str = None):
"""Implements get /cases."""
page = 1 if page is None else page
limit = 10 if limit is None else limit
Expand All @@ -29,11 +37,48 @@ def list_cases(self, page: int = None, limit: int = None):
if limit <= 0:
validation_error = {"message": "limit must be >0"}
if validation_error is not None:
return jsonify(validation_error), 400
predicate = CaseController.parse_filter(filter)
if predicate is None:
validation_error = {"message:" "cannot understand query"}
return jsonify(validation_error), 422
cases = self.store.fetch_cases(page, limit)
count = self.store.count_cases()
cases = self.store.fetch_cases(page, limit, predicate)
count = self.store.count_cases(predicate)
response = {"cases": cases, "total": count}
if count > page * limit:
response["nextPage"] = page + 1

return jsonify(response), 200

@staticmethod
def parse_filter(filter: str) -> Filter:
"""Interpret the filter query in the incoming request."""
if filter is None:
return Anything()
# split query on spaces
components = filter.split(" ")
filters = [CaseController.individual_filter(c) for c in components]
if None in filters:
return None
if len(filters) == 1:
return filters[0]
else:
return AndFilter(filters)

@staticmethod
def individual_filter(term: str) -> Filter:
"""Turn a single property:value filter request into a filter object"""
# keyword value pairs separated by colon
(keyword, value) = term.split(":")
if len(keyword) == 0 or len(value) == 0:
return None
# special case dateconfirmedbefore, dateconfirmedafter
if keyword == "dateconfirmedbefore":
return PropertyFilter(
"confirmation_date", FilterOperator.LESS_THAN, date.fromisoformat(value)
)
if keyword == "dateconfirmedafter":
return PropertyFilter(
"confirmation_date", FilterOperator.GREATER_THAN, date.fromisoformat(value)
)
# anything else (not supported yet) is equality
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ def get_case(id):
def list_cases():
page = request.args.get("page", type=int)
limit = request.args.get("limit", type=int)
return case_controller.list_cases(page=page, limit=limit)
filter = request.args.get("q", type=str)
return case_controller.list_cases(page=page, limit=limit, filter=filter)


def set_up_controllers():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from typing import Any, List


class Filter:
"""Represents any filter on a collection."""

pass


class Anything(Filter):
"""Represents a lack of constraints."""

pass


class PropertyFilter(Filter):
"""Represents a test that an object's property has a value that satisfies some constraint."""

def __init__(self, property_name: str, operation: str, value: Any):
valid_ops = [FilterOperator.LESS_THAN, FilterOperator.GREATER_THAN]
if operation not in valid_ops:
raise ValueError(f"Unknown operation {operation}")
self.property_name = property_name
self.operation = operation
self.value = value


class FilterOperator:
LESS_THAN = "<"
GREATER_THAN = ">"


class AndFilter(Filter):
"""Represents a composition of filters, satisfied if all components are satisfied."""

def __init__(self, filters: List[Filter]):
self.filters = filters
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import datetime
import os
import pymongo
from reusable_data_service.model.case import Case
from reusable_data_service.model.filter import Filter, Anything, AndFilter, PropertyFilter, FilterOperator
from json import loads
from bson.errors import InvalidId
from bson.json_util import dumps
Expand Down Expand Up @@ -37,14 +39,16 @@ def case_by_id(self, id: str):
except InvalidId:
return None

def fetch_cases(self, page: int, limit: int):
def fetch_cases(self, page: int, limit: int, filter: Filter):
cases = self.get_case_collection().find(
{}, skip=(page - 1) * limit, limit=limit
filter.to_mongo_query(), skip=(page - 1) * limit, limit=limit
)
return [Case.from_json(dumps(c)) for c in cases]

def count_cases(self) -> int:
return self.get_case_collection().count_documents({})
def count_cases(self, filter: Filter) -> int:
if isinstance(filter, Anything):
return self.get_case_collection().estimated_document_count()
return self.get_case_collection().count_documents(filter.to_mongo_query())

@staticmethod
def setup():
Expand All @@ -56,3 +60,30 @@ def setup():
mongo_connection_string, mongo_database, mongo_collection
)
return mongo_store


# Add methods to the Filter classes here to turn them into Mongo queries.
def anything_query(self):
return {}

Anything.to_mongo_query = anything_query

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
match self.operation:
case FilterOperator.LESS_THAN:
return { self.property_name: { "$lt" : value }}
case FilterOperator.GREATER_THAN:
return { self.property_name: { "$gt" : value }}
case _:
raise ValueError(f"Unhandled operation {self.operation}")

PropertyFilter.to_mongo_query = property_query


def and_query(self):
return { "$and": [f.to_mongo_query() for f in self.filters] }

AndFilter.to_mongo_query = and_query
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ def case_by_id(self, id: str):
def put_case(self, id: str, case: Case):
self.cases[id] = case

def fetch_cases(self, page: int, limit: int):
def fetch_cases(self, page: int, limit: int, *args):
return list(self.cases.values())[(page - 1) * limit : page * limit]

def count_cases(self):
def count_cases(self, *args):
return len(self.cases)


Expand Down
82 changes: 82 additions & 0 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 @@ -69,3 +69,85 @@ def test_list_cases_with_pagination_query(client_with_patched_mongo):
assert len(response.json["cases"]) == 10
assert response.json["total"] == 25
assert response.json["nextPage"] == 3


def test_list_cases_with_negative_page_rejected(client_with_patched_mongo):
response = client_with_patched_mongo.get(f"/api/cases?page=-2")
assert response.status_code == 400


def test_list_cases_with_negative_page_rejected(client_with_patched_mongo):
response = client_with_patched_mongo.get(f"/api/cases?limit=-2")
assert response.status_code == 400


def test_list_cases_filter_confirmation_date_before(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)]
)
response = client_with_patched_mongo.get(
f"/api/cases?q=dateconfirmedbefore%3a2022-05-10"
)
assert response.status_code == 200
assert len(response.json["cases"]) == 9
assert response.json["total"] == 9
dates = [c["confirmation_date"] for c in response.json["cases"]]
assert "2022-05-11" not in dates
assert "2022-05-10" not in dates
assert "2022-05-09" in dates


def test_list_cases_filter_confirmation_date_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)]
)
response = client_with_patched_mongo.get(
f"/api/cases?q=dateconfirmedafter%3a2022-05-10"
)
assert response.status_code == 200
assert len(response.json["cases"]) == 10
assert response.json["total"] == 21
dates = [c["confirmation_date"] for c in response.json["cases"]]
assert "2022-05-09" not in dates
assert "2022-05-10" not in dates
assert "2022-05-11" in dates


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)]
)
response = client_with_patched_mongo.get(
f"/api/cases?q=dateconfirmedafter%3a2022-05-10%20dateconfirmedbefore%3a2022-05-13"
)
assert response.status_code == 200
assert len(response.json["cases"]) == 2
assert response.json["total"] == 2
dates = [c["confirmation_date"] for c in response.json["cases"]]
assert "2022-05-10" not in dates
assert "2022-05-11" in dates
assert "2022-05-12" in dates
assert "2022-05-13" not in dates


def test_list_cases_no_matching_results(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)]
)
response = client_with_patched_mongo.get(
f"/api/cases?q=dateconfirmedafter%3a2023-05-10"
)
assert response.status_code == 200
assert len(response.json["cases"]) == 0
assert response.json["total"] == 0


def test_list_cases_with_bad_filter_rejected(client_with_patched_mongo):
response = client_with_patched_mongo.get(
f"/api/cases?q=country%3A"
)
assert response.status_code == 422