diff --git a/.flake8 b/.flake8 index 2de7a154c9..e783fbbeb3 100644 --- a/.flake8 +++ b/.flake8 @@ -69,6 +69,7 @@ ignore = F841, E713, E712, + B028, max-line-length = 200 exclude=,test_*.py diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 01b5407489..d050ecb6bc 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -80,7 +80,20 @@ jobs: - uses: actions/setup-python@v4 with: python-version: '3.10' + - uses: actions/checkout@v3 + + - name: Cache pip + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + - run: | pip install pip-audit - pip-audit ${GITHUB_WORKSPACE} + cd ${GITHUB_WORKSPACE} + sed -i '/dropbox/d' pyproject.toml # Remove dropbox temporarily https://github.com/dropbox/dropbox-sdk-python/pull/456 + pip-audit --desc on . diff --git a/.mergify.yml b/.mergify.yml index b74648a8f5..0881dd591b 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -71,23 +71,3 @@ pull_request_rules: assignees: - "{{ author }}" - - - name: backport to version-13-pre-release - conditions: - - label="backport version-13-pre-release" - actions: - backport: - branches: - - version-13-pre-release - assignees: - - "{{ author }}" - - - name: backport to version-12-hotfix - conditions: - - label="backport version-12-hotfix" - actions: - backport: - branches: - - version-12-hotfix - assignees: - - "{{ author }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4b3ea6d1ea..0c6bbe8ec9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,8 +48,8 @@ repos: )$ - - repo: https://github.com/timothycrosley/isort - rev: 5.9.1 + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 hooks: - id: isort diff --git a/cypress/integration/control_color.js b/cypress/integration/control_color.js index aa3a45eed8..e97dbe0f06 100644 --- a/cypress/integration/control_color.js +++ b/cypress/integration/control_color.js @@ -26,7 +26,7 @@ context("Control Color", () => { //Checking if the css attribute is correct cy.get(".color-map").should("have.css", "color", "rgb(79, 157, 217)"); - cy.get(".hue-map").should("have.css", "color", "rgb(0, 145, 255)"); + cy.get(".hue-map").should("have.css", "color", "rgb(0, 144, 255)"); //Checking if the correct color is being selected cy.get("@dialog").then((dialog) => { diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index a5281d9b09..d3462492f6 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -229,19 +229,15 @@ context("Control Link", () => { ); cy.reload(); cy.new_form("ToDo"); - cy.fill_field("description", "new", "Text Editor"); - cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form"); - cy.findByRole("button", { name: "Save" }).click(); - cy.wait("@save_form"); + cy.fill_field("description", "new", "Text Editor").wait(200); + cy.save(); cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( "contain", "Administrator" ); // if user clears default value explicitly, system should not reset default again cy.get_field("assigned_by").clear().blur(); - cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form"); - cy.findByRole("button", { name: "Save" }).click(); - cy.wait("@save_form"); + cy.save(); cy.get_field("assigned_by").should("have.value", ""); cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( "contain", diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index 60fa46bc88..c5b3a44f0d 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -10,7 +10,7 @@ context("Folder Navigation", () => { cy.get(".filter-selector > .btn").findByText("1 filter").click(); cy.findByRole("button", { name: "Clear Filters" }).click(); cy.get(".filter-action-buttons > .text-muted").findByText("+ Add a Filter").click(); - cy.get(".fieldname-select-area > .awesomplete > .form-control").type("Fol{enter}"); + cy.get(".fieldname-select-area > .awesomplete > .form-control:last").type("Fol{enter}"); cy.get( ".filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback" ).type("Home{enter}"); diff --git a/cypress/integration/form.js b/cypress/integration/form.js index fa0d758223..8186647a14 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -26,6 +26,11 @@ context("Form", () => { }); }); + beforeEach(() => { + cy.login(); + cy.visit("/app/website"); + }); + it("create a new form", () => { cy.visit("/app/todo/new"); cy.get_field("description", "Text Editor") @@ -172,4 +177,57 @@ context("Form", () => { send_welcome_email: 0, }); }); + + it("update docfield property using set_df_property in child table", () => { + cy.visit("/app/contact/Test Form Contact 1"); + cy.window() + .its("cur_frm") + .then((frm) => { + cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table"); + + // set property before form_render event of child table + cy.get("@table") + .find('[data-idx="1"]') + .invoke("attr", "data-name") + .then((cdn) => { + frm.set_df_property( + "phone_nos", + "hidden", + 1, + "Contact Phone", + "is_primary_phone", + cdn + ); + }); + + cy.get("@table").find('[data-idx="1"] .edit-grid-row').click(); + cy.get(".grid-row-open").as("table-form"); + cy.get("@table-form") + .find('.frappe-control[data-fieldname="is_primary_phone"]') + .should("be.hidden"); + cy.get("@table-form").find(".grid-footer-toolbar").click(); + + // set property on form_render event of child table + cy.get("@table").find('[data-idx="1"] .edit-grid-row').click(); + cy.get("@table") + .find('[data-idx="1"]') + .invoke("attr", "data-name") + .then((cdn) => { + frm.set_df_property( + "phone_nos", + "hidden", + 0, + "Contact Phone", + "is_primary_phone", + cdn + ); + }); + + cy.get(".grid-row-open").as("table-form"); + cy.get("@table-form") + .find('.frappe-control[data-fieldname="is_primary_phone"]') + .should("be.visible"); + cy.get("@table-form").find(".grid-footer-toolbar").click(); + }); + }); }); diff --git a/cypress/integration/form_builder.js b/cypress/integration/form_builder.js index 84494ddebf..85d7ef91e0 100644 --- a/cypress/integration/form_builder.js +++ b/cypress/integration/form_builder.js @@ -87,7 +87,7 @@ context("Form Builder", () => { cy.get_open_dialog().find(".msgprint").should("contain", "Options is required"); cy.hide_dialog(); - cy.get(first_field).click(); + cy.get(first_field).click({ force: true }); cy.get(".sidebar-container .frappe-control[data-fieldname='options'] input") .click() @@ -114,7 +114,7 @@ context("Form Builder", () => { cy.get_open_dialog().find(".msgprint").should("contain", "In List View"); cy.hide_dialog(); - cy.get(first_field).click(); + cy.get(first_field).click({ force: true }); cy.get(".sidebar-container .field label .label-area").contains("In List View").click(); // validate In Global Search @@ -273,4 +273,29 @@ context("Form Builder", () => { .find(".msgprint") .should("contain", "cannot be hidden and mandatory without any default value"); }); + + it("Undo/Redo", () => { + cy.visit(`/app/form-builder/${doctype_name}`); + + // click on second tab + cy.get(".tabs .tab:last").click(); + + let first_column = ".tab-content.active .section-columns-container:first .column:first"; + let first_field = first_column + " .field:first"; + let label = "div[title='Double click to edit label'] span:first"; + + // drag the first field to second position + cy.get(first_field).drag(first_column + " .field:nth-child(2)", { + target: { x: 100, y: 10 }, + }); + cy.get(first_field).find(label).should("have.text", "Check"); + + // undo + cy.get("body").type("{ctrl}z"); + cy.get(first_field).find(label).should("have.text", "Data"); + + // redo + cy.get("body").type("{ctrl}{shift}z"); + cy.get(first_field).find(label).should("have.text", "Check"); + }); }); diff --git a/cypress/integration/list_view_settings.js b/cypress/integration/list_view_settings.js index 898fe1dec4..ff9a30ce5c 100644 --- a/cypress/integration/list_view_settings.js +++ b/cypress/integration/list_view_settings.js @@ -15,11 +15,13 @@ context("List View Settings", () => { cy.clear_filters(); cy.wait(300); cy.get(".list-count").should("contain", "20 of"); + cy.get("[href='#icon-small-message']").should("be.visible"); cy.get(".menu-btn-group button").click(); cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click(); cy.get(".modal-dialog").should("contain", "DocType Settings"); cy.findByLabelText("Disable Count").check({ force: true }); + cy.findByLabelText("Disable Comment Count").check({ force: true }); cy.findByLabelText("Disable Sidebar Stats").check({ force: true }); cy.findByRole("button", { name: "Save" }).click(); @@ -27,11 +29,13 @@ context("List View Settings", () => { cy.get(".list-count").should("be.empty"); cy.get(".list-sidebar .list-tags").should("not.exist"); + cy.get("[href='#icon-small-message']").should("not.be.visible"); cy.get(".menu-btn-group button").click({ force: true }); cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click(); cy.get(".modal-dialog").should("contain", "DocType Settings"); cy.findByLabelText("Disable Count").uncheck({ force: true }); + cy.findByLabelText("Disable Comment Count").uncheck({ force: true }); cy.findByLabelText("Disable Sidebar Stats").uncheck({ force: true }); cy.findByRole("button", { name: "Save" }).click(); }); diff --git a/cypress/integration/navigation.js b/cypress/integration/navigation.js index 2302296f23..cf1b5dc89d 100644 --- a/cypress/integration/navigation.js +++ b/cypress/integration/navigation.js @@ -18,6 +18,7 @@ context("Navigation", () => { it.only("Navigate to previous page after login", () => { cy.visit("/app/todo"); cy.get(".page-head").findByTitle("To Do").should("be.visible"); + cy.clear_filters(); cy.request("/api/method/logout"); cy.reload().as("reload"); cy.get("@reload").get(".page-card .btn-primary").contains("Login").click(); diff --git a/cypress/integration/view_routing.js b/cypress/integration/view_routing.js index 1e3b841c79..9267974154 100644 --- a/cypress/integration/view_routing.js +++ b/cypress/integration/view_routing.js @@ -103,8 +103,9 @@ context("View", () => { }); it("Route to File View", () => { + cy.intercept("POST", "/api/method/frappe.desk.reportview.get").as("list_loaded"); cy.visit("app/file"); - cy.wait(500); + cy.wait("@list_loaded"); cy.window() .its("cur_list") .then((list) => { @@ -113,7 +114,7 @@ context("View", () => { }); cy.visit("app/file/view/home/Attachments"); - cy.wait(500); + cy.wait("@list_loaded"); cy.window() .its("cur_list") .then((list) => { diff --git a/cypress/integration/workspace_blocks.js b/cypress/integration/workspace_blocks.js index 1f641de6c3..3b75ffb8c1 100644 --- a/cypress/integration/workspace_blocks.js +++ b/cypress/integration/workspace_blocks.js @@ -148,4 +148,39 @@ context("Workspace Blocks", () => { .should("eq", "Pending"); cy.go("back"); }); + + it("Number Card Block", () => { + cy.create_records([ + { + doctype: "Number Card", + label: "Test Number Card", + document_type: "ToDo", + color: "#f74343", + }, + ]); + + cy.get(".codex-editor__redactor .ce-block"); + cy.get(".standard-actions .btn-secondary[data-label=Edit]").click(); + + cy.get(".ce-block").first().click({ force: true }).type("{enter}"); + cy.get(".block-list-container .block-list-item").contains("Number Card").click(); + + // add number card + cy.fill_field("number_card_name", "Test Number Card", "Link"); + cy.get('[data-fieldname="number_card_name"] ul li').contains("Test Number Card").click(); + cy.click_modal_primary_button("Add"); + cy.get(".ce-block .number-widget-box").first().as("number_card"); + cy.get("@number_card").find(".widget-title").should("contain", "Test Number Card"); + cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); + cy.get("@number_card").find(".widget-title").should("contain", "Test Number Card"); + + // edit number card + cy.get(".standard-actions .btn-secondary[data-label=Edit]").click(); + cy.get("@number_card").realHover().find(".widget-control .edit-button").click(); + cy.get_field("label", "Data").invoke("val", "ToDo Count"); + cy.click_modal_primary_button("Save"); + cy.get("@number_card").find(".widget-title").should("contain", "ToDo Count"); + cy.get('.standard-actions .btn-primary[data-label="Save"]').click(); + cy.get("@number_card").find(".widget-title").should("contain", "ToDo Count"); + }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 0a25ff5cab..c067974d9f 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -285,7 +285,7 @@ Cypress.Commands.add("get_open_dialog", () => { Cypress.Commands.add("save", () => { cy.intercept("/api/method/frappe.desk.form.save.savedocs").as("save_call"); - cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: "top", force: true }); + cy.get(`.page-container:visible button[data-label="Save"]`).click({ force: true }); cy.wait("@save_call"); }); Cypress.Commands.add("hide_dialog", () => { diff --git a/frappe/__init__.py b/frappe/__init__.py index b7fd117868..332224f989 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -23,7 +23,7 @@ import click from werkzeug.local import Local, release_local from frappe.query_builder import ( - get_qb_engine, + get_query, get_query_builder, patch_query_aggregation, patch_query_execute, @@ -238,13 +238,12 @@ def init(site: str, sites_path: str = ".", new_site: bool = False) -> None: local.jenv = None local.jloader = None local.cache = {} - local.document_cache = {} local.form_dict = _dict() local.preload_assets = {"style": [], "script": []} local.session = _dict() local.dev_server = _dev_server local.qb = get_query_builder(local.conf.db_type or "mariadb") - local.qb.engine = get_qb_engine() + local.qb.get_query = get_query setup_module_map() if not _qb_patched.get(local.conf.db_type): @@ -571,7 +570,7 @@ def get_user(): def get_roles(username=None) -> list[str]: """Returns roles of current user.""" - if not local.session: + if not local.session or not local.session.user: return ["Guest"] import frappe.permissions @@ -623,6 +622,7 @@ def sendmail( header=None, print_letterhead=False, with_container=False, + email_read_tracker_url=None, ): """Send email using user's default **Email Account** or global default **Email Account**. @@ -704,6 +704,7 @@ def sendmail( header=header, print_letterhead=print_letterhead, with_container=with_container, + email_read_tracker_url=email_read_tracker_url, ) # build email queue and send the email if send_now is True. @@ -770,7 +771,12 @@ def is_whitelisted(method): is_guest = session["user"] == "Guest" if method not in whitelisted or is_guest and method not in guest_methods: - throw(_("Not permitted"), PermissionError) + summary = _("You are not permitted to access this resource.") + detail = _("Function {0} is not whitelisted.").format( + bold(f"{method.__module__}.{method.__name__}") + ) + msg = f"
{summary}{detail}
" + throw(msg, PermissionError, title="Method Not Allowed") if is_guest and method not in xss_safe_methods: # strictly sanitize form_dict @@ -1070,25 +1076,10 @@ def set_value(doctype, docname, fieldname, value=None): def get_cached_doc(*args, **kwargs) -> "Document": - def _respond(doc, from_redis=False): - if isinstance(doc, dict): - local.document_cache[key] = doc = get_doc(doc) - - elif from_redis: - local.document_cache[key] = doc - + if (key := can_cache_doc(args)) and (doc := cache().hget("document_cache", key)): return doc - if key := can_cache_doc(args): - # local cache - has "ready" `Document` objects - if doc := local.document_cache.get(key): - return _respond(doc) - - # redis cache - if doc := cache().hget("document_cache", key): - return _respond(doc, True) - - # Not found in local/redis, fetch from DB + # Not found in cache, fetch from DB doc = get_doc(*args, **kwargs) # Store in cache @@ -1101,14 +1092,7 @@ def get_cached_doc(*args, **kwargs) -> "Document": def _set_document_in_cache(key: str, doc: "Document") -> None: - local.document_cache[key] = doc - - # Avoid setting in local.cache since we're already using local.document_cache above - # Try pickling the doc object as-is first, else fallback to doc.as_dict() - try: - cache().hset("document_cache", key, doc, cache_locally=False) - except Exception: - cache().hset("document_cache", key, doc.as_dict(), cache_locally=False) + cache().hset("document_cache", key, doc) def can_cache_doc(args) -> str | None: @@ -1134,12 +1118,11 @@ def get_document_cache_key(doctype: str, name: str): def clear_document_cache(doctype, name): cache().hdel("last_modified", doctype) - key = get_document_cache_key(doctype, name) - if key in local.document_cache: - del local.document_cache[key] - cache().hdel("document_cache", key) + cache().hdel("document_cache", get_document_cache_key(doctype, name)) + if doctype == "System Settings" and hasattr(local, "system_settings"): delattr(local, "system_settings") + if doctype == "Website Settings" and hasattr(local, "website_settings"): delattr(local, "website_settings") @@ -1399,23 +1382,37 @@ def get_all_apps(with_internal_apps=True, sites_path=None): @request_cache -def get_installed_apps(sort=False, frappe_last=False): - """Get list of installed apps in current site.""" +def get_installed_apps(sort=False, frappe_last=False, *, _ensure_on_bench=False): + """ + Get list of installed apps in current site. + + :param sort: [DEPRECATED] Sort installed apps based on the sequence in sites/apps.txt + :param frappe_last: [DEPRECATED] Keep frappe last. Do not use this, reverse the app list instead. + :param ensure_on_bench: Only return apps that are present on bench. + """ + from frappe.utils.deprecations import deprecation_warning + if getattr(flags, "in_install_db", True): return [] if not db: connect() - if not local.all_apps: - local.all_apps = cache().get_value("all_apps", get_all_apps) - installed = json.loads(db.get_global("installed_apps") or "[]") if sort: + if not local.all_apps: + local.all_apps = cache().get_value("all_apps", get_all_apps) + + deprecation_warning("`sort` argument is deprecated and will be removed in v15.") installed = [app for app in local.all_apps if app in installed] + if _ensure_on_bench: + all_apps = cache().get_value("all_apps", get_all_apps) + installed = [app for app in installed if app in all_apps] + if frappe_last: + deprecation_warning("`frappe_last` argument is deprecated and will be removed in v15.") if "frappe" in installed: installed.remove("frappe") installed.append("frappe") @@ -1445,19 +1442,17 @@ def _load_app_hooks(app_name: str | None = None): import types hooks = {} - apps = [app_name] if app_name else get_installed_apps(sort=True) + apps = [app_name] if app_name else get_installed_apps(_ensure_on_bench=True) 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): @@ -1573,7 +1568,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/api.py b/frappe/api.py index 309adbc564..084bee060b 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -131,6 +131,7 @@ class _RESTAPIHandler: doc = frappe.get_doc(self.doctype, self.name) if not doc.has_permission("read"): raise frappe.PermissionError + doc.apply_fieldlevel_read_permissions() frappe.local.response.update({"data": doc}) def update_doc(self): diff --git a/frappe/app.py b/frappe/app.py index 2fe9991c4c..a647b251c8 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -41,9 +41,6 @@ def application(request: Request): init_request(request) - frappe.recorder.record() - frappe.monitor.start() - frappe.rate_limiter.apply() frappe.api.validate_auth() if request.method == "OPTIONS": @@ -74,15 +71,15 @@ def application(request: Request): response = handle_exception(e) else: - rollback = after_request(rollback) + rollback = sync_database(rollback) finally: if request.method in UNSAFE_HTTP_METHODS and frappe.db and rollback: frappe.db.rollback() - frappe.rate_limiter.update() - frappe.monitor.stop(response) - frappe.recorder.dump() + if getattr(frappe.local, "initialised", False): + for after_request_task in frappe.get_hooks("after_request"): + frappe.call(after_request_task, response=response, request=request) log_request(request, response) process_response(response) @@ -119,6 +116,9 @@ def init_request(request): if request.method != "OPTIONS": frappe.local.http_request = HTTPRequest() + for before_request_task in frappe.get_hooks("before_request"): + frappe.call(before_request_task) + def setup_read_only_mode(): """During maintenance_mode reads to DB can still be performed to reduce downtime. This @@ -318,7 +318,7 @@ def handle_exception(e): return response -def after_request(rollback): +def sync_database(rollback: bool) -> bool: # if HTTP method would change server state, commit if necessary if ( frappe.db @@ -332,9 +332,8 @@ def after_request(rollback): rollback = False # update session - if getattr(frappe.local, "session_obj", None): - updated_in_db = frappe.local.session_obj.update() - if updated_in_db: + if session := getattr(frappe.local, "session_obj", None): + if session.update(): frappe.db.commit() rollback = False @@ -376,6 +375,7 @@ def serve( "0.0.0.0", int(port), application, + exclude_patterns=["test_*"], use_reloader=False if in_test_env else not no_reload, use_debugger=not in_test_env, use_evalex=not in_test_env, diff --git a/frappe/auth.py b/frappe/auth.py index d1dc10817c..f1cdac52bd 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") @@ -305,7 +307,7 @@ class LoginManager: current_hour = int(now_datetime().strftime("%H")) - if login_before and current_hour > login_before: + if login_before and current_hour >= login_before: frappe.throw(_("Login not allowed at this time"), frappe.AuthenticationError) if login_after and current_hour < login_after: diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 128bcc90cc..2754da879f 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -1,5 +1,7 @@ # Copyright (c) 2018, Frappe Technologies and Contributors # License: MIT. See LICENSE +from typing import TYPE_CHECKING + import frappe from frappe.automation.doctype.auto_repeat.auto_repeat import ( create_repeated_entries, @@ -10,8 +12,11 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, add_months, getdate, today +if TYPE_CHECKING: + from frappe.custom.doctype.custom_field.custom_field import CustomField -def add_custom_fields(): + +def add_custom_fields() -> "CustomField": df = dict( fieldname="auto_repeat", label="Auto Repeat", @@ -22,15 +27,17 @@ def add_custom_fields(): print_hide=1, read_only=1, ) - create_custom_field("ToDo", df) + return create_custom_field("ToDo", df) or frappe.get_doc( + "Custom Field", dict(fieldname=df["fieldname"], dt="ToDo") + ) class TestAutoRepeat(FrappeTestCase): - def setUp(self): - if not frappe.db.sql( - "SELECT `fieldname` FROM `tabCustom Field` WHERE `fieldname`='auto_repeat' and `dt`=%s", "Todo" - ): - add_custom_fields() + @classmethod + def setUpClass(cls): + cls.custom_field = add_custom_fields() + cls.addClassCleanup(cls.custom_field.delete) + return super().setUpClass() def test_daily_auto_repeat(self): todo = frappe.get_doc( diff --git a/frappe/desk/page/translation_tool/__init__.py b/frappe/automation/doctype/reminder/__init__.py similarity index 100% rename from frappe/desk/page/translation_tool/__init__.py rename to frappe/automation/doctype/reminder/__init__.py diff --git a/frappe/automation/doctype/reminder/reminder.js b/frappe/automation/doctype/reminder/reminder.js new file mode 100644 index 0000000000..6d1a72bab2 --- /dev/null +++ b/frappe/automation/doctype/reminder/reminder.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Reminder", { +// refresh(frm) { + +// }, +// }); diff --git a/frappe/automation/doctype/reminder/reminder.json b/frappe/automation/doctype/reminder/reminder.json new file mode 100644 index 0000000000..a288f205a2 --- /dev/null +++ b/frappe/automation/doctype/reminder/reminder.json @@ -0,0 +1,90 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2023-02-22 11:23:58.183276", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "remind_at", + "description", + "reminder_doctype", + "reminder_docname", + "notified" + ], + "fields": [ + { + "default": "__user", + "fieldname": "user", + "fieldtype": "Link", + "hidden": 1, + "label": "User", + "options": "User", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "reminder_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "reminder_docname", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Document Name", + "options": "reminder_doctype", + "read_only": 1 + }, + { + "default": "now", + "fieldname": "remind_at", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Remind At", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "notified", + "fieldtype": "Check", + "hidden": 1, + "label": "notified" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-02-24 13:47:50.419648", + "modified_by": "Administrator", + "module": "Automation", + "name": "Reminder", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "if_owner": 1, + "read": 1, + "role": "All", + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "description" +} \ No newline at end of file diff --git a/frappe/automation/doctype/reminder/reminder.py b/frappe/automation/doctype/reminder/reminder.py new file mode 100644 index 0000000000..795cdfda69 --- /dev/null +++ b/frappe/automation/doctype/reminder/reminder.py @@ -0,0 +1,78 @@ +# Copyright (c) 2023, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint +from frappe.utils.data import add_to_date, get_datetime, now_datetime + + +class Reminder(Document): + @staticmethod + def clear_old_logs(days=30): + from frappe.query_builder import Interval + from frappe.query_builder.functions import Now + + table = frappe.qb.DocType("Reminder") + frappe.db.delete(table, filters=(table.remind_at < (Now() - Interval(days=days)))) + + def validate(self): + self.user = frappe.session.user + if get_datetime(self.remind_at) < now_datetime(): + frappe.throw(_("Reminder cannot be created in past.")) + + def send_reminder(self): + if self.notified: + return + + self.db_set("notified", 1, update_modified=False) + + try: + notification = frappe.new_doc("Notification Log") + notification.for_user = self.user + notification.set("type", "Alert") + notification.document_type = self.reminder_doctype + notification.document_name = self.reminder_docname + notification.subject = self.description + notification.insert() + except Exception: + self.log_error("Failed to send reminder") + + +@frappe.whitelist() +def create_new_reminder( + remind_at: str, + description: str, + reminder_doctype: str | None = None, + reminder_docname: str | None = None, +): + reminder = frappe.new_doc("Reminder") + + reminder.description = description + reminder.remind_at = remind_at + reminder.reminder_doctype = reminder_doctype + reminder.reminder_docname = reminder_docname + + return reminder.insert() + + +def send_reminders(): + # Ensure that we send all reminders that might be before next job execution. + job_freq = cint(frappe.get_conf().scheduler_interval) or 240 + upper_threshold = add_to_date(now_datetime(), seconds=job_freq, as_string=True, as_datetime=True) + + lower_threshold = add_to_date(now_datetime(), hours=-8, as_string=True, as_datetime=True) + + pending_reminders = frappe.get_all( + "Reminder", + filters=[ + ("remind_at", "<=", upper_threshold), + ("remind_at", ">=", lower_threshold), # dont send too old reminders if failed to send + ("notified", "=", 0), + ], + pluck="name", + ) + + for reminder in pending_reminders: + frappe.get_doc("Reminder", reminder).send_reminder() diff --git a/frappe/automation/doctype/reminder/test_reminder.py b/frappe/automation/doctype/reminder/test_reminder.py new file mode 100644 index 0000000000..84cc258701 --- /dev/null +++ b/frappe/automation/doctype/reminder/test_reminder.py @@ -0,0 +1,28 @@ +# Copyright (c) 2023, Frappe Technologies and Contributors +# See license.txt + +import frappe +from frappe.automation.doctype.reminder.reminder import create_new_reminder, send_reminders +from frappe.desk.doctype.notification_log.notification_log import get_notification_logs +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_to_date, now_datetime + + +class TestReminder(FrappeTestCase): + def test_reminder(self): + + description = "TEST_REMINDER" + + create_new_reminder( + remind_at=add_to_date(now_datetime(), minutes=1, as_datetime=True, as_string=True), + description=description, + ) + + send_reminders() + + notifications = get_notification_logs()["notification_logs"] + self.assertIn( + description, + [n.subject for n in notifications], + msg=f"Failed to find reminder notification \n{notifications}", + ) diff --git a/frappe/boot.py b/frappe/boot.py index 31e101aedc..9594635c70 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -12,6 +12,7 @@ from frappe.desk.doctype.route_history.route_history import frequently_visited_l from frappe.desk.form.load import get_meta_bundle from frappe.email.inbox import get_email_accounts from frappe.model.base_document import get_controller +from frappe.permissions import has_permission from frappe.query_builder import DocType from frappe.query_builder.functions import Count from frappe.query_builder.terms import ParameterizedValueWrapper, SubQuery @@ -101,7 +102,7 @@ def get_bootinfo(): bootinfo.app_logo_url = get_app_logo() bootinfo.link_title_doctypes = get_link_title_doctypes() bootinfo.translated_doctypes = get_translated_doctypes() - bootinfo.subscription_expiry = add_subscription_expiry() + bootinfo.subscription_conf = add_subscription_conf() return bootinfo @@ -234,7 +235,10 @@ def get_user_pages_or_reports(parent, cache=False): has_role[p.name] = {"modified": p.modified, "title": p.title} elif parent == "Report": - reports = frappe.get_all( + if not has_permission("Report", raise_exception=False): + return {} + + reports = frappe.get_list( "Report", fields=["name", "report_type"], filters={"name": ("in", has_role.keys())}, @@ -243,6 +247,10 @@ def get_user_pages_or_reports(parent, cache=False): for report in reports: has_role[report.name]["report_type"] = report.report_type + non_permitted_reports = set(has_role.keys()) - {r.name for r in reports} + for r in non_permitted_reports: + has_role.pop(r, None) + # Expire every six hours _cache.set_value("has_role:" + parent, has_role, frappe.session.user, 21600) return has_role @@ -431,8 +439,8 @@ def load_currency_docs(bootinfo): bootinfo.docs += currency_docs -def add_subscription_expiry(): +def add_subscription_conf(): try: - return frappe.conf.subscription["expiry"] + return frappe.conf.subscription except Exception: return "" diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index eeddef1865..24a4c6a271 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -127,8 +127,6 @@ def clear_doctype_cache(doctype=None): for key in ("is_table", "doctype_modules", "document_cache"): cache.delete_value(key) - frappe.local.document_cache = {} - def clear_single(dt): for name in doctype_cache_keys: cache.hdel(name, dt) @@ -160,11 +158,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/client.py b/frappe/client.py index 4dc118ea06..b09f9168f4 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -86,6 +86,8 @@ def get(doctype, name=None, filters=None, parent=None): doc = frappe.get_doc(doctype) # single doc.check_permission() + doc.apply_fieldlevel_read_permissions() + return doc.as_dict() diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index a6610c9213..36fa81f8a5 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -5,7 +5,6 @@ import click import frappe from frappe.commands import get_site, pass_context from frappe.exceptions import SiteNotSpecifiedError -from frappe.utils import cint @click.command("trigger-scheduler-event", help="Trigger a scheduler event") @@ -74,36 +73,40 @@ def disable_scheduler(context): @click.command("scheduler") @click.option("--site", help="site name") -@click.argument("state", type=click.Choice(["pause", "resume", "disable", "enable"])) +@click.argument("state", type=click.Choice(["pause", "resume", "disable", "enable", "status"])) +@click.option( + "--format", "-f", default="text", type=click.Choice(["json", "text"]), help="Output format" +) +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") @pass_context -def scheduler(context, state, site=None): +def scheduler(context, state: str, format: str, verbose: bool = False, site: str | None = None): """Control scheduler state.""" - import frappe.utils.scheduler - from frappe.installer import update_site_config + import frappe + from frappe.utils.scheduler import is_scheduler_inactive, toggle_scheduler - if not site: - site = get_site(context) + site = site or get_site(context) - try: - frappe.init(site=site) + output = { + "text": "Scheduler is {status} for site {site}", + "json": '{{"status": "{status}", "site": "{site}"}}', + } - if state == "pause": - update_site_config("pause_scheduler", 1) - elif state == "resume": - update_site_config("pause_scheduler", 0) - elif state == "disable": - frappe.connect() - frappe.utils.scheduler.disable_scheduler() - frappe.db.commit() - elif state == "enable": - frappe.connect() - frappe.utils.scheduler.enable_scheduler() - frappe.db.commit() + with frappe.init_site(site=site): + match state: + case "status": + frappe.connect() + status = "disabled" if is_scheduler_inactive(verbose=verbose) else "enabled" + return print(output[format].format(status=status, site=site)) + case "pause" | "resume": + from frappe.installer import update_site_config - print(f"Scheduler {state}d for site {site}") + update_site_config("pause_scheduler", state == "pause") + case "enable" | "disable": + frappe.connect() + toggle_scheduler(state == "enable") + frappe.db.commit() - finally: - frappe.destroy() + print(output[format].format(status=f"{state}d", site=site)) @click.command("set-maintenance-mode") diff --git a/frappe/commands/site.py b/frappe/commands/site.py index fbbdde8e03..25c8c3159d 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -44,7 +44,7 @@ from frappe.exceptions import SiteNotSpecifiedError @click.option( "--force", help="Force restore if site/database already exists", is_flag=True, default=False ) -@click.option("--source_sql", help="Initiate database with a SQL file") +@click.option("--source-sql", "--source_sql", help="Initiate database with a SQL file") @click.option("--install-app", multiple=True, help="Install app after installation") @click.option( "--set-default", is_flag=True, default=False, help="Set the new site as default site" @@ -67,10 +67,13 @@ def new_site( set_default=False, ): "Create a new site" - from frappe.installer import _new_site + from frappe.installer import _new_site, extract_sql_from_archive frappe.init(site=site, new_site=True) + if source_sql: + source_sql = extract_sql_from_archive(source_sql) + _new_site( db_name, site, @@ -592,6 +595,8 @@ def disable_user(context, email): @pass_context def migrate(context, skip_failing=False, skip_search_index=False): "Run patches, sync schema and rebuild files/translations" + from traceback_with_variables import activate_by_import + from frappe.migrate import SiteMigration for site in context.sites: diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index bb943c7223..f41cca3c57 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -17,6 +17,7 @@ DATA_IMPORT_DEPRECATION = ( "[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n" "Use `data-import` command instead to import data via 'Data Import'." ) +EXTRA_ARGS_CTX = {"ignore_unknown_options": True, "allow_extra_args": True} @click.command("build") @@ -485,9 +486,10 @@ def bulk_rename(context, doctype, path): frappe.destroy() -@click.command("db-console") +@click.command("db-console", context_settings=EXTRA_ARGS_CTX) +@click.argument("extra_args", nargs=-1) @pass_context -def database(context): +def database(context, extra_args): """ Enter into the Database console for given site. """ @@ -496,14 +498,18 @@ def database(context): raise SiteNotSpecifiedError frappe.init(site=site) if not frappe.conf.db_type or frappe.conf.db_type == "mariadb": - _mariadb() + _mariadb(extra_args=extra_args) elif frappe.conf.db_type == "postgres": - _psql() + _psql(extra_args=extra_args) -@click.command("mariadb") +@click.command( + "mariadb", + context_settings=EXTRA_ARGS_CTX, +) +@click.argument("extra_args", nargs=-1) @pass_context -def mariadb(context): +def mariadb(context, extra_args): """ Enter into mariadb console for a given site. """ @@ -511,21 +517,22 @@ def mariadb(context): if not site: raise SiteNotSpecifiedError frappe.init(site=site) - _mariadb() + _mariadb(extra_args=extra_args) -@click.command("postgres") +@click.command("postgres", context_settings=EXTRA_ARGS_CTX) +@click.argument("extra_args", nargs=-1) @pass_context -def postgres(context): +def postgres(context, extra_args): """ Enter into postgres console for a given site. """ site = get_site(context) frappe.init(site=site) - _psql() + _psql(extra_args=extra_args) -def _mariadb(): +def _mariadb(extra_args=None): from frappe.database.mariadb.database import MariaDBDatabase mysql = which("mysql") @@ -543,10 +550,12 @@ def _mariadb(): "--safe-updates", "-A", ] + if extra_args: + command += list(extra_args) os.execv(mysql, command) -def _psql(): +def _psql(extra_args=None): psql = which("psql") host = frappe.conf.db_host or "127.0.0.1" @@ -554,7 +563,10 @@ def _psql(): env = os.environ.copy() env["PGPASSWORD"] = frappe.conf.db_password conn_string = f"postgresql://{frappe.conf.db_name}@{host}:{port}/{frappe.conf.db_name}" - subprocess.run([psql, conn_string], check=True, env=env) + psql_cmd = [psql, conn_string] + if extra_args: + psql_cmd = psql_cmd + list(extra_args) + subprocess.run(psql_cmd, check=True, env=env) @click.command("jupyter") @@ -562,7 +574,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") ) @@ -896,7 +908,7 @@ def run_ui_tests( os.chdir(app_base_path) - node_bin = subprocess.getoutput("npm bin") + node_bin = subprocess.getoutput("yarn bin") cypress_path = f"{node_bin}/cypress" drag_drop_plugin_path = f"{node_bin}/../@4tw/cypress-drag-drop" real_events_plugin_path = f"{node_bin}/../cypress-real-events" @@ -1001,7 +1013,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 +1042,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") @@ -1061,6 +1083,8 @@ def set_config(context, key, value, global_=False, parse=False, as_dict=False): common_site_config_path = os.path.join(sites_path, "common_site_config.json") update_site_config(key, value, validate=False, site_config_path=common_site_config_path) else: + if not context.sites: + raise SiteNotSpecifiedError for site in context.sites: frappe.init(site=site) update_site_config(key, value, validate=False) @@ -1176,6 +1200,7 @@ commands = [ data_import, import_doc, make_app, + create_patch, mariadb, postgres, request, diff --git a/frappe/contacts/doctype/address/address.json b/frappe/contacts/doctype/address/address.json index ce8e435bfa..c30299c7ad 100644 --- a/frappe/contacts/doctype/address/address.json +++ b/frappe/contacts/doctype/address/address.json @@ -204,7 +204,6 @@ "read": 1, "report": 1, "role": "System Manager", - "set_user_permissions": 1, "share": 1, "write": 1 } diff --git a/frappe/contacts/doctype/address_template/address_template.jinja b/frappe/contacts/doctype/address_template/address_template.jinja new file mode 100644 index 0000000000..65ea58eb21 --- /dev/null +++ b/frappe/contacts/doctype/address_template/address_template.jinja @@ -0,0 +1,10 @@ +{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%} +{{ city }}
+{% if state %}{{ state }}
{% endif -%} +{% if pincode %}{{ pincode }}
{% endif -%} +{{ country }}
+
+{% if phone %}{{ _("Phone") }}: {{ phone }}
{% endif -%} +{% if fax %}{{ _("Fax") }}: {{ fax }}
{% endif -%} +{% if email_id %}{{ _("Email") }}: {{ email_id }}
{% endif -%} diff --git a/frappe/contacts/doctype/address_template/address_template.json b/frappe/contacts/doctype/address_template/address_template.json index 48eacc0fc7..58b8210a49 100644 --- a/frappe/contacts/doctype/address_template/address_template.json +++ b/frappe/contacts/doctype/address_template/address_template.json @@ -53,7 +53,6 @@ "read": 1, "report": 1, "role": "System Manager", - "set_user_permissions": 1, "share": 1, "write": 1 } @@ -62,4 +61,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/frappe/contacts/doctype/address_template/address_template.py b/frappe/contacts/doctype/address_template/address_template.py index a8806b336b..a33115b105 100644 --- a/frappe/contacts/doctype/address_template/address_template.py +++ b/frappe/contacts/doctype/address_template/address_template.py @@ -4,52 +4,36 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint from frappe.utils.jinja import validate_template class AddressTemplate(Document): def validate(self): + validate_template(self.template) + if not self.template: self.template = get_default_address_template() - self.defaults = frappe.db.get_values( - "Address Template", {"is_default": 1, "name": ("!=", self.name)} - ) - if not self.is_default: - if not self.defaults: - self.is_default = 1 - if cint(frappe.db.get_single_value("System Settings", "setup_complete")): - frappe.msgprint(_("Setting this Address Template as default as there is no other default")) - - validate_template(self.template) + if not self.is_default and not self._get_previous_default(): + self.is_default = 1 + if frappe.db.get_single_value("System Settings", "setup_complete"): + frappe.msgprint(_("Setting this Address Template as default as there is no other default")) def on_update(self): - if self.is_default and self.defaults: - for d in self.defaults: - frappe.db.set_value("Address Template", d[0], "is_default", 0) + if self.is_default and (previous_default := self._get_previous_default()): + frappe.db.set_value("Address Template", previous_default, "is_default", 0) def on_trash(self): if self.is_default: frappe.throw(_("Default Address Template cannot be deleted")) + def _get_previous_default(self) -> str | None: + return frappe.db.get_value("Address Template", {"is_default": 1, "name": ("!=", self.name)}) + @frappe.whitelist() -def get_default_address_template(): - """Get default address template (translated)""" - return ( - """{{ address_line1 }}
{% if address_line2 %}{{ address_line2 }}
{% endif -%}\ -{{ city }}
-{% if state %}{{ state }}
{% endif -%} -{% if pincode %}{{ pincode }}
{% endif -%} -{{ country }}
-{% if phone %}""" - + _("Phone") - + """: {{ phone }}
{% endif -%} -{% if fax %}""" - + _("Fax") - + """: {{ fax }}
{% endif -%} -{% if email_id %}""" - + _("Email") - + """: {{ email_id }}
{% endif -%}""" - ) +def get_default_address_template() -> str: + """Return the default address template.""" + from pathlib import Path + + return (Path(__file__).parent / "address_template.jinja").read_text() diff --git a/frappe/contacts/doctype/address_template/test_address_template.py b/frappe/contacts/doctype/address_template/test_address_template.py index ee45ce98f8..c3c5b544d6 100644 --- a/frappe/contacts/doctype/address_template/test_address_template.py +++ b/frappe/contacts/doctype/address_template/test_address_template.py @@ -1,39 +1,39 @@ # Copyright (c) 2015, Frappe Technologies and Contributors # License: MIT. See LICENSE import frappe +from frappe.contacts.doctype.address_template.address_template import get_default_address_template from frappe.tests.utils import FrappeTestCase +from frappe.utils.jinja import validate_template class TestAddressTemplate(FrappeTestCase): - def setUp(self): - self.make_default_address_template() + def setUp(self) -> None: + frappe.db.delete("Address Template", {"country": "India"}) + frappe.db.delete("Address Template", {"country": "Brazil"}) + + def test_default_address_template(self): + validate_template(get_default_address_template()) def test_default_is_unset(self): - a = frappe.get_doc("Address Template", "India") - a.is_default = 1 - a.save() + frappe.get_doc({"doctype": "Address Template", "country": "India", "is_default": 1}).insert() - b = frappe.get_doc("Address Template", "Brazil") - b.is_default = 1 - b.save() + self.assertEqual(frappe.db.get_value("Address Template", "India", "is_default"), 1) + + frappe.get_doc({"doctype": "Address Template", "country": "Brazil", "is_default": 1}).insert() self.assertEqual(frappe.db.get_value("Address Template", "India", "is_default"), 0) + self.assertEqual(frappe.db.get_value("Address Template", "Brazil", "is_default"), 1) - def tearDown(self): - a = frappe.get_doc("Address Template", "India") - a.is_default = 1 - a.save() + def test_delete_address_template(self): + india = frappe.get_doc( + {"doctype": "Address Template", "country": "India", "is_default": 0} + ).insert() - @classmethod - def make_default_address_template(self): - template = """{{ address_line1 }}
{% if address_line2 %}{{ address_line2 }}
{% endif -%}{{ city }}
{% if state %}{{ state }}
{% endif -%}{% if pincode %}{{ pincode }}
{% endif -%}{{ country }}
{% if phone %}Phone: {{ phone }}
{% endif -%}{% if fax %}Fax: {{ fax }}
{% endif -%}{% if email_id %}Email: {{ email_id }}
{% endif -%}""" + brazil = frappe.get_doc( + {"doctype": "Address Template", "country": "Brazil", "is_default": 1} + ).insert() - if not frappe.db.exists("Address Template", "India"): - frappe.get_doc( - {"doctype": "Address Template", "country": "India", "is_default": 1, "template": template} - ).insert() + india.reload() # might have been modified by the second template + india.delete() # should not raise an error - if not frappe.db.exists("Address Template", "Brazil"): - frappe.get_doc( - {"doctype": "Address Template", "country": "Brazil", "template": template} - ).insert() + self.assertRaises(frappe.ValidationError, brazil.delete) diff --git a/frappe/contacts/doctype/contact/contact.json b/frappe/contacts/doctype/contact/contact.json index c756a8ecb8..3090746657 100644 --- a/frappe/contacts/doctype/contact/contact.json +++ b/frappe/contacts/doctype/contact/contact.json @@ -275,7 +275,6 @@ "read": 1, "report": 1, "role": "System Manager", - "set_user_permissions": 1, "share": 1, "write": 1 }, diff --git a/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py b/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py index d15d518f63..74c797ca65 100644 --- a/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py +++ b/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py @@ -112,6 +112,3 @@ class TestAddressesAndContacts(FrappeTestCase): 1, ] self.assertListEqual(test_item, report_data[idx]) - - def tearDown(self): - frappe.db.rollback() diff --git a/frappe/core/doctype/access_log/access_log.py b/frappe/core/doctype/access_log/access_log.py index ca2909b970..c194f5d603 100644 --- a/frappe/core/doctype/access_log/access_log.py +++ b/frappe/core/doctype/access_log/access_log.py @@ -8,7 +8,13 @@ from frappe.utils import cstr class AccessLog(Document): - pass + @staticmethod + def clear_old_logs(days=30): + from frappe.query_builder import Interval + from frappe.query_builder.functions import Now + + table = frappe.qb.DocType("Access Log") + frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) @frappe.whitelist() diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 9944961ca9..6b948947a8 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -487,28 +487,32 @@ def parse_email(communication, email_strings): """ Parse email to add timeline links. When automatic email linking is enabled, an email from email_strings can contain - a doctype and docname ie in the format `admin+doctype+docname@example.com`, + a doctype and docname ie in the format `admin+doctype+docname@example.com` or `admin+doctype=docname@example.com`, the email is parsed and doctype and docname is extracted and timeline link is added. """ - if not frappe.get_all("Email Account", filters={"enable_automatic_linking": 1}): + if not frappe.db.get_value("Email Account", filters={"enable_automatic_linking": 1}): return - delimiter = "+" - for email_string in email_strings: if email_string: for email in email_string.split(","): - if delimiter in email: - email = email.split("@")[0] - email_local_parts = email.split(delimiter) - if not len(email_local_parts) == 3: - continue - + email_username = email.split("@", 1)[0] + email_local_parts = email_username.split("+") + docname = doctype = None + if len(email_local_parts) == 3: doctype = unquote(email_local_parts[1]) docname = unquote(email_local_parts[2]) - if doctype and docname and frappe.db.exists(doctype, docname): - communication.add_link(doctype, docname) + elif len(email_local_parts) == 2: + document_parts = email_local_parts[1].split("=", 1) + if len(document_parts) != 2: + continue + + doctype = unquote(document_parts[0]) + docname = unquote(document_parts[1]) + + if doctype and docname and frappe.db.get_value(doctype, docname, ignore=True): + communication.add_link(doctype, docname) def get_email_without_link(email): @@ -521,7 +525,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/communication/email.py b/frappe/core/doctype/communication/email.py index fbb34bc7e6..2e199e014d 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -12,6 +12,7 @@ from frappe.utils import ( cint, get_datetime, get_formatted_email, + get_imaginary_pixel_response, get_string_between, list_to_str, split_emails, @@ -249,18 +250,7 @@ def mark_email_as_seen(name: str = None): frappe.log_error("Unable to mark as seen", None, "Communication", name) finally: - frappe.response.update( - { - "type": "binary", - "filename": "imaginary_pixel.png", - "filecontent": ( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" - b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\r" - b"IDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0" - b"\xa0\x00\x00\x00\x00IEND\xaeB`\x82" - ), - } - ) + frappe.response.update(frappe.utils.get_imaginary_pixel_response()) def update_communication_as_read(name): diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index 5b208eaeb7..04e57f10cf 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -219,17 +219,17 @@ class TestCommunication(FrappeTestCase): self.assertIn(comm_note_2.name, data) def test_link_in_email(self): - frappe.delete_doc_if_exists("Note", "test document link in email") - create_email_account() - note = frappe.get_doc( - { - "doctype": "Note", - "title": "test document link in email", - "content": "test document link in email", - } - ).insert(ignore_permissions=True) + notes = {} + for i in range(2): + frappe.delete_doc_if_exists("Note", f"test document link in email {i}") + notes[i] = frappe.get_doc( + { + "doctype": "Note", + "title": f"test document link in email {i}", + } + ).insert(ignore_permissions=True) comm = frappe.get_doc( { @@ -237,14 +237,15 @@ class TestCommunication(FrappeTestCase): "communication_medium": "Email", "subject": "Document Link in Email", "sender": "comm_sender@example.com", - "recipients": f'comm_recipient+{quote("Note")}+{quote(note.name)}@example.com', + "recipients": f'comm_recipient+{quote("Note")}+{quote(notes[0].name)}@example.com,comm_recipient+{quote("Note")}={quote(notes[1].name)}@example.com', } ).insert(ignore_permissions=True) doc_links = [ (timeline_link.link_doctype, timeline_link.link_name) for timeline_link in comm.timeline_links ] - self.assertIn(("Note", note.name), doc_links) + self.assertIn(("Note", notes[0].name), doc_links) + self.assertIn(("Note", notes[1].name), doc_links) def test_parse_emails(self): emails = get_emails( diff --git a/frappe/core/doctype/custom_docperm/custom_docperm.json b/frappe/core/doctype/custom_docperm/custom_docperm.json index 2c594f5624..208b0beef9 100644 --- a/frappe/core/doctype/custom_docperm/custom_docperm.json +++ b/frappe/core/doctype/custom_docperm/custom_docperm.json @@ -27,7 +27,6 @@ "report", "export", "import", - "set_user_permissions", "column_break_19", "share", "print", @@ -179,13 +178,6 @@ "fieldtype": "Check", "label": "Import" }, - { - "default": "0", - "description": "This role update User Permissions for a user", - "fieldname": "set_user_permissions", - "fieldtype": "Check", - "label": "Set User Permissions" - }, { "fieldname": "column_break_19", "fieldtype": "Column Break" @@ -223,7 +215,7 @@ } ], "links": [], - "modified": "2020-12-03 15:20:48.296730", + "modified": "2023-02-20 13:19:04.889081", "modified_by": "Administrator", "module": "Core", "name": "Custom DocPerm", diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json index 9e948dac8c..faa9a33bf1 100644 --- a/frappe/core/doctype/data_import/data_import.json +++ b/frappe/core/doctype/data_import/data_import.json @@ -94,6 +94,7 @@ "fieldtype": "Select", "hidden": 1, "label": "Status", + "no_copy": 1, "options": "Pending\nSuccess\nPartial Success\nError", "read_only": 1 }, @@ -170,7 +171,7 @@ ], "hide_toolbar": 1, "links": [], - "modified": "2022-02-01 20:08:37.624914", + "modified": "2022-02-14 10:08:37.624914", "modified_by": "Administrator", "module": "Core", "name": "Data Import", @@ -194,4 +195,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 0d8d7ea671..90b1c6cb77 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -304,6 +304,7 @@ }, { "default": "0", + "depends_on": "eval:!in_list(['Section Break', 'Column Break', 'Tab Break'], doc.fieldtype)", "fieldname": "permlevel", "fieldtype": "Int", "label": "Perm Level", @@ -555,7 +556,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-01-11 20:46:43.164926", + "modified": "2023-02-20 12:07:29.552523", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/docperm/docperm.json b/frappe/core/doctype/docperm/docperm.json index 4411a67435..3ce49c4d6b 100644 --- a/frappe/core/doctype/docperm/docperm.json +++ b/frappe/core/doctype/docperm/docperm.json @@ -26,7 +26,6 @@ "report", "export", "import", - "set_user_permissions", "column_break_19", "share", "print", @@ -178,13 +177,6 @@ "fieldtype": "Check", "label": "Import" }, - { - "default": "0", - "description": "This role update User Permissions for a user", - "fieldname": "set_user_permissions", - "fieldtype": "Check", - "label": "Set User Permissions" - }, { "fieldname": "column_break_19", "fieldtype": "Column Break" @@ -218,7 +210,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2020-12-03 15:15:30.488212", + "modified": "2023-02-20 13:21:45.071310", "modified_by": "Administrator", "module": "Core", "name": "DocPerm", diff --git a/frappe/core/doctype/docshare/test_docshare.py b/frappe/core/doctype/docshare/test_docshare.py index f2ed8a32af..b874042d15 100644 --- a/frappe/core/doctype/docshare/test_docshare.py +++ b/frappe/core/doctype/docshare/test_docshare.py @@ -125,3 +125,17 @@ class TestDocShare(FrappeTestCase): ) frappe.share.remove(doctype, submittable_doc.name, self.user) + + def test_share_int_pk(self): + test_doc = frappe.new_doc("Console Log") + + test_doc.insert() + frappe.share.add("Console Log", test_doc.name, self.user) + + frappe.set_user(self.user) + self.assertIn( + str(test_doc.name), [str(name) for name in frappe.get_list("Console Log", pluck="name")] + ) + + test_doc.reload() + self.assertTrue(test_doc.has_permission("read")) diff --git a/frappe/core/doctype/doctype/boilerplate/controller._py b/frappe/core/doctype/doctype/boilerplate/controller._py index 6db99def55..d8f02bf09c 100644 --- a/frappe/core/doctype/doctype/boilerplate/controller._py +++ b/frappe/core/doctype/doctype/boilerplate/controller._py @@ -4,5 +4,6 @@ # import frappe {base_class_import} + class {classname}({base_class}): {custom_controller} diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index e851a50674..d92277152c 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -55,7 +55,7 @@ frappe.ui.form.on("DocType", { msg += __("If you just want to customize for your site, use {0} instead.", [ customize_form_link, ]); - frm.dashboard.add_comment(msg, "yellow"); + frm.dashboard.add_comment(msg, "yellow", true); } if (frm.is_new()) { @@ -104,88 +104,7 @@ frappe.ui.form.on("DocType", { frappe.ui.form.on("DocField", { form_render(frm, doctype, docname) { - // Render two select fields for Fetch From instead of Small Text for better UX - let field = frm.cur_grid.grid_form.fields_dict.fetch_from; - $(field.input_area).hide(); - - let $doctype_select = $(``); - let $wrapper = $('
'); - $wrapper.append($doctype_select, $field_select); - field.$input_wrapper.append($wrapper); - $doctype_select.wrap('
'); - $field_select.wrap('
'); - - let row = frappe.get_doc(doctype, docname); - let curr_value = { doctype: null, fieldname: null }; - if (row.fetch_from) { - let [doctype, fieldname] = row.fetch_from.split("."); - curr_value.doctype = doctype; - curr_value.fieldname = fieldname; - } - - let doctypes = frm.doc.fields - .filter((df) => df.fieldtype == "Link") - .filter((df) => df.options && df.fieldname != row.fieldname) - .sort((a, b) => a.options.localeCompare(b.options)) - .map((df) => ({ - label: `${df.options} (${df.fieldname})`, - value: df.fieldname, - })); - $doctype_select.add_options([ - { label: __("Select DocType"), value: "", selected: true }, - ...doctypes, - ]); - - $doctype_select.on("change", () => { - row.fetch_from = ""; - frm.dirty(); - update_fieldname_options(); - }); - - function update_fieldname_options() { - $field_select.find("option").remove(); - - let link_fieldname = $doctype_select.val(); - if (!link_fieldname) return; - let link_field = frm.doc.fields.find((df) => df.fieldname === link_fieldname); - let link_doctype = link_field.options; - frappe.model.with_doctype(link_doctype, () => { - let fields = frappe.meta - .get_docfields(link_doctype, null, { - fieldtype: ["not in", frappe.model.no_value_type], - }) - .sort((a, b) => a.label.localeCompare(b.label)) - .map((df) => ({ - label: `${df.label} (${df.fieldtype})`, - value: df.fieldname, - })); - $field_select.add_options([ - { - label: __("Select Field"), - value: "", - selected: true, - disabled: true, - }, - ...fields, - ]); - - if (curr_value.fieldname) { - $field_select.val(curr_value.fieldname); - } - }); - } - - $field_select.on("change", () => { - let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`; - row.fetch_from = fetch_from; - frm.dirty(); - }); - - if (curr_value.doctype) { - $doctype_select.val(curr_value.doctype); - update_fieldname_options(); - } + frm.trigger("setup_fetch_from_fields", doctype, docname); }, fieldtype: function (frm) { 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..6cc0adcc87 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) @@ -981,7 +983,7 @@ def change_name_column_type(doctype_name: str, type: str) -> None: def validate_links_table_fieldnames(meta): """Validate fieldnames in Links table""" - if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures: + if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures or frappe.flags.in_migrate: return fieldnames = tuple(field.fieldname for field in meta.fields) @@ -1096,10 +1098,7 @@ def validate_fields(meta): ) def check_link_table_options(docname, d): - if frappe.flags.in_patch: - return - - if frappe.flags.in_fixtures: + if frappe.flags.in_patch or frappe.flags.in_fixtures: return if d.fieldtype in ("Link",) + table_fields: @@ -1133,7 +1132,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 +1345,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)): @@ -1416,10 +1415,9 @@ def validate_fields(meta): ) df_options_str = "" - frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True) + frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", alert=True) def check_child_table_option(docfield): - if frappe.flags.in_fixtures: return if docfield.fieldtype not in ["Table MultiSelect", "Table"]: @@ -1462,31 +1460,34 @@ def validate_fields(meta): check_invalid_fieldnames(meta.get("name"), d.fieldname) check_unique_fieldname(meta.get("name"), d.fieldname) check_fieldname_length(d.fieldname) - check_illegal_mandatory(meta.get("name"), d) - check_link_table_options(meta.get("name"), d) - check_dynamic_link_options(d) check_hidden_and_mandatory(meta.get("name"), d) - check_in_list_view(meta.get("istable"), d) - check_in_global_search(d) - check_illegal_default(d) check_unique_and_text(meta.get("name"), d) - check_illegal_depends_on_conditions(d) - check_child_table_option(d) check_table_multiselect_option(d) scrub_options_in_select(d) scrub_fetch_from(d) validate_data_field_type(d) - check_max_height(d) - check_no_of_ratings(d) - check_fold(fields) - check_search_fields(meta, fields) - check_title_field(meta) - check_timeline_field(meta) - check_is_published_field(meta) - check_website_search_field(meta) - check_sort_field(meta) - check_image_field(meta) + if not frappe.flags.in_migrate: + check_link_table_options(meta.get("name"), d) + check_illegal_mandatory(meta.get("name"), d) + check_dynamic_link_options(d) + check_in_list_view(meta.get("istable"), d) + check_in_global_search(d) + check_illegal_depends_on_conditions(d) + check_illegal_default(d) + check_child_table_option(d) + check_max_height(d) + check_no_of_ratings(d) + + if not frappe.flags.in_migrate: + check_fold(fields) + check_search_fields(meta, fields) + check_title_field(meta) + check_timeline_field(meta) + check_is_published_field(meta) + check_website_search_field(meta) + check_sort_field(meta) + check_image_field(meta) def get_fields_not_allowed_in_list_view(meta) -> list[str]: @@ -1603,11 +1604,6 @@ def validate_permissions(doctype, for_remove=False, alert=False): d.set("import", 0) d.set("export", 0) - for ptype, label in [["set_user_permissions", _("Set User Permissions")]]: - if d.get(ptype): - d.set(ptype, 0) - frappe.msgprint(_("{0} cannot be set for Single types").format(label)) - def check_if_submittable(d): if d.submit and not issubmittable: frappe.throw(_("{0}: Cannot set Assign Submit if not Submittable").format(get_txt(d))) diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index e8226d4f9d..fead7672fe 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -172,32 +172,6 @@ class TestDocType(FrappeTestCase): if condition: self.assertFalse(re.match(pattern, condition)) - def test_data_field_options(self): - doctype_name = "Test Data Fields" - valid_data_field_options = frappe.model.data_field_options + ("",) - invalid_data_field_options = ("Invalid Option 1", frappe.utils.random_string(5)) - - for field_option in valid_data_field_options + invalid_data_field_options: - test_doctype = frappe.get_doc( - { - "doctype": "DocType", - "name": doctype_name, - "module": "Core", - "custom": 1, - "fields": [ - {"fieldname": f"{field_option}_field", "fieldtype": "Data", "options": field_option} - ], - } - ) - - if field_option in invalid_data_field_options: - # assert that only data options in frappe.model.data_field_options are valid - self.assertRaises(frappe.ValidationError, test_doctype.insert) - else: - test_doctype.insert() - self.assertEqual(test_doctype.name, doctype_name) - test_doctype.delete() - def test_sync_field_order(self): import os @@ -552,13 +526,14 @@ class TestDocType(FrappeTestCase): self.assertRaises(InvalidFieldNameError, validate_links_table_fieldnames, doc) def test_create_virtual_doctype(self): - """Test virtual DOcTYpe.""" + """Test virtual DocType.""" virtual_doc = new_doctype("Test Virtual Doctype") virtual_doc.is_virtual = 1 - virtual_doc.insert() - virtual_doc.save() + virtual_doc.insert(ignore_if_duplicate=True) + virtual_doc.reload() doc = frappe.get_doc("DocType", "Test Virtual Doctype") + self.assertDictEqual(doc.as_dict(), virtual_doc.as_dict()) self.assertEqual(doc.is_virtual, 1) self.assertFalse(frappe.db.table_exists("Test Virtual Doctype")) diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.json b/frappe/core/doctype/document_naming_settings/document_naming_settings.json index 4c86b2ec1d..9a12f3f77e 100644 --- a/frappe/core/doctype/document_naming_settings/document_naming_settings.json +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.json @@ -81,10 +81,10 @@ }, { "depends_on": "transaction_type", - "description": "Generate 3 preview of names generate by any valid series.", + "description": "Get a preview of generated names with a series.", "fieldname": "try_naming_series", "fieldtype": "Data", - "label": "Try a naming Series" + "label": "Try a Naming Series" }, { "fieldname": "transaction_type", @@ -111,7 +111,7 @@ "icon": "fa fa-sort-by-order", "issingle": 1, "links": [], - "modified": "2022-05-30 23:51:36.136535", + "modified": "2023-02-20 13:11:56.662100", "modified_by": "Administrator", "module": "Core", "name": "Document Naming Settings", @@ -130,4 +130,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} +} \ No newline at end of file diff --git a/frappe/core/doctype/error_snapshot/test_error_snapshot.py b/frappe/core/doctype/error_snapshot/test_error_snapshot.py index 8ff48bc5c6..4779d56c7b 100644 --- a/frappe/core/doctype/error_snapshot/test_error_snapshot.py +++ b/frappe/core/doctype/error_snapshot/test_error_snapshot.py @@ -1,9 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE from frappe.tests.utils import FrappeTestCase +from frappe.utils.logger import sanitized_dict # test_records = frappe.get_test_records('Error Snapshot') class TestErrorSnapshot(FrappeTestCase): - pass + def test_form_dict_sanitization(self): + self.assertNotEqual(sanitized_dict({"pwd": "SECRET", "usr": "WHAT"}).get("pwd"), "SECRET") diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index 4fd092a00b..159cf1ce39 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -24,6 +24,8 @@ frappe.ui.form.on("File", { preview_file: function (frm) { let $preview = ""; + let file_name = frm.doc.file_name.split("?")[0]; + let file_extension = file_name.split(".").pop()?.toLowerCase(); if (frappe.utils.is_image_file(frm.doc.file_url)) { $preview = $(`
@@ -40,7 +42,7 @@ frappe.ui.form.on("File", { ${__("Your browser does not support the video element.")}
`); - } else if (frm.doc.file_name.split("?")[0].endsWith(".pdf")) { + } else if (file_extension === "pdf") { $preview = $(`
`); - } else if (frm.doc.file_name.split("?")[0].endsWith(".mp3")) { + } else if (file_extension === "mp3") { $preview = $(`
diff --git a/frappe/public/icons/timeless/icons.svg b/frappe/public/icons/timeless/icons.svg index fed77e7df1..cfaf3ba1d7 100644 --- a/frappe/public/icons/timeless/icons.svg +++ b/frappe/public/icons/timeless/icons.svg @@ -835,6 +835,13 @@ + + + + + + + diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 3383c6aaeb..6697c034bc 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -83,7 +83,6 @@ import "./frappe/ui/toolbar/search_utils.js"; import "./frappe/ui/toolbar/about.js"; import "./frappe/ui/toolbar/navbar.html"; import "./frappe/ui/toolbar/toolbar.js"; -import "./frappe/ui/toolbar/subscription.js"; // import "./frappe/ui/toolbar/notifications.js"; import "./frappe/views/communication.js"; import "./frappe/views/translation_manager.js"; diff --git a/frappe/public/js/form_builder/components/Column.vue b/frappe/public/js/form_builder/components/Column.vue index a8f1f84118..acb1ff735e 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,39 +24,68 @@ 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++; } } } // remove column columns.splice(index, 1); - store.selected_field = null; + store.form.selected_field = null; } function move_columns_to_section() { @@ -72,7 +101,7 @@ function move_columns_to_section() { store.selected(column.df.name) ? 'selected' : '' ]" :title="column.df.fieldname" - @click.stop="store.selected_field = column.df" + @click.stop="store.form.selected_field = column.df" @mouseover.stop="hovered = true" @mouseout.stop="hovered = false" > diff --git a/frappe/public/js/form_builder/components/Field.vue b/frappe/public/js/form_builder/components/Field.vue index 5a7ce5626f..e0230765b5 100644 --- a/frappe/public/js/form_builder/components/Field.vue +++ b/frappe/public/js/form_builder/components/Field.vue @@ -19,7 +19,7 @@ function remove_field() { } let index = props.column.fields.indexOf(props.field); props.column.fields.splice(index, 1); - store.selected_field = null; + store.form.selected_field = null; } function move_fields_to_column() { @@ -32,15 +32,26 @@ 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); props.column.fields.splice(index + 1, 0, duplicate_field); - store.selected_field = duplicate_field.df; + store.form.selected_field = duplicate_field.df; } @@ -52,7 +63,7 @@ function duplicate_field() { store.selected(field.df.name) ? 'selected' : '' ]" :title="field.df.fieldname" - @click.stop="store.selected_field = field.df" + @click.stop="store.form.selected_field = field.df" @mouseover.stop="hovered = true" @mouseout.stop="hovered = false" > diff --git a/frappe/public/js/form_builder/components/FieldProperties.vue b/frappe/public/js/form_builder/components/FieldProperties.vue index b5597d20c5..b8e687ac06 100644 --- a/frappe/public/js/form_builder/components/FieldProperties.vue +++ b/frappe/public/js/form_builder/components/FieldProperties.vue @@ -14,18 +14,18 @@ let docfield_df = computed(() => { if (in_list(frappe.model.layout_fields, df.fieldtype) || df.hidden) { return false; } - if (df.depends_on && !evaluate_depends_on_value(df.depends_on, store.selected_field)) { + if (df.depends_on && !evaluate_depends_on_value(df.depends_on, store.form.selected_field)) { return false; } if ( in_list(["fetch_from", "fetch_if_empty"], df.fieldname) && - in_list(frappe.model.no_value_type, store.selected_field.fieldtype) + in_list(frappe.model.no_value_type, store.form.selected_field.fieldtype) ) { return false; } - if (df.fieldname === "reqd" && store.selected_field.fieldtype === "Check") { + if (df.fieldname === "reqd" && store.form.selected_field.fieldtype === "Check") { return false; } @@ -34,11 +34,11 @@ let docfield_df = computed(() => { df.options = ""; args.value = {}; - if (in_list(["Table", "Link"], store.selected_field.fieldtype)) { + if (in_list(["Table", "Link"], store.form.selected_field.fieldtype)) { df.fieldtype = "Link"; df.options = "DocType"; - if (store.selected_field.fieldtype === "Table") { + if (store.form.selected_field.fieldtype === "Table") { args.value.is_table_field = 1; } } @@ -63,14 +63,14 @@ let docfield_df = computed(() => {