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 geojson #2779

Merged
merged 10 commits into from
Jul 25, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from data_service.model.case_reference import CaseReference
from data_service.model.document import Document
from data_service.model.field import Field
from data_service.model.geojson import Feature
from data_service.util.errors import (
ConflictError,
DependencyFailedError,
Expand Down Expand Up @@ -40,6 +41,7 @@ class DayZeroCase(Document):
confirmationDate: datetime.date = dataclasses.field(init=False)
caseReference: CaseReference = dataclasses.field(init=False, default=None)
caseExclusion: CaseExclusionMetadata = dataclasses.field(init=False, default=None)
location: Feature = dataclasses.field(init=False, default=None)

custom_fields = []

Expand All @@ -55,6 +57,8 @@ def from_dict(cls, dictionary: dict[str, Any]) -> type:
for key in dictionary:
if key in cls.date_fields():
value = cls.interpret_date(dictionary[key])
elif key in cls.location_fields():
value = Feature.from_dict(dictionary[key])
elif key == "caseReference":
caseRef = dictionary[key]
value = (
Expand Down Expand Up @@ -91,7 +95,6 @@ def validate(self):
elif self.caseReference is None:
raise ValidationError("Case Reference must have a value")
self.caseReference.validate()
print(f"validating custom fields {self.custom_fields}")
for field in self.custom_fields:
if field.required is True and attrgetter(field.key)(self) is None:
raise ValidationError(f"{field.key} must have a value")
Expand Down
22 changes: 19 additions & 3 deletions data-serving/reusable-data-service/data_service/model/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import operator

from data_service.model.document_update import DocumentUpdate
from data_service.model.geojson import Feature
from data_service.util.json_encoder import JSONEncoder

from typing import List
Expand All @@ -26,7 +27,15 @@ def to_json(self):
@classmethod
def date_fields(cls) -> list[str]:
"""Record where dates are kept because they sometimes need special treatment."""
return [f.name for f in dataclasses.fields(cls) if f.type == datetime.date]
return cls.fields_of_class(datetime.date)

@classmethod
def location_fields(cls) -> list[str]:
return cls.fields_of_class(Feature)

@classmethod
def fields_of_class(cls, a_class: type) -> list[str]:
return [f.name for f in dataclasses.fields(cls) if f.type == a_class]

@staticmethod
def interpret_date(maybe_date) -> datetime.date:
Expand Down Expand Up @@ -55,7 +64,9 @@ def field_names(cls) -> List[str]:
fields = []
for f in dataclasses.fields(cls):
if dataclasses.is_dataclass(f.type):
if cls.include_dataclass_fields(f.type):
if hasattr(f.type, "custom_field_names"):
fields += [f"{f.name}.{g}" for g in f.type.custom_field_names()]
elif cls.include_dataclass_fields(f.type):
fields += [f"{f.name}.{g.name}" for g in dataclasses.fields(f.type)]
else:
fields.append(f.name)
Expand All @@ -64,7 +75,7 @@ def field_names(cls) -> List[str]:
@classmethod
def delimiter_separated_header(cls, sep: str) -> str:
"""Create a line naming all of the fields in this class and member dataclasses."""
return sep.join(cls.field_names()) + "\n"
return sep.join(cls.field_names()) + "\r\n"

@classmethod
def tsv_header(cls) -> str:
Expand Down Expand Up @@ -99,6 +110,11 @@ def field_values(self) -> List[str]:
if issubclass(f.type, Document):
if self.include_dataclass_fields(f.type):
fields += value.field_values()
elif hasattr(f.type, "custom_field_names"):
if value is not None:
fields += value.custom_field_values()
else:
fields += f.type.custom_none_field_values()
else:
fields.append(str(value) if value is not None else "")
return fields
Expand Down
109 changes: 109 additions & 0 deletions data-serving/reusable-data-service/data_service/model/geojson.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import dataclasses

from typing import Any, Dict, List

from data_service.util.errors import ValidationError


@dataclasses.dataclass
class Point:
"""Represents a GeoJSON point, but because we only deal with lat/long
coordinates it has the additional validation constraint that it must be
two-dimensional and be in the range ((-90<=lat<=90), (-180<=lon<=180))."""

_: dataclasses.KW_ONLY
type: str = dataclasses.field(init=False, default="Point")
coordinates: List[float] = dataclasses.field(init=False, default_factory=lambda: [])

def validate(self):
if self.type != "Point":
raise ValidationError(f"Type must be Point for a Point, not {self.type}")
if self.coordinates is None:
raise ValidationError("Point must have coordinates")
if len(self.coordinates) != 2:
raise ValidationError(
f"Point must have two coordinates, I have {len(self.coordinates)}"
)
latitude = self.coordinates[0]
if latitude < -90.0 or latitude > 90.0:
raise ValidationError(
f"latitude must be between -90º and 90º, got {latitude}"
)
longitude = self.coordinates[1]
if longitude < -180.0 or longitude > 180.0:
raise ValidationError(
f"longitude must be between -180º and 180º, got {longitude}"
)

@classmethod
def from_dict(cls, d: Dict[str, Any]):
p = cls()
p.type = d.get("type", None)
p.coordinates = d.get("coordinates", None)
return p


@dataclasses.dataclass
class Feature:
"""Represents a GeoJSON feature, but with restrictions that are appropriate
to Global.health. To whit: the geometry _must_ be a point (it cannot be a MultiPoint,
a Line, or any other type of geometry, and nor can it be None);
the properties dictionmary _must_ include a three-letter
country code. These constraints are tested at validation time, not construction time."""

_: dataclasses.KW_ONLY
type: str = dataclasses.field(init=False, default="Feature")
geometry: Point = dataclasses.field(init=False, default=None)
properties: Dict[str, Any] = dataclasses.field(
init=False, default_factory=lambda: {}
)
field_getters = {
"country": lambda f: f.properties.get("country", ""),
"latitude": lambda f: str(f.geometry.coordinates[0]),
"longitude": lambda f: str(f.geometry.coordinates[1]),
"admin1": lambda f: f.properties.get("admin1", ""),
"admin2": lambda f: f.properties.get("admin2", ""),
"admin3": lambda f: f.properties.get("admin3", ""),
}

def validate(self):
if self.type != "Feature":
raise ValidationError(
f"Type must be Feature for a Feature, not {self.type}"
)
if not isinstance(self.geometry, Point):
raise ValidationError(
f"geometry of a Feature must be a Point in G.h, {self.geometry} is not one"
)
self.geometry.validate()
country = self.properties.get("country", "None")
if len(country) != 3:
raise ValidationError(
f"country must be defined as an ISO 3166-1 alpha-3 code, not {country}"
)

@classmethod
def from_dict(cls, d: Dict[str, Any]):
if d is None:
return None
f = cls()
f.type = d.get("type", None)
f.properties = d.get("properties", None)
g = d.get("geometry", None)
f.geometry = Point.from_dict(g) if g is not None else None
return f

@classmethod
def custom_field_names(cls) -> List[str]:
"""Provide an application-specific report of this class's fields and values for CSV export."""
return list(cls.field_getters.keys())

@classmethod
def custom_none_field_values(cls) -> List[str]:
"""Provide an application-specific report of this class's fields and values for CSV export."""
print("Asked for None values")
return [""] * len(cls.field_getters)

def custom_field_values(self) -> List[str]:
"""Provide an application-specific report of this class's fields and values for CSV export."""
return [getter(self) for getter in self.field_getters.values()]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"confirmationDate": "2021-12-31T01:23:45.678Z",
"caseReference": {
"sourceId": "fedc09876543210987654321"
},
"location": {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
12.345,
67.89
]
},
"properties": {
"country": "IND"
}
}
}
15 changes: 15 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
@@ -1,5 +1,6 @@
import bson
import freezegun
import json
import pymongo

