-
Notifications
You must be signed in to change notification settings - Fork 7
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 custom fields #2757
Merged
Merged
2714 custom fields #2757
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
7108c75
Rename case controller tests #2714
iamleeg ba1e720
Can add a custom field with an acceptable name #2714
iamleeg 9c966e9
Require types of custom fields to come from specific list #2714
iamleeg 4b70c2f
Refactor: push MemoryStore into the app #2714
iamleeg 4a8579f
If I put MemoryStore in prod I had better test it #2714
iamleeg e8e1c50
Store additional data fields #2714
iamleeg 2892d45
Remove an unnecessary argument from the case controller #2714
iamleeg c3aeac3
end-to-end test for adding a custom field #2714
iamleeg 607d711
Use list instead of set for observers #2714
iamleeg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
39 changes: 39 additions & 0 deletions
39
data-serving/reusable-data-service/data_service/controller/schema_controller.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import dataclasses | ||
|
||
from data_service.model.case import Case, make_custom_case_class | ||
from data_service.model.field import Field | ||
from data_service.util.errors import ConflictError, PreconditionUnsatisfiedError | ||
|
||
|
||
class SchemaController: | ||
"""Manipulate the fields on the Case class.""" | ||
|
||
def __init__(self, store): | ||
self.store = store | ||
|
||
def add_field(self, name: str, type_name: str, description: str): | ||
global Case | ||
"""Add a field of the specified type to the Case class. There cannot | ||
already be a field of that name, either built in, as part of the | ||
DayZeroCase schema, or added through this method previously. | ||
|
||
Additionally dataclasses imposes other conditions (for example names | ||
cannot be Python keywords). | ||
|
||
The description will be used in the data dictionary.""" | ||
existing_fields = dataclasses.fields(Case) | ||
if name in [f.name for f in existing_fields]: | ||
raise ConflictError(f"field {name} already exists") | ||
if type_name not in Field.acceptable_types: | ||
raise PreconditionUnsatisfiedError( | ||
f"cannot use {type_name} as the type of a field" | ||
) | ||
type = Field.model_type(type_name) | ||
fields_list = [(f.name, f.type, f) for f in existing_fields] | ||
fields_list.append((name, type, dataclasses.field(init=False, default=None))) | ||
# re-invent the Case class | ||
Case = make_custom_case_class("Case", fields_list) | ||
# create a storable model of the field and store it | ||
# FIXME rewrite the validation logic above to use the data model | ||
field_model = Field(name, type_name, description) | ||
self.store.add_field(field_model) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
data-serving/reusable-data-service/data_service/model/field.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import dataclasses | ||
from datetime import date | ||
|
||
from data_service.model.document import Document | ||
from data_service.util.errors import PreconditionUnsatisfiedError | ||
|
||
|
||
@dataclasses.dataclass | ||
class Field(Document): | ||
"""Represents a custom field in a Document object.""" | ||
|
||
key: str = dataclasses.field(init=True, default=None) | ||
type: str = dataclasses.field(init=True, default=None) | ||
data_dictionary_text: str = dataclasses.field(init=True, default=None) | ||
STRING = "string" | ||
DATE = "date" | ||
type_map = {STRING: str, DATE: date} | ||
acceptable_types = type_map.keys() | ||
|
||
@classmethod | ||
def model_type(cls, name: str) -> type: | ||
try: | ||
return cls.type_map[name] | ||
except KeyError: | ||
raise PreconditionUnsatisfiedError(f"cannot use type {name} in a Field") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
data-serving/reusable-data-service/data_service/stores/memory_store.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
from functools import reduce | ||
from operator import attrgetter, and_ | ||
from typing import List, Optional | ||
|
||
from data_service.model.case import Case | ||
from data_service.model.case_exclusion_metadata import CaseExclusionMetadata | ||
from data_service.model.document_update import DocumentUpdate | ||
from data_service.model.field import Field | ||
from data_service.model.filter import ( | ||
Filter, | ||
Anything, | ||
PropertyFilter, | ||
AndFilter, | ||
FilterOperator, | ||
) | ||
|
||
|
||
class MemoryStore: | ||
"""Simple dictionary-based store for cases.""" | ||
|
||
def __init__(self): | ||
self.cases = dict() | ||
self.fields = [] | ||
self.next_id = 0 | ||
|
||
def case_by_id(self, id: str): | ||
return self.cases.get(id) | ||
|
||
def put_case(self, id: str, case: Case): | ||
"""This is test-only interface for populating the store.""" | ||
self.cases[id] = case | ||
|
||
def insert_case(self, case: Case): | ||
"""This is the external case insertion API that the case controller uses.""" | ||
self.next_id += 1 | ||
id = str(self.next_id) | ||
case._id = id | ||
self.put_case(id, case) | ||
|
||
def replace_case(self, id: str, case: Case): | ||
self.put_case(id, case) | ||
|
||
def update_case(self, id: str, update: DocumentUpdate): | ||
case = self.case_by_id(id) | ||
case.apply_update(update) | ||
|
||
def batch_update(self, updates: dict[str, DocumentUpdate]): | ||
for id, update in iter(updates.items()): | ||
self.update_case(id, update) | ||
return len(updates) | ||
|
||
def update_case_status( | ||
self, id: str, status: str, exclusion: CaseExclusionMetadata | ||
): | ||
case = self.case_by_id(id) | ||
case.caseReference.status = status | ||
case.caseExclusion = exclusion | ||
|
||
def fetch_cases(self, page: int, limit: int, predicate: Filter): | ||
return list(self.cases.values())[(page - 1) * limit : page * limit] | ||
|
||
def count_cases(self, predicate: Filter = Anything()): | ||
return len([True for c in self.cases.values() if predicate(c)]) | ||
|
||
def batch_upsert(self, cases: List[Case]): | ||
for case in cases: | ||
self.insert_case(case) | ||
return len(cases), 0 | ||
|
||
def excluded_cases(self, source_id: str, filter: Filter): | ||
return [ | ||
c | ||
for c in self.cases.values() | ||
if c.caseReference.sourceId == source_id | ||
and c.caseReference.status == "EXCLUDED" | ||
] | ||
|
||
def delete_case(self, case_id: str): | ||
del self.cases[case_id] | ||
|
||
def delete_cases(self, query: Filter): | ||
self.cases = dict() | ||
|
||
def matching_case_iterator(self, query: Filter): | ||
return iter(self.cases.values()) | ||
|
||
def identified_case_iterator(self, case_ids): | ||
ids_as_ints = [int(x) for x in case_ids] | ||
all_cases = list(self.cases.values()) | ||
matching_cases = [all_cases[i] for i in ids_as_ints] | ||
return iter(matching_cases) | ||
|
||
def add_field(self, field: Field): | ||
self.fields.append(field) | ||
|
||
def get_case_fields(self) -> List[Field]: | ||
return self.fields | ||
|
||
|
||
def anything_call(self, case: Case): | ||
return True | ||
|
||
|
||
Anything.__call__ = anything_call | ||
|
||
|
||
def property_call(self, case: Case): | ||
my_value = self.value | ||
its_value = attrgetter(self.property_name)(case) | ||
match self.operation: | ||
case FilterOperator.LESS_THAN: | ||
return its_value < my_value | ||
case FilterOperator.GREATER_THAN: | ||
return its_value > my_value | ||
case FilterOperator.EQUAL: | ||
return its_value == my_value | ||
case _: | ||
raise ValueError(f"Unhandled operation {self.operation}") | ||
|
||
|
||
PropertyFilter.__call__ = property_call | ||
|
||
|
||
def and_call(self, case: Case): | ||
return reduce(and_, [f(case) for f in self.filters]) | ||
|
||
|
||
AndFilter.__call__ = and_call |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
except ConflictError
returns 409,except PreconditionUnsatisfiedError
returns 400,and
except KeyError
returns 400?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so
KeyError
shouldn't happen: that will come fromField.model_type
if you try to use a field type that isn't in the allowed list. So we turn it intoPreconditionUnsatisfiedError
which is the same as when we explicitly check that the type isn't found in the allowed list: i.e. both exception paths mean the same thing so they result in the same error (400 bad request: you've asked for something we aren't going to let you do).ConflictError
does indeed result in a 409 Conflict status.