Skip to content

Commit 7e0c44d

Browse files
committed
Added: update api route (#34) (#42)
1 parent d2dae30 commit 7e0c44d

File tree

4 files changed

+179
-19
lines changed

4 files changed

+179
-19
lines changed

libreforms_fastapi/app/__init__.py

+81-4
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,10 @@ async def start_test_logger():
230230
default_group = Group(id=1, name="default", permissions=default_permissions)
231231
session.add(default_group)
232232
session.commit()
233-
logger.info("Default group created.")
233+
logger.info("Default group created")
234234
else:
235235
# print(default_group.get_permissions())
236-
logger.info("Default group already exists.")
236+
logger.info("Default group already exists")
237237

238238

239239
# Initialize the document database
@@ -568,8 +568,85 @@ async def api_form_read_all(form_name: str, background_tasks: BackgroundTasks, r
568568
# # *** Should we use PATCH instead of PUT? In libreForms-flask, we only pass
569569
# the changed details ... But maybe pydantic can handle the journaling and
570570
# metadata. See https://github.com/signebedi/libreforms-fastapi/issues/20.
571-
# @app.put("/api/form/update/{form_name}")
572-
# async def api_form_update():
571+
@app.patch("/api/form/update/{form_name}/{document_id}", dependencies=[Depends(api_key_auth)])
572+
async def api_form_update(form_name: str, document_id: str, background_tasks: BackgroundTasks, request: Request, session: SessionLocal = Depends(get_db), key: str = Depends(X_API_KEY), body: Dict = Body(...)):
573+
574+
if form_name not in get_form_names():
575+
raise HTTPException(status_code=404, detail=f"Form '{form_name}' not found")
576+
577+
# Yield the pydantic form model, setting update to True, which will mark
578+
# all fields as Optional
579+
FormModel = get_form_config(form_name=form_name, update=True)
580+
581+
# # Here we validate and coerce data into its proper type
582+
form_data = FormModel.model_validate(body)
583+
json_data = form_data.model_dump_json()
584+
585+
# Ugh, I'd like to find a more efficient way to get the user data. But alas, that
586+
# the sqlalchemy-signing table is not optimized alongside the user model...
587+
user = session.query(User).filter_by(api_key=key).first()
588+
589+
# Here we validate the user groups permit this level of access to the form
590+
try:
591+
user.validate_permission(form_name=form_name, required_permission="update_own")
592+
# print("\n\n\nUser has valid permissions\n\n\n")
593+
except Exception as e:
594+
raise HTTPException(status_code=403, detail=f"{e}")
595+
596+
# Here, if the user is not able to see other user's data, then we denote the constraint.
597+
try:
598+
user.validate_permission(form_name=form_name, required_permission="update_all")
599+
limit_query_to = False
600+
except Exception as e:
601+
limit_query_to = user.username
602+
603+
metadata={
604+
DocumentDatabase.last_editor_field: user.username,
605+
}
606+
607+
# Add the remote addr host if enabled
608+
if config.COLLECT_USAGE_STATISTICS:
609+
metadata[DocumentDatabase.ip_address_field] = request.client.host
610+
611+
# Process the validated form submission as needed
612+
_ = DocumentDatabase.update_document(
613+
form_name=form_name,
614+
document_id=document_id,
615+
json_data=json_data,
616+
metadata=metadata
617+
)
618+
619+
# Send email
620+
if config.SMTP_ENABLED:
621+
background_tasks.add_task(
622+
mailer.send_mail,
623+
subject="Form Submitted",
624+
content=document_id,
625+
to_address=user.email,
626+
)
627+
628+
629+
# Write this query to the TransactionLog
630+
if config.COLLECT_USAGE_STATISTICS:
631+
632+
endpoint = request.url.path
633+
remote_addr = request.client.host
634+
635+
background_tasks.add_task(
636+
write_api_call_to_transaction_log,
637+
api_key=key,
638+
endpoint=endpoint,
639+
remote_addr=remote_addr,
640+
query_params=json_data,
641+
)
642+
643+
return {
644+
"message": "Form updated received and validated",
645+
"document_id": document_id,
646+
"data": json_data,
647+
}
648+
649+
573650

574651
# Delete form
575652
# @app.delete("/api/form/delete/{form_name}", dependencies=[Depends(api_key_auth)])

libreforms_fastapi/cli/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ def cli_id(username, environment):
602602
f"ID: {user.id}\n"
603603
f"Username: {user.username}\n"
604604
f"Email: {user.email}\n"
605+
f"Groups: {user.email}\n"
605606
f"Active: {user.active}\n"
606607
f"Created Date: {user.created_date.strftime('%Y-%m-%d %H:%M:%S')}\n"
607608
f"Last Login: {user.last_login.strftime('%Y-%m-%d %H:%M:%S') if user.last_login else 'Never'}\n"

libreforms_fastapi/utils/document_database.py

+58-8
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ def create_document(self, form_name:str, json_data, metadata={}):
334334
self.approved_field: metadata.get(self.approved_field, None),
335335
self.approved_by_field: metadata.get(self.approved_by_field, None),
336336
self.approval_signature_field: metadata.get(self.approval_signature_field, None),
337+
self.journal_field: []
337338
}
338339
}
339340