from datetime import datetime
Expand Down Expand Up @@ -194,6 +195,20 @@ def test_post_case_list_cases_round_trip(client_with_patched_mongo):
assert get_response.json["cases"][0]["confirmationDate"] == "2022-01-23"


def test_post_case_list_cases_geojson_round_trip(client_with_patched_mongo):
with open("./tests/data/case.with_location.json", "r") as file:
case_json = json.load(file)
post_response = client_with_patched_mongo.post(
"/api/cases",
json=case_json,
)
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]["location"]["properties"]["country"] == "IND"


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",
Expand Down
15 changes: 12 additions & 3 deletions data-serving/reusable-data-service/tests/test_case_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from data_service.model.case import Case
from data_service.model.case_reference import CaseReference
from data_service.model.document_update import DocumentUpdate
from data_service.model.geojson import Feature, Point
from data_service.util.errors import ValidationError


Expand All @@ -18,11 +19,19 @@ def test_case_from_minimal_json_is_valid():
assert case is not None


def test_case_with_geojson_is_valid():
with open("./tests/data/case.with_location.json", "r") as file:
case = Case.from_json(file.read())
assert case is not None
assert case.location is not None
assert type(case.location) == Feature


def test_csv_header():
header_line = Case.csv_header()
assert (
header_line
== "_id,confirmationDate,caseReference.sourceId,caseReference.status\n"
== "_id,confirmationDate,caseReference.sourceId,caseReference.status,location.country,location.latitude,location.longitude,location.admin1,location.admin2,location.admin3\r\n"
)


Expand All @@ -35,7 +44,7 @@ def test_csv_row_with_no_id():
case.confirmationDate = date(2022, 6, 13)
case.caseReference = ref
csv = case.to_csv()
assert csv == ",2022-06-13,abcd12903478565647382910,UNVERIFIED\r\n"
assert csv == ",2022-06-13,abcd12903478565647382910,UNVERIFIED,,,,,,\r\n"


def test_csv_row_with_id():
Expand All @@ -49,7 +58,7 @@ def test_csv_row_with_id():
case.confirmationDate = date(2022, 6, 13)
case.caseReference = ref
csv = case.to_csv()
assert csv == f"{id1},2022-06-13,{id2},UNVERIFIED\r\n"
assert csv == f"{id1},2022-06-13,{id2},UNVERIFIED,,,,,,\r\n"


def test_apply_update_to_case():
Expand Down
Loading