Skip to content

Commit

Permalink
Modified: pydantic model accepts partial data on updates (#34) (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
signebedi committed Mar 24, 2024
1 parent 7e0c44d commit 20dcf95
Show file tree
Hide file tree
Showing 3 changed files with 71 additions and 42 deletions.
25 changes: 19 additions & 6 deletions libreforms_fastapi/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,13 +575,18 @@ async def api_form_update(form_name: str, document_id: str, background_tasks: Ba
raise HTTPException(status_code=404, detail=f"Form '{form_name}' not found")

# Yield the pydantic form model, setting update to True, which will mark
# all fields as Optional
# all fields as Optional. Nb. Maybe we should pass the full data payload,
# including unchanged fields. The benefit is simplicity all over the
# application, because we can just pull the data, update fields as appropriate,
# and pass the full payload to the document database to parse and clean up.
FormModel = get_form_config(form_name=form_name, update=True)

# # Here we validate and coerce data into its proper type
form_data = FormModel.model_validate(body)
json_data = form_data.model_dump_json()

print("\n\n\n", json_data)

# Ugh, I'd like to find a more efficient way to get the user data. But alas, that
# the sqlalchemy-signing table is not optimized alongside the user model...
user = session.query(User).filter_by(api_key=key).first()
Expand Down Expand Up @@ -652,6 +657,8 @@ async def api_form_update(form_name: str, document_id: str, background_tasks: Ba
# @app.delete("/api/form/delete/{form_name}", dependencies=[Depends(api_key_auth)])
# async def api_form_delete():



# Search forms
@app.get("/api/form/search/{form_name}")
async def api_form_search(form_name: str, background_tasks: BackgroundTasks, request: Request, session: SessionLocal = Depends(get_db), key: str = Depends(X_API_KEY), search_term: str = Query(None, title="Search Term")):
Expand Down Expand Up @@ -863,11 +870,18 @@ async def api_auth_create(user_request: CreateUserRequest, background_tasks: Bac
# @app.patch("/api/auth/update")
# async def api_auth_update(user_request: CreateUserRequest, session: SessionLocal = Depends(get_db)):


# Get User / id
# @app.patch("/api/auth/get")
# @app.get("/api/auth/get")
# async def api_auth_get(user_request: CreateUserRequest, session: SessionLocal = Depends(get_db)):

# Request Password Reset - Forgot Password
# @app.patch("/api/auth/forgot_password")
# async def api_auth_forgot_password(user_request: CreateUserRequest, session: SessionLocal = Depends(get_db)):

# Confirm password reset
# @app.patch("/api/auth/forgot_password/{single_use_token}")
# async def api_auth_forgot_password_confirm(user_request: CreateUserRequest, session: SessionLocal = Depends(get_db)):

# Rotate user API key
# @app.patch("/api/auth/rotate_key")
# async def api_auth_rotate_key():
Expand All @@ -891,12 +905,11 @@ async def api_auth_create(user_request: CreateUserRequest, background_tasks: Bac
# Add new user
# > paired with add newadmin UI route

# Modify user *** including disable user
# Modify user *** including disable user

# Get Transaction Statistics
# Paired with the Transaction Statistics
# Paired with the Transaction Statistics admin UI route

# Toggle user active status

# Update application config

Expand Down
64 changes: 34 additions & 30 deletions libreforms_fastapi/utils/document_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ def create_document(self, form_name:str, json_data, metadata={}):

return document_id

def update_document(self, form_name:str, document_id:str, json_data, metadata={}, limit_users:Union[bool, str]=False, exclude_deleted:bool=True):
def update_document(self, form_name:str, document_id:str, json_data:str, metadata={}, limit_users:Union[bool, str]=False, exclude_deleted:bool=True):
"""Updates existing form in specified form's database."""

self._check_form_exists(form_name)
Expand Down Expand Up @@ -375,19 +375,34 @@ def update_document(self, form_name:str, document_id:str, json_data, metadata={}

current_timestamp = datetime.now(self.timezone)

print("\n\n\n\nDocument: ", document)

# This is a little hackish but TinyDB write data to file as Python dictionaries, not JSON.
updated_data_dict = json.loads(json_data)

# Here we remove data that has not been changed
dropping_unchanged_data = {}
for field in updated_data_dict.keys():
if field in document['data'].keys():
if updated_data_dict[field] != document['data'][field]:
dropping_unchanged_data[field] = updated_data_dict[field]
print(field)
if all([
field in document['data'].keys(),
updated_data_dict[field] != document['data'][field],
updated_data_dict[field] is not None
]):

print(f"\n\n\n{field} in document data but has different value")
dropping_unchanged_data[field] = updated_data_dict[field]
elif all([
field not in document['data'].keys(),
updated_data_dict[field] is not None
]):

# Build the journal
print(f"\n\n\n{field} not in document data")
dropping_unchanged_data[field] = updated_data_dict[field]

print("\n\n\nDropping Unchanged Fields: ", dropping_unchanged_data)

# Build the journal
journal = document['metadata'].get(self.journal_field)
journal.append (
{
Expand All @@ -398,34 +413,23 @@ def update_document(self, form_name:str, document_id:str, json_data, metadata={}
}
)

# Prepare the updated data and metadata
update_dict = {
"data": updated_data_dict,
"metadata": {
# Here we update only a few metadata fields ... fields like approval and signature should be
# handled through separate API calls.
self.last_modified_field: current_timestamp.isoformat(),
self.last_editor_field: metadata.get(self.last_editor_field, None),
self.ip_address_field: metadata.get(self.ip_address_field, None),
self.journal_field: journal,

# These fields should all remain the same
self.form_name_field: document['metadata'].get(self.form_name_field),
self.is_deleted_field: document['metadata'].get(self.is_deleted_field),
self.document_id_field: document['metadata'].get(self.document_id_field),
self.timezone_field: document['metadata'].get(self.timezone_field),
self.created_at_field: document['metadata'].get(self.created_at_field),
self.created_by_field: document['metadata'].get(self.created_by_field),
self.signature_field: document['metadata'].get(self.signature_field),
self.approved_field: document['metadata'].get(self.approved_field),
self.approved_by_field: document['metadata'].get(self.approved_by_field),
self.approval_signature_field: document['metadata'].get(self.approval_signature_field),
}
}
# Now we update the document with the changes
for field in dropping_unchanged_data.keys():
document['data'][field] = dropping_unchanged_data[field]

# Here we update only a few metadata fields ... fields like approval and signature should be
# handled through separate API calls.
document['metadata'][self.last_modified_field] = current_timestamp.isoformat()
document['metadata'][self.last_editor_field] = metadata.get(self.last_editor_field, None)
document['metadata'][self.ip_address_field] = metadata.get(self.ip_address_field, None)
document['metadata'][self.journal_field] = journal


print("\n\n\nUpdated Document: ", document)

# Update only the fields that are provided in json_data and metadata, not replacing the entire
# document. The partial approach will minimize the room for mistakes from overwriting entire documents.
_ = self.databases[form_name].update(update_dict, doc_ids=[document_id])
_ = self.databases[form_name].update(document, doc_ids=[document_id])

if self.use_logger:
self.logger.info(f"Updated document for {form_name} with document_id {document_id}")
Expand Down
24 changes: 18 additions & 6 deletions libreforms_fastapi/utils/pydantic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,16 +342,28 @@ def get_form_config(form_name, config_path=config.FORM_CONFIG_PATH, update=False
required = field_info.get("required", False) # Default to not required field
validators = field_info.get("validators", [])

# Ensure Optional is always used with a specific type
if (default is ... and python_type != Optional) or (not required) or (update):
python_type = Optional[python_type]

field_definitions[field_name] = (python_type, default)
# If update is True, provide None as the default value ... the idea here is that
# the model has already imposed default value constraints at the time of creation.
# But, it is a legitimate format for clients to pass either (1) data that only
# includes the changes the client wants to make or (2) all the data, changing only
# the fields the client wants to change. We need to be able to make sense of these
# two cases. See: https://github.com/signebedi/libreforms-fastapi/issues/34
if update:
field_definitions[field_name] = (Optional[python_type], None)
else:
default = field_info.get("default", ...)
# Use Optional type if default is not set or field is not required
if (default is ... and python_type != Optional) or not required:
python_type = Optional[python_type]
field_definitions[field_name] = (python_type, default)


for validator_func in validators:
# This assumes `validator_func` is callable that accepts a single
# This assumes validator_func is callable that accepts a single
# value and returns a value or raises an exception
pass

print(field_definitions)

# Creating the model dynamically, allowing arbitrary types
class Config:
Expand Down

0 comments on commit 20dcf95

Please sign in to comment.