@@ -345,37 +346,86 @@ def create_document(self, form_name:str, json_data, metadata={}):
345346

346347
return document_id
347348

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

351352
self._check_form_exists(form_name)
353+
if self.use_logger:
354+
self.logger.info(f"Starting update for {form_name} with document_id {document_id}")
352355

353356
# Ensure the document exists
354-
existing_document = self.databases[form_name].get(document_id=document_id)
355-
if not existing_document:
356-
raise ValueError(f"No document found with ID {document_id} in form {form_name}")
357+
document = self.databases[form_name].get(doc_id=document_id)
358+
if not document:
359+
if self.use_logger:
360+
self.logger.warning(f"No document for {form_name} with document_id {document_id}")
361+
return None
362+
363+
# If exclude_deleted is set, then we return None if the document is marked as deleted
364+
if exclude_deleted and document['metadata'][self.is_deleted_field] == True:
365+
if self.use_logger:
366+
self.logger.warning(f"Document for {form_name} with document_id {document_id} is deleted and was not updated")
367+
return None
368+
369+
# If we are limiting user access based on group-based access controls, and this user is
370+
# not the document creator, then return None
371+
if isinstance(limit_users, str) and document['metadata'][self.created_by_field] != limit_users:
372+
if self.use_logger:
373+
self.logger.warning(f"Insufficient permissions to update document for {form_name} with document_id {document_id}")
374+
return None
357375

358376
current_timestamp = datetime.now(self.timezone)
359377

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

381+
# Here we remove data that has not been changed
382+
dropping_unchanged_data = {}
383+
for field in updated_data_dict.keys():
384+
if field in document['data'].keys():
385+
if updated_data_dict[field] != document['data'][field]:
386+
dropping_unchanged_data[field] = updated_data_dict[field]
387+
388+
389+
# Build the journal
390+
391+
journal = document['metadata'].get(self.journal_field)
392+
journal.append (
393+
{
394+
self.last_modified_field: current_timestamp.isoformat(),
395+
self.last_editor_field: metadata.get(self.last_editor_field, None),
396+
self.ip_address_field: metadata.get(self.ip_address_field, None),
397+
**dropping_unchanged_data,
398+
}
399+
)
400+
363401
# Prepare the updated data and metadata
364402
update_dict = {
365403
"data": updated_data_dict,
366404
"metadata": {
367405
# Here we update only a few metadata fields ... fields like approval and signature should be
368406
# handled through separate API calls.
369407
self.last_modified_field: current_timestamp.isoformat(),
370-
self.last_editor_field: metadata.get(self.last_editor_field, existing_document["metadata"][self.last_editor_field]),
408+
self.last_editor_field: metadata.get(self.last_editor_field, None),
409+
self.ip_address_field: metadata.get(self.ip_address_field, None),
410+
self.journal_field: journal,
411+
412+
# These fields should all remain the same
413+
self.form_name_field: document['metadata'].get(self.form_name_field),
414+
self.is_deleted_field: document['metadata'].get(self.is_deleted_field),
415+
self.document_id_field: document['metadata'].get(self.document_id_field),
416+
self.timezone_field: document['metadata'].get(self.timezone_field),
417+
self.created_at_field: document['metadata'].get(self.created_at_field),
418+
self.created_by_field: document['metadata'].get(self.created_by_field),
419+
self.signature_field: document['metadata'].get(self.signature_field),
420+
self.approved_field: document['metadata'].get(self.approved_field),
421+
self.approved_by_field: document['metadata'].get(self.approved_by_field),
422+
self.approval_signature_field: document['metadata'].get(self.approval_signature_field),
371423
}
372424
}
373425

374-
update_dict['metadata'][self.journal_field] = update_dict.copy()
375-
376426
# Update only the fields that are provided in json_data and metadata, not replacing the entire
377427
# document. The partial approach will minimize the room for mistakes from overwriting entire documents.
378-
_ = self.databases[form_name].update(update_dict, document_id=document_id)
428+
_ = self.databases[form_name].update(update_dict, doc_ids=[document_id])
379429

380430
if self.use_logger:
381431
self.logger.info(f"Updated document for {form_name} with document_id {document_id}")

libreforms_fastapi/utils/pydantic_models.py

