diff --git a/.github/helper/ci.py b/.github/helper/ci.py new file mode 100644 index 0000000000..2eadd468c1 --- /dev/null +++ b/.github/helper/ci.py @@ -0,0 +1,103 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE +import os +from pathlib import Path + +STANDARD_INCLUSIONS = ["*.py"] + +STANDARD_EXCLUSIONS = [ + "*.js", + "*.xml", + "*.pyc", + "*.css", + "*.less", + "*.scss", + "*.vue", + "*.html", + "*/test_*", + "*/node_modules/*", + "*/doctype/*/*_dashboard.py", + "*/patches/*", + ".github/*", +] + +# tested via commands' test suite +TESTED_VIA_CLI = [ + "*/frappe/installer.py", + "*/frappe/build.py", + "*/frappe/database/__init__.py", + "*/frappe/database/db_manager.py", + "*/frappe/database/**/setup_db.py", +] + +FRAPPE_EXCLUSIONS = [ + "*/tests/*", + "*/commands/*", + "*/frappe/change_log/*", + "*/frappe/exceptions*", + "*/frappe/coverage.py", + "*frappe/setup.py", + "*/doctype/*/*_dashboard.py", + "*/patches/*", +] + TESTED_VIA_CLI + + +def get_bench_path(): + return Path(__file__).resolve().parents[4] + + +class CodeCoverage: + def __init__(self, with_coverage, app): + self.with_coverage = with_coverage + self.app = app or "frappe" + + def __enter__(self): + if self.with_coverage: + import os + + from coverage import Coverage + + # Generate coverage report only for app that is being tested + source_path = os.path.join(get_bench_path(), "apps", self.app) + print(f"Source path: {source_path}") + omit = STANDARD_EXCLUSIONS[:] + + if self.app == "frappe": + omit.extend(FRAPPE_EXCLUSIONS) + + self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS) + self.coverage.start() + + def __exit__(self, exc_type, exc_value, traceback): + if self.with_coverage: + self.coverage.stop() + self.coverage.save() + self.coverage.xml_report() + + +if __name__ == "__main__": + app = "frappe" + site = os.environ.get("SITE") or "test_site" + use_orchestrator = bool(os.environ.get("ORCHESTRATOR_URL")) + build_number = 1 + total_builds = 1 + + try: + build_number = int(os.environ.get("BUILD_NUMBER")) + except Exception: + pass + + try: + total_builds = int(os.environ.get("TOTAL_BUILDS")) + except Exception: + pass + + with CodeCoverage(with_coverage=True, app=app): + if use_orchestrator: + from frappe.parallel_test_runner import ParallelTestWithOrchestrator + + ParallelTestWithOrchestrator(app, site=site) + else: + from frappe.parallel_test_runner import ParallelTestRunner + + ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds) diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 61c01870b3..ffc64a946f 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -30,9 +30,8 @@ jobs: - name: Clone uses: actions/checkout@v3 - - name: Check for valid Python & Merge Conflicts + - name: Check for Merge Conflicts run: | - python -m compileall -f "${GITHUB_WORKSPACE}" if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}" then echo "Found merge conflicts" exit 1 diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index f5b87178ee..2a0915f387 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -20,6 +20,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 + outputs: + build: ${{ steps.check-build.outputs.build }} + strategy: fail-fast: false matrix: @@ -122,8 +125,9 @@ jobs: - name: Run Tests if: ${{ steps.check-build.outputs.build == 'strawberry' }} - run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage + run: cd ~/frappe-bench/sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py env: + SITE: test_site CI_BUILD_ID: ${{ github.run_id }} ORCHESTRATOR_URL: http://test-orchestrator.frappe.io @@ -143,9 +147,11 @@ jobs: uses: actions/checkout@v3 - name: Download artifacts + if: ${{ needs.test.outputs.build == 'strawberry' }} uses: actions/download-artifact@v3 - name: Upload coverage data + if: ${{ needs.test.outputs.build == 'strawberry' }} uses: codecov/codecov-action@v3 with: name: MariaDB diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index d8f9a4bf99..1537430384 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -19,6 +19,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 + outputs: + build: ${{ steps.check-build.outputs.build }} + strategy: fail-fast: false matrix: @@ -125,8 +128,9 @@ jobs: - name: Run Tests if: ${{ steps.check-build.outputs.build == 'strawberry' }} - run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage + run: cd ~/frappe-bench/sites && ../env/bin/python3 ../apps/frappe/.github/helper/ci.py env: + SITE: test_site CI_BUILD_ID: ${{ github.run_id }} ORCHESTRATOR_URL: http://test-orchestrator.frappe.io @@ -146,9 +150,11 @@ jobs: uses: actions/checkout@v3 - name: Download artifacts + if: ${{ needs.test.outputs.build == 'strawberry' }} uses: actions/download-artifact@v3 - name: Upload coverage data + if: ${{ needs.test.outputs.build == 'strawberry' }} uses: codecov/codecov-action@v3 with: name: Postgres diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 4d5ec5c1db..b1d56a963c 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -17,6 +17,8 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 60 + outputs: + build: ${{ steps.check-build.outputs.build }} strategy: fail-fast: false @@ -184,18 +186,21 @@ jobs: uses: actions/checkout@v2 - name: Download artifacts + if: ${{ needs.test.outputs.build == 'strawberry' }} uses: actions/download-artifact@v3 - name: Upload python coverage data + if: ${{ needs.test.outputs.build == 'strawberry' }} uses: codecov/codecov-action@v3 with: - name: MariaDB + name: UIBackend fail_ci_if_error: true verbose: true files: ./coverage-py-1/coverage.xml,./coverage-py-2/coverage.xml,./coverage-py-3/coverage.xml flags: server-ui - name: Upload JS coverage data + if: ${{ needs.test.outputs.build == 'strawberry' }} uses: codecov/codecov-action@v3 with: name: Cypress diff --git a/README.md b/README.md index 4942d87e18..01f4199fdd 100644 --- a/README.md +++ b/README.md @@ -14,25 +14,28 @@
+ + + + + + + + + - - - - - -
-Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com) +Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com).
diff --git a/cypress/integration/control_data.js b/cypress/integration/control_data.js index ee6dfbca95..4c7ee589ab 100644 --- a/cypress/integration/control_data.js +++ b/cypress/integration/control_data.js @@ -41,7 +41,7 @@ context("Data Control", () => { it("check custom formatters", () => { cy.visit(`/app/doctype/User`); cy.get( - '[data-fieldname="fields"] .grid-row[data-idx="2"] [data-fieldname="fieldtype"] .static-area' + '[data-fieldname="fields"] .grid-row[data-idx="3"] [data-fieldname="fieldtype"] .static-area' ).should("have.text", "Section Break"); }); diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js index 31572b7976..995f8d0d9f 100644 --- a/cypress/integration/dashboard_links.js +++ b/cypress/integration/dashboard_links.js @@ -31,6 +31,7 @@ context("Dashboard links", () => { cy.get(".list-row-col > .level-item > .ellipsis").eq(0).click({ force: true }); //To check if initially the dashboard contains only the "Contact" link and there is no counter + cy.select_form_tab("Connections"); cy.get('[data-doctype="Contact"]').should("contain", "Contact"); //Adding a new contact @@ -44,6 +45,7 @@ context("Dashboard links", () => { cy.get(".list-row-col > .level-item > .ellipsis").eq(0).click({ force: true }); //To check if the counter for contact doc is "1" after adding the contact + cy.select_form_tab("Connections"); cy.get('[data-doctype="Contact"] > .count').should("contain", "1"); cy.get('[data-doctype="Contact"]').contains("Contact").click(); @@ -64,8 +66,8 @@ context("Dashboard links", () => { it("Report link in dashboard", () => { cy.visit("/app/user"); cy.visit("/app/user/Administrator"); - cy.get('[data-doctype="Contact"]').should("contain", "Contact"); - cy.findByText("Connections"); + cy.select_form_tab("Connections"); + cy.get('.document-link[data-doctype="Contact"]').contains("Contact"); cy.window() .its("cur_frm") .then((cur_frm) => { @@ -76,8 +78,9 @@ context("Dashboard links", () => { }, ]; cur_frm.dashboard.render_report_links(); - cy.get('[data-report="Website Analytics"]').contains("Website Analytics").click(); - cy.findByText("Website Analytics"); + cy.get('.document-link[data-report="Website Analytics"]') + .contains("Website Analytics") + .click(); }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index cbb88cb8cb..44b6c6b4fb 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -240,6 +240,10 @@ Cypress.Commands.add("new_form", (doctype) => { cy.get("body").should("have.attr", "data-ajax-state", "complete"); }); +Cypress.Commands.add("select_form_tab", (label) => { + cy.get(".form-tabs-list [data-toggle='tab']").contains(label).click().wait(500); +}); + Cypress.Commands.add("go_to_list", (doctype) => { let dt_in_route = doctype.toLowerCase().replace(/ /g, "-"); cy.visit(`/app/${dt_in_route}`); diff --git a/frappe/__init__.py b/frappe/__init__.py index 1d3cf7f62e..a796db9a83 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -2283,14 +2283,22 @@ def safe_eval(code, eval_globals=None, eval_locals=None): def get_website_settings(key): if not hasattr(local, "website_settings"): - local.website_settings = db.get_singles_dict("Website Settings", cast=True) + try: + local.website_settings = get_cached_doc("Website Settings") + except DoesNotExistError: + clear_last_message() + return return local.website_settings.get(key) def get_system_settings(key): if not hasattr(local, "system_settings"): - local.system_settings = db.get_singles_dict("System Settings", cast=True) + try: + local.system_settings = get_cached_doc("System Settings") + except DoesNotExistError: # possible during new install + clear_last_message() + return return local.system_settings.get(key) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 9a8a976b9f..e90a62f0a2 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1740,3 +1740,24 @@ def get_field(doc, fieldname): for field in doc.fields: if field.fieldname == fieldname: return field + + +@frappe.whitelist() +def set_field_order(doctype, field_order): + """Update field order in doctype""" + + frappe.only_for("System Manager") + + field_order = json.loads(field_order) + + idx = 1 + for fieldname in field_order: + docfield = frappe.qb.DocType("DocField") + frappe.qb.update(docfield).set(docfield.idx, idx).where( + (docfield.fieldname == fieldname) & (docfield.parent == doctype) + ).run() + idx += 1 + + # save to update + frappe.get_doc("DocType", doctype).save() + frappe.clear_cache(doctype=doctype) diff --git a/frappe/core/doctype/language/language.json b/frappe/core/doctype/language/language.json index 9ab8f55f6b..7e9bbb1038 100644 --- a/frappe/core/doctype/language/language.json +++ b/frappe/core/doctype/language/language.json @@ -51,7 +51,7 @@ "icon": "fa fa-globe", "in_create": 1, "links": [], - "modified": "2021-10-18 14:02:06.818219", + "modified": "2022-08-14 18:54:03.490836", "modified_by": "Administrator", "module": "Core", "name": "Language", @@ -76,8 +76,10 @@ } ], "search_fields": "language_name", + "show_title_field_in_link": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "language_name", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 7582954175..d4536c6355 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -8,6 +8,7 @@ from unittest.mock import patch import frappe import frappe.exceptions from frappe.core.doctype.user.user import ( + handle_password_test_fail, reset_password, sign_up, test_password_strength, @@ -191,6 +192,12 @@ class TestUser(unittest.TestCase): # Score 1; should now fail result = test_password_strength("bee2ve") self.assertEqual(result["feedback"]["password_policy_validation_passed"], False) + self.assertRaises( + frappe.exceptions.ValidationError, handle_password_test_fail, result["feedback"] + ) + self.assertRaises( + frappe.exceptions.ValidationError, handle_password_test_fail, result + ) # test backwards compatibility # Score 4; should pass result = test_password_strength("Eastern_43A1W") @@ -200,7 +207,7 @@ class TestUser(unittest.TestCase): user = frappe.get_doc("User", "test@example.com") frappe.flags.in_test = False user.new_password = "password" - self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid Password", user.save) + self.assertRaises(frappe.exceptions.ValidationError, user.save) user.reload() user.new_password = "Eastern_43A1W" user.save() diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 82e3fa71f3..c0d306f70f 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -7,6 +7,7 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "user_details_tab", "enabled", "section_break_3", "email", @@ -22,23 +23,31 @@ "send_welcome_email", "unsubscribed", "user_image", + "roles_permissions_tab", "sb1", "role_profile_name", "roles_html", "roles", + "sb_allow_modules", + "module_profile", + "modules_html", + "block_modules", + "home_settings", "short_bio", "gender", "birth_date", "interest", - "banner_image", - "desk_theme", "column_break_26", "phone", "location", "bio", - "mute_sounds", "column_break_22", "mobile_no", + "settings_tab", + "desk_settings_section", + "mute_sounds", + "desk_theme", + "banner_image", "change_password", "new_password", "logout_all_sessions", @@ -61,11 +70,6 @@ "send_me_a_copy", "allowed_in_mentions", "user_emails", - "sb_allow_modules", - "module_profile", - "modules_html", - "block_modules", - "home_settings", "sb2", "defaults", "sb3", @@ -87,7 +91,8 @@ "api_key", "generate_keys", "column_break_65", - "api_secret" + "api_secret", + "connections_tab" ], "fields": [ { @@ -232,7 +237,7 @@ "collapsible": 1, "depends_on": "enabled", "fieldname": "short_bio", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "More Information" }, { @@ -398,7 +403,6 @@ "permlevel": 1 }, { - "collapsible": 1, "depends_on": "eval:in_list(['System User'], doc.user_type)", "fieldname": "sb_allow_modules", "fieldtype": "Section Break", @@ -615,13 +619,13 @@ "options": "Module Profile" }, { - "description": "Stores the datetime when the last reset password key was generated.", - "fieldname": "last_reset_password_key_generated_on", - "fieldtype": "Datetime", - "hidden": 1, - "label": "Last Reset Password Key Generated On", - "read_only": 1 - }, + "description": "Stores the datetime when the last reset password key was generated.", + "fieldname": "last_reset_password_key_generated_on", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Last Reset Password Key Generated On", + "read_only": 1 + }, { "fieldname": "column_break_75", "fieldtype": "Column Break" @@ -648,18 +652,45 @@ "label": "Auto follow documents that you Like" }, { - "default": "0", - "depends_on": "eval:(doc.document_follow_notify== 1)", - "fieldname": "follow_shared_documents", - "fieldtype": "Check", - "label": "Auto follow documents that are shared with you" - }, + "default": "0", + "depends_on": "eval:(doc.document_follow_notify== 1)", + "fieldname": "follow_shared_documents", + "fieldtype": "Check", + "label": "Auto follow documents that are shared with you" + }, { "default": "0", "depends_on": "eval:(doc.document_follow_notify== 1)", "fieldname": "follow_assigned_documents", "fieldtype": "Check", "label": "Auto follow documents that are assigned to you" + }, + { + "fieldname": "user_details_tab", + "fieldtype": "Tab Break", + "label": "User Details" + }, + { + "fieldname": "roles_permissions_tab", + "fieldtype": "Tab Break", + "label": "Roles & Permissions" + }, + { + "fieldname": "settings_tab", + "fieldtype": "Tab Break", + "label": "Settings" + }, + { + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 + }, + { + "collapsible": 1, + "fieldname": "desk_settings_section", + "fieldtype": "Section Break", + "label": "Desk Settings" } ], "icon": "fa fa-user", @@ -722,7 +753,7 @@ "link_fieldname": "user" } ], - "modified": "2022-05-25 01:00:51.345319", + "modified": "2022-08-11 14:47:04.100892", "modified_by": "Administrator", "module": "Core", "name": "User", @@ -762,4 +793,4 @@ "states": [], "title_field": "full_name", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 232e915435..a982403935 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -540,7 +540,7 @@ class User(Document): feedback = result.get("feedback", None) if feedback and not feedback.get("password_policy_validation_passed", False): - handle_password_test_fail(result) + handle_password_test_fail(feedback) def suggest_username(self): def _check_suggestion(suggestion): @@ -686,7 +686,7 @@ def update_password(new_password, logout_all_sessions=0, key=None, old_password= feedback = result.get("feedback", None) if feedback and not feedback.get("password_policy_validation_passed", False): - handle_password_test_fail(result) + handle_password_test_fail(feedback) res = _get_user_for_update_password(key, old_password) if res.get("message"): @@ -1042,13 +1042,15 @@ def notify_admin_access_to_system_manager(login_manager=None): ) -def handle_password_test_fail(result): - suggestions = result["feedback"]["suggestions"][0] if result["feedback"]["suggestions"] else "" - warning = result["feedback"]["warning"] if "warning" in result["feedback"] else "" - suggestions += ( - "
" + _("Hint: Include symbols, numbers and capital letters in the password") + "
" - ) - frappe.throw(" ".join([_("Invalid Password:"), warning, suggestions])) +def handle_password_test_fail(feedback: dict): + # Backward compatibility + if "feedback" in feedback: + feedback = feedback["feedback"] + + suggestions = feedback.get("suggestions", []) + warning = feedback.get("warning", "") + + frappe.throw(msg=" ".join([warning] + suggestions), title=_("Invalid Password")) def update_gravatar(name): diff --git a/frappe/email/queue.py b/frappe/email/queue.py index bc02c6be32..b593dd9a21 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -3,9 +3,8 @@ import frappe from frappe import _, msgprint -from frappe.query_builder import DocType, Interval -from frappe.query_builder.functions import Now from frappe.utils import cint, get_url, now_datetime +from frappe.utils.data import getdate from frappe.utils.verified_command import get_signed_params, verify_request @@ -16,26 +15,17 @@ def get_emails_sent_this_month(email_account=None): if email_account=None, email account filter is not applied while counting """ - q = """ - SELECT - COUNT(*) - FROM - `tabEmail Queue` - WHERE - `status`='Sent' - AND - EXTRACT(YEAR_MONTH FROM `creation`) = EXTRACT(YEAR_MONTH FROM NOW()) - """ + today = getdate() + month_start = today.replace(day=1) - q_args = {} - if email_account is not None: - if email_account: - q += " AND email_account = %(email_account)s" - q_args["email_account"] = email_account - else: - q += " AND (email_account is null OR email_account='')" + filters = { + "status": "Sent", + "creation": [">=", str(month_start)], + } + if email_account: + filters["email_account"] = email_account - return frappe.db.sql(q, q_args)[0][0] + return frappe.db.count("Email Queue", filters=filters) def get_emails_sent_today(email_account=None): diff --git a/frappe/public/js/frappe/form/column.js b/frappe/public/js/frappe/form/column.js index 939ce6e2d7..705b98e6ed 100644 --- a/frappe/public/js/frappe/form/column.js +++ b/frappe/public/js/frappe/form/column.js @@ -4,22 +4,22 @@ export default class Column { this.df = df; this.section = section; + this.section.columns.push(this); this.make(); this.resize_all_columns(); } make() { this.wrapper = $(` -
+
- `) - .appendTo(this.section.body) - .find("form") - .on("submit", function () { - return false; - }); + `).appendTo(this.section.body); + + this.form = this.wrapper.find("form").on("submit", function () { + return false; + }); if (this.df.label) { $(` @@ -41,7 +41,15 @@ export default class Column { .addClass("col-sm-" + colspan); } + add_field() {} + refresh() { this.section.refresh(); } + + make_sortable() { + this.sortable = new Sortable(this.form.get(0), { + group: this.section.layout.frm.doctype, + }); + } } diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 77eacf6964..feadc08ead 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -13,6 +13,7 @@ import "./script_helpers"; import "./sidebar/form_sidebar"; import "./footer/footer"; import "./form_tour"; +import "./form_editor"; import { UndoManager } from "./undo_manager"; frappe.ui.form.Controller = class FormController { @@ -263,10 +264,19 @@ frappe.ui.form.Form = class FrappeForm { frm: this, }); + this.form_editor = new frappe.ui.form.FormEditor({ + frm: this, + }); + //this.form_editor.setup(); + // workflow state this.states = new frappe.ui.form.States({ frm: this, }); + + this.form_editor = new frappe.ui.form.FormEditor({ + frm: this, + }); } watch_model_updates() { @@ -1900,7 +1910,7 @@ frappe.ui.form.Form = class FrappeForm { } // uncollapse section - if (field.section.is_collapsed()) { + if (field.section?.is_collapsed()) { field.section.collapse(false); } @@ -1909,7 +1919,9 @@ frappe.ui.form.Form = class FrappeForm { // focus if text field if (focus) { - $el.find("input, select, textarea").focus(); + setTimeout(() => { + $el.find("input, select, textarea").focus(); + }, 500); } // highlight control inside field diff --git a/frappe/public/js/frappe/form/form_editor.js b/frappe/public/js/frappe/form/form_editor.js new file mode 100644 index 0000000000..ac9bc79f85 --- /dev/null +++ b/frappe/public/js/frappe/form/form_editor.js @@ -0,0 +1,81 @@ +frappe.ui.form.FormEditor = class FormEditor { + constructor({ frm }) { + this.frm = frm; + } + + setup() { + this.setup_sortable(); + this.setup_switch_tabs_on_hover(); + } + + setup_sortable() { + // setup sortable in all column + for (let section of this.frm.layout.sections) { + for (let column of section.columns) { + column.make_sortable(); + } + } + // sortable for moving tabs + if (this.frm.layout.tab_link_container) { + this.tab_sortable = new Sortable(this.frm.layout.tab_link_container.get(0)); + } + } + + setup_switch_tabs_on_hover() { + for (let tab of this.frm.layout.tabs) { + tab.setup_switch_on_hover(); + } + } + + save() { + this.field_order = []; + if (this.frm.layout.is_tabbed_layout()) { + for (let tab of this.frm.layout.tab_link_container.find(".nav-link")) { + this.add_field_to_field_order(tab); + const tab_id = tab.getAttribute("href").slice(1); + + this.add_sections(document.getElementById(tab_id)); + } + } else { + this.add_sections(this.frm.layout.page); + } + + frappe + .call("frappe.core.doctype.doctype.doctype.set_field_order", { + doctype: this.frm.doctype, + field_order: this.field_order, + }) + .then(() => frappe.toast("Field order updated")); + } + + add_sections(container) { + for (let section of $(container).find(".form-section")) { + this.add_field_to_field_order(section); + for (let column of $(section).find(".form-column")) { + this.add_field_to_field_order(column); + + for (let control of $(column).find(".frappe-control")) { + this.add_field_to_field_order(control); + } + } + } + } + + rebuid_fields_list() { + // rebuild the .fields_list and .fields_dict property of sections and columns + // refresh is based on the these properties + + for (let section of this.frm.layout.sections) { + section.rebuild_fields_list_from_dom(); + } + } + + add_field_to_field_order(element) { + const fieldname = element.getAttribute("data-fieldname"); + const fieldobj = this.frm.fields_dict[fieldname]; + const is_custom_field = fieldobj ? fieldobj.df && fieldobj.df.is_custom_field : false; + if (fieldname && !is_custom_field && fieldname.substr(0, 2) !== "__") { + this.field_order.push(fieldname); + } + } +}; diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index d396a4587a..770df2a5a9 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -9,8 +9,11 @@ frappe.ui.form.Layout = class Layout { this.tabs = []; this.sections = []; this.page_breaks = []; + this.sections_dict = {}; this.fields_list = []; this.fields_dict = {}; + this.section_count = 0; + this.column_count = 0; $.extend(this, opts); } @@ -41,7 +44,7 @@ frappe.ui.form.Layout = class Layout {
`).appendTo(this.page); - this.tabs_list = this.page.find(".form-tabs"); + this.tab_link_container = this.page.find(".form-tabs"); this.tabs_content = $(`
`).appendTo( this.page ); @@ -211,14 +214,11 @@ frappe.ui.form.Layout = class Layout { fieldobj.perm = this.frm.perm; } - this.section.fields_list.push(fieldobj); - this.section.fields_dict[df.fieldname] = fieldobj; - fieldobj.section = this.section; + this.section.add_field(fieldobj); + this.column.add_field(fieldobj); if (this.current_tab) { - fieldobj.tab = this.current_tab; - this.current_tab.fields_list.push(fieldobj); - this.current_tab.fields_dict[df.fieldname] = fieldobj; + this.current_tab.add_field(fieldobj); } } @@ -226,7 +226,7 @@ frappe.ui.form.Layout = class Layout { const fieldobj = frappe.ui.form.make_control({ df: df, doctype: this.doctype, - parent: this.column.wrapper.get(0), + parent: this.column.form.get(0), frm: this.frm, render_input: render, doc: this.doc, @@ -276,13 +276,18 @@ frappe.ui.form.Layout = class Layout { this.fold_btn.trigger("click"); } - make_section(df) { + make_section(df = {}) { + this.section_count++; + if (!df.fieldname) df.fieldname = `__section_${this.section_count}`; + this.section = new Section( this.current_tab ? this.current_tab.wrapper : this.page, df, this.card_layout, this ); + this.sections.push(this.section); + this.sections_dict[df.fieldname] = this.section; // append to layout fields if (df) { @@ -293,7 +298,10 @@ frappe.ui.form.Layout = class Layout { this.column = null; } - make_column(df) { + make_column(df = {}) { + this.column_count++; + if (!df.fieldname) df.fieldname = `__column_${this.section_count}`; + this.column = new Column(this.section, df); if (df && df.fieldname) { this.fields_list.push(this.column); @@ -302,7 +310,7 @@ frappe.ui.form.Layout = class Layout { make_tab(df) { this.section = null; - let tab = new Tab(this, df, this.frm, this.tabs_list, this.tabs_content); + let tab = new Tab(this, df, this.frm, this.tab_link_container, this.tabs_content); this.current_tab = tab; this.make_section({ fieldtype: "Section Break" }); this.tabs.push(tab); @@ -370,11 +378,23 @@ frappe.ui.form.Layout = class Layout { const visible_tabs = this.tabs.filter((tab) => !tab.hidden); if (visible_tabs && visible_tabs.length == 1) { - visible_tabs[0].parent.toggleClass("hide show"); + visible_tabs[0].tab_link.toggleClass("hide show"); } this.set_tab_as_active(); } + select_tab(label_or_fieldname) { + for (let tab of this.tabs) { + if ( + tab.label.toLowerCase() === label_or_fieldname.toLowerCase() || + tab.df.fieldname?.toLowerCase() === label_or_fieldname.toLowerCase() + ) { + tab.set_active(); + return; + } + } + } + set_tab_as_active() { let frm_active_tab = this?.frm.get_active_tab?.(); if (frm_active_tab) { @@ -456,7 +476,7 @@ frappe.ui.form.Layout = class Layout { } setup_events() { - this.tabs_list.off("click").on("click", ".nav-link", (e) => { + this.tab_link_container.off("click").on("click", ".nav-link", (e) => { e.preventDefault(); e.stopImmediatePropagation(); $(e.currentTarget).tab("show"); diff --git a/frappe/public/js/frappe/form/section.js b/frappe/public/js/frappe/form/section.js index d2b5cb4ec1..af40b0aa48 100644 --- a/frappe/public/js/frappe/form/section.js +++ b/frappe/public/js/frappe/form/section.js @@ -4,6 +4,7 @@ export default class Section { this.card_layout = card_layout; this.parent = parent; this.df = df || {}; + this.columns = []; this.fields_list = []; this.fields_dict = {}; @@ -28,9 +29,8 @@ export default class Section { let make_card = this.card_layout; this.wrapper = $(`
+ ${make_card ? "card-section" : ""}" data-fieldname="${this.df.fieldname}"> `).appendTo(this.parent); - this.layout && this.layout.sections.push(this); if (this.df) { if (this.df.label) { @@ -82,6 +82,12 @@ export default class Section { } } + add_field(fieldobj) { + this.fields_list.push(fieldobj); + this.fields_dict[fieldobj.fieldname] = fieldobj; + fieldobj.section = this.section; + } + refresh(hide) { if (!this.df) return; // hide if explicitly hidden diff --git a/frappe/public/js/frappe/form/tab.js b/frappe/public/js/frappe/form/tab.js index e5fab2d982..0a0eb8c80e 100644 --- a/frappe/public/js/frappe/form/tab.js +++ b/frappe/public/js/frappe/form/tab.js @@ -1,14 +1,12 @@ export default class Tab { - constructor(parent, df, frm, tabs_list, tabs_content) { - this.parent = parent; + constructor(layout, df, frm, tab_link_container, tabs_content) { + this.layout = layout; this.df = df || {}; this.frm = frm; this.doctype = this.frm.doctype; this.label = this.df && this.df.label; - this.tabs_list = tabs_list; + this.tab_link_container = tab_link_container; this.tabs_content = tabs_content; - this.fields_list = []; - this.fields_dict = {}; this.make(); this.setup_listeners(); this.refresh(); @@ -16,17 +14,18 @@ export default class Tab { make() { const id = `${frappe.scrub(this.doctype, "-")}-${this.df.fieldname}`; - this.parent = $(` + this.tab_link = $(` - `).appendTo(this.tabs_list); + `).appendTo(this.tab_link_container); this.wrapper = $(`
`).appendTo(this.tabs_content); @@ -38,11 +37,6 @@ export default class Tab { // hide if explicitly hidden let hide = this.df.hidden || this.df.hidden_due_to_dependency; - // hide if dashboard and not saved - if (!hide && this.df.show_dashboard && this.frm.is_new() && !this.fields_list.length) { - hide = true; - } - // hide if no read permission if (!hide && this.frm && !this.frm.get_perm(this.df.permlevel || 0, "read")) { hide = true; @@ -60,29 +54,38 @@ export default class Tab { } } + // hide if dashboard and not saved + if (!hide && this.df.show_dashboard && this.frm.is_new()) { + hide = true; + } + this.toggle(!hide); } toggle(show) { - this.parent.toggleClass("hide", !show); + this.tab_link.toggleClass("hide", !show); this.wrapper.toggleClass("hide", !show); - this.parent.toggleClass("show", show); + this.tab_link.toggleClass("show", show); this.wrapper.toggleClass("show", show); this.hidden = !show; } show() { - this.parent.show(); + this.tab_link.show(); } hide() { - this.parent.hide(); + this.tab_link.hide(); + } + + add_field(fieldobj) { + fieldobj.tab = this; } set_active() { - this.parent.find(".nav-link").tab("show"); - this.wrapper.addClass("active"); - this.frm?.set_active_tab?.(this); + this.tab_link.find(".nav-link").tab("show"); + this.wrapper.addClass("show"); + this.frm.active_tab = this; } is_active() { @@ -90,12 +93,26 @@ export default class Tab { } is_hidden() { - return this.wrapper.hasClass("hide"); + return this.wrapper.hasClass("hide") && this.tab_link.hasClass("hide"); } setup_listeners() { - this.parent.find(".nav-link").on("shown.bs.tab", () => { + this.tab_link.find(".nav-link").on("shown.bs.tab", () => { this?.frm.set_active_tab?.(this); }); } + + setup_switch_on_hover() { + this.tab_link.on("dragenter", () => { + this.action = setTimeout(() => { + this.set_active(); + }, 2000); + }); + this.tab_link.on("dragout", () => { + if (this.action) { + clearTimeout(this.action); + this.action = null; + } + }); + } } diff --git a/frappe/test_runner.py b/frappe/test_runner.py index e8c1656ca8..1e3573336a 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -143,7 +143,6 @@ def set_test_email_config(): "mail_server": "smtp.example.com", "mail_login": "test@example.com", "mail_password": "test", - "admin_password": "admin", } ) diff --git a/frappe/tests/test_frappe_client.py b/frappe/tests/test_frappe_client.py index facb0b3b72..e80e43f49c 100644 --- a/frappe/tests/test_frappe_client.py +++ b/frappe/tests/test_frappe_client.py @@ -2,31 +2,19 @@ # License: MIT. See LICENSE import base64 -import unittest import requests import frappe from frappe.core.doctype.user.user import generate_keys -from frappe.frappeclient import AuthError, FrappeClient, FrappeException +from frappe.frappeclient import FrappeClient, FrappeException +from frappe.tests.utils import FrappeTestCase from frappe.utils.data import get_url -class TestFrappeClient(unittest.TestCase): +class TestFrappeClient(FrappeTestCase): PASSWORD = frappe.conf.admin_password or "admin" - @classmethod - def setUpClass(cls) -> None: - site_url = get_url() - try: - FrappeClient(site_url, "Administrator", cls.PASSWORD, verify=False) - except AuthError: - raise unittest.SkipTest( - f"AuthError raised for {site_url} [usr=Administrator, pwd={cls.PASSWORD}]" - ) - - return super().setUpClass() - def test_insert_many(self): server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Note", {"title": ("in", ("Sing", "a", "song", "of", "sixpence"))}) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 42094e145f..9dd8661fc4 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -19,30 +19,48 @@ from PIL import Image import frappe from frappe.installer import parse_app_name from frappe.model.document import Document +from frappe.tests.utils import FrappeTestCase from frappe.utils import ( ceil, + dict_to_str, evaluate_filters, + execute_in_shell, floor, format_timedelta, get_bench_path, + get_file_timestamp, get_gravatar, + get_site_info, + get_sites, get_url, money_in_words, parse_timedelta, random_string, + remove_blanks, + safe_json_loads, scrub_urls, validate_email_address, + validate_name, + validate_phone_number_with_country_code, validate_url, ) from frappe.utils.data import ( add_to_date, + add_years, cast, + cstr, + duration_to_seconds, + get_datetime, get_first_day_of_week, get_time, get_timedelta, + get_timespan_date_range, + get_year_ending, getdate, now_datetime, nowtime, + pretty_date, + to_timedelta, validate_python_code, ) from frappe.utils.dateutils import get_dates_from_timegrain @@ -322,11 +340,36 @@ class TestValidationUtils(unittest.TestCase): self.assertFalse(validate_email_address("someone")) self.assertFalse(validate_email_address("someone@----.com")) + self.assertFalse( + validate_email_address("test@example.com test2@example.com,undisclosed-recipient") + ) + # Invalid with throw self.assertRaises( frappe.InvalidEmailAddressError, validate_email_address, "someone.com", throw=True ) + def test_valid_phone(self): + valid_phones = ["+91 1234567890", ""] + + for phone in valid_phones: + validate_phone_number_with_country_code(phone, "field") + self.assertRaises( + frappe.InvalidPhoneNumberError, + validate_phone_number_with_country_code, + "+420 1234567890", + "field", + ) + + def test_validate_name(self): + valid_names = ["", "abc", "asd a13", "asd-asd"] + for name in valid_names: + validate_name(name, True) + + invalid_names = ["asd$wat", "asasd/ads"] + for name in invalid_names: + self.assertRaises(frappe.InvalidNameError, validate_name, name, True) + class TestImage(unittest.TestCase): def test_strip_exif_data(self): @@ -476,6 +519,79 @@ class TestDateUtils(unittest.TestCase): self.assertIsInstance(get_timedelta(str(timedelta_input)), timedelta) self.assertIsInstance(get_timedelta(str(time_input)), timedelta) + def test_to_timedelta(self): + self.assertEqual(to_timedelta("00:00:01"), timedelta(seconds=1)) + self.assertEqual(to_timedelta("10:00:01"), timedelta(seconds=1, hours=10)) + self.assertEqual(to_timedelta(time(hour=2)), timedelta(hours=2)) + + def test_add_date_utils(self): + self.assertEqual(add_years(datetime(2020, 1, 1), 1), datetime(2021, 1, 1)) + + def test_duration_to_sec(self): + self.assertEqual(duration_to_seconds("3h 34m 45s"), 12885) + self.assertEqual(duration_to_seconds("1h"), 3600) + self.assertEqual(duration_to_seconds("110m"), 110 * 60) + self.assertEqual(duration_to_seconds("110m"), 110 * 60) + + def test_get_timespan_date_range(self): + + supported_timespans = [ + "last week", + "last month", + "last quarter", + "last 6 months", + "last year", + "yesterday", + "today", + "tomorrow", + "this week", + "this month", + "this quarter", + "this year", + "next week", + "next month", + "next quarter", + "next 6 months", + "next year", + ] + + for ts in supported_timespans: + res = get_timespan_date_range(ts) + self.assertEqual(len(res), 2) + + # Manual type checking eh? + self.assertIsInstance(res[0], date) + self.assertIsInstance(res[1], date) + + def test_timesmap_utils(self): + self.assertEqual(get_year_ending(date(2021, 1, 1)), date(2021, 12, 31)) + self.assertEqual(get_year_ending(date(2021, 1, 31)), date(2021, 12, 31)) + + def test_pretty_date(self): + from frappe import _ + + # differnt cases + now = get_datetime() + + test_cases = { + now: _("just now"), + add_to_date(now, minutes=-1): _("1 minute ago"), + add_to_date(now, minutes=-3): _("3 minutes ago"), + add_to_date(now, hours=-1): _("1 hour ago"), + add_to_date(now, hours=-2): _("2 hours ago"), + add_to_date(now, days=-1): _("Yesterday"), + add_to_date(now, days=-5): _("5 days ago"), + add_to_date(now, days=-8): _("1 week ago"), + add_to_date(now, days=-14): _("2 weeks ago"), + add_to_date(now, days=-32): _("1 month ago"), + add_to_date(now, days=-32 * 2): _("2 months ago"), + add_to_date(now, years=-1, days=-5): _("1 year ago"), + add_to_date(now, years=-2, days=-10): _("2 years ago"), + } + + for dt, exp_message in test_cases.items(): + self.assertEqual(pretty_date(dt), exp_message) + def test_date_from_timegrain(self): start_date = getdate("2021-01-01") @@ -724,7 +840,7 @@ class TestLazyLoader(unittest.TestCase): self.assertEqual(["Module `frappe.tests.data.load_sleep` loaded"], output) -class TestIdenticon(unittest.TestCase): +class TestIdenticon(FrappeTestCase): def test_get_gravatar(self): # developers@frappe.io has a gravatar linked so str URL will be returned frappe.flags.in_test = False @@ -747,3 +863,38 @@ class TestIdenticon(unittest.TestCase): identicon_bs64 = identicon.base64() self.assertIsInstance(identicon_bs64, str) self.assertTrue(identicon_bs64.startswith("data:image/png;base64,")) + + +class TestContainerUtils(FrappeTestCase): + def test_dict_to_str(self): + self.assertEqual(dict_to_str({"a": "b"}), "a=b") + + def test_remove_blanks(self): + a = {"asd": "", "b": None, "c": "d"} + remove_blanks(a) + self.assertEqual(len(a), 1) + self.assertEqual(a["c"], "d") + + +class TestMiscUtils(FrappeTestCase): + def test_get_file_timestamp(self): + self.assertIsInstance(get_file_timestamp(__file__), str) + + def test_execute_in_shell(self): + err, out = execute_in_shell("ls") + self.assertIn("apps", cstr(out)) + + def test_get_all_sites(self): + self.assertIn(frappe.local.site, get_sites()) + + def test_get_site_info(self): + info = get_site_info() + + installed_apps = [app["app_name"] for app in info["installed_apps"]] + self.assertIn("frappe", installed_apps) + self.assertGreaterEqual(len(info["users"]), 1) + + def test_safe_json_load(self): + self.assertEqual(safe_json_loads("{}"), {}) + self.assertEqual(safe_json_loads("{ /}"), "{ /}") + self.assertEqual(safe_json_loads("12"), 12) # this is a quirk diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 9f25b33266..f84ad5a0da 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -9,10 +9,18 @@ import os import re import sys import traceback -from collections.abc import Generator, Iterable, MutableMapping, MutableSequence, Sequence +from collections.abc import ( + Container, + Generator, + Iterable, + MutableMapping, + MutableSequence, + Sequence, +) from email.header import decode_header, make_header from email.utils import formataddr, parseaddr from gzip import GzipFile +from typing import Any, Literal from urllib.parse import quote, urlparse from redis.exceptions import ConnectionError @@ -85,13 +93,10 @@ def get_formatted_email(user, mail=None): def extract_email_id(email): """fetch only the email part of the Email Address""" - email_id = parse_addr(email)[1] - if email_id and isinstance(email_id, str) and not isinstance(email_id, str): - email_id = email_id.decode("utf-8", "ignore") - return email_id + return cstr(parse_addr(email)[1]) -def validate_phone_number_with_country_code(phone_number, fieldname): +def validate_phone_number_with_country_code(phone_number: str, fieldname: str) -> None: from phonenumbers import NumberParseException, is_valid_number, parse from frappe import _ @@ -138,6 +143,8 @@ def validate_name(name, throw=False): """Returns True if the name is valid valid names may have unicode and ascii characters, dash, quotes, numbers anything else is considered invalid + + Note: "Name" here is name of a person, not the primary key in Frappe doctypes. """ if not name: return False @@ -218,7 +225,11 @@ def split_emails(txt): return email_list -def validate_url(txt, throw=False, valid_schemes=None): +def validate_url( + txt: str, + throw: bool = False, + valid_schemes: str | Container[str] | None = None, +) -> bool: """ Checks whether `txt` has a valid URL string @@ -244,7 +255,7 @@ def validate_url(txt, throw=False, valid_schemes=None): return is_valid -def random_string(length): +def random_string(length: int) -> str: """generate a random string""" import string from random import choice @@ -252,7 +263,7 @@ def random_string(length): return "".join(choice(string.ascii_letters + string.digits) for i in range(length)) -def has_gravatar(email): +def has_gravatar(email: str) -> str: """Returns gravatar url if user has set an avatar at gravatar.com""" import requests @@ -261,9 +272,7 @@ def has_gravatar(email): # since querying gravatar for every item will be slow return "" - hexdigest = hashlib.md5(frappe.as_unicode(email).encode("utf-8")).hexdigest() - - gravatar_url = f"https://secure.gravatar.com/avatar/{hexdigest}?d=404&s=200" + gravatar_url = get_gravatar_url(email, "404") try: res = requests.get(gravatar_url) if res.status_code == 200: @@ -274,13 +283,12 @@ def has_gravatar(email): return "" -def get_gravatar_url(email): - return "https://secure.gravatar.com/avatar/{hash}?d=mm&s=200".format( - hash=hashlib.md5(email.encode("utf-8")).hexdigest() - ) +def get_gravatar_url(email: str, default: Literal["mm", "404"] = "mm") -> str: + hexdigest = hashlib.md5(frappe.as_unicode(email).encode("utf-8")).hexdigest() + return f"https://secure.gravatar.com/avatar/{hexdigest}?d={default}&s=200" -def get_gravatar(email): +def get_gravatar(email: str) -> str: from frappe.utils.identicon import Identicon return has_gravatar(email) or Identicon(email).base64() @@ -310,7 +318,7 @@ def log(event, details): frappe.logger(event).info(details) -def dict_to_str(args, sep="&"): +def dict_to_str(args: dict[str, Any], sep: str = "&") -> str: """ Converts a dictionary to URL """ @@ -346,18 +354,13 @@ def set_default(key, val): return frappe.db.set_default(key, val) -def remove_blanks(d): +def remove_blanks(d: dict) -> dict: """ - Returns d with empty ('' or None) values stripped + Returns d with empty ('' or None) values stripped. Mutates inplace. """ - empty_keys = [] - for key in d: - if d[key] == "" or d[key] is None: - # del d[key] raises runtime exception, using a workaround - empty_keys.append(key) - for key in empty_keys: - del d[key] - + for k, v in tuple(d.items()): + if v == "" or v == None: + del d[k] return d @@ -417,21 +420,20 @@ def execute_in_shell(cmd, verbose=0, low_priority=False): import tempfile from subprocess import Popen - with tempfile.TemporaryFile() as stdout: - with tempfile.TemporaryFile() as stderr: - kwargs = {"shell": True, "stdout": stdout, "stderr": stderr} + with (tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr): + kwargs = {"shell": True, "stdout": stdout, "stderr": stderr} - if low_priority: - kwargs["preexec_fn"] = lambda: os.nice(10) + if low_priority: + kwargs["preexec_fn"] = lambda: os.nice(10) - p = Popen(cmd, **kwargs) - p.wait() + p = Popen(cmd, **kwargs) + p.wait() - stdout.seek(0) - out = stdout.read() + stdout.seek(0) + out = stdout.read() - stderr.seek(0) - err = stderr.read() + stderr.seek(0) + err = stderr.read() if verbose: if err: @@ -563,7 +565,7 @@ def update_progress_bar(txt, i, l, absolute=False): sys.stdout.flush() return - if not getattr(frappe.local, "request", None) or is_cli(): + if not getattr(frappe.local, "request", None) or is_cli(): # pragma: no cover lt = len(txt) try: col = 40 if os.get_terminal_size().columns > 80 else 20 @@ -746,7 +748,7 @@ def get_site_info(): kwargs = { "fields": ["user", "creation", "full_name"], - "filters": {"Operation": "Login", "Status": "Success"}, + "filters": {"operation": "Login", "status": "Success"}, "limit": "10", } diff --git a/frappe/utils/data.py b/frappe/utils/data.py index bdef60b930..6d4a96ce5f 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -483,13 +483,11 @@ def get_quarter_ending(date): return date -def get_year_ending(date): +def get_year_ending(date) -> datetime.date: """returns year ending of the given date""" date = getdate(date) - # first day of next year (note year starts from 1) - date = add_to_date(f"{date.year}-01-01", months=12) - # last day of this month - return add_to_date(date, days=-1) + next_year_start = datetime.date(date.year + 1, 1, 1) + return add_to_date(next_year_start, days=-1) def get_time(time_str: str) -> datetime.time: @@ -724,60 +722,77 @@ def get_weekday(datetime: datetime.datetime | None = None) -> str: return weekdays[datetime.weekday()] -def get_timespan_date_range(timespan: str) -> tuple[datetime.datetime, datetime.datetime]: - today = nowdate() - date_range_map = { - "last week": lambda: ( - get_first_day_of_week(add_to_date(today, days=-7)), - get_last_day_of_week(add_to_date(today, days=-7)), - ), - "last month": lambda: ( - get_first_day(add_to_date(today, months=-1)), - get_last_day(add_to_date(today, months=-1)), - ), - "last quarter": lambda: ( - get_quarter_start(add_to_date(today, months=-3)), - get_quarter_ending(add_to_date(today, months=-3)), - ), - "last 6 months": lambda: ( - get_quarter_start(add_to_date(today, months=-6)), - get_quarter_ending(add_to_date(today, months=-3)), - ), - "last year": lambda: ( - get_year_start(add_to_date(today, years=-1)), - get_year_ending(add_to_date(today, years=-1)), - ), - "yesterday": lambda: (add_to_date(today, days=-1),) * 2, - "today": lambda: (today, today), - "tomorrow": lambda: (add_to_date(today, days=1),) * 2, - "this week": lambda: (get_first_day_of_week(today), get_last_day_of_week(today)), - "this month": lambda: (get_first_day(today), get_last_day(today)), - "this quarter": lambda: (get_quarter_start(today), get_quarter_ending(today)), - "this year": lambda: (get_year_start(today), get_year_ending(today)), - "next week": lambda: ( - get_first_day_of_week(add_to_date(today, days=7)), - get_last_day_of_week(add_to_date(today, days=7)), - ), - "next month": lambda: ( - get_first_day(add_to_date(today, months=1)), - get_last_day(add_to_date(today, months=1)), - ), - "next quarter": lambda: ( - get_quarter_start(add_to_date(today, months=3)), - get_quarter_ending(add_to_date(today, months=3)), - ), - "next 6 months": lambda: ( - get_quarter_start(add_to_date(today, months=3)), - get_quarter_ending(add_to_date(today, months=6)), - ), - "next year": lambda: ( - get_year_start(add_to_date(today, years=1)), - get_year_ending(add_to_date(today, years=1)), - ), - } +def get_timespan_date_range(timespan: str) -> tuple[datetime.datetime, datetime.datetime] | None: + today = getdate() - if timespan in date_range_map: - return date_range_map[timespan]() + match timespan: + case "last week": + return ( + get_first_day_of_week(add_to_date(today, days=-7)), + get_last_day_of_week(add_to_date(today, days=-7)), + ) + case "last month": + return ( + get_first_day(add_to_date(today, months=-1)), + get_last_day(add_to_date(today, months=-1)), + ) + case "last quarter": + return ( + get_quarter_start(add_to_date(today, months=-3)), + get_quarter_ending(add_to_date(today, months=-3)), + ) + case "last 6 months": + return ( + get_quarter_start(add_to_date(today, months=-6)), + get_quarter_ending(add_to_date(today, months=-3)), + ) + case "last year": + return ( + get_year_start(add_to_date(today, years=-1)), + get_year_ending(add_to_date(today, years=-1)), + ) + + case "yesterday": + return (add_to_date(today, days=-1),) * 2 + case "today": + return (today, today) + case "tomorrow": + return (add_to_date(today, days=1),) * 2 + case "this week": + return (get_first_day_of_week(today), get_last_day_of_week(today)) + case "this month": + return (get_first_day(today), get_last_day(today)) + case "this quarter": + return (get_quarter_start(today), get_quarter_ending(today)) + case "this year": + return (get_year_start(today), get_year_ending(today)) + case "next week": + return ( + get_first_day_of_week(add_to_date(today, days=7)), + get_last_day_of_week(add_to_date(today, days=7)), + ) + case "next month": + return ( + get_first_day(add_to_date(today, months=1)), + get_last_day(add_to_date(today, months=1)), + ) + case "next quarter": + return ( + get_quarter_start(add_to_date(today, months=3)), + get_quarter_ending(add_to_date(today, months=3)), + ) + case "next 6 months": + return ( + get_quarter_start(add_to_date(today, months=3)), + get_quarter_ending(add_to_date(today, months=6)), + ) + case "next year": + return ( + get_year_start(add_to_date(today, years=1)), + get_year_ending(add_to_date(today, years=1)), + ) + case _: + return def global_date_format(date, format="long"): @@ -1460,15 +1475,15 @@ def pretty_date(iso_datetime: datetime.datetime | str) -> str: elif dt_diff_days < 12: return _("1 week ago") elif dt_diff_days < 31.0: - return _("{0} weeks ago").format(cint(math.ceil(dt_diff_days / 7.0))) + return _("{0} weeks ago").format(dt_diff_days // 7) elif dt_diff_days < 46: return _("1 month ago") elif dt_diff_days < 365.0: - return _("{0} months ago").format(cint(math.ceil(dt_diff_days / 30.0))) + return _("{0} months ago").format(dt_diff_days // 30) elif dt_diff_days < 550.0: return _("1 year ago") else: - return f"{cint(math.floor(dt_diff_days / 365.0))} years ago" + return _("{0} years ago").format(dt_diff_days // 365) def comma_or(some_list, add_quotes=True): @@ -1658,14 +1673,14 @@ operator_map = { "in": lambda a, b: operator.contains(b, a), "not in": lambda a, b: not operator.contains(b, a), # comparison operators - "=": lambda a, b: operator.eq(a, b), - "!=": lambda a, b: operator.ne(a, b), - ">": lambda a, b: operator.gt(a, b), - "<": lambda a, b: operator.lt(a, b), - ">=": lambda a, b: operator.ge(a, b), - "<=": lambda a, b: operator.le(a, b), - "not None": lambda a, b: a and True or False, - "None": lambda a, b: (not a) and True or False, + "=": operator.eq, + "!=": operator.ne, + ">": operator.gt, + "<": operator.lt, + ">=": operator.ge, + "<=": operator.le, + "not None": lambda a, b: a is not None, + "None": lambda a, b: a is None, } @@ -1687,13 +1702,12 @@ def evaluate_filters(doc, filters: dict | list | tuple): def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None): - ret = False if fieldtype: val2 = cast(fieldtype, val2) if condition in operator_map: - ret = operator_map[condition](val1, val2) + return operator_map[condition](val1, val2) - return ret + return False def get_filter(doctype: str, f: dict | list | tuple, filters_config=None) -> "frappe._dict":