diff --git a/frappe/__init__.py b/frappe/__init__.py index 2cf833ea57..7cffb9a512 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1469,14 +1469,12 @@ def _load_app_hooks(app_name: str | None = None): for app in apps: try: app_hooks = get_module(f"{app}.hooks") - except ImportError: + except ImportError as e: if local.flags.in_install_app: # if app is not installed while restoring # ignore it pass - print(f'Could not find app "{app}"') - if not request: - raise SystemExit + print(f'Could not find app "{app}": \n{e}') raise def _is_valid_hook(obj): @@ -1592,7 +1590,7 @@ def read_file(path, raise_not_found=False): def get_attr(method_string: str) -> Any: """Get python method object from its name.""" - app_name = method_string.split(".")[0] + app_name = method_string.split(".", 1)[0] if ( not local.flags.in_uninstall and not local.flags.in_install diff --git a/frappe/auth.py b/frappe/auth.py index d1dc10817c..3321784ce2 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -55,7 +55,9 @@ class HTTPRequest: def set_request_ip(self): if frappe.get_request_header("X-Forwarded-For"): - frappe.local.request_ip = (frappe.get_request_header("X-Forwarded-For").split(",")[0]).strip() + frappe.local.request_ip = ( + frappe.get_request_header("X-Forwarded-For").split(",", 1)[0] + ).strip() elif frappe.get_request_header("REMOTE_ADDR"): frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR") diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index eeddef1865..9c9f081c60 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -160,11 +160,10 @@ def clear_doctype_cache(doctype=None): def clear_controller_cache(doctype=None): if not doctype: - del frappe.controllers - frappe.controllers = {} + frappe.controllers.pop(frappe.local.site, None) return - for site_controllers in frappe.controllers.values(): + if site_controllers := frappe.controllers.get(frappe.local.site): site_controllers.pop(doctype, None) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index bb943c7223..280e656f1c 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -562,7 +562,7 @@ def _psql(): def jupyter(context): """Start an interactive jupyter notebook""" installed_packages = ( - r.split("==")[0] + r.split("==", 1)[0] for r in subprocess.check_output([sys.executable, "-m", "pip", "freeze"], encoding="utf8") ) @@ -1001,7 +1001,7 @@ def request(context, args=None, path=None): frappe.local.form_dict = frappe._dict() if args.startswith("/api/method"): - frappe.local.form_dict.cmd = args.split("?")[0].split("/")[-1] + frappe.local.form_dict.cmd = args.split("?", 1)[0].split("/")[-1] elif path: with open(os.path.join("..", path)) as f: args = json.loads(f.read()) @@ -1030,6 +1030,16 @@ def make_app(destination, app_name, no_git=False): make_boilerplate(destination, app_name, no_git=no_git) +@click.command("create-patch") +def create_patch(): + "Creates a new patch interactively" + from frappe.utils.boilerplate import PatchCreator + + pc = PatchCreator() + pc.fetch_user_inputs() + pc.create_patch_file() + + @click.command("set-config") @click.argument("key") @click.argument("value") @@ -1176,6 +1186,7 @@ commands = [ data_import, import_doc, make_app, + create_patch, mariadb, postgres, request, diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 9944961ca9..9756bc73c0 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -499,7 +499,7 @@ def parse_email(communication, email_strings): if email_string: for email in email_string.split(","): if delimiter in email: - email = email.split("@")[0] + email = email.split("@", 1)[0] email_local_parts = email.split(delimiter) if not len(email_local_parts) == 3: continue @@ -521,7 +521,7 @@ def get_email_without_link(email): try: _email = email.split("@") - email_id = _email[0].split("+")[0] + email_id = _email[0].split("+", 1)[0] email_host = _email[1] except IndexError: return email diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 14ef2fd8fb..671a6e86e6 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -604,6 +604,7 @@ { "default": "0", "depends_on": "eval: doc.is_submittable", + "description": "Enabling this will submit documents in background", "fieldname": "queue_in_background", "fieldtype": "Check", "label": "Queue in Background" @@ -707,7 +708,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2022-12-14 09:47:27.315351", + "modified": "2023-01-04 17:23:09.206018", "modified_by": "Administrator", "module": "Core", "name": "DocType", @@ -744,4 +745,4 @@ "states": [], "track_changes": 1, "translated_doctype": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index e1bb23b388..64b6f3123d 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -366,8 +366,10 @@ class DocType(Document): d.fieldname = d.fieldname + "_column" elif d.fieldtype == "Tab Break": d.fieldname = d.fieldname + "_tab" - else: + elif d.fieldtype in ("Section Break", "Column Break", "Tab Break"): d.fieldname = d.fieldtype.lower().replace(" ", "_") + "_" + str(random_string(4)) + else: + frappe.throw(_("Row #{}: Fieldname is required").format(d.idx), title="Missing Fieldname") else: if d.fieldname in restricted: frappe.throw(_("Fieldname {0} is restricted").format(d.fieldname), InvalidFieldNameError) @@ -883,7 +885,7 @@ def validate_series(dt, autoname=None, name=None): if not autoname and dt.get("fields", {"fieldname": "naming_series"}): dt.autoname = "naming_series:" elif dt.autoname and dt.autoname.startswith("naming_series:"): - fieldname = dt.autoname.split("naming_series:")[0] or "naming_series" + fieldname = dt.autoname.split("naming_series:", 1)[0] or "naming_series" if not dt.get("fields", {"fieldname": fieldname}): frappe.throw( _("Fieldname called {0} must exist to enable autonaming").format(frappe.bold(fieldname)), @@ -911,7 +913,7 @@ def validate_series(dt, autoname=None, name=None): and (not autoname.startswith("format:")) ): - prefix = autoname.split(".")[0] + prefix = autoname.split(".", 1)[0] doctype = frappe.qb.DocType("DocType") used_in = ( frappe.qb.from_(doctype) @@ -1133,7 +1135,7 @@ def validate_fields(meta): d.options = options def check_hidden_and_mandatory(docname, d): - if d.hidden and d.reqd and not d.default: + if d.hidden and d.reqd and not d.default and not frappe.flags.in_migrate: frappe.throw( _("{0}: Field {1} in row {2} cannot be hidden and mandatory without default").format( docname, d.label, d.idx @@ -1346,7 +1348,7 @@ def validate_fields(meta): if meta.sort_field: sort_fields = [meta.sort_field] if "," in meta.sort_field: - sort_fields = [d.split()[0] for d in meta.sort_field.split(",")] + sort_fields = [d.split(maxsplit=1)[0] for d in meta.sort_field.split(",")] for fieldname in sort_fields: if fieldname not in (fieldname_list + list(default_fields) + list(child_table_fields)): diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 16254da4cc..1323359030 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -329,7 +329,11 @@ class File(Document): self.file_url = duplicate_file.file_url def set_file_name(self): - if not self.file_name and self.file_url: + if not self.file_name and not self.file_url: + frappe.throw( + _("Fields `file_name` or `file_url` must be set for File"), exc=frappe.MandatoryError + ) + elif not self.file_name and self.file_url: self.file_name = self.file_url.split("/")[-1] else: self.file_name = re.sub(r"/", "", self.file_name) diff --git a/frappe/core/doctype/file/utils.py b/frappe/core/doctype/file/utils.py index d99e5cff48..17a092e340 100644 --- a/frappe/core/doctype/file/utils.py +++ b/frappe/core/doctype/file/utils.py @@ -225,7 +225,7 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F def _save_file(match): data = match.group(1).split("data:")[1] headers, content = data.split(",") - mtype = headers.split(";")[0] + mtype = headers.split(";", 1)[0] if isinstance(content, str): content = content.encode("utf-8") @@ -237,7 +237,7 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F if "filename=" in headers: filename = headers.split("filename=")[-1] - filename = safe_decode(filename).split(";")[0] + filename = safe_decode(filename).split(";", 1)[0] else: filename = get_random_filename(content_type=mtype) diff --git a/frappe/core/doctype/installed_applications/installed_applications.js b/frappe/core/doctype/installed_applications/installed_applications.js index 71c54a749a..507cf76875 100644 --- a/frappe/core/doctype/installed_applications/installed_applications.js +++ b/frappe/core/doctype/installed_applications/installed_applications.js @@ -57,6 +57,10 @@ frappe.ui.form.on("Installed Applications", { }); dialog.fields_dict.apps.grid.refresh(); + // hack: change checkboxes to drag handles. + let grid = $(dialog.fields_dict.apps.grid.parent); + grid.find(".grid-row-check:first").remove() && + grid.find(".grid-row-check").replaceWith(frappe.utils.icon("menu")); dialog.show(); }); }, diff --git a/frappe/core/doctype/package_import/package_import.py b/frappe/core/doctype/package_import/package_import.py index 19762eae4a..4939b357b0 100644 --- a/frappe/core/doctype/package_import/package_import.py +++ b/frappe/core/doctype/package_import/package_import.py @@ -26,7 +26,7 @@ class PackageImport(Document): attachment = attachment[0] # get package_name from file (package_name-0.0.0.tar.gz) - package_name = attachment.file_name.split(".")[0].rsplit("-", 1)[0] + package_name = attachment.file_name.split(".", 1)[0].rsplit("-", 1)[0] if not os.path.exists(frappe.get_site_path("packages")): os.makedirs(frappe.get_site_path("packages")) diff --git a/frappe/core/doctype/patch_log/patch_log.json b/frappe/core/doctype/patch_log/patch_log.json index 9750c51279..b586aeabfc 100644 --- a/frappe/core/doctype/patch_log/patch_log.json +++ b/frappe/core/doctype/patch_log/patch_log.json @@ -1,6 +1,6 @@ { "actions": [], - "autoname": "PATCHLOG.#####", + "autoname": "hash", "creation": "2013-01-17 11:36:45", "description": "List of patches executed", "doctype": "DocType", @@ -20,11 +20,11 @@ "icon": "fa fa-cog", "idx": 1, "links": [], - "modified": "2022-06-13 05:34:37.845368", + "modified": "2023-01-17 15:35:11.688615", "modified_by": "Administrator", "module": "Core", "name": "Patch Log", - "naming_rule": "Expression (old style)", + "naming_rule": "Random", "owner": "Administrator", "permissions": [ { diff --git a/frappe/core/doctype/submission_queue/submission_queue.js b/frappe/core/doctype/submission_queue/submission_queue.js index 93d6b981dc..6e64be780a 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.js +++ b/frappe/core/doctype/submission_queue/submission_queue.js @@ -3,11 +3,17 @@ frappe.ui.form.on("Submission Queue", { refresh: function (frm) { - if (frm.doc.status === "Queued" && frm.doc.job_id) { + if (frm.doc.status === "Queued" && frappe.boot.user.roles.includes("System Manager")) { frm.add_custom_button(__("Unlock Reference Document"), () => { - frappe.confirm(__("Are you sure you want to go ahead with this action?"), () => { - frm.call("unlock_doc"); - }); + frappe.confirm( + ` + Are you sure you want to go ahead with this action? + Doing this could unlock other submissions of this document which are in queue (if present) + and could lead to non-ideal conditions.`, + () => { + frm.call("unlock_doc"); + } + ); }); } }, diff --git a/frappe/core/doctype/submission_queue/submission_queue.json b/frappe/core/doctype/submission_queue/submission_queue.json index d1f66ffa13..04668e1c76 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.json +++ b/frappe/core/doctype/submission_queue/submission_queue.json @@ -20,8 +20,9 @@ "fields": [ { "fieldname": "job_id", - "fieldtype": "Data", + "fieldtype": "Link", "label": "Job Id", + "options": "RQ Job", "read_only": 1 }, { @@ -80,14 +81,14 @@ }, { "fieldname": "exception", - "fieldtype": "Text", + "fieldtype": "Long Text", "label": "Exception", "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-11-12 16:48:37.797232", + "modified": "2023-01-23 12:45:53.997708", "modified_by": "Administrator", "module": "Core", "name": "Submission Queue", @@ -102,6 +103,11 @@ "report": 1, "role": "System Manager", "share": 1 + }, + { + "if_owner": 1, + "read": 1, + "role": "All" } ], "sort_field": "modified", diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index 2bb4200a87..be0c20fc32 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -4,8 +4,6 @@ from urllib.parse import quote from rq import get_current_job -from rq.exceptions import NoSuchJobError -from rq.job import Job import frappe from frappe import _ @@ -13,7 +11,6 @@ from frappe.desk.doctype.notification_log.notification_log import enqueue_create from frappe.model.document import Document from frappe.monitor import add_data_to_monitor from frappe.utils import now, time_diff_in_seconds -from frappe.utils.background_jobs import get_redis_conn from frappe.utils.data import cint @@ -39,6 +36,7 @@ class SubmissionQueue(Document): frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) def insert(self, to_be_queued_doc: Document, action: str): + self.status = "Queued" self.to_be_queued_doc = to_be_queued_doc self.action_for_queuing = action super().insert(ignore_permissions=True) @@ -70,6 +68,7 @@ class SubmissionQueue(Document): def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str): # Set the job id for that submission doctype self.update_job_id(get_current_job().id) + _action = action_for_queuing.lower() if _action == "update": _action = "submit" @@ -85,7 +84,7 @@ class SubmissionQueue(Document): ) values = {"status": "Finished"} except Exception: - values = {"status": "Failed", "exception": frappe.get_traceback()} + values = {"status": "Failed", "exception": frappe.get_traceback(with_context=True)} frappe.db.rollback() values["ended_at"] = now() @@ -96,22 +95,27 @@ class SubmissionQueue(Document): if submission_status == "Failed": doctype = self.doctype docname = self.name - message = _("Submission of {0} {1} with action {2} failed") + message = _("Action {0} failed on {1} {2}. View it {3}") else: doctype = self.ref_doctype docname = self.ref_docname - message = _("Submission of {0} {1} with action {2} completed successfully") + message = _("Action {0} completed successfully on {1} {2}. View it {3}") - message = message.format( - frappe.bold(str(self.ref_doctype)), frappe.bold(self.ref_docname), frappe.bold(action) + message_replacements = ( + frappe.bold(action), + frappe.bold(str(self.ref_doctype)), + frappe.bold(str(self.ref_docname)), ) + time_diff = time_diff_in_seconds(now(), self.created_at) if cint(time_diff) <= 60: frappe.publish_realtime( "msgprint", { - "message": message - + f". View it here", + "message": message.format( + *message_replacements, + f"here", + ), "alert": True, "indicator": "red" if submission_status == "Failed" else "green", }, @@ -122,50 +126,27 @@ class SubmissionQueue(Document): "type": "Alert", "document_type": doctype, "document_name": docname, - "subject": message, + "subject": message.format(*message_replacements, "here"), } notify_to = frappe.db.get_value("User", self.enqueued_by, fieldname="email") enqueue_create_notification([notify_to], notification_doc) - def _unlock_reference_doc(self): - """ - Only execute if self.job_id is defined. - """ - try: - job = Job.fetch(self.job_id, connection=get_redis_conn()) - status = job.get_status(refresh=True) - exc = job.exc_info - except NoSuchJobError: - exc = None - status = "failed" - - if status in ("queued", "started"): - frappe.msgprint(_("Document in queue for execution!")) - return - - self.queued_doc.unlock() - values = ( - {"status": "Finished"} if status == "finished" else {"status": "Failed", "exception": exc} - ) - frappe.db.set_value(self.doctype, self.name, values, update_modified=False) - frappe.msgprint(_("Document Unlocked")) - @frappe.whitelist() def unlock_doc(self): # NOTE: this can lead to some weird unlocking/locking behaviours. # for example: hitting unlock on a submission could lead to unlocking of another submission # of the same reference document. - if self.status != "Queued" and not self.job_id: + if self.status != "Queued": return - self._unlock_reference_doc() + self.queued_doc.unlock() + frappe.msgprint(_("Document Unlocked")) def queue_submission(doc: Document, action: str, alert: bool = True): queue = frappe.new_doc("Submission Queue") - queue.state = "Queued" queue.ref_doctype = doc.doctype queue.ref_docname = doc.name queue.insert(doc, action) @@ -185,9 +166,25 @@ def get_latest_submissions(doctype, docname): # NOTE: not used creation as orderby intentianlly as we have used update_modified=False everywhere # hence assuming modified will be equal to creation for submission queue documents - dt = "Submission Queue" - filters = {"ref_doctype": doctype, "ref_docname": docname} - return { - "latest_submission": frappe.db.get_value(dt, filters), - "latest_failed_submission": frappe.db.get_value(dt, filters | {"status": "Failed"}), - } + latest_submission = frappe.db.get_value( + "Submission Queue", + filters={"ref_doctype": doctype, "ref_docname": docname}, + fieldname=["name", "exception", "status"], + ) + + out = None + if latest_submission: + out = { + "latest_submission": latest_submission[0], + "exc": format_tb(latest_submission[1]), + "status": latest_submission[2], + } + + return out + + +def format_tb(traceback: str | None = None): + if not traceback: + return + + return traceback.strip().split("\n")[-1] diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index 10dc75ba39..8742d2e040 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -277,7 +277,7 @@ def create_user(email, *roles): user = frappe.new_doc("User") user.email = email - user.first_name = email.split("@")[0] + user.first_name = email.split("@", 1)[0] if not roles: roles = ("System Manager",) diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 5917ba2756..39d9133412 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -287,7 +287,7 @@ def user_linked_with_permission_on_doctype(doc, user): def apply_permissions_for_non_standard_user_type(doc, method=None): """Create user permission for the non standard user type""" - if not frappe.db.table_exists("User Type"): + if not frappe.db.table_exists("User Type") or frappe.flags.in_migrate: return user_types = frappe.cache().get_value( diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py index 67f809abf7..add7fa373f 100644 --- a/frappe/database/mariadb/setup_db.py +++ b/frappe/database/mariadb/setup_db.py @@ -19,7 +19,7 @@ def get_mariadb_version(version_string: str = ""): # MariaDB classifies their versions as Major (1st and 2nd number), and Minor (3rd number) # Example: Version 10.3.13 is Major Version = 10.3, Minor Version = 13 version_string = version_string or get_mariadb_variables().get("version") - version = version_string.split("-")[0] + version = version_string.split("-", 1)[0] return version.rsplit(".", 1) diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index fafd317155..53a5a50cec 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -306,8 +306,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ ) # process recurring events - start = start.split(" ")[0] - end = end.split(" ")[0] + start = start.split(" ", 1)[0] + end = end.split(" ", 1)[0] add_events = [] remove_events = [] @@ -315,7 +315,7 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ new_event = e.copy() enddate = ( - add_days(date, int(date_diff(e.ends_on.split(" ")[0], e.starts_on.split(" ")[0]))) + add_days(date, int(date_diff(e.ends_on.split(" ", 1)[0], e.starts_on.split(" ", 1)[0]))) if (e.starts_on and e.ends_on) else date ) @@ -337,8 +337,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ repeat = "3000-01-01" if cstr(e.repeat_till) == "" else e.repeat_till if e.repeat_on == "Yearly": - start_year = cint(start.split("-")[0]) - end_year = cint(end.split("-")[0]) + start_year = cint(start.split("-", 1)[0]) + end_year = cint(end.split("-", 1)[0]) # creates a string with date (27) and month (07) eg: 07-27 event_start = "-".join(event_start.split("-")[1:]) @@ -357,7 +357,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ if e.repeat_on == "Monthly": # creates a string with date (27) and month (07) and year (2019) eg: 2019-07-27 - date = start.split("-")[0] + "-" + start.split("-")[1] + "-" + event_start.split("-")[2] + year, month = start.split("-", maxsplit=2)[:2] + date = f"{year}-{month}-" + event_start.split("-", maxsplit=3)[2] # last day of month issue, start from prev month! try: diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index b5b58ebfa3..94f3842ab7 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -4,12 +4,14 @@ import io import os import frappe +from frappe import _ from frappe.build import scrub_html_template from frappe.model.meta import Meta from frappe.model.utils import render_include from frappe.modules import get_module_path, load_doctype_module, scrub from frappe.translate import extract_messages_from_code, make_dict_from_messages from frappe.utils import get_html_format +from frappe.utils.data import get_link_to_form ASSET_KEYS = ( "__js", @@ -50,7 +52,7 @@ def get_meta(doctype, cached=True): class FormMeta(Meta): def __init__(self, doctype): - super().__init__(doctype) + self.__dict__.update(frappe.get_meta(doctype).__dict__) self.load_assets() def load_assets(self): @@ -132,7 +134,7 @@ class FormMeta(Meta): for fname in os.listdir(path): if fname.endswith(".html"): with open(os.path.join(path, fname), encoding="utf-8") as f: - templates[fname.split(".")[0]] = scrub_html_template(f.read()) + templates[fname.split(".", 1)[0]] = scrub_html_template(f.read()) self.set("__templates", templates or None) @@ -184,19 +186,40 @@ class FormMeta(Meta): """add search fields found in the doctypes indicated by link fields' options""" for df in self.get("fields", {"fieldtype": "Link", "options": ["!=", "[Select]"]}): if df.options: - search_fields = frappe.get_meta(df.options).search_fields + try: + search_fields = frappe.get_meta(df.options).search_fields + except frappe.DoesNotExistError: + self._show_missing_doctype_msg(df) + if search_fields: search_fields = search_fields.split(",") df.search_fields = [sf.strip() for sf in search_fields] + def _show_missing_doctype_msg(self, df): + # A link field is referring to non-existing doctype, this usually happens when + # customizations are removed or some custom app is removed but hasn't cleaned + # up after itself. + frappe.clear_last_message() + + msg = _("Field {0} is referring to non-existing doctype {1}.").format( + frappe.bold(df.fieldname), frappe.bold(df.options) + ) + + if df.get("is_custom_field"): + custom_field_link = get_link_to_form("Custom Field", df.name) + msg += " " + _("Please delete the field from {2} or add the required doctype.").format( + custom_field_link + ) + + frappe.throw(msg, title=_("Missing DocType")) + def add_linked_document_type(self): for df in self.get("fields", {"fieldtype": "Link"}): if df.options: try: df.linked_document_type = frappe.get_meta(df.options).document_type except frappe.DoesNotExistError: - # edge case where options="[Select]" - pass + self._show_missing_doctype_msg(df) def load_print_formats(self): print_formats = frappe.db.sql( @@ -226,7 +249,7 @@ class FormMeta(Meta): def load_templates(self): if not self.custom: module = load_doctype_module(self.name) - app = module.__name__.split(".")[0] + app = module.__name__.split(".", 1)[0] templates = {} if hasattr(module, "form_grid_templates"): for key, path in module.form_grid_templates.items(): diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index b4a51ffaf3..7abd6657e5 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -428,7 +428,7 @@ def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None): if isinstance(columns[0], str): first_col = columns[0].split(":") if len(first_col) > 1: - first_col_fieldtype = first_col[1].split("/")[0] + first_col_fieldtype = first_col[1].split("/", 1)[0] else: first_col_fieldtype = columns[0].get("fieldtype") diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index a0b78a4035..00ae27d145 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -181,7 +181,7 @@ def extract_fieldname(field): fieldname = field for sep in (" as ", " AS "): if sep in fieldname: - fieldname = fieldname.split(sep)[0] + fieldname = fieldname.split(sep, 1)[0] # certain functions allowed, extract the fieldname from the function if fieldname.startswith("count(") or fieldname.startswith("sum(") or fieldname.startswith("avg("): @@ -452,13 +452,14 @@ def handle_duration_fieldtype_values(doctype, data, fields): def parse_field(field: str) -> tuple[str | None, str]: """Parse a field into parenttype and fieldname.""" - key = field.split(" as ")[0] + key = field.split(" as ", 1)[0] if key.startswith(("count(", "sum(", "avg(")): raise ValueError if "." in key: - return key.split(".")[0][4:-1], key.split(".")[1].strip("`") + table, column = key.split(".", 2)[:2] + return table[4:-1], column.strip("`") return None, key.strip("`") diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 446f842a0b..2af9b575be 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -76,7 +76,7 @@ def search_widget( standard_queries = frappe.get_hooks().standard_queries or {} - if query and query.split()[0].lower() != "select": + if query and query.split(maxsplit=1)[0].lower() != "select": # by method try: is_whitelisted(frappe.get_attr(query)) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 30d51c4c03..4745a8f1ca 100644 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -6,6 +6,7 @@ import frappe import frappe.utils from frappe import _ from frappe.email.doctype.email_group.email_group import add_subscribers +from frappe.rate_limiter import rate_limit from frappe.utils.safe_exec import is_job_queued from frappe.utils.verified_command import get_signed_params, verify_request from frappe.website.website_generator import WebsiteGenerator @@ -227,7 +228,6 @@ class Newsletter(WebsiteGenerator): ) -@frappe.whitelist(allow_guest=True) def confirmed_unsubscribe(email, group): """unsubscribe the email(user) from the mailing list(email_group)""" frappe.flags.ignore_permissions = True @@ -238,9 +238,13 @@ def confirmed_unsubscribe(email, group): @frappe.whitelist(allow_guest=True) -def subscribe(email, email_group=_("Website")): # noqa +@rate_limit(limit=10, seconds=60 * 60) +def subscribe(email, email_group=None): # noqa """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email.""" + if email_group is None: + email_group = _("Website") + # build subscription confirmation URL api_endpoint = frappe.utils.get_url( "/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription" diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 41fdfeeda1..ce19fb7b07 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -45,6 +45,7 @@ class Notification(Document): frappe.cache().hdel("notifications", self.document_type) def on_update(self): + frappe.cache().hdel("notifications", self.document_type) path = export_module_json(self, self.is_standard, self.module) if path: # js diff --git a/frappe/frappeclient.py b/frappe/frappeclient.py index ec5f205197..3f7577fac6 100644 --- a/frappe/frappeclient.py +++ b/frappe/frappeclient.py @@ -288,7 +288,11 @@ class FrappeClient: if doctype != "User" and not frappe.db.exists("User", doc.get("owner")): frappe.get_doc( - {"doctype": "User", "email": doc.get("owner"), "first_name": doc.get("owner").split("@")[0]} + { + "doctype": "User", + "email": doc.get("owner"), + "first_name": doc.get("owner").split("@", 1)[0], + } ).insert() if update: diff --git a/frappe/installer.py b/frappe/installer.py index f3c50659af..9c2807d7cd 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -242,7 +242,7 @@ def parse_app_name(name: str) -> str: _repo = name.split(":")[1].rsplit("/", 1)[1] else: _repo = name.rsplit("/", 2)[2] - repo = _repo.split(".")[0] + repo = _repo.split(".", 1)[0] else: _, repo, _ = fetch_details_from_tag(name) return repo @@ -271,7 +271,7 @@ def install_app(name, verbose=False, set_as_patched=True, force=False): frappe.clear_cache() if name not in frappe.get_all_apps(): - raise Exception("App not in apps.txt") + raise Exception(f"App {name} not in apps.txt") if not force and name in installed_apps: click.secho(f"App {name} already installed", fg="yellow") @@ -785,7 +785,7 @@ def is_downgrade(sql_file_path, verbose=False): for app in all_apps: app_name = app[0] - app_version = app[1].split(" ")[0] + app_version = app[1].split(" ", 1)[0] if app_name == "frappe": try: diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.json b/frappe/integrations/doctype/ldap_settings/ldap_settings.json index b8f73cebed..0b3bf06239 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.json +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.json @@ -88,8 +88,7 @@ "fieldtype": "Link", "label": "Default User Role", "mandatory_depends_on": "eval: doc.default_user_type == \"System User\"", - "options": "Role", - "reqd": 1 + "options": "Role" }, { "description": "Must be enclosed in '()' and include '{0}', which is a placeholder for the user/login name. i.e. (&(objectclass=user)(uid={0}))", @@ -302,7 +301,7 @@ "in_create": 1, "issingle": 1, "links": [], - "modified": "2022-12-05 21:52:31.146035", + "modified": "2023-01-24 11:20:06.049708", "modified_by": "Administrator", "module": "Integrations", "name": "LDAP Settings", diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index 094c440672..21e5c5b312 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: class LDAPSettings(Document): def validate(self): - self.default_user_type = self.default_user_type or "System User" + self.default_user_type = self.default_user_type or "Website User" if not self.enabled: return diff --git a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py index 9080e0c82a..0417ea30e4 100644 --- a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py @@ -173,7 +173,6 @@ class LDAP_TestCase: "ldap_username_field", "ldap_first_name_field", "require_trusted_certificate", - "default_role", ] # fields that are required to have ldap functioning need to be mandatory for mandatory_field in mandatory_fields: diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 644868da92..26544c5c0e 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -36,54 +36,60 @@ DOCTYPES_FOR_DOCTYPE = {"DocType", *TABLE_DOCTYPES_FOR_DOCTYPE.values()} def get_controller(doctype): - """Returns the **class** object of the given DocType. + """ + Returns the locally cached **class** object of the given DocType. For `custom` type, returns `frappe.model.document.Document`. - :param doctype: DocType name as string.""" - - def _get_controller(): - from frappe.model.document import Document - from frappe.utils.nestedset import NestedSet - - module_name, custom = frappe.db.get_value( - "DocType", doctype, ("module", "custom"), cache=True - ) or ("Core", False) - - if custom: - is_tree = frappe.db.get_value("DocType", doctype, "is_tree", ignore=True, cache=True) - _class = NestedSet if is_tree else Document - else: - class_overrides = frappe.get_hooks("override_doctype_class") - if class_overrides and class_overrides.get(doctype): - import_path = class_overrides[doctype][-1] - module_path, classname = import_path.rsplit(".", 1) - module = frappe.get_module(module_path) - if not hasattr(module, classname): - raise ImportError(f"{doctype}: {classname} does not exist in module {module_path}") - else: - module = load_doctype_module(doctype, module_name) - classname = doctype.replace(" ", "").replace("-", "") - - if hasattr(module, classname): - _class = getattr(module, classname) - if issubclass(_class, BaseDocument): - _class = getattr(module, classname) - else: - raise ImportError(doctype) - else: - raise ImportError(doctype) - return _class + :param doctype: DocType name as string. + """ if frappe.local.dev_server: - return _get_controller() + return import_controller(doctype) site_controllers = frappe.controllers.setdefault(frappe.local.site, {}) if doctype not in site_controllers: - site_controllers[doctype] = _get_controller() + site_controllers[doctype] = import_controller(doctype) return site_controllers[doctype] +def import_controller(doctype): + from frappe.model.document import Document + from frappe.utils.nestedset import NestedSet + + module_name = "Core" + if doctype not in DOCTYPES_FOR_DOCTYPE: + meta = frappe.get_meta(doctype) + if meta.custom: + return NestedSet if meta.get("is_tree") else Document + + module_name = meta.module + + module_path = None + class_overrides = frappe.get_hooks("override_doctype_class") + if class_overrides and class_overrides.get(doctype): + import_path = class_overrides[doctype][-1] + module_path, classname = import_path.rsplit(".", 1) + module = frappe.get_module(module_path) + + else: + module = load_doctype_module(doctype, module_name) + classname = doctype.replace(" ", "").replace("-", "") + + class_ = getattr(module, classname, None) + if class_ is None: + raise ImportError( + doctype + if module_path is None + else f"{doctype}: {classname} does not exist in module {module_path}" + ) + + if not issubclass(class_, BaseDocument): + raise ImportError(f"{doctype}: {classname} is not a subclass of BaseDocument") + + return class_ + + class BaseDocument: _reserved_keywords = { "doctype", diff --git a/frappe/model/create_new.py b/frappe/model/create_new.py index 51810c3e18..f8b7a73a3b 100644 --- a/frappe/model/create_new.py +++ b/frappe/model/create_new.py @@ -115,7 +115,7 @@ def get_static_default_value(df, doctype_user_permissions, allowed_records): return df.default elif df.fieldtype == "Select" and df.options and df.options not in ("[Select]", "Loading..."): - return df.options.split("\n")[0] + return df.options.split("\n", 1)[0] def validate_value_via_user_permissions( diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index aea2991356..505d0f4da1 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -442,7 +442,7 @@ class DatabaseQuery: if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field): continue - table_name = field.split(".")[0] + table_name = field.split(".", 1)[0] if table_name.lower().startswith("group_concat("): table_name = table_name[13:] @@ -974,8 +974,9 @@ class DatabaseQuery: # will covert to # `tabItem`.`idx` desc, `tabItem`.`modified` desc args.order_by = ", ".join( - f"`tab{self.doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}" + f"`tab{self.doctype}`.`{f_split[0].strip()}` {f_split[1].strip()}" for f in self.doctype_meta.sort_field.split(",") + if (f_split := f.split(maxsplit=2)) ) else: sort_field = self.doctype_meta.sort_field or "modified" @@ -1106,8 +1107,9 @@ def get_order_by(doctype, meta): # will covert to # `tabItem`.`idx` desc, `tabItem`.`modified` desc order_by = ", ".join( - f"`tab{doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}" + f"`tab{doctype}`.`{f_split[0].strip()}` {f_split[1].strip()}" for f in meta.sort_field.split(",") + if (f_split := f.split(maxsplit=2)) ) else: diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index 48eaa63460..bfad833d38 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -176,7 +176,7 @@ def update_naming_series(doc): if doc.meta.autoname.startswith("naming_series:") and getattr(doc, "naming_series", None): revert_series_if_last(doc.naming_series, doc.name, doc) - elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash", "autoincrement"): + elif doc.meta.autoname.split(":", 1)[0] not in ("Prompt", "field", "hash", "autoincrement"): revert_series_if_last(doc.meta.autoname, doc.name, doc) diff --git a/frappe/model/naming.py b/frappe/model/naming.py index 93be2204b4..29831451b0 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -59,8 +59,8 @@ class NamingSeries: if not NAMING_SERIES_PATTERN.match(self.series): frappe.throw( _( - 'Special Characters except "-", "#", ".", "/", "{" and "}" not allowed in naming series', - ), + "Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}" + ).format(frappe.bold(self.series)), exc=InvalidNamingSeriesError, ) diff --git a/frappe/model/utils/rename_field.py b/frappe/model/utils/rename_field.py index 9e4fc5d84a..c17d01183b 100644 --- a/frappe/model/utils/rename_field.py +++ b/frappe/model/utils/rename_field.py @@ -27,7 +27,7 @@ def rename_field(doctype, old_fieldname, new_fieldname): frappe.db.sql( """update `tab%s` set parentfield=%s where parentfield=%s""" - % (new_field.options.split("\n")[0], "%s", "%s"), + % (new_field.options.split("\n", 1)[0], "%s", "%s"), (new_fieldname, old_fieldname), ) diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index 3690da0657..36e329409a 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -252,7 +252,7 @@ def load_code_properties(doc, path): if hasattr(doc, "get_code_fields"): dirname, filename = os.path.split(path) for key, extn in doc.get_code_fields().items(): - codefile = os.path.join(dirname, filename.split(".")[0] + "." + extn) + codefile = os.path.join(dirname, filename.split(".", 1)[0] + "." + extn) if os.path.exists(codefile): with open(codefile) as txtfile: doc.set(key, txtfile.read()) diff --git a/frappe/modules/patch_handler.py b/frappe/modules/patch_handler.py index 15144a1630..230c1547c6 100644 --- a/frappe/modules/patch_handler.py +++ b/frappe/modules/patch_handler.py @@ -152,7 +152,7 @@ def run_single(patchmodule=None, method=None, methodargs=None, force=False): return True -def execute_patch(patchmodule, method=None, methodargs=None): +def execute_patch(patchmodule: str, method=None, methodargs=None): """execute the patch""" _patch_mode(True) @@ -162,7 +162,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): docstring = "" else: has_patch_file = True - patch = f"{patchmodule.split()[0]}.execute" + patch = f"{patchmodule.split(maxsplit=1)[0]}.execute" _patch = frappe.get_attr(patch) docstring = _patch.__doc__ or "" diff --git a/frappe/public/js/form_builder/components/Column.vue b/frappe/public/js/form_builder/components/Column.vue index a8f1f84118..a9c4bf0ea1 100644 --- a/frappe/public/js/form_builder/components/Column.vue +++ b/frappe/public/js/form_builder/components/Column.vue @@ -4,7 +4,7 @@ import Field from "./Field.vue"; import EditableInput from "./EditableInput.vue"; import { ref } from "vue"; import { useStore } from "../store"; -import { move_children_to_parent } from "../utils"; +import { move_children_to_parent, confirm_dialog } from "../utils"; let props = defineProps(["section", "column"]); let store = useStore(); @@ -24,32 +24,61 @@ function remove_column() { if (store.is_customize_form && props.column.df.is_custom_field == 0) { frappe.msgprint(__("Cannot delete standard field. You can hide it if you want")); throw "cannot delete standard field"; + } else if (props.column.fields.length == 0 || store.has_standard_field(props.column)) { + delete_column(); + } else { + confirm_dialog( + __("Delete Column", null, "Title of confirmation dialog"), + __("Are you sure you want to delete the column? All the fields in the column will be moved to the previous column.", null, "Confirmation dialog message"), + () => delete_column(), + __("Delete column", null, "Button text"), + () => delete_column(true), + __("Delete entire column with fields", null, "Button text") + ); } +} +function delete_column(with_children) { // move all fields to previous column let columns = props.section.columns; let index = columns.indexOf(props.column); - if (index > 0) { - let prev_column = columns[index - 1]; - prev_column.fields = [...prev_column.fields, ...props.column.fields]; - } else { - if (props.column.fields.length != 0) { - // create a new column if current column has fields and push fields to it - columns.unshift({ - df: store.get_df("Column Break"), - fields: props.column.fields, - is_first: true, - }); - index++; + if (with_children && index == 0 && columns.length == 1) { + if (props.column.fields.length == 0) { + frappe.msgprint(__("Section must have at least one column")); + throw "section must have at least one column"; + } + + columns.unshift({ + df: store.get_df("Column Break"), + fields: [], + is_first: true, + }); + index++; + } + + if (!with_children) { + if (index > 0) { + let prev_column = columns[index - 1]; + prev_column.fields = [...prev_column.fields, ...props.column.fields]; } else { - // set next column as first column - let next_column = columns[index + 1]; - if (next_column) { - next_column.is_first = true; + if (props.column.fields.length == 0) { + // set next column as first column + let next_column = columns[index + 1]; + if (next_column) { + next_column.is_first = true; + } else { + frappe.msgprint(__("Section must have at least one column")); + throw "section must have at least one column"; + } } else { - frappe.msgprint(__("Section must have at least one column")); - throw "section must have at least one column"; + // create a new column if current column has fields and push fields to it + columns.unshift({ + df: store.get_df("Column Break"), + fields: props.column.fields, + is_first: true, + }); + index++; } } } diff --git a/frappe/public/js/form_builder/components/Field.vue b/frappe/public/js/form_builder/components/Field.vue index 5a7ce5626f..cf3e21c310 100644 --- a/frappe/public/js/form_builder/components/Field.vue +++ b/frappe/public/js/form_builder/components/Field.vue @@ -32,10 +32,21 @@ function move_fields_to_column() { function duplicate_field() { let duplicate_field = clone_field(props.field); + if (store.is_customize_form) { + duplicate_field.df.is_custom_field = 1; + } + if (duplicate_field.df.label) { duplicate_field.df.label = duplicate_field.df.label + " Copy"; } duplicate_field.df.fieldname = ""; + duplicate_field.df.__islocal = 1; + duplicate_field.df.__unsaved = 1; + duplicate_field.df.owner = frappe.session.user; + + delete duplicate_field.df.creation; + delete duplicate_field.df.modified; + delete duplicate_field.df.modified_by; // push duplicate_field after props.field in the same column let index = props.column.fields.indexOf(props.field); diff --git a/frappe/public/js/form_builder/components/FormBuilder.vue b/frappe/public/js/form_builder/components/FormBuilder.vue index c641643414..942c25ba04 100644 --- a/frappe/public/js/form_builder/components/FormBuilder.vue +++ b/frappe/public/js/form_builder/components/FormBuilder.vue @@ -109,6 +109,12 @@ onMounted(() => { box-shadow: var(--card-shadow); background-color: var(--card-bg); + :deep(.section-columns.has-one-column .field) { + input.form-control, .signature-field { + width: calc(50% - 19px); + } + } + :deep(.column-container .field.sortable-chosen) { background-color: var(--bg-light-gray); border-radius: var(--border-radius-sm); @@ -191,6 +197,8 @@ onMounted(() => { } :deep(.preview) { + --field-placeholder-color: var(--fg-bg-color); + .tab, .column, .field, [data-is-custom="1"] { background-color: var(--fg-color); } @@ -221,6 +229,12 @@ onMounted(() => { .section-columns { margin-top: 8px; + &.has-one-column .field { + input.form-control, .signature-field { + width: calc(50% - 15px); + } + } + .section-columns-container { .column { padding-left: 15px; diff --git a/frappe/public/js/form_builder/components/Section.vue b/frappe/public/js/form_builder/components/Section.vue index d23a599fa6..4624c72a38 100644 --- a/frappe/public/js/form_builder/components/Section.vue +++ b/frappe/public/js/form_builder/components/Section.vue @@ -4,7 +4,7 @@ import Column from "./Column.vue"; import EditableInput from "./EditableInput.vue"; import { ref } from "vue"; import { useStore } from "../store"; -import { section_boilerplate, move_children_to_parent } from "../utils"; +import { section_boilerplate, move_children_to_parent, confirm_dialog } from "../utils"; let props = defineProps(["tab", "section"]); let store = useStore(); @@ -27,25 +27,42 @@ function remove_section() { if (store.is_customize_form && props.section.df.is_custom_field == 0) { frappe.msgprint(__("Cannot delete standard field. You can hide it if you want")); throw "cannot delete standard field"; + } else if (store.has_standard_field(props.section)) { + delete_section(); + } else if (is_section_empty()) { + delete_section(true); + } else { + confirm_dialog( + __("Delete Section", null, "Title of confirmation dialog"), + __("Are you sure you want to delete the section? All the columns along with fields in the section will be moved to the previous section.", null, "Confirmation dialog message"), + () => delete_section(), + __("Delete section", null, "Button text"), + () => delete_section(true), + __("Delete entire section with columns", null, "Button text") + ); } +} +function delete_section(with_children) { let sections = props.tab.sections; let index = sections.indexOf(props.section); - if (index > 0) { - let prev_section = sections[index - 1]; - if (!is_section_empty()) { - // move all columns from current section to previous section - prev_section.columns = [...prev_section.columns, ...props.section.columns]; + if (!with_children) { + if (index > 0) { + let prev_section = sections[index - 1]; + if (!is_section_empty()) { + // move all columns from current section to previous section + prev_section.columns = [...prev_section.columns, ...props.section.columns]; + } + } else if (index == 0 && !is_section_empty()) { + // create a new section and push columns to it + sections.unshift({ + df: store.get_df("Section Break"), + columns: props.section.columns, + is_first: true, + }); + index++; } - } else if (index == 0 && !is_section_empty()) { - // create a new section and push columns to it - sections.unshift({ - df: store.get_df("Section Break"), - columns: props.section.columns, - is_first: true, - }); - index++; } // remove section @@ -130,7 +147,13 @@ function move_sections_to_tab() {