+39-7
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def passwords_match(cls, data: Any) -> Any:
7070
"field_name": "text_input",
7171
"default": "Default Text",
7272
"validators": [],
73+
"required": False,
7374
"options": None
7475
},
7576
"number_input": {
@@ -78,6 +79,7 @@ def passwords_match(cls, data: Any) -> Any:
7879
"field_name": "number_input",
7980
"default": 42,
8081
"validators": [],
82+
"required": False,
8183
"options": None
8284
},
8385
"email_input": {
@@ -86,6 +88,7 @@ def passwords_match(cls, data: Any) -> Any:
8688
"field_name": "email_input",
8789
"default": "[email protected]",
8890
"validators": [],
91+
"required": False,
8992
"options": None
9093
},
9194
"date_input": {
@@ -94,6 +97,7 @@ def passwords_match(cls, data: Any) -> Any:
9497
"field_name": "date_input",
9598
"default": "2024-01-01",
9699
"validators": [],
100+
"required": False,
97101
"options": None
98102
},
99103
"checkbox_input": {
@@ -102,6 +106,7 @@ def passwords_match(cls, data: Any) -> Any:
102106
"field_name": "checkbox_input",
103107
"options": ["Option1", "Option2", "Option3"],
104108
"validators": [],
109+
"required": False,
105110
"default": ["Option1", "Option3"]
106111
},
107112
"radio_input": {
@@ -110,6 +115,7 @@ def passwords_match(cls, data: Any) -> Any:
110115
"field_name": "radio_input",
111116
"options": ["Option1", "Option2"],
112117
"validators": [],
118+
"required": False,
113119
"default": "Option2"
114120
},
115121
"select_input": {
@@ -118,6 +124,7 @@ def passwords_match(cls, data: Any) -> Any:
118124
"field_name": "select_input",
119125
"options": ["Option1", "Option2", "Option3"],
120126
"validators": [],
127+
"required": False,
121128
"default": "Option2"
122129
},
123130
"textarea_input": {
@@ -126,14 +133,16 @@ def passwords_match(cls, data: Any) -> Any:
126133
"field_name": "textarea_input",
127134
"default": "Default textarea content.",
128135
"validators": [],
136+
"required": False,
129137
"options": None
130138
},
131139
"file_input": {
132140
"input_type": "file",
133-
"output_type": Optional[bytes],
141+
"output_type": bytes,
134142
"field_name": "file_input",
135143
"options": None,
136144
"validators": [],
145+
"required": False,
137146
"default": None # File inputs can't have default values
138147
},
139148
},
@@ -295,8 +304,12 @@ def get_form_names(config_path=config.FORM_CONFIG_PATH):
295304
# print("Config file does not exist. Using the default configuration.")
296305
return form_config.keys()
297306

298-
def get_form_config(form_name, config_path=config.FORM_CONFIG_PATH):
299-
"""Yields a single config dict for the form name passed, following a factory pattern approach"""
307+
def get_form_config(form_name, config_path=config.FORM_CONFIG_PATH, update=False):
308+
"""
309+
Yields a single config dict for the form name passed, following a factory pattern approach.
310+
311+
If update is set to True, all fields will be set to optional.
312+
"""
300313
# Try to open config_path and if not existent or empty, use example config
301314
form_config = example_form_config # Default to example_form_config
302315

@@ -318,21 +331,40 @@ def get_form_config(form_name, config_path=config.FORM_CONFIG_PATH):
318331
field_definitions = {}
319332

320333
for field_name, field_info in fields.items():
334+
335+
# Should we consider making an Enum for fields with a limited set of options...
336+
# essentially requiring that the values passed are in the Enum of acceptable
337+
# values? Bit difficult to implement for List and other data types, but may
338+
# be worthwhile.
339+
321340
python_type: Type = field_info["output_type"]
322341
default = field_info.get("default", ...)
323-
342+
required = field_info.get("required", False) # Default to not required field
343+
validators = field_info.get("validators", [])
344+
324345
# Ensure Optional is always used with a specific type
325-
if default is ... and python_type != Optional:
346+
if (default is ... and python_type != Optional) or (not required) or (update):
326347
python_type = Optional[python_type]
327348

328349
field_definitions[field_name] = (python_type, default)
329-
350+
351+
for validator_func in validators:
352+
# This assumes `validator_func` is callable that accepts a single
353+
# value and returns a value or raises an exception
354+
pass
355+
330356
# Creating the model dynamically, allowing arbitrary types
331357
class Config:
332358
arbitrary_types_allowed = True
333359

334360
model = create_model(form_name, __config__=Config, **field_definitions)
335-
361+
362+
for field_name, field_info in fields.items():
363+
validators = field_info.get("validators", [])
364+
for v in validators:
365+
# Placeholder for adding the validators to the model here
366+
pass
367+
336368
return model
337369

338370

0 commit comments

Comments
 (0)