-
Notifications
You must be signed in to change notification settings - Fork 12
chore!: refactor code for better maintainability #75
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
Changes from all commits
b9bfa55
e921e26
7374711
3a01170
d828751
ae3c6e2
2d09137
982dec0
edf30ac
f68417a
c487acc
2147ae0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,4 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from datetime import datetime | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -32,11 +33,98 @@ def parse_datetime(cls, v: Any) -> datetime: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(f"Invalid datetime value: {v}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @frappe.whitelist(allow_guest=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _coerce_field_value(value: Any, fieldtype: str) -> Any: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Coerce a submitted value to its comparable type, matching frontend conditionals.ts logic.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if value is None or value == "": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Matches isBoolean types in the frontend registry (Switch, Checkbox) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if fieldtype in ("Switch", "Checkbox"): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return bool(value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if fieldtype == "Number": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return float(value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except (TypeError, ValueError): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return str(value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+36
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Treat zero-length collections as empty answers.
🩹 Suggested fix+def _is_empty_value(value: Any) -> bool:
+ return value is None or value == "" or (
+ isinstance(value, (list, tuple, dict, set)) and len(value) == 0
+ )
+
+
def _coerce_field_value(value: Any, fieldtype: str) -> Any:
"""Coerce a submitted value to its comparable type, matching frontend conditionals.ts logic."""
- if value is None or value == "":
+ if _is_empty_value(value):
return None
+ if fieldtype == "Table":
+ return value
# Matches isBoolean types in the frontend registry (Switch, Checkbox)
if fieldtype in ("Switch", "Checkbox"):
return bool(value)
if fieldtype == "Number":
try:
return float(value)
except (TypeError, ValueError):
return None
return str(value)
...
- if is_required and (value is None or value == ""):
+ if is_required and _is_empty_value(value):
errors.append(_("{0} is required").format(field.label))Also applies to: 115-116 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _evaluate_conditions(conditions: list[dict], form_data: dict, field_map: dict) -> bool: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Evaluate AND-joined conditions against submitted form data.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for condition in conditions: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fieldname = condition.get("fieldname") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| operator = condition.get("operator") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expected = condition.get("value") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| field = field_map.get(fieldname) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not field: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| actual = _coerce_field_value(form_data.get(fieldname), field.fieldtype) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Coerce the condition's expected value through the same function so | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # types match — avoids str(True)=="True" vs "true" mismatches and | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # str(3.0)=="3.0" vs "3" mismatches for Number fields. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expected_coerced = _coerce_field_value(expected, field.fieldtype) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if operator == "Is" and actual != expected_coerced: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if operator == "Is Not" and actual == expected_coerced: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if operator == "Is Empty" and actual is not None and actual != "": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if operator == "Is Not Empty" and (actual is None or actual == ""): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+51
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate decoded conditional logic before evaluating it. Line 99 assumes 🛡️ Suggested fix def _evaluate_conditions(conditions: list[dict], form_data: dict, field_map: dict) -> bool:
"""Evaluate AND-joined conditions against submitted form data."""
+ if not isinstance(conditions, list):
+ return False
+
for condition in conditions:
+ if not isinstance(condition, dict):
+ return False
+
fieldname = condition.get("fieldname")
operator = condition.get("operator")
expected = condition.get("value")
field = field_map.get(fieldname)
if not field:
return False
actual = _coerce_field_value(form_data.get(fieldname), field.fieldtype)
expected_coerced = _coerce_field_value(expected, field.fieldtype)
- if operator == "is" and actual != expected_coerced:
- return False
- if operator == "is_not" and actual == expected_coerced:
- return False
- if operator == "is_empty" and actual is not None and actual != "":
- return False
- if operator == "is_not_empty" and (actual is None or actual == ""):
- return False
+ if operator == "is":
+ if actual != expected_coerced:
+ return False
+ elif operator == "is_not":
+ if actual == expected_coerced:
+ return False
+ elif operator == "is_empty":
+ if actual is not None and actual != "":
+ return False
+ elif operator == "is_not_empty":
+ if actual is None or actual == "":
+ return False
+ else:
+ return False
return True
...
try:
logic = json.loads(other.conditional_logic)
except (json.JSONDecodeError, TypeError):
continue
+ if not isinstance(logic, dict):
+ continueAlso applies to: 94-102 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _validate_form_response(form: "Form", form_data: dict) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Validate required and conditionally-required fields server-side. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Mirrors the shouldFieldBeVisible / shouldFieldBeRequired logic in | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| frontend/src/utils/conditionals.ts so that direct API calls cannot | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| bypass frontend validation. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| field_map = {f.fieldname: f for f in form.fields} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors: list[str] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for field in form.fields: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| is_visible = not field.hidden | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| is_required = bool(field.reqd) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for other in form.fields: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not other.conditional_logic: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logic = json.loads(other.conditional_logic) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except (json.JSONDecodeError, TypeError): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if logic.get("target_field") != field.fieldname: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| conditions_met = _evaluate_conditions(logic.get("conditions", []), form_data, field_map) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if conditions_met: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| action = logic.get("action") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if action == "Show Field": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| is_visible = True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| elif action == "Hide Field": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| is_visible = False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| elif action == "Require Answer": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| is_required = True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not is_visible: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value = form_data.get(field.fieldname) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if is_required and (value is None or value == ""): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errors.append(_("{0} is required").format(field.label)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if errors: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| frappe.throw("\n".join(errors), frappe.ValidationError) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def submit_form_response( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| form_id: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| form_data: list[dict], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission_status: SubmissionStatus = SubmissionStatus.SUBMITTED, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission_status: str = SubmissionStatus.SUBMITTED.value, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Submit a form response | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -49,6 +137,14 @@ def submit_form_response( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The name of the submission | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status = SubmissionStatus(submission_status) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except ValueError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| frappe.throw( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _("Invalid submission status: {0}").format(submission_status), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| frappe.ValidationError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| form: Form = frappe.get_doc("Form", form_id) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| linked_doctype = form.linked_doctype | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -59,12 +155,22 @@ def submit_form_response( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| frappe.PermissionError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| form_data_dict = {item["fieldname"]: item["value"] for item in form_data} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Whitelist to declared form fields only — prevents injecting system fields | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # like owner, docstatus, etc. into the linked DocType. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| allowed_fieldnames = {f.fieldname for f in form.fields} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| form_data_dict = {k: v for k, v in form_data_dict.items() if k in allowed_fieldnames} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+158
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Because this endpoint is 🧪 Suggested fix- form_data_dict = {item["fieldname"]: item["value"] for item in form_data}
+ form_data_dict: dict[str, Any] = {}
+ for item in form_data:
+ if (
+ not isinstance(item, dict)
+ or not isinstance(item.get("fieldname"), str)
+ or "value" not in item
+ ):
+ frappe.throw(_("Invalid form data payload"), frappe.ValidationError)
+
+ form_data_dict[item["fieldname"]] = item["value"]🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if status == SubmissionStatus.SUBMITTED: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _validate_form_response(form, form_data_dict) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission = frappe.new_doc(linked_doctype) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for data in form_data: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission.set(data["fieldname"], data["value"]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for fieldname, value in form_data_dict.items(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission.set(fieldname, value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+158
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject unexpected field names before calling This endpoint is 🔒 Suggested hardening- form_data_dict = {item["fieldname"]: item["value"] for item in form_data}
+ allowed_fieldnames = {field.fieldname for field in form.fields}
+ unexpected_fieldnames = [
+ item["fieldname"] for item in form_data if item["fieldname"] not in allowed_fieldnames
+ ]
+ if unexpected_fieldnames:
+ frappe.throw(
+ _("Invalid field(s): {0}").format(", ".join(unexpected_fieldnames)),
+ frappe.ValidationError,
+ )
+
+ form_data_dict = {
+ item["fieldname"]: item["value"]
+ for item in form_data
+ if item["fieldname"] in allowed_fieldnames
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission.fp_linked_form = form_id | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission.fp_submission_status = submission_status.value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission.fp_submission_status = status.value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submission.insert(ignore_permissions=True, ignore_mandatory=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Share the submission with the owner | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
Guest access here can expose schema for arbitrary DocTypes.
get_doctype_fieldsis guest-accessible and accepts anydoctypename, but it does not enforce a permission or publish-scope check before returning field metadata. That enables unauthenticated schema enumeration outside intended form contexts.🔒 Suggested guard (limit guest access to published-form doctypes)