diff --git a/.github/frappe-framework-logo-dark.svg b/.github/frappe-framework-logo-dark.svg new file mode 100644 index 0000000000..7c43cea2db --- /dev/null +++ b/.github/frappe-framework-logo-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 72712c3d5f..e990228185 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,7 +14,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4 + - uses: dessant/lock-threads@v5 with: github-token: ${{ github.token }} issue-inactive-days: 14 diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 97000bff15..351958de07 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -85,7 +85,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.11' + python-version: '3.12' - name: Check for valid Python & Merge Conflicts run: | @@ -132,6 +132,7 @@ jobs: env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} AFTER: ${{ env.GITHUB_EVENT_PATH.after }} + FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} TYPE: server DB: ${{ matrix.db }} @@ -142,6 +143,7 @@ jobs: SITE: test_site CI_BUILD_ID: ${{ github.run_id }} BUILD_NUMBER: ${{ matrix.container }} + FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} TOTAL_BUILDS: 2 COVERAGE_RCFILE: /home/runner/frappe-bench/apps/frappe/.coveragerc diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 00e370e4ed..cbc0f74470 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -67,7 +67,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.11' + python-version: '3.12' - name: Check for valid Python & Merge Conflicts run: | @@ -120,6 +120,7 @@ jobs: env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} AFTER: ${{ env.GITHUB_EVENT_PATH.after }} + FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} TYPE: ui DB: mariadb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7443bde6a8..7161bb90ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: rev: v2.7.1 hooks: - id: prettier - types_or: [javascript] + types_or: [javascript, vue, scss] # Ignore any files that might contain jinja / bundles exclude: | (?x)^( @@ -44,7 +44,8 @@ repos: .*boilerplate.*| frappe/www/website_script.js| frappe/templates/includes/.*| - frappe/public/js/lib/.* + frappe/public/js/lib/.*| + frappe/website/doctype/website_theme/website_theme_template.scss )$ diff --git a/README.md b/README.md index aefa0db1d2..9a1cee5534 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@
-

-
- - - -

+ + + +

a web framework with "batteries included"

@@ -71,12 +69,12 @@ Full-stack web application framework that uses Python and MariaDB on the server 1. [Code of Conduct](CODE_OF_CONDUCT.md) 1. [Contribution Guidelines](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines) 1. [Security Policy](SECURITY.md) -1. [Translations](https://translate.erpnext.com) ## Resources 1. [frappeframework.com](https://frappeframework.com) - Official documentation of the Frappe Framework. 1. [frappe.school](https://frappe.school) - Pick from the various courses by the maintainers or from the community. +1. [buildwithhussain.dev](https://buildwithhussain.dev) - Watch Frappe Framework being used in the wild to build world-class web apps. ## License This repository has been released under the [MIT License](LICENSE). diff --git a/cypress/fixtures/form_builder_doctype.js b/cypress/fixtures/form_builder_doctype.js index 08b598f82a..995971bed4 100644 --- a/cypress/fixtures/form_builder_doctype.js +++ b/cypress/fixtures/form_builder_doctype.js @@ -10,6 +10,12 @@ export default { fieldtype: "Data", label: "Data 3", }, + { + fieldname: "gender", + fieldtype: "Link", + label: "Gender", + options: "Gender", + }, { fieldname: "tab", fieldtype: "Tab Break", diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index 03ef96783a..dff04a5693 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -2,7 +2,11 @@ context("Awesome Bar", () => { before(() => { cy.visit("/login"); cy.login(); - cy.visit("/app/website"); + cy.visit("/app/todo"); // Make sure ToDo filters are cleared. + cy.clear_filters(); + cy.visit("/app/blog-post"); // Make sure Blog Post filters are cleared. + cy.clear_filters(); + cy.visit("/app/website"); // Go to some other page. }); beforeEach(() => { @@ -11,36 +15,61 @@ context("Awesome Bar", () => { cy.get("@awesome_bar").type("{selectall}"); }); + after(() => { + cy.visit("/app/todo"); // Make sure we're not bleeding any filters to the next spec. + cy.clear_filters(); + }); + it("navigates to doctype list", () => { cy.get("@awesome_bar").type("todo"); - cy.wait(100); + cy.wait(100); // Wait a bit before hitting enter. cy.get(".awesomplete").findByRole("listbox").should("be.visible"); cy.get("@awesome_bar").type("{enter}"); cy.get(".title-text").should("contain", "To Do"); cy.location("pathname").should("eq", "/app/todo"); }); - it("find text in doctype list", () => { + it("finds text in doctype list", () => { cy.get("@awesome_bar").type("test in todo"); - cy.wait(100); + cy.wait(150); // Wait a bit before hitting enter. cy.get("@awesome_bar").type("{enter}"); cy.get(".title-text").should("contain", "To Do"); - cy.wait(200); - const name_filter = cy.get('[data-original-title="ID"] > input'); - name_filter.should("have.value", "%test%"); - cy.clear_filters(); + cy.wait(200); // Wait a bit longer before checking the filter. + cy.get('[data-original-title="ID"] > input').should("have.value", "%test%"); + }); + + it("filter preserved, now finds something else", () => { + cy.visit("/app/todo"); + cy.get(".title-text").should("contain", "To Do"); + cy.wait(200); // Wait a bit longer before checking the filter. + cy.get('[data-original-title="ID"] > input').as("filter"); + cy.get("@filter").should("have.value", "%test%"); + cy.get("@awesome_bar").type("anothertest in todo"); + cy.wait(200); // Wait a bit longer before hitting enter. + cy.get("@awesome_bar").type("{enter}"); + cy.wait(200); // Wait a bit longer before checking the filter. + cy.get("@filter").should("have.value", "%anothertest%"); + }); + + it("navigates to another doctype, filter not bleeding", () => { + cy.get("@awesome_bar").type("blog post"); + cy.wait(150); // Wait a bit before hitting enter. + cy.get("@awesome_bar").type("{enter}"); + cy.get(".title-text").should("contain", "Blog Post"); + cy.wait(200); // Wait a bit longer before checking the filter. + cy.location("search").should("be.empty"); }); it("navigates to new form", () => { cy.get("@awesome_bar").type("new blog post"); - cy.wait(100); + cy.wait(150); // Wait a bit before hitting enter cy.get("@awesome_bar").type("{enter}"); cy.get(".title-text:visible").should("have.text", "New Blog Post"); }); it("calculates math expressions", () => { cy.get("@awesome_bar").type("55 + 32"); - cy.wait(100); + cy.wait(150); // Wait a bit before hitting enter cy.get("@awesome_bar").type("{downarrow}{enter}"); cy.get(".modal-title").should("contain", "Result"); cy.get(".msgprint").should("contain", "55 + 32 = 87"); diff --git a/cypress/integration/control_float.js b/cypress/integration/control_float.js index e7d6b398f1..08b71eb870 100644 --- a/cypress/integration/control_float.js +++ b/cypress/integration/control_float.js @@ -32,10 +32,13 @@ context("Control Float", () => { cy.wait(200); cy.fill_field("float_number", d.input, "Float").blur(); cy.get_field("float_number", "Float").should("have.value", d.blur_expected); - + cy.wait(100); cy.get_field("float_number", "Float").focus(); + cy.wait(100); cy.get_field("float_number", "Float").blur(); + cy.wait(100); cy.get_field("float_number", "Float").focus(); + cy.wait(100); cy.get_field("float_number", "Float").should("have.value", d.focus_expected); }); }); @@ -49,17 +52,17 @@ context("Control Float", () => { { input: "364.87,334", blur_expected: "36.487,334", - focus_expected: "36487.334", + focus_expected: "36.487,334", }, { - input: "36487,334", - blur_expected: "36.487,334", - focus_expected: "36487.334", + input: "36487,335", + blur_expected: "36.487,335", + focus_expected: "36.487,335", }, { - input: "100", - blur_expected: "100,000", - focus_expected: "100", + input: "2*(2+47)+1,5+1", + blur_expected: "100,500", + focus_expected: "100,500", }, ], }, @@ -67,19 +70,19 @@ context("Control Float", () => { number_format: "#,###.##", values: [ { - input: "364,87.334", - blur_expected: "36,487.334", - focus_expected: "36487.334", + input: "464,87.334", + blur_expected: "46,487.334", + focus_expected: "46,487.334", }, { - input: "36487.334", - blur_expected: "36,487.334", - focus_expected: "36487.334", + input: "46487.335", + blur_expected: "46,487.335", + focus_expected: "46,487.335", }, { - input: "100", - blur_expected: "100.000", - focus_expected: "100", + input: "3*(2+47)+1.5+1", + blur_expected: "149.500", + focus_expected: "149.500", }, ], }, @@ -90,13 +93,13 @@ context("Control Float", () => { { input: "12.345", blur_expected: "12.345,000", - focus_expected: "12345", + focus_expected: "12.345,000", }, { // parseFloat would reduce 12,340 to 12,34 if this string was ever to be parsed input: "12.340", blur_expected: "12.340,000", - focus_expected: "12340", + focus_expected: "12.340,000", }, ], }, diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 0746f4460e..7f8123645d 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -152,7 +152,7 @@ context("Control Link", () => { cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); cy.wait("@search_link"); - cy.get("@input").type("todo for link"); + cy.get("@input").type("todo for link", { delay: 200 }); cy.wait("@search_link"); cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible"); cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 }); @@ -260,7 +260,7 @@ context("Control Link", () => { cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); cy.wait("@search_link"); - cy.get("@input").type("Sonstiges", { delay: 100 }); + cy.get("@input").type("Sonstiges", { delay: 200 }); cy.wait("@search_link"); cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible"); cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 }); @@ -291,7 +291,7 @@ context("Control Link", () => { cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); cy.wait("@search_link"); - cy.get("@input").type("Non-Conforming", { delay: 100 }); + cy.get("@input").type("Non-Conforming", { delay: 200 }); cy.wait("@search_link"); cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible"); cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 }); diff --git a/cypress/integration/customize_form.js b/cypress/integration/customize_form.js index 07bf139da1..bd8ca1d73b 100644 --- a/cypress/integration/customize_form.js +++ b/cypress/integration/customize_form.js @@ -5,6 +5,8 @@ context("Customize Form", () => { }); it("Changing to naming rule should update autoname", () => { cy.fill_field("doc_type", "ToDo", "Link").blur(); + cy.wait(2000); + cy.findByRole("tab", { name: "Details" }).click(); cy.click_form_section("Naming"); const naming_rule_default_autoname_map = { "Set by user": "prompt", diff --git a/cypress/integration/form.js b/cypress/integration/form.js index facc73f536..73df3b1ab0 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -116,50 +116,6 @@ context("Form", () => { cy.get_field("location").should("have.value", "Bermuda"); }); - it("let user undo/redo field value changes", { scrollBehavior: false }, () => { - const undo = () => cy.get("body").type("{esc}").type("{ctrl+z}").wait(500); - const redo = () => cy.get("body").type("{esc}").type("{ctrl+y}").wait(500); - - cy.new_form("User"); - - jump_to_field("Email"); - type_value("admin@example.com"); - - jump_to_field("Username"); - type_value("admin42"); - - jump_to_field("Send Welcome Email"); - cy.focused().uncheck(); - - // make a mistake - jump_to_field("Username"); - type_value("admin24"); - - // undo behaviour - undo(); - cy.get_field("username").should("have.value", "admin42"); - - // redo behaviour - redo(); - cy.get_field("username").should("have.value", "admin24"); - - // undo everything & redo everything, ensure same values at the end - undo(); - undo(); - undo(); - undo(); - redo(); - redo(); - redo(); - redo(); - - cy.compare_document({ - username: "admin24", - email: "admin@example.com", - 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() diff --git a/cypress/integration/form_builder.js b/cypress/integration/form_builder.js index 2a5865b904..4f7a1c5b1f 100644 --- a/cypress/integration/form_builder.js +++ b/cypress/integration/form_builder.js @@ -35,6 +35,40 @@ context("Form Builder", () => { cy.get(".title-area .indicator-pill.orange").should("have.text", "Not Saved"); }); + it("Check if Filters are applied to the link field", () => { + // Visit the Form Builder + cy.visit(`/app/doctype/${doctype_name}`); + cy.findByRole("tab", { name: "Form" }).click(); + + cy.get("[data-fieldname='gender']").click(); + + // click on filter action button + cy.get('[data-fieldname="gender"] .field-actions button:first').click(); + + // add filter + cy.get(".modal-body .clear-filters").click(); + cy.get(".modal-body .filter-action-buttons .add-filter").click(); + cy.wait(100); + cy.get(".modal-body .filter-box .list_filter .filter-field .link-field input").type( + "Male" + ); + cy.get(".btn-modal-primary").click(); + + // Save the document + cy.click_doc_primary_button("Save"); + + // Open a new Form + cy.new_form(doctype_name); + // Click on the "salutation" field + cy.get_field("gender").clear().click(); + + cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); + cy.wait("@search_link").then((data) => { + expect(data.response.body.message.length).to.eq(1); + expect(data.response.body.message[0].value).to.eq("Male"); + }); + }); + it("Add empty section and save", () => { cy.visit(`/app/doctype/${doctype_name}`); cy.findByRole("tab", { name: "Form" }).click(); @@ -43,7 +77,8 @@ context("Form Builder", () => { // add new section cy.get(first_section).click(15, 10); - cy.get(first_section).find(".section-actions button:first").click(); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item:first").click(); // save cy.click_doc_primary_button("Save"); @@ -184,12 +219,14 @@ context("Form Builder", () => { // add new section cy.get(first_section).click(15, 10); - cy.get(first_section).find(".section-actions button:first").click(); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item:first").click(); cy.get(".tab-content.active .form-section-container").should("have.length", 2); // add new column - cy.get(first_section).find(".column:first").click(15, 10); - cy.get(first_section).find(".column:first .column-actions button:first").click(); + cy.get(first_section).click(15, 10); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item:last").click(); cy.get(first_section).find(".column").should("have.length", 2); }); @@ -197,13 +234,15 @@ context("Form Builder", () => { let first_section = ".tab-content.active .form-section-container:first"; // remove column - cy.get(first_section).find(".column:first").click(15, 10); - cy.get(first_section).find(".column:first .column-actions button:last").click(); + cy.get(first_section).click(15, 10); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item:last").click(); cy.get(first_section).find(".column").should("have.length", 1); // remove section cy.get(first_section).click(15, 10); - cy.get(first_section).find(".section-actions button:last").click(); + cy.get(first_section).find(".dropdown-btn:first").click(); + cy.get(".dropdown-options:visible .dropdown-item").eq(1).click(); cy.get(".tab-content.active .form-section-container").should("have.length", 1); // remove tab diff --git a/cypress/integration/grid_configuration.js b/cypress/integration/grid_configuration.js index 3d49ed1503..b94028a42d 100644 --- a/cypress/integration/grid_configuration.js +++ b/cypress/integration/grid_configuration.js @@ -4,7 +4,7 @@ context("Grid Configuration", () => { cy.visit("/app/doctype/User"); }); it("Set user wise grid settings", () => { - cy.findByRole("tab", { name: "Form" }).click(); + cy.findByRole("tab", { name: "Settings" }).click(); cy.get('.form-section[data-fieldname="fields_section"]').click(); cy.wait(100); cy.get('.frappe-control[data-fieldname="fields"]').as("table"); diff --git a/cypress/integration/multi_select_dialog.js b/cypress/integration/multi_select_dialog.js index 1be56d3b3d..7b30ad8c85 100644 --- a/cypress/integration/multi_select_dialog.js +++ b/cypress/integration/multi_select_dialog.js @@ -76,6 +76,11 @@ context("MultiSelectDialog", () => { }); it("tests more button", () => { + cy.get_open_dialog() + .get(`.frappe-control[data-fieldname="search_term"]`) + .find('input[data-fieldname="search_term"]') + .should("exist") + .type("Test", { delay: 200 }); cy.get_open_dialog() .get(`.frappe-control[data-fieldname="more_child_btn"]`) .should("exist") diff --git a/cypress/integration/permissions.js b/cypress/integration/permissions.js index 4e371cc17f..9c21a7914d 100644 --- a/cypress/integration/permissions.js +++ b/cypress/integration/permissions.js @@ -1,4 +1,4 @@ -context("Permissions API", () => { +context.skip("Permissions API", () => { before(() => { cy.visit("/login"); cy.remove_role("frappe@example.com", "System Manager"); diff --git a/cypress/integration/theme_switcher_dialog.js b/cypress/integration/theme_switcher_dialog.js deleted file mode 100644 index 53c3323a6d..0000000000 --- a/cypress/integration/theme_switcher_dialog.js +++ /dev/null @@ -1,37 +0,0 @@ -context("Theme Switcher Shortcut", () => { - before(() => { - cy.login(); - cy.visit("/app"); - }); - beforeEach(() => { - cy.reload(); - }); - it("Check Toggle", () => { - cy.open_theme_dialog(); - cy.get(".modal-backdrop").should("exist"); - cy.intercept("POST", "/api/method/frappe.core.doctype.user.user.switch_theme").as( - "set_theme" - ); - cy.findByText("Timeless Night").click(); - cy.wait("@set_theme"); - cy.close_theme("{ctrl+shift+g}"); - cy.get(".modal-backdrop").should("not.exist"); - }); - it("Check Enter", () => { - cy.open_theme_dialog(); - cy.intercept("POST", "/api/method/frappe.core.doctype.user.user.switch_theme").as( - "set_theme" - ); - cy.findByText("Frappe Light").click(); - cy.wait("@set_theme"); - cy.close_theme("{enter}"); - cy.get(".modal-backdrop").should("not.exist"); - }); -}); - -Cypress.Commands.add("open_theme_dialog", () => { - cy.get("body").type("{ctrl+shift+g}"); -}); -Cypress.Commands.add("close_theme", (shortcut_keys) => { - cy.get(".modal-header").type(shortcut_keys); -}); diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js deleted file mode 100644 index f12974d271..0000000000 --- a/cypress/integration/timeline.js +++ /dev/null @@ -1,91 +0,0 @@ -import custom_submittable_doctype from "../fixtures/custom_submittable_doctype"; - -context("Timeline", () => { - before(() => { - cy.visit("/login"); - cy.login(); - }); - - it("Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo", () => { - //Adding new ToDo - cy.new_form("ToDo"); - cy.get('[data-fieldname="description"] .ql-editor.ql-blank') - .type("Test ToDo", { force: true }) - .wait(200); - cy.get(".page-head .page-actions").findByRole("button", { name: "Save" }).click(); - - cy.go_to_list("ToDo"); - cy.clear_filters(); - cy.click_listview_row_item(0); - - //To check if the comment box is initially empty and tying some text into it - cy.get('[data-fieldname="comment"] .ql-editor') - .should("contain", "") - .type("Testing Timeline"); - - //Adding new comment - cy.get(".comment-box").findByRole("button", { name: "Comment" }).click(); - - //To check if the commented text is visible in the timeline content - cy.get(".timeline-content").should("contain", "Testing Timeline"); - - //Editing comment - cy.click_timeline_action_btn("Edit"); - cy.get('.timeline-content [data-fieldname="comment"] .ql-editor').first().type(" 123"); - cy.click_timeline_action_btn("Save"); - - //To check if the edited comment text is visible in timeline content - cy.get(".timeline-content").should("contain", "Testing Timeline 123"); - - //Discarding comment - cy.click_timeline_action_btn("Edit"); - cy.click_timeline_action_btn("Dismiss"); - - //To check if after discarding the timeline content is same as previous - cy.get(".timeline-content").should("contain", "Testing Timeline 123"); - - //Deleting the added comment - cy.get(".timeline-message-box .more-actions > .action-btn").click(); //Menu button in timeline item - cy.get(".timeline-message-box .more-actions .dropdown-item") - .contains("Delete") - .click({ force: true }); - cy.get_open_dialog().findByRole("button", { name: "Yes" }).click({ force: true }); - - cy.get(".timeline-content").should("not.contain", "Testing Timeline 123"); - }); - - it("Timeline should have submit and cancel activity information", () => { - cy.visit("/app/doctype"); - - //Creating custom doctype - cy.insert_doc("DocType", custom_submittable_doctype, true); - - cy.visit("/app/custom-submittable-doctype"); - cy.click_listview_primary_button("Add Custom Submittable DocType"); - - //Adding a new entry for the created custom doctype - cy.fill_field("title", "Test"); - cy.click_modal_primary_button("Save"); - cy.click_modal_primary_button("Submit"); - - cy.visit("/app/custom-submittable-doctype"); - cy.click_listview_row_item(0); - - //To check if the submission of the documemt is visible in the timeline content - cy.get(".timeline-content").should("contain", "You submitted this document"); - cy.get('[id="page-Custom Submittable DocType"] .page-actions') - .findByRole("button", { name: "Cancel" }) - .click(); - cy.get_open_dialog().findByRole("button", { name: "Yes" }).click(); - - //To check if the cancellation of the documemt is visible in the timeline content - cy.get(".timeline-content").should("contain", "You cancelled this document"); - - //Deleting the document - cy.visit("/app/custom-submittable-doctype"); - cy.select_listview_row_checkbox(0); - cy.get(".page-actions").findByRole("button", { name: "Actions" }).click(); - cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click(); - cy.click_modal_primary_button("Yes"); - }); -}); diff --git a/cypress/integration/web_form.js b/cypress/integration/web_form.js index f6268b69cd..9db8b09ada 100644 --- a/cypress/integration/web_form.js +++ b/cypress/integration/web_form.js @@ -156,6 +156,7 @@ context("Web Form", () => { cy.findByRole("tab", { name: "Customization" }).click(); cy.fill_field("breadcrumbs", '[{"label": _("Notes"), "route":"note"}]', "Code"); + cy.wait(2000); cy.get(".form-tabs .nav-item .nav-link").contains("Customization").click(); cy.save(); diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js index d52417b234..1499c8772e 100644 --- a/cypress/integration/workspace.js +++ b/cypress/integration/workspace.js @@ -20,7 +20,6 @@ context("Workspace 2.0", () => { cy.get(".codex-editor__redactor .ce-block"); cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); cy.fill_field("title", "Test Private Page", "Data"); - cy.fill_field("icon", "edit", "Icon"); cy.get_open_dialog().find(".modal-header").click(); cy.get_open_dialog().find(".btn-primary").click(); @@ -52,7 +51,6 @@ context("Workspace 2.0", () => { cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); cy.fill_field("title", "Test Child Page", "Data"); cy.fill_field("parent", "Test Private Page", "Select"); - cy.fill_field("icon", "edit", "Icon"); cy.get_open_dialog().find(".modal-header").click(); cy.get_open_dialog().find(".btn-primary").click(); diff --git a/cypress/integration/workspace_blocks.js b/cypress/integration/workspace_blocks.js index 3b75ffb8c1..7744bfd4da 100644 --- a/cypress/integration/workspace_blocks.js +++ b/cypress/integration/workspace_blocks.js @@ -20,7 +20,6 @@ context("Workspace Blocks", () => { cy.get(".codex-editor__redactor .ce-block"); cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); cy.fill_field("title", "Test Block Page", "Data"); - cy.fill_field("icon", "edit", "Icon"); cy.get_open_dialog().find(".modal-header").click(); cy.get_open_dialog().find(".btn-primary").click(); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index a2d0eb2698..50d86d42b1 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -37,22 +37,16 @@ Cypress.Commands.add("login", (email, password) => { // cy.session clears all localStorage on new login, so we need to retain the last route const session_last_route = window.localStorage.getItem("session_last_route"); return cy - .session( - [email, password] || "", - () => { - return cy.request({ - url: "/api/method/login", - method: "POST", - body: { - usr: email, - pwd: password, - }, - }); - }, - { - cacheAcrossSpecs: true, - } - ) + .session([email, password] || "", () => { + return cy.request({ + url: "/api/method/login", + method: "POST", + body: { + usr: email, + pwd: password, + }, + }); + }) .then(() => { if (session_last_route) { window.localStorage.setItem("session_last_route", session_last_route); diff --git a/frappe/__init__.py b/frappe/__init__.py index 567c4cc439..a43ecdb2ff 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -17,7 +17,6 @@ import inspect import json import os import re -import unicodedata import warnings from collections.abc import Callable from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, overload @@ -43,9 +42,8 @@ from .utils.jinja import ( get_template, render_template, ) -from .utils.lazy_loader import lazy_import -__version__ = "15.0.0-dev" +__version__ = "16.0.0-dev" __title__ = "Frappe Framework" controllers = {} @@ -61,6 +59,32 @@ if _dev_server: warnings.simplefilter("always", DeprecationWarning) warnings.simplefilter("always", PendingDeprecationWarning) +# Always initialize sentry SDK if the DSN is sent +if sentry_dsn := os.getenv("FRAPPE_SENTRY_DSN"): + import sentry_sdk + from sentry_sdk.integrations.argv import ArgvIntegration + from sentry_sdk.integrations.atexit import AtexitIntegration + from sentry_sdk.integrations.dedupe import DedupeIntegration + from sentry_sdk.integrations.excepthook import ExcepthookIntegration + from sentry_sdk.integrations.modules import ModulesIntegration + + from frappe.utils.sentry import before_send + + sentry_sdk.init( + dsn=sentry_dsn, + before_send=before_send, + release=__version__, + auto_enabling_integrations=False, + default_integrations=False, + integrations=[ + AtexitIntegration(), + ExcepthookIntegration(), + DedupeIntegration(), + ModulesIntegration(), + ArgvIntegration(), + ], + ) + class _dict(dict): """dict like object that exposes keys as attributes""" @@ -169,6 +193,7 @@ if TYPE_CHECKING: # pragma: no cover from frappe.database.mariadb.database import MariaDBDatabase from frappe.database.postgres.database import PostgresDatabase + from frappe.email.doctype.email_queue.email_queue import EmailQueue from frappe.model.document import Document from frappe.query_builder.builder import MariaDB, Postgres from frappe.utils.redis_wrapper import RedisWrapper @@ -243,7 +268,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force=False) local.jloader = None local.cache = {} local.form_dict = _dict() - local.preload_assets = {"style": [], "script": []} + local.preload_assets = {"style": [], "script": [], "icons": []} local.session = _dict() local.dev_server = _dev_server local.qb = get_query_builder(local.conf.db_type) @@ -422,7 +447,7 @@ def errprint(msg: str) -> None: :param msg: Message.""" msg = as_unicode(msg) - if not request or (not "cmd" in local.form_dict) or conf.developer_mode: + if not request or ("cmd" not in local.form_dict) or conf.developer_mode: print(msg) error_log.append({"exc": msg}) @@ -433,11 +458,11 @@ def print_sql(enable: bool = True) -> None: def log(msg: str) -> None: - """Add to `debug_log`. + """Add to `debug_log` :param msg: Message.""" if not request: - if conf.get("logging") or False: + if conf.get("logging"): print(repr(msg)) debug_log.append(as_unicode(msg)) @@ -454,6 +479,8 @@ def msgprint( primary_action: str = None, is_minimizable: bool = False, wide: bool = False, + *, + realtime=False, ) -> None: """Print a message to the user (via HTTP response). Messages are sent in the `__server_messages` property in the @@ -467,6 +494,7 @@ def msgprint( :param primary_action: [optional] Bind a primary server/client side action. :param is_minimizable: [optional] Allow users to minimize the modal :param wide: [optional] Show wide modal + :param realtime: Publish message immediately using websocket. """ import inspect import sys @@ -533,7 +561,10 @@ def msgprint( if wide: out.wide = wide - message_log.append(out) + if realtime: + publish_realtime(event="msgprint", message=out) + else: + message_log.append(out) _raise_exception() @@ -665,7 +696,7 @@ def sendmail( print_letterhead=False, with_container=False, email_read_tracker_url=None, -): +) -> Optional["EmailQueue"]: """Send email using user's default **Email Account** or global default **Email Account**. @@ -750,7 +781,7 @@ def sendmail( ) # build email queue and send the email if send_now is True. - builder.process(send_now=now) + return builder.process(send_now=now) whitelisted = [] @@ -1963,7 +1994,7 @@ def get_all(doctype, *args, **kwargs): frappe.get_all("ToDo", fields=["*"], filters = [["modified", ">", "2014-01-01"]]) """ kwargs["ignore_permissions"] = True - if not "limit_page_length" in kwargs: + if "limit_page_length" not in kwargs: kwargs["limit_page_length"] = 0 return get_list(doctype, *args, **kwargs) @@ -2012,7 +2043,7 @@ def as_json(obj: dict | list, indent=1, separators=None, ensure_ascii=True) -> s def are_emails_muted(): - return flags.mute_emails or cint(conf.get("mute_emails") or 0) or False + return flags.mute_emails or cint(conf.get("mute_emails")) def get_test_records(doctype): diff --git a/frappe/app.py b/frappe/app.py index add62c2bbd..5ddabfbfc9 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -22,7 +22,7 @@ import frappe.rate_limiter import frappe.recorder import frappe.utils.response from frappe import _ -from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, validate_auth +from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, validate_auth # noqa from frappe.middlewares import StaticDataMiddleware from frappe.utils import CallbackManager, cint, get_site_name from frappe.utils.data import escape_html @@ -179,9 +179,12 @@ def init_request(request): raise frappe.SessionStopped("Session Stopped") else: frappe.connect(set_admin_as_user=False) + if request.path.startswith("/api/method/upload_file"): + from frappe.core.api.file import get_max_file_size - request.max_content_length = cint(frappe.local.conf.get("max_file_size")) or 10 * 1024 * 1024 - + request.max_content_length = get_max_file_size() + else: + request.max_content_length = cint(frappe.local.conf.get("max_file_size")) or 25 * 1024 * 1024 make_form_dict(request) if request.method != "OPTIONS": @@ -283,11 +286,11 @@ def set_cors_headers(response): response.headers.extend(cors_headers) -def make_form_dict(request): +def make_form_dict(request: Request): import json request_data = request.get_data(as_text=True) - if "application/json" in (request.content_type or "") and request_data: + if request_data and request.is_json: args = json.loads(request_data) else: args = {} @@ -299,9 +302,8 @@ def make_form_dict(request): frappe.local.form_dict = frappe._dict(args) - if "_" in frappe.local.form_dict: - # _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict - frappe.local.form_dict.pop("_") + # _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict + frappe.local.form_dict.pop("_", None) def handle_exception(e): diff --git a/frappe/auth.py b/frappe/auth.py index 4b53e76533..10bac8261a 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -25,6 +25,7 @@ from frappe.website.utils import get_home_page SAFE_HTTP_METHODS = frozenset(("GET", "HEAD", "OPTIONS")) UNSAFE_HTTP_METHODS = frozenset(("POST", "PUT", "DELETE", "PATCH")) +MAX_PASSWORD_SIZE = 512 class HTTPRequest: @@ -96,7 +97,6 @@ class HTTPRequest: class LoginManager: - __slots__ = ("user", "info", "full_name", "user_type", "resume") def __init__(self): @@ -235,6 +235,9 @@ class LoginManager: if not (user and pwd): self.fail(_("Incomplete login details"), user=user) + if len(pwd) > MAX_PASSWORD_SIZE: + self.fail(_("Password size exceeded the maximum allowed size"), user=user) + _raw_user_name = user user = User.find_by_credentials(user, pwd) @@ -305,8 +308,8 @@ class LoginManager: def validate_hour(self): """check if user is logging in during restricted hours""" - login_before = int(frappe.db.get_value("User", self.user, "login_before", ignore=True) or 0) - login_after = int(frappe.db.get_value("User", self.user, "login_after", ignore=True) or 0) + login_before = cint(frappe.db.get_value("User", self.user, "login_before", ignore=True)) + login_after = cint(frappe.db.get_value("User", self.user, "login_after", ignore=True)) if not (login_before or login_after): return @@ -574,13 +577,13 @@ def validate_auth(): validate_oauth(authorization_header) validate_auth_via_api_keys(authorization_header) - # If login via bearer, basic or keypair didn't work then authentication failed and we - # should terminate here. - if frappe.session.user in ("", "Guest"): - raise frappe.AuthenticationError - validate_auth_via_hooks() + # If login via bearer, basic or keypair didn't work then authentication failed and we + # should terminate here. + if len(authorization_header) == 2 and frappe.session.user in ("", "Guest"): + raise frappe.AuthenticationError + def validate_oauth(authorization_header): """ @@ -621,7 +624,7 @@ def validate_oauth(authorization_header): frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user")) frappe.local.form_dict = form_dict except AttributeError: - raise frappe.AuthenticationError + pass def validate_auth_via_api_keys(authorization_header): @@ -647,7 +650,7 @@ def validate_auth_via_api_keys(authorization_header): frappe.InvalidAuthorizationToken, ) except (AttributeError, TypeError, ValueError): - raise frappe.AuthenticationError + pass def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None): diff --git a/frappe/boot.py b/frappe/boot.py index 3bae1cfd2c..b75c861b40 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -122,12 +122,12 @@ def get_letter_heads(): def load_conf_settings(bootinfo): - from frappe import conf + from frappe.core.api.file import get_max_file_size - bootinfo.max_file_size = conf.get("max_file_size") or 10485760 + bootinfo.max_file_size = get_max_file_size() for key in ("developer_mode", "socketio_port", "file_watcher_port"): - if key in conf: - bootinfo[key] = conf.get(key) + if key in frappe.conf: + bootinfo[key] = frappe.conf.get(key) def load_desktop_data(bootinfo): diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index 5d453e3568..cf760cf4f0 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -202,7 +202,7 @@ def start_scheduler(): def start_worker( queue, quiet=False, rq_username=None, rq_password=None, burst=False, strategy=None ): - """Start a backgrond worker""" + """Start a background worker""" from frappe.utils.background_jobs import start_worker start_worker( @@ -225,7 +225,7 @@ def start_worker( @click.option("--quiet", is_flag=True, default=False, help="Hide Log Outputs") @click.option("--burst", is_flag=True, default=False, help="Run Worker in Burst mode.") def start_worker_pool(queue, quiet=False, num_workers=2, burst=False): - """Start a backgrond worker""" + """Start a pool of background workers""" from frappe.utils.background_jobs import start_worker_pool start_worker_pool( diff --git a/frappe/commands/site.py b/frappe/commands/site.py index a833431142..65f896eb24 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -48,6 +48,11 @@ from frappe.exceptions import SiteNotSpecifiedError @click.option( "--set-default", is_flag=True, default=False, help="Set the new site as default site" ) +@click.option( + "--setup-db/--no-setup-db", + default=True, + help="Create user and database in mariadb/postgres; only bootstrap if false", +) def new_site( site, db_root_username=None, @@ -64,6 +69,7 @@ def new_site( db_host=None, db_port=None, set_default=False, + setup_db=True, ): "Create a new site" from frappe.installer import _new_site, extract_sql_from_archive @@ -88,6 +94,7 @@ def new_site( db_type=db_type, db_host=db_host, db_port=db_port, + setup_db=setup_db, ) if set_default: @@ -406,7 +413,6 @@ def _reinstall( verbose=False, ): from frappe.installer import _new_site - from frappe.utils.synchronization import filelock if not yes: click.confirm("This will wipe your database. Are you sure you want to reinstall?", abort=True) @@ -944,9 +950,9 @@ def move(dest_dir, site): site_dump_exists = True count = 0 while site_dump_exists: - final_new_path = new_path + (count and str(count) or "") + final_new_path = new_path + str(count or "") site_dump_exists = os.path.exists(final_new_path) - count = int(count or 0) + 1 + count += 1 shutil.move(old_path, final_new_path) frappe.destroy() diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 715c079af3..c257812452 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -2,17 +2,16 @@ import json import os import subprocess import sys -from shutil import which import click import frappe +from frappe import _ from frappe.commands import get_site, pass_context from frappe.coverage import CodeCoverage from frappe.exceptions import SiteNotSpecifiedError from frappe.utils import cint, update_progress_bar -find_executable = which # backwards compatibility EXTRA_ARGS_CTX = {"ignore_unknown_options": True, "allow_extra_args": True} @@ -465,19 +464,11 @@ def database(context, extra_args): Enter into the Database console for given site. """ site = get_site(context) - if not site: - raise SiteNotSpecifiedError frappe.init(site=site) - if frappe.conf.db_type == "mariadb": - _mariadb(extra_args=extra_args) - elif frappe.conf.db_type == "postgres": - _psql(extra_args=extra_args) + _enter_console(extra_args=extra_args) -@click.command( - "mariadb", - context_settings=EXTRA_ARGS_CTX, -) +@click.command("mariadb", context_settings=EXTRA_ARGS_CTX) @click.argument("extra_args", nargs=-1) @pass_context def mariadb(context, extra_args): @@ -485,10 +476,9 @@ def mariadb(context, extra_args): Enter into mariadb console for a given site. """ site = get_site(context) - if not site: - raise SiteNotSpecifiedError frappe.init(site=site) - _mariadb(extra_args=extra_args) + frappe.conf.db_type = "mariadb" + _enter_console(extra_args=extra_args) @click.command("postgres", context_settings=EXTRA_ARGS_CTX) @@ -500,42 +490,27 @@ def postgres(context, extra_args): """ site = get_site(context) frappe.init(site=site) - _psql(extra_args=extra_args) + frappe.conf.db_type = "postgres" + _enter_console(extra_args=extra_args) -def _mariadb(extra_args=None): - mariadb = which("mariadb") or which("mysql") - command = [ - mariadb, - "--port", - str(frappe.conf.db_port), - "-u", - frappe.conf.db_name, - f"-p{frappe.conf.db_password}", - frappe.conf.db_name, - "-h", - frappe.conf.db_host, - "--pager=less -SFX", - "--safe-updates", - "-A", - ] - if extra_args: - command += list(extra_args) - os.execv(mariadb, command) +def _enter_console(extra_args=None): + from frappe.database import get_command - -def _psql(extra_args=None): - psql = which("psql") - - host = frappe.conf.db_host - port = frappe.conf.db_port - env = os.environ.copy() - env["PGPASSWORD"] = frappe.conf.db_password - conn_string = f"postgresql://{frappe.conf.db_name}@{host}:{port}/{frappe.conf.db_name}" - psql_cmd = [psql, conn_string] - if extra_args: - psql_cmd = psql_cmd + list(extra_args) - subprocess.run(psql_cmd, check=True, env=env) + bin, args, bin_name = get_command( + host=frappe.conf.db_host, + port=frappe.conf.db_port, + user=frappe.conf.db_name, + password=frappe.conf.db_password, + db_name=frappe.conf.db_name, + extra=list(extra_args) if extra_args else [], + ) + if not bin: + frappe.throw( + _("{} not found in PATH! This is required to access the console.").format(bin_name), + exc=frappe.ExecutableNotFound, + ) + os.execv(bin, [bin] + args) @click.command("jupyter") diff --git a/frappe/contacts/doctype/address/address.json b/frappe/contacts/doctype/address/address.json index 0ec67103a7..54d6b03739 100644 --- a/frappe/contacts/doctype/address/address.json +++ b/frappe/contacts/doctype/address/address.json @@ -51,12 +51,14 @@ "fieldname": "address_line1", "fieldtype": "Data", "label": "Address Line 1", + "length": 240, "reqd": 1 }, { "fieldname": "address_line2", "fieldtype": "Data", - "label": "Address Line 2" + "label": "Address Line 2", + "length": 240 }, { "fieldname": "city", @@ -148,7 +150,7 @@ "icon": "fa fa-map-marker", "idx": 5, "links": [], - "modified": "2023-10-30 05:50:23.912366", + "modified": "2023-11-20 17:28:41.698356", "modified_by": "Administrator", "module": "Contacts", "name": "Address", diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json index 8de35fa622..3e15774fd1 100644 --- a/frappe/core/doctype/communication/communication.json +++ b/frappe/core/doctype/communication/communication.json @@ -31,6 +31,7 @@ "additional_info", "communication_date", "read_receipt", + "send_after", "column_break_14", "sender_full_name", "read_by_recipient", @@ -125,7 +126,7 @@ "fieldtype": "Select", "hidden": 1, "label": "Delivery Status", - "options": "\nSent\nBounced\nOpened\nMarked As Spam\nRejected\nDelayed\nSoft-Bounced\nClicked\nRecipient Unsubscribed\nError\nExpired\nSending\nRead" + "options": "\nSent\nBounced\nOpened\nMarked As Spam\nRejected\nDelayed\nSoft-Bounced\nClicked\nRecipient Unsubscribed\nError\nExpired\nSending\nRead\nScheduled" }, { "fieldname": "section_break_8", @@ -390,12 +391,17 @@ "hidden": 1, "label": "IMAP Folder", "read_only": 1 + }, + { + "fieldname": "send_after", + "fieldtype": "Datetime", + "label": "Send After" } ], "icon": "fa fa-comment", "idx": 1, "links": [], - "modified": "2023-08-29 17:20:52.541483", + "modified": "2023-11-27 20:38:27.467076", "modified_by": "Administrator", "module": "Core", "name": "Communication", diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 0e089edb6c..32500c9158 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -87,6 +87,7 @@ class Communication(Document, CommunicationEmailMixin): "Expired", "Sending", "Read", + "Scheduled", ] email_account: DF.Link | None email_status: DF.Literal["Open", "Spam", "Trash"] @@ -106,6 +107,7 @@ class Communication(Document, CommunicationEmailMixin): reference_name: DF.DynamicLink | None reference_owner: DF.ReadOnly | None seen: DF.Check + send_after: DF.Datetime | None sender: DF.Data | None sender_full_name: DF.Data | None sent_or_received: DF.Literal["Sent", "Received"] @@ -162,7 +164,8 @@ class Communication(Document, CommunicationEmailMixin): self.seen = 1 self.sent_or_received = "Sent" - self.set_status() + if not self.send_after: # Handle empty string, always set NULL + self.send_after = None validate_email(self) @@ -173,6 +176,10 @@ class Communication(Document, CommunicationEmailMixin): self.set_sender_full_name() + if self.is_new(): + self.set_status() + self.mark_email_as_spam() + def validate_reference(self): if self.reference_doctype and self.reference_name: if not self.reference_owner: @@ -333,9 +340,6 @@ class Communication(Document, CommunicationEmailMixin): ) def set_status(self): - if not self.is_new(): - return - if self.reference_doctype and self.reference_name: self.status = "Linked" elif self.communication_type == "Communication": @@ -343,15 +347,16 @@ class Communication(Document, CommunicationEmailMixin): else: self.status = "Closed" - # set email status to spam - email_rule = frappe.db.get_value("Email Rule", {"email_id": self.sender, "is_spam": 1}) + if self.send_after and self.is_new(): + self.delivery_status = "Scheduled" + + def mark_email_as_spam(self): if ( self.communication_type == "Communication" and self.communication_medium == "Email" - and self.sent_or_received == "Sent" - and email_rule + and self.sent_or_received == "Received" + and frappe.db.exists("Email Rule", {"email_id": self.sender, "is_spam": 1}) ): - self.email_status = "Spam" @classmethod @@ -433,7 +438,18 @@ class Communication(Document, CommunicationEmailMixin): frappe.db.commit() def parse_email_for_timeline_links(self): - parse_email(self, [self.recipients, self.cc, self.bcc]) + if not frappe.db.get_value("Email Account", self.email_account, "enable_automatic_linking"): + return + + for doctype, docname in parse_email([self.recipients, self.cc, self.bcc]): + if not frappe.db.get_value(doctype, docname, ignore=True): + continue + + self.add_link(doctype, docname) + + if not self.reference_doctype: + self.reference_doctype = doctype + self.reference_name = docname # Timeline Links def set_timeline_links(self): @@ -452,20 +468,13 @@ class Communication(Document, CommunicationEmailMixin): add_contact_links_to_communication(self, contact_name) def deduplicate_timeline_links(self): - if self.timeline_links: - links, duplicate = [], False + if not self.timeline_links: + return - for l in self.timeline_links: - t = (l.link_doctype, l.link_name) - if not t in links: - links.append(t) - else: - duplicate = True - - if duplicate: - self.timeline_links.clear() - for l in links: - self.add_link(link_doctype=l[0], link_name=l[1]) + unique_links = {(link.link_doctype, link.link_name) for link in self.timeline_links} + self.timeline_links = [] + for doctype, name in unique_links: + self.add_link(doctype, name) def add_link(self, link_doctype, link_name, autosave=False): self.append("timeline_links", {"link_doctype": link_doctype, "link_name": link_name}) @@ -477,7 +486,7 @@ class Communication(Document, CommunicationEmailMixin): return self.timeline_links def remove_link(self, link_doctype, link_name, autosave=False, ignore_permissions=True): - for l in self.timeline_links: + for l in list(self.timeline_links): if l.link_doctype == link_doctype and l.link_name == link_name: self.timeline_links.remove(l) @@ -574,36 +583,35 @@ def add_contact_links_to_communication(communication, contact_name): communication.add_link(contact_link.link_doctype, contact_link.link_name) -def parse_email(communication, email_strings): +def parse_email(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` or `admin+doctype=docname@example.com`, - the email is parsed and doctype and docname is extracted and timeline link is added. + the email is parsed and doctype and docname is extracted. """ - if not frappe.db.get_value("Email Account", filters={"enable_automatic_linking": 1}): - return - for email_string in email_strings: - if email_string: - for email in email_string.split(","): - 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 not email_string: + continue - elif len(email_local_parts) == 2: - document_parts = email_local_parts[1].split("=", 1) - if len(document_parts) != 2: - continue + for email in email_string.split(","): + 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]) - doctype = unquote(document_parts[0]) - docname = unquote(document_parts[1]) + elif len(email_local_parts) == 2: + document_parts = email_local_parts[1].split("=", 1) + if len(document_parts) != 2: + continue - if doctype and docname and frappe.db.get_value(doctype, docname, ignore=True): - communication.add_link(doctype, docname) + doctype = unquote(document_parts[0]) + docname = unquote(document_parts[1]) + + if doctype and docname: + yield doctype, docname def get_email_without_link(email): diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 5cb7bc668e..a0c9d35f20 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -46,6 +46,7 @@ def make( print_letterhead=True, email_template=None, communication_type=None, + send_after=None, **kwargs, ) -> dict[str, str]: """Make a new communication. Checks for email permissions for specified Document. @@ -64,6 +65,7 @@ def make( :param attachments: List of File names or dicts with keys "fname" and "fcontent" :param send_me_a_copy: Send a copy to the sender (default **False**). :param email_template: Template which is used to compose mail . + :param send_after: Send after the given datetime. """ if kwargs: from frappe.utils.commands import warn @@ -99,6 +101,7 @@ def make( email_template=email_template, communication_type=communication_type, add_signature=False, + send_after=send_after, ) @@ -124,6 +127,7 @@ def _make( email_template=None, communication_type=None, add_signature=True, + send_after=None, ) -> dict[str, str]: """Internal method to make a new communication that ignores Permission checks.""" @@ -151,6 +155,7 @@ def _make( "read_receipt": read_receipt, "has_attachment": 1 if attachments else 0, "communication_type": communication_type, + "send_after": send_after, } ) comm.flags.skip_add_signature = not add_signature diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index 8db13b8993..81b882113b 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -288,8 +288,9 @@ class CommunicationEmailMixin: "delayed": True, "communication": self.name, "read_receipt": self.read_receipt, - "is_notification": (self.sent_or_received == "Received" and True) or False, + "is_notification": (self.sent_or_received == "Received"), "print_letterhead": print_letterhead, + "send_after": self.send_after, } def send_email( diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index 90b2c624ec..bab2d2e019 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -1,10 +1,9 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE from typing import TYPE_CHECKING -from urllib.parse import quote import frappe -from frappe.core.doctype.communication.communication import Communication, get_emails +from frappe.core.doctype.communication.communication import Communication, get_emails, parse_email from frappe.core.doctype.communication.email import add_attachments from frappe.email.doctype.email_queue.email_queue import EmailQueue from frappe.tests.utils import FrappeTestCase @@ -219,36 +218,25 @@ class TestCommunication(FrappeTestCase): self.assertIn(comm_note_1.name, data) self.assertIn(comm_note_2.name, data) - def test_link_in_email(self): - create_email_account() + def test_parse_email(self): + to = "Jon Doe " + cc = """=?UTF-8?Q?Max_Mu=C3=9F?= , + erp+Customer+that%20company@example.org""" + bcc = "" - 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) + results = list(parse_email([to, cc, bcc])) + self.assertEqual([("Customer", "that company")], results) - comm = frappe.get_doc( - { - "doctype": "Communication", - "communication_medium": "Email", - "subject": "Document Link in Email", - "sender": "comm_sender@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) + results = list(parse_email([to, bcc])) + self.assertEqual(results, []) - doc_links = [ - (timeline_link.link_doctype, timeline_link.link_name) for timeline_link in comm.timeline_links - ] - self.assertIn(("Note", notes[0].name), doc_links) - self.assertIn(("Note", notes[1].name), doc_links) + to = "jane.doe+A+Test@example.org" + cc = "" + bcc = "=?UTF-8?Q?Max_Mu=C3=9F?= " + results = list(parse_email([to, cc, bcc])) + self.assertEqual([("A", "Test"), ("Note", "Very important")], results) - def test_parse_emails(self): + def test_get_emails(self): emails = get_emails( [ "comm_recipient+DocType+DocName@example.com", @@ -293,6 +281,40 @@ class TestCommunication(FrappeTestCase): self.assertEqual(comm_with_signature.content.count(signature), 1) self.assertEqual(comm_without_signature.content.count(signature), 1) + def test_mark_as_spam(self): + frappe.get_doc( + { + "doctype": "Email Rule", + "email_id": "spammer@example.com", + "is_spam": 1, + } + ).insert(ignore_permissions=True) + + spam_comm: Communication = frappe.get_doc( + { + "doctype": "Communication", + "communication_medium": "Email", + "subject": "This is spam", + "sender": "spammer@example.com", + "recipients": "comm_recipient@example.com", + "sent_or_received": "Received", + } + ).insert(ignore_permissions=True) + + self.assertEqual(spam_comm.email_status, "Spam") + + normal_comm: Communication = frappe.get_doc( + { + "doctype": "Communication", + "communication_medium": "Email", + "subject": "This is spam", + "sender": "friendlyhuman@example.com", + "recipients": "comm_recipient@example.com", + "sent_or_received": "Received", + } + ).insert(ignore_permissions=True) + self.assertNotEqual(normal_comm.email_status, "Spam") + class TestCommunicationEmailMixin(FrappeTestCase): def new_communication(self, recipients=None, cc=None, bcc=None) -> Communication: diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py index 88d5b2b4b1..691474c3d3 100644 --- a/frappe/core/doctype/data_import/exporter.py +++ b/frappe/core/doctype/data_import/exporter.py @@ -144,8 +144,7 @@ class Exporter: value = doc.get(df.fieldname, None) if df.fieldtype == "Duration": - value = flt(value or 0) - value = format_duration(value, df.hide_days) + value = format_duration(flt(value), df.hide_days) row[i] = value return rows diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 6b8469bd48..22b84d1cbc 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -19,10 +19,12 @@ "reqd", "is_virtual", "search_index", + "not_nullable", "column_break_18", "options", "sort_options", "show_dashboard", + "link_filters", "defaults_section", "default", "column_break_6", @@ -560,13 +562,25 @@ "fieldname": "sort_options", "fieldtype": "Check", "label": "Sort Options" + }, + { + "fieldname": "link_filters", + "fieldtype": "JSON", + "label": "Link Filters" + }, + { + "default": "0", + "depends_on": "eval:!in_list([\"Check\", \"Currency\", \"Float\", \"Int\", \"Percent\", \"Rating\", \"Select\", \"Table\", \"Table MultiSelect\"], doc.fieldtype)", + "fieldname": "not_nullable", + "fieldtype": "Check", + "label": "Not Nullable" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-10-25 06:53:45.194081", + "modified": "2023-11-16 11:26:56.364594", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/docfield/docfield.py b/frappe/core/doctype/docfield/docfield.py index c4067646e6..dc26c1f96f 100644 --- a/frappe/core/doctype/docfield/docfield.py +++ b/frappe/core/doctype/docfield/docfield.py @@ -87,10 +87,12 @@ class DocField(Document): is_virtual: DF.Check label: DF.Data | None length: DF.Int + link_filters: DF.JSON | None mandatory_depends_on: DF.Code | None max_height: DF.Data | None no_copy: DF.Check non_negative: DF.Check + not_nullable: DF.Check oldfieldname: DF.Data | None oldfieldtype: DF.Data | None options: DF.SmallText | None diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 7b5c58dedd..bbdcfd1817 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -136,6 +136,7 @@ class DocType(Document): is_virtual: DF.Check issingle: DF.Check istable: DF.Check + link_filters: DF.JSON links: DF.Table[DocTypeLink] make_attachments_public: DF.Check max_attachments: DF.Int @@ -364,16 +365,23 @@ class DocType(Document): SET `{fieldname}` = source.`{source_fieldname}` FROM `tab{link_doctype}` as source WHERE `{link_fieldname}` = source.name - AND ifnull(`{fieldname}`, '')='' """ + if df.not_nullable: + update_query += "AND `{fieldname}`=''" + else: + update_query += "AND ifnull(`{fieldname}`, '')=''" + else: update_query = """ UPDATE `tab{doctype}` as target INNER JOIN `tab{link_doctype}` as source ON `target`.`{link_fieldname}` = `source`.`name` SET `target`.`{fieldname}` = `source`.`{source_fieldname}` - WHERE ifnull(`target`.`{fieldname}`, '')="" """ + if df.not_nullable: + update_query += "WHERE `target`.`{fieldname}`=''" + else: + update_query += "WHERE ifnull(`target`.`{fieldname}`, '')=''" self.flags.update_fields_to_fetch_queries.append( update_query.format( diff --git a/frappe/core/doctype/doctype/doctype_list.js b/frappe/core/doctype/doctype/doctype_list.js index 963e863380..56f345420f 100644 --- a/frappe/core/doctype/doctype/doctype_list.js +++ b/frappe/core/doctype/doctype/doctype_list.js @@ -101,6 +101,7 @@ frappe.listview_settings["DocType"] = { role: "System Manager", share: 1, write: 1, + submit: values.is_submittable ? 1 : 0, }, ], fields: [{ fieldtype: "Section Break" }], diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.json b/frappe/core/doctype/document_naming_rule/document_naming_rule.json index 1e2247c250..f6b3245086 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.json +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.json @@ -35,6 +35,7 @@ { "fieldname": "prefix", "fieldtype": "Data", + "in_list_view": 1, "label": "Prefix", "mandatory_depends_on": "eval:doc.naming_by===\"Numbered\"", "reqd": 1 @@ -44,6 +45,7 @@ "description": "Warning: Updating counter may lead to document name conflicts if not done properly", "fieldname": "counter", "fieldtype": "Int", + "in_list_view": 1, "label": "Counter", "no_copy": 1 }, @@ -78,6 +80,7 @@ "description": "Rules with higher priority number will be applied first.", "fieldname": "priority", "fieldtype": "Int", + "in_standard_filter": 1, "label": "Priority" }, { @@ -87,7 +90,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-04-24 15:14:32.054272", + "modified": "2023-11-21 11:58:25.712375", "modified_by": "Administrator", "module": "Core", "name": "Document Naming Rule", @@ -107,7 +110,7 @@ } ], "quick_entry": 1, - "sort_field": "modified", + "sort_field": "priority", "sort_order": "DESC", "states": [], "title_field": "document_type", diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule_list.js b/frappe/core/doctype/document_naming_rule/document_naming_rule_list.js new file mode 100644 index 0000000000..0dba534891 --- /dev/null +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule_list.js @@ -0,0 +1,3 @@ +frappe.listview_settings["Document Naming Rule"] = { + hide_name_column: true, +}; diff --git a/frappe/core/doctype/domain/domain.py b/frappe/core/doctype/domain/domain.py index 0c98856490..50e187968e 100644 --- a/frappe/core/doctype/domain/domain.py +++ b/frappe/core/doctype/domain/domain.py @@ -4,6 +4,7 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.model.document import Document +from frappe.utils import cint class Domain(Document): @@ -28,7 +29,7 @@ class Domain(Document): self.setup_properties() self.set_values() - if not int(frappe.defaults.get_defaults().setup_complete or 0): + if not cint(frappe.defaults.get_defaults().setup_complete): # if setup not complete, setup desktop etc. self.setup_sidebar_items() self.set_default_portal_role() diff --git a/frappe/core/doctype/file/utils.py b/frappe/core/doctype/file/utils.py index 3bb69e72ac..a1b6612d4c 100644 --- a/frappe/core/doctype/file/utils.py +++ b/frappe/core/doctype/file/utils.py @@ -184,7 +184,7 @@ def remove_file_by_url(file_url: str, doctype: str = None, name: str = None) -> def get_content_hash(content: bytes | str) -> str: if isinstance(content, str): content = content.encode() - return hashlib.md5(content).hexdigest() # nosec + return hashlib.md5(content, usedforsecurity=False).hexdigest() # nosec def generate_file_name(name: str, suffix: str | None = None, is_private: bool = False) -> str: @@ -376,7 +376,7 @@ def relink_files(doc, fieldname, temp_doc_name): "attached_to_field": fieldname, "creation": ( "between", - [now_datetime() - add_to_date(date=now_datetime(), minutes=-60), now_datetime()], + [add_to_date(date=now_datetime(), minutes=-60), now_datetime()], ), }, ) diff --git a/frappe/core/doctype/log_settings/log_settings.py b/frappe/core/doctype/log_settings/log_settings.py index a32535e109..7b8deb6ef7 100644 --- a/frappe/core/doctype/log_settings/log_settings.py +++ b/frappe/core/doctype/log_settings/log_settings.py @@ -10,20 +10,6 @@ from frappe.model.document import Document from frappe.utils import cint from frappe.utils.caching import site_cache -DEFAULT_LOGTYPES_RETENTION = { - "Error Log": 30, - "Activity Log": 90, - "Email Queue": 30, - "Scheduled Job Log": 90, - "Route History": 90, - "Submission Queue": 30, - "Prepared Report": 30, - "Webhook Request Log": 30, - "Integration Request": 90, - "Unhandled Email": 30, - "Reminder": 30, -} - @runtime_checkable class LogType(Protocol): @@ -81,12 +67,14 @@ class LogSettings(Document): def add_default_logtypes(self): existing_logtypes = {d.ref_doctype for d in self.logs_to_clear} added_logtypes = set() - for logtype, retention in DEFAULT_LOGTYPES_RETENTION.items(): + default_logtypes_retention = frappe.get_hooks("default_log_clearing_doctypes", {}) + + for logtype, retentions in default_logtypes_retention.items(): if logtype not in existing_logtypes and _supports_log_clearing(logtype): if not frappe.db.exists("DocType", logtype): continue - self.append("logs_to_clear", {"ref_doctype": logtype, "days": cint(retention)}) + self.append("logs_to_clear", {"ref_doctype": logtype, "days": cint(retentions[-1])}) added_logtypes.add(logtype) if added_logtypes: diff --git a/frappe/core/doctype/recorder/recorder.py b/frappe/core/doctype/recorder/recorder.py index c8ca1cc798..f5ef909a2a 100644 --- a/frappe/core/doctype/recorder/recorder.py +++ b/frappe/core/doctype/recorder/recorder.py @@ -4,7 +4,7 @@ import frappe from frappe.model.document import Document from frappe.recorder import get as get_recorder_data -from frappe.utils import cint, evaluate_filters, make_filter_dict +from frappe.utils import cint, evaluate_filters class Recorder(Document): @@ -27,6 +27,7 @@ class Recorder(Document): sql_queries: DF.Table[RecorderQuery] time: DF.Datetime | None time_in_queries: DF.Float + # end: auto-generated types def load_from_db(self): @@ -38,7 +39,7 @@ class Recorder(Document): @staticmethod def get_list(args): - start = cint(args.get("start")) or 0 + start = cint(args.get("start")) page_length = cint(args.get("page_length")) or 20 requests = Recorder.get_filtered_requests(args)[start : start + page_length] diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 0443766de1..2d78892f14 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -45,6 +45,7 @@ class Report(Document): report_script: DF.Code | None report_type: DF.Literal["Report Builder", "Query Report", "Script Report", "Custom Report"] roles: DF.Table[HasRole] + # end: auto-generated types def validate(self): """only administrator can save standard report""" @@ -129,7 +130,7 @@ class Report(Document): if frappe.flags.in_import: return - if self.is_standard == "Yes" and (frappe.local.conf.get("developer_mode") or 0) == 1: + if self.is_standard == "Yes" and frappe.conf.developer_mode: export_to_files( record_list=[["Report", self.name]], record_module=self.module, create_init=True ) @@ -155,7 +156,6 @@ class Report(Document): def execute_script_report(self, filters): # save the timestamp to automatically set to prepared threshold = 15 - res = [] start_time = datetime.datetime.now() @@ -382,7 +382,7 @@ class Report(Document): def is_prepared_report_enabled(report): - return cint(frappe.db.get_value("Report", report, "prepared_report")) or 0 + return cint(frappe.db.get_value("Report", report, "prepared_report")) def get_report_module_dotted_path(module, report_name): diff --git a/frappe/core/doctype/rq_job/rq_job.py b/frappe/core/doctype/rq_job/rq_job.py index b6a6f99b57..81fa3fdf3e 100644 --- a/frappe/core/doctype/rq_job/rq_job.py +++ b/frappe/core/doctype/rq_job/rq_job.py @@ -59,6 +59,7 @@ class RQJob(Document): ] time_taken: DF.Duration | None timeout: DF.Duration | None + # end: auto-generated types def load_from_db(self): try: @@ -79,7 +80,7 @@ class RQJob(Document): @staticmethod def get_list(args): - start = cint(args.get("start")) or 0 + start = cint(args.get("start")) page_length = cint(args.get("page_length")) or 20 order_desc = "desc" in args.get("order_by", "") @@ -87,7 +88,9 @@ class RQJob(Document): matched_job_ids = RQJob.get_matching_job_ids(args)[start : start + page_length] conn = get_redis_conn() - jobs = [serialize_job(job) for job in Job.fetch_many(job_ids=matched_job_ids, connection=conn)] + jobs = [ + serialize_job(job) for job in Job.fetch_many(job_ids=matched_job_ids, connection=conn) if job + ] return sorted(jobs, key=lambda j: j.modified, reverse=order_desc) diff --git a/frappe/core/doctype/rq_worker/rq_worker.py b/frappe/core/doctype/rq_worker/rq_worker.py index d3ea97203e..025feb18a6 100644 --- a/frappe/core/doctype/rq_worker/rq_worker.py +++ b/frappe/core/doctype/rq_worker/rq_worker.py @@ -4,6 +4,7 @@ import datetime from contextlib import suppress +import pytz from rq import Worker import frappe @@ -33,6 +34,7 @@ class RQWorker(Document): total_working_time: DF.Duration | None utilization_percent: DF.Percent worker_name: DF.Data | None + # end: auto-generated types def load_from_db(self): @@ -46,7 +48,7 @@ class RQWorker(Document): @staticmethod def get_list(args): - start = cint(args.get("start")) or 0 + start = cint(args.get("start")) page_length = cint(args.get("page_length")) or 20 workers = get_workers() @@ -105,5 +107,7 @@ def serialize_worker(worker: Worker) -> frappe._dict: def compute_utilization(worker: Worker) -> float: with suppress(Exception): - total_time = (datetime.datetime.utcnow() - worker.birth_date).total_seconds() + total_time = ( + datetime.datetime.now(pytz.UTC) - worker.birth_date.replace(tzinfo=pytz.UTC) + ).total_seconds() return worker.total_working_time / total_time * 100 diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json index 451c4108a0..782c0749f8 100644 --- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json @@ -7,7 +7,8 @@ "field_order": [ "status", "scheduled_job_type", - "details" + "details", + "debug_log" ], "fields": [ { @@ -35,10 +36,16 @@ "options": "Scheduled Job Type", "read_only": 1, "reqd": 1 + }, + { + "fieldname": "debug_log", + "fieldtype": "Code", + "label": "Debug Log", + "read_only": 1 } ], "links": [], - "modified": "2022-06-13 05:41:21.090972", + "modified": "2023-11-09 12:06:41.781270", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Log", diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py index 40f6823057..e4bfe21e2d 100644 --- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py @@ -16,6 +16,7 @@ class ScheduledJobLog(Document): if TYPE_CHECKING: from frappe.types import DF + debug_log: DF.Code | None details: DF.Code | None scheduled_job_type: DF.Link status: DF.Literal["Scheduled", "Complete", "Failed"] diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py index 6f56180c89..59f615d9de 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -2,7 +2,8 @@ # License: MIT. See LICENSE import json -from datetime import datetime +from datetime import datetime, timedelta +from random import randint import click from croniter import croniter @@ -110,7 +111,12 @@ class ScheduledJobType(Document): # immediately, even when it's meant to be daily. # A dynamic fallback like current time might miss the scheduler interval and job will never start. last_execution = get_datetime(self.last_execution or self.creation) - return croniter(self.cron_format, last_execution).get_next(datetime) + next_execution = croniter(self.cron_format, last_execution).get_next(datetime) + + jitter = 0 + if self.frequency in ("Hourly Long", "Daily Long"): + jitter = randint(1, 600) + return next_execution + timedelta(seconds=jitter) def execute(self): self.scheduler_log = None @@ -145,6 +151,8 @@ class ScheduledJobType(Document): dict(doctype="Scheduled Job Log", scheduled_job_type=self.name) ).insert(ignore_permissions=True) self.scheduler_log.db_set("status", status) + if frappe.debug_log: + self.scheduler_log.db_set("debug_log", "\n".join(frappe.debug_log)) if status == "Failed": self.scheduler_log.db_set("details", frappe.get_traceback()) if status == "Start": diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 54b23094f8..45fa621cec 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -23,38 +23,23 @@ "float_precision", "currency_precision", "rounding_method", - "sec_backup_limit", - "backup_limit", - "encrypt_backup", - "background_workers", - "enable_scheduler", - "dormant_days", "permissions", "apply_strict_user_permissions", "column_break_21", - "allow_guests_to_upload_files", - "force_web_capture_mode_for_uploads", + "allow_older_web_view_links", + "security_tab", "security", "session_expiry", "document_share_key_expiry", - "column_break_13", + "column_break_txqh", "deny_multiple_sessions", + "disable_user_pass_login", + "login_methods_section", "allow_login_using_mobile_number", "allow_login_using_user_name", - "disable_user_pass_login", + "column_break_uhqk", "login_with_email_link", "login_with_email_link_expiry", - "allow_error_traceback", - "strip_exif_metadata_from_uploaded_images", - "allow_older_web_view_links", - "password_settings", - "logout_on_password_reset", - "force_user_to_reset_password", - "reset_password_link_expiry_duration", - "password_reset_limit", - "column_break_31", - "enable_password_policy", - "minimum_password_score", "brute_force_security", "allow_consecutive_login_attempts", "column_break_34", @@ -66,6 +51,16 @@ "two_factor_method", "lifespan_qrcode_image", "otp_issuer_name", + "password_tab", + "password_settings", + "logout_on_password_reset", + "force_user_to_reset_password", + "reset_password_link_expiry_duration", + "password_reset_limit", + "column_break_31", + "enable_password_policy", + "minimum_password_score", + "email_tab", "email", "email_footer_address", "email_retry_limit", @@ -75,17 +70,31 @@ "attach_view_link", "welcome_email_template", "reset_password_template", - "prepared_report_section", - "max_auto_email_report_per_user", + "files_tab", + "files_section", + "max_file_size", + "allow_guests_to_upload_files", + "force_web_capture_mode_for_uploads", + "strip_exif_metadata_from_uploaded_images", + "column_break_uqma", + "allowed_file_extensions", + "updates_tab", "system_updates_section", "disable_system_update_notification", "disable_change_log_notification", + "backups_tab", + "sec_backup_limit", + "backup_limit", + "encrypt_backup", + "advanced_tab", + "prepared_report_section", + "max_auto_email_report_per_user", + "background_workers", + "enable_scheduler", + "dormant_days", "telemetry_section", - "enable_telemetry", - "files_section", - "max_file_size", - "column_break_uqma", - "allowed_file_extensions" + "allow_error_traceback", + "enable_telemetry" ], "fields": [ { @@ -126,7 +135,6 @@ "read_only": 1 }, { - "collapsible": 1, "fieldname": "date_and_number_format", "fieldtype": "Section Break", "label": "Date and Number Format" @@ -171,10 +179,8 @@ "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" }, { - "collapsible": 1, "fieldname": "sec_backup_limit", - "fieldtype": "Section Break", - "label": "Backups" + "fieldtype": "Section Break" }, { "default": "3", @@ -184,7 +190,6 @@ "label": "Number of Backups" }, { - "collapsible": 1, "fieldname": "background_workers", "fieldtype": "Section Break", "label": "Background Workers" @@ -198,7 +203,6 @@ "label": "Enable Scheduled Jobs" }, { - "collapsible": 1, "fieldname": "permissions", "fieldtype": "Section Break", "label": "Permissions" @@ -211,10 +215,8 @@ "label": "Apply Strict User Permissions" }, { - "collapsible": 1, "fieldname": "security", - "fieldtype": "Section Break", - "label": "Security" + "fieldtype": "Section Break" }, { "default": "170:00", @@ -223,10 +225,6 @@ "fieldtype": "Data", "label": "Session Expiry (idle timeout)" }, - { - "fieldname": "column_break_13", - "fieldtype": "Column Break" - }, { "default": "0", "description": "Note: Multiple sessions will be allowed in case of mobile device", @@ -255,7 +253,6 @@ "label": "Show Full Error and Allow Reporting of Issues to the Developer" }, { - "collapsible": 1, "fieldname": "password_settings", "fieldtype": "Section Break", "label": "Password" @@ -286,7 +283,6 @@ "options": "2\n3\n4" }, { - "collapsible": 1, "fieldname": "brute_force_security", "fieldtype": "Section Break", "label": "Brute Force Security" @@ -309,7 +305,6 @@ "label": "Allow Login After Fail" }, { - "collapsible": 1, "fieldname": "two_factor_authentication", "fieldtype": "Section Break", "label": "Two Factor Authentication" @@ -338,6 +333,7 @@ }, { "default": "OTP App", + "depends_on": "enable_two_factor_auth", "description": "Choose authentication method to be used by all users", "fieldname": "two_factor_method", "fieldtype": "Select", @@ -345,7 +341,7 @@ "options": "OTP App\nSMS\nEmail" }, { - "depends_on": "eval:doc.two_factor_method == \"OTP App\"", + "depends_on": "eval:doc.enable_two_factor_auth && doc.two_factor_method == \"OTP App\"", "description": "Time in seconds to retain QR code image on server. Min:240", "fieldname": "lifespan_qrcode_image", "fieldtype": "Int", @@ -359,10 +355,8 @@ "label": "OTP Issuer Name" }, { - "collapsible": 1, "fieldname": "email", - "fieldtype": "Section Break", - "label": "Email" + "fieldtype": "Section Break" }, { "description": "Your organization name and address for the email footer.", @@ -430,7 +424,6 @@ "label": "Include Web View Link in Email" }, { - "collapsible": 1, "fieldname": "prepared_report_section", "fieldtype": "Section Break", "label": "Reports" @@ -456,10 +449,8 @@ "label": "Encrypt Backups" }, { - "collapsible": 1, "fieldname": "system_updates_section", - "fieldtype": "Section Break", - "label": "System Updates" + "fieldtype": "Section Break" }, { "default": "0", @@ -547,7 +538,6 @@ "label": "Disable Document Sharing" }, { - "collapsible": 1, "fieldname": "telemetry_section", "fieldtype": "Section Break", "label": "Telemetry" @@ -578,10 +568,8 @@ "label": "Force Web Capture Mode for Uploads" }, { - "collapsible": 1, "fieldname": "files_section", - "fieldtype": "Section Break", - "label": "Files" + "fieldtype": "Section Break" }, { "fieldname": "max_file_size", @@ -598,12 +586,60 @@ "fieldname": "allowed_file_extensions", "fieldtype": "Small Text", "label": "Allowed File Extensions" + }, + { + "fieldname": "security_tab", + "fieldtype": "Tab Break", + "label": "Login" + }, + { + "fieldname": "email_tab", + "fieldtype": "Tab Break", + "label": "Email" + }, + { + "fieldname": "files_tab", + "fieldtype": "Tab Break", + "label": "Files" + }, + { + "fieldname": "updates_tab", + "fieldtype": "Tab Break", + "label": "Updates" + }, + { + "fieldname": "backups_tab", + "fieldtype": "Tab Break", + "label": "Backups" + }, + { + "fieldname": "advanced_tab", + "fieldtype": "Tab Break", + "label": "Advanced" + }, + { + "fieldname": "password_tab", + "fieldtype": "Tab Break", + "label": "Password" + }, + { + "fieldname": "column_break_txqh", + "fieldtype": "Column Break" + }, + { + "fieldname": "login_methods_section", + "fieldtype": "Section Break", + "label": "Login Methods" + }, + { + "fieldname": "column_break_uhqk", + "fieldtype": "Column Break" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2023-10-17 16:12:28.145496", + "modified": "2023-11-27 14:08:01.927794", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py index 9a34ccd5b6..1a548b580b 100644 --- a/frappe/core/doctype/system_settings/system_settings.py +++ b/frappe/core/doctype/system_settings/system_settings.py @@ -97,8 +97,8 @@ class SystemSettings(Document): def validate(self): from frappe.twofactor import toggle_two_factor_auth - enable_password_policy = cint(self.enable_password_policy) and True or False - minimum_password_score = cint(getattr(self, "minimum_password_score", 0)) or 0 + enable_password_policy = cint(self.enable_password_policy) + minimum_password_score = cint(getattr(self, "minimum_password_score", 0)) if enable_password_policy and minimum_password_score <= 0: frappe.throw(_("Please select Minimum Password Score")) elif not enable_password_policy: @@ -195,7 +195,7 @@ def update_last_reset_password_date(): def load(): from frappe.utils.momentjs import get_all_timezones - if not "System Manager" in frappe.get_roles(): + if "System Manager" not in frappe.get_roles(): frappe.throw(_("Not permitted"), frappe.PermissionError) all_defaults = frappe.db.get_defaults() diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index cdb3e394ee..028af756df 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -9,6 +9,7 @@ import frappe.defaults import frappe.permissions import frappe.share from frappe import STANDARD_USERS, _, msgprint, throw +from frappe.auth import MAX_PASSWORD_SIZE from frappe.core.doctype.user_type.user_type import user_linked_with_permission_on_doctype from frappe.desk.doctype.notification_settings.notification_settings import ( create_notification_settings, @@ -823,6 +824,9 @@ def update_password( old_password (str, optional): Old password. Defaults to None. """ + if len(new_password) > MAX_PASSWORD_SIZE: + frappe.throw(_("Password size exceeded the maximum allowed size.")) + result = test_password_strength(new_password) feedback = result.get("feedback", None) @@ -872,7 +876,7 @@ def test_password_strength( "Arguments `key` and `old_password` are deprecated in function `test_password_strength`." ) - enable_password_policy = frappe.get_system_settings("enable_password_policy") or 0 + enable_password_policy = frappe.get_system_settings("enable_password_policy") if not enable_password_policy: return {} @@ -885,7 +889,7 @@ def test_password_strength( if new_password: result = _test_password_strength(new_password, user_inputs=user_data) password_policy_validation_passed = False - minimum_password_score = cint(frappe.get_system_settings("minimum_password_score")) or 0 + minimum_password_score = cint(frappe.get_system_settings("minimum_password_score")) # score should be greater than 0 and minimum_password_score if result.get("score") and result.get("score") >= minimum_password_score: @@ -1223,27 +1227,31 @@ def create_contact(user, ignore_links=False, ignore_mandatory=False): contact_name = get_contact_name(user.email) if not contact_name: - contact = frappe.get_doc( - { - "doctype": "Contact", - "first_name": user.first_name, - "last_name": user.last_name, - "user": user.name, - "gender": user.gender, - } - ) + try: + contact = frappe.get_doc( + { + "doctype": "Contact", + "first_name": user.first_name, + "last_name": user.last_name, + "user": user.name, + "gender": user.gender, + } + ) - if user.email: - contact.add_email(user.email, is_primary=True) + if user.email: + contact.add_email(user.email, is_primary=True) - if user.phone: - contact.add_phone(user.phone, is_primary_phone=True) + if user.phone: + contact.add_phone(user.phone, is_primary_phone=True) - if user.mobile_no: - contact.add_phone(user.mobile_no, is_primary_mobile_no=True) - contact.insert( - ignore_permissions=True, ignore_links=ignore_links, ignore_mandatory=ignore_mandatory - ) + if user.mobile_no: + contact.add_phone(user.mobile_no, is_primary_mobile_no=True) + + contact.insert( + ignore_permissions=True, ignore_links=ignore_links, ignore_mandatory=ignore_mandatory + ) + except frappe.DuplicateEntryError: + pass else: contact = frappe.get_doc("Contact", contact_name) contact.first_name = user.first_name diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 355b390b3f..0d7c2f9c9f 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -31,6 +31,7 @@ class UserType(Document): user_doctypes: DF.Table[UserDocumentType] user_id_field: DF.Literal user_type_modules: DF.Table[UserTypeModule] + # end: auto-generated types def validate(self): self.set_modules() @@ -140,7 +141,7 @@ class UserType(Document): for row in self.user_doctypes: docperm = add_role_permissions(row.document_type, self.role) - values = {perm: row.get(perm) or 0 for perm in perms} + values = {perm: row.get(perm, default=0) for perm in perms} for perm in ["print", "email", "share"]: values[perm] = 1 diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 53a003c88e..332969b036 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -15,6 +15,7 @@ "fieldname", "insert_after", "length", + "link_filters", "column_break_6", "fieldtype", "precision", @@ -444,6 +445,12 @@ "fieldname": "sort_options", "fieldtype": "Check", "label": "Sort Options" + }, + { + "fieldname": "link_filters", + "fieldtype": "JSON", + "hidden": 1, + "label": "Link Filters" } ], "icon": "fa fa-glass", diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index a1aa4fd342..c77d2f4bb2 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -94,6 +94,7 @@ class CustomField(Document): is_virtual: DF.Check label: DF.Data | None length: DF.Int + link_filters: DF.JSON | None mandatory_depends_on: DF.Code | None module: DF.Link | None no_copy: DF.Check diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index c2e2e83d7f..e2fb630af3 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -84,7 +84,7 @@ frappe.ui.form.on("Customize Form", { if (!in_list(["Table", "Table MultiSelect"], f.fieldtype)) return; frm.add_custom_button( - f.options, + __(f.options), () => frm.set_value("doc_type", f.options), __("Customize Child Table") ); @@ -97,7 +97,7 @@ frappe.ui.form.on("Customize Form", { if (frm.doc.doc_type) { frappe.model.with_doctype(frm.doc.doc_type).then(() => { - frm.page.set_title(__("Customize Form - {0}", [frm.doc.doc_type])); + frm.page.set_title(__("Customize Form - {0}", [__(frm.doc.doc_type)])); frappe.customize_form.set_primary_action(frm); frm.add_custom_button( @@ -149,6 +149,7 @@ frappe.ui.form.on("Customize Form", { ); render_form_builder(frm); + frm.get_field("form_builder").tab.set_active(); }); } diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 32fd3302ec..aad7a59b37 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -7,10 +7,12 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "details_tab", "doc_type", "properties", "label", "search_fields", + "link_filters", "column_break_5", "istable", "is_calendar_and_gantt", @@ -24,17 +26,6 @@ "naming_section", "naming_rule", "autoname", - "document_actions_section", - "actions", - "document_links_section", - "links", - "document_states_section", - "states", - "form_tab", - "form_builder", - "fields_section_break", - "fields", - "settings_tab", "form_settings_section", "image_field", "max_attachments", @@ -59,7 +50,17 @@ "section_break_8", "sort_field", "column_break_10", - "sort_order" + "sort_order", + "document_actions_section", + "actions", + "document_links_section", + "links", + "document_states_section", + "states", + "fields_section_break", + "fields", + "form_tab", + "form_builder" ], "fields": [ { @@ -180,7 +181,6 @@ "depends_on": "doc_type", "fieldname": "fields_section_break", "fieldtype": "Section Break", - "hidden": 1, "label": "Fields" }, { @@ -372,11 +372,6 @@ "fieldtype": "Check", "label": "Is Calendar and Gantt" }, - { - "fieldname": "settings_tab", - "fieldtype": "Tab Break", - "label": "Settings" - }, { "fieldname": "form_builder", "fieldtype": "HTML", @@ -386,6 +381,17 @@ "fieldname": "form_tab", "fieldtype": "Tab Break", "label": "Form" + }, + { + "fieldname": "link_filters", + "fieldtype": "JSON", + "hidden": 1, + "label": "Link Filters" + }, + { + "fieldname": "details_tab", + "fieldtype": "Tab Break", + "label": "Details" } ], "hide_toolbar": 1, @@ -394,7 +400,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-10-31 02:04:25.955931", + "modified": "2023-11-16 11:23:06.427432", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 730d3dfc6c..34933978a6 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -54,6 +54,7 @@ class CustomizeForm(Document): is_calendar_and_gantt: DF.Check istable: DF.Check label: DF.Data | None + link_filters: DF.JSON | None links: DF.Table[DocTypeLink] make_attachments_public: DF.Check max_attachments: DF.Int @@ -681,6 +682,17 @@ def is_standard_or_system_generated_field(df): return not df.get("is_custom_field") or df.get("is_system_generated") +@frappe.whitelist() +def get_link_filters_from_doc_without_customisations(doctype, fieldname): + """Get the filters of a link field from a doc without customisations + In backend the customisations are not applied. + Customisations are applied in the client side. + """ + doc = frappe.get_doc("DocType", doctype) + field = list(filter(lambda x: x.fieldname == fieldname, doc.fields)) + return field[0].link_filters + + doctype_properties = { "search_fields": "Data", "title_field": "Data", @@ -761,6 +773,7 @@ docfield_properties = { "hide_days": "Check", "hide_seconds": "Check", "is_virtual": "Check", + "link_filters": "JSON", } doctype_link_properties = { diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index fa86df2735..a3aec328bd 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -24,6 +24,7 @@ "no_copy", "allow_in_quick_entry", "translatable", + "link_filters", "column_break_7", "default", "precision", @@ -204,7 +205,7 @@ "label": "Permissions" }, { - "description": "This field will appear only if the fieldname defined here has value OR the rules are true (examples): \nmyfield\neval:doc.myfield=='My Value'\neval:doc.age>18", + "description": "This field will appear only if the fieldname defined here has value OR the rules are true (examples):\nmyfield\neval:doc.myfield=='My Value'\neval:doc.age>18", "fieldname": "depends_on", "fieldtype": "Code", "label": "Depends On", @@ -471,13 +472,18 @@ "fieldname": "sort_options", "fieldtype": "Check", "label": "Sort Options" + }, + { + "fieldname": "link_filters", + "fieldtype": "JSON", + "label": "Link Filters" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-10-25 06:55:50.718441", + "modified": "2023-11-07 13:17:21.373626", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.py b/frappe/custom/doctype/customize_form_field/customize_form_field.py index 1afb5ad34b..59b0155a98 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.py +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.py @@ -86,6 +86,7 @@ class CustomizeFormField(Document): is_virtual: DF.Check label: DF.Data | None length: DF.Int + link_filters: DF.JSON | None mandatory_depends_on: DF.Code | None no_copy: DF.Check non_negative: DF.Check diff --git a/frappe/database/__init__.py b/frappe/database/__init__.py index 76ad24b6e6..d88536ad99 100644 --- a/frappe/database/__init__.py +++ b/frappe/database/__init__.py @@ -3,25 +3,39 @@ # Database Module # -------------------- +from shutil import which from frappe.database.database import savepoint -def setup_database(force, source_sql=None, verbose=None, no_mariadb_socket=False): +def setup_database(force, verbose=None, no_mariadb_socket=False): import frappe if frappe.conf.db_type == "postgres": import frappe.database.postgres.setup_db - return frappe.database.postgres.setup_db.setup_database(force, source_sql, verbose) + return frappe.database.postgres.setup_db.setup_database() else: import frappe.database.mariadb.setup_db return frappe.database.mariadb.setup_db.setup_database( - force, source_sql, verbose, no_mariadb_socket=no_mariadb_socket + force, verbose, no_mariadb_socket=no_mariadb_socket ) +def bootstrap_database(db_name, verbose=None, source_sql=None): + import frappe + + if frappe.conf.db_type == "postgres": + import frappe.database.postgres.setup_db + + return frappe.database.postgres.setup_db.bootstrap_database(db_name, verbose, source_sql) + else: + import frappe.database.mariadb.setup_db + + return frappe.database.mariadb.setup_db.bootstrap_database(db_name, verbose, source_sql) + + def drop_user_and_database(db_name, root_login=None, root_password=None): import frappe @@ -50,3 +64,74 @@ def get_db(host=None, user=None, password=None, port=None): import frappe.database.mariadb.database return frappe.database.mariadb.database.MariaDBDatabase(host, user, password, port=port) + + +def get_command( + host=None, port=None, user=None, password=None, db_name=None, extra=None, dump=False +): + import frappe + + if frappe.conf.db_type == "postgres": + if dump: + bin, bin_name = which("pg_dump"), "pg_dump" + else: + bin, bin_name = which("psql"), "psql" + + host = frappe.utils.esc(host, "$ ") + user = frappe.utils.esc(user, "$ ") + db_name = frappe.utils.esc(db_name, "$ ") + + if password: + password = frappe.utils.esc(password, "$ ") + conn_string = f"postgresql://{user}:{password}@{host}:{port}/{db_name}" + else: + conn_string = f"postgresql://{user}@{host}:{port}/{db_name}" + + command = [conn_string] + + if extra: + command.extend(extra) + + else: + if dump: + bin, bin_name = which("mariadb-dump") or which("mysqldump"), "mariadb-dump" + else: + bin, bin_name = which("mariadb") or which("mysql"), "mariadb" + + host = frappe.utils.esc(host, "$ ") + user = frappe.utils.esc(user, "$ ") + db_name = frappe.utils.esc(db_name, "$ ") + + command = [ + f"--user={user}", + f"--host={host}", + f"--port={port}", + ] + + if password: + password = frappe.utils.esc(password, "$ ") + command.append(f"--password={password}") + + if dump: + command.extend( + [ + "--single-transaction", + "--quick", + "--lock-tables=false", + ] + ) + else: + command.extend( + [ + "--pager=less -SFX", + "--safe-updates", + "--no-auto-rehash", + ] + ) + + command.append(db_name) + + if extra: + command.extend(extra) + + return bin, command, bin_name diff --git a/frappe/database/database.py b/frappe/database/database.py index 9a6cb7772a..d04135e827 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -1,7 +1,6 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import datetime import itertools import json import random @@ -14,7 +13,6 @@ from time import time from typing import Any from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder -from pypika.terms import Criterion, NullValue import frappe import frappe.defaults @@ -163,6 +161,8 @@ class Database: :param auto_commit: Commit after executing the query. :param update: Update this dict to all rows (if returned `as_dict`). :param run: Returns query without executing it if False. + :param pluck: Get the plucked field only. + :param explain: Print `EXPLAIN` in error log. Examples: # return customer names as dicts @@ -369,7 +369,7 @@ class Database: self.commit() self.sql(query, debug=debug) - def check_transaction_status(self, query): + def check_transaction_status(self, query: str): """Raises exception if more than 200,000 `INSERT`, `UPDATE` queries are executed in one transaction. This is to ensure that writes are always flushed otherwise this could cause the system to hang.""" @@ -388,13 +388,13 @@ class Database: msg += _("The changes have been reverted.") + "
" raise frappe.TooManyWritesError(msg) - def check_implicit_commit(self, query): + def check_implicit_commit(self, query: str): if ( self.transaction_writes and query and is_query_type(query, ("start", "alter", "drop", "create", "begin", "truncate")) ): - raise ImplicitCommitError("This statement can cause implicit commit") + raise ImplicitCommitError("This statement can cause implicit commit", query) def fetch_as_dict(self) -> list[frappe._dict]: """Internal. Converts results to dict.""" diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py index 6431217585..68cd39f2f5 100644 --- a/frappe/database/db_manager.py +++ b/frappe/database/db_manager.py @@ -1,4 +1,5 @@ import frappe +from frappe import _ class DbManager: @@ -49,37 +50,38 @@ class DbManager: return self.db.sql("SHOW DATABASES", pluck=True) @staticmethod - def restore_database(target, source, user, password): - import os + def restore_database(verbose, target, source, user, password): + import shlex from shutil import which - from frappe.utils import make_esc + from frappe.database import get_command + from frappe.utils import execute_in_shell - esc = make_esc("$ ") pv = which("pv") - mariadb_cli = which("mariadb") or which("mysql") + + command = [] if pv: - pipe = f"{pv} {source} |" - source = "" - else: - pipe = "" - source = f"< {source}" - - if pipe: + command.extend([pv, source, "|"]) + source = [] print("Restoring Database file...") + else: + source = ["<", source] - command = "{pipe} {mariadb_cli} -u {user} -p{password} -h{host} -P{port} {target} {source}" - command = command.format( - pipe=pipe, - user=esc(user), - password=esc(password), - host=esc(frappe.conf.db_host), - target=esc(target), - source=source, + bin, args, bin_name = get_command( + host=frappe.conf.db_host, port=frappe.conf.db_port, - mariadb_cli=mariadb_cli, + user=user, + password=password, + db_name=target, ) - - os.system(command) + if not bin: + frappe.throw( + _("{} not found in PATH! This is required to restore the database.").format(bin_name), + exc=frappe.ExecutableNotFound, + ) + command.append(bin) + command.append(shlex.join(args)) + command.extend(source) + execute_in_shell(" ".join(command), check_exit_code=True, verbose=verbose) frappe.cache.delete_keys("") # Delete all keys associated with this site. diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index df64bdc86a..1f087a243a 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -310,7 +310,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database): ) @staticmethod - def get_on_duplicate_update(key=None): + def get_on_duplicate_update(): return "ON DUPLICATE key UPDATE " def get_table_columns_description(self, table_name): @@ -329,7 +329,8 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database): and Seq_in_index = 1 limit 1 ), 0) as 'index', - column_key = 'UNI' as 'unique' + column_key = 'UNI' as 'unique', + (is_nullable = 'NO') AS 'not_nullable' from information_schema.columns as columns where table_name = '{table_name}' """.format( table_name=table_name diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index 209535b9fd..0486ab9463 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -3,6 +3,7 @@ from pymysql.constants.ER import DUP_ENTRY import frappe from frappe import _ from frappe.database.schema import DBTable +from frappe.utils.defaults import get_not_null_defaults class MariaDBTable(DBTable): @@ -23,7 +24,7 @@ class MariaDBTable(DBTable): additional_definitions += index_defs # child table columns - if self.meta.get("istable") or 0: + if self.meta.get("istable", default=0): additional_definitions += [ f"parent varchar({varchar_len})", f"parentfield varchar({varchar_len})", @@ -69,7 +70,7 @@ class MariaDBTable(DBTable): add_column_query = [ f"ADD COLUMN `{col.fieldname}` {col.get_definition()}" for col in self.add_column ] - columns_to_modify = set(self.change_type + self.set_default) + columns_to_modify = set(self.change_type + self.set_default + self.change_nullability) modify_column_query = [ f"MODIFY `{col.fieldname}` {col.get_definition(for_modification=True)}" for col in columns_to_modify @@ -102,12 +103,23 @@ class MariaDBTable(DBTable): if index_record := frappe.db.get_column_index(self.table_name, col.fieldname, unique=False): drop_index_query.append(f"DROP INDEX `{index_record.Key_name}`") + for col in self.change_nullability: + if col.not_nullable: + try: + table = frappe.qb.DocType(self.doctype) + frappe.qb.update(table).set( + col.fieldname, col.default or get_not_null_defaults(col.fieldtype) + ).where(table[col.fieldname].isnull()).run() + except Exception: + print(f"Failed to update data in {self.table_name} for {col.fieldname}") + raise try: for query_parts in [add_column_query, modify_column_query, add_index_query, drop_index_query]: if query_parts: query_body = ", ".join(query_parts) query = f"ALTER TABLE `{self.table_name}` {query_body}" - frappe.db.sql(query) + # nosemgrep + frappe.db.sql_ddl(query) except Exception as e: if query := locals().get("query"): # this weirdness is to avoid potentially unbounded vars diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py index 87a7f6d343..de1173e507 100644 --- a/frappe/database/mariadb/setup_db.py +++ b/frappe/database/mariadb/setup_db.py @@ -23,7 +23,7 @@ def get_mariadb_version(version_string: str = ""): return version.rsplit(".", 1) -def setup_database(force, source_sql, verbose, no_mariadb_socket=False): +def setup_database(force, verbose, no_mariadb_socket=False): frappe.local.session = frappe._dict({"user": "Administrator"}) db_name = frappe.local.conf.db_name @@ -55,8 +55,6 @@ def setup_database(force, source_sql, verbose, no_mariadb_socket=False): # close root connection root_conn.close() - bootstrap_database(db_name, verbose, source_sql) - def drop_user_and_database(db_name, root_login, root_password): frappe.local.db = get_root_connection(root_login, root_password) @@ -75,8 +73,8 @@ def bootstrap_database(db_name, verbose, source_sql=None): sys.exit(1) import_db_from_sql(source_sql, verbose) - frappe.connect(db_name=db_name) + if "tabDefaultValue" not in frappe.db.get_tables(cached=False): from click import secho @@ -97,7 +95,9 @@ def import_db_from_sql(source_sql=None, verbose=False): db_name = frappe.conf.db_name if not source_sql: source_sql = os.path.join(os.path.dirname(__file__), "framework_mariadb.sql") - DbManager(frappe.local.db).restore_database(db_name, source_sql, db_name, frappe.conf.db_password) + DbManager(frappe.local.db).restore_database( + verbose, db_name, source_sql, db_name, frappe.conf.db_password + ) if verbose: print("Imported from database %s" % source_sql) diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index edb2dc745a..37fc9601f2 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -160,11 +160,16 @@ class PostgresDatabase(PostgresExceptionUtil, Database): return LazyDecode(self._cursor.query) def get_connection(self): - conn = psycopg2.connect( - "host='{}' dbname='{}' user='{}' password='{}' port={}".format( - self.host, self.user, self.user, self.password, self.port - ) - ) + conn_settings = { + "user": self.user, + "dbname": self.user, + "host": self.host, + "password": self.password, + } + if self.port: + conn_settings["port"] = self.port + + conn = psycopg2.connect(**conn_settings) conn.set_isolation_level(ISOLATION_LEVEL_REPEATABLE_READ) return conn @@ -387,7 +392,8 @@ class PostgresDatabase(PostgresExceptionUtil, Database): END AS type, BOOL_OR(b.index) AS index, SPLIT_PART(COALESCE(a.column_default, NULL), '::', 1) AS default, - BOOL_OR(b.unique) AS unique + BOOL_OR(b.unique) AS unique, + COALESCE(a.is_nullable = 'NO', false) AS not_nullable FROM information_schema.columns a LEFT JOIN (SELECT indexdef, tablename, @@ -397,7 +403,7 @@ class PostgresDatabase(PostgresExceptionUtil, Database): WHERE tablename='{table_name}') b ON SUBSTRING(b.indexdef, '(.*)') LIKE CONCAT('%', a.column_name, '%') WHERE a.table_name = '{table_name}' - GROUP BY a.column_name, a.data_type, a.column_default, a.character_maximum_length; + GROUP BY a.column_name, a.data_type, a.column_default, a.character_maximum_length, a.is_nullable; """.format( table_name=table_name ), diff --git a/frappe/database/postgres/schema.py b/frappe/database/postgres/schema.py index 073fafd8c7..48fd66e31a 100644 --- a/frappe/database/postgres/schema.py +++ b/frappe/database/postgres/schema.py @@ -2,6 +2,7 @@ import frappe from frappe import _ from frappe.database.schema import DBTable, get_definition from frappe.utils import cint, flt +from frappe.utils.defaults import get_not_null_defaults class PostgresTable(DBTable): @@ -16,7 +17,7 @@ class PostgresTable(DBTable): additional_definitions += ",\n".join(column_defs) # child table columns - if self.meta.get("istable") or 0: + if self.meta.get("istable", default=0): if column_defs: additional_definitions += ",\n" @@ -45,7 +46,7 @@ class PostgresTable(DBTable): docstatus smallint not null default '0', idx bigint not null default '0', {additional_definitions} - )""" + )""", ) self.create_indexes() @@ -139,11 +140,34 @@ class PostgresTable(DBTable): if col.fieldname != "name": # if index key exists drop_contraint_query += f'DROP INDEX IF EXISTS "unique_{col.fieldname}" ;' + + change_nullability = [] + for col in self.change_nullability: + default = col.default or get_not_null_defaults(col.fieldtype) + if isinstance(default, str): + default = frappe.db.escape(default) + change_nullability.append( + f"ALTER COLUMN \"{col.fieldname}\" {'SET' if col.not_nullable else 'DROP'} NOT NULL" + ) + change_nullability.append(f'ALTER COLUMN "{col.fieldname}" SET DEFAULT {default}') + + if col.not_nullable: + try: + table = frappe.qb.DocType(self.doctype) + frappe.qb.update(table).set( + col.fieldname, col.default or get_not_null_defaults(col.fieldtype) + ).where(table[col.fieldname].isnull()).run() + except Exception: + print(f"Failed to update data in {self.table_name} for {col.fieldname}") + raise try: if query: final_alter_query = "ALTER TABLE `{}` {}".format(self.table_name, ", ".join(query)) # nosemgrep frappe.db.sql(final_alter_query) + if change_nullability: + # nosemgrep + frappe.db.sql(f"ALTER TABLE `{self.table_name}` {','.join(change_nullability)}") if create_contraint_query: # nosemgrep frappe.db.sql(create_contraint_query) diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index 9d1d045b04..8de3e532b9 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -1,9 +1,10 @@ import os import frappe +from frappe import _ -def setup_database(force, source_sql=None, verbose=False): +def setup_database(): root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password) root_conn.commit() root_conn.sql("end") @@ -14,9 +15,6 @@ def setup_database(force, source_sql=None, verbose=False): root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name)) root_conn.close() - bootstrap_database(frappe.conf.db_name, verbose, source_sql=source_sql) - frappe.connect() - def bootstrap_database(db_name, verbose, source_sql=None): frappe.connect(db_name=db_name) @@ -38,13 +36,11 @@ def bootstrap_database(db_name, verbose, source_sql=None): def import_db_from_sql(source_sql=None, verbose=False): + import shlex from shutil import which - from subprocess import PIPE, run - # we can't pass psql password in arguments in postgresql as mysql. So - # set password connection parameter in environment variable - subprocess_env = os.environ.copy() - subprocess_env["PGPASSWORD"] = str(frappe.conf.db_password) + from frappe.database import get_command + from frappe.utils import execute_in_shell # bootstrap db if not source_sql: @@ -52,27 +48,33 @@ def import_db_from_sql(source_sql=None, verbose=False): pv = which("pv") - _command = ( - f"psql {frappe.conf.db_name} " - f"-h {frappe.conf.db_host} -p {str(frappe.conf.db_port)} " - f"-U {frappe.conf.db_name}" - ) + command = [] if pv: - command = f"{pv} {source_sql} | " + _command + command.extend([pv, source_sql, "|"]) + source = [] + print("Restoring Database file...") else: - command = _command + f" -f {source_sql}" + source = ["-f", source_sql] - print("Restoring Database file...") - if verbose: - print(command) + bin, args, bin_name = get_command( + host=frappe.conf.db_host, + port=frappe.conf.db_port, + user=frappe.conf.db_name, + password=frappe.conf.db_password, + db_name=frappe.conf.db_name, + ) - restore_proc = run(command, env=subprocess_env, shell=True, stdout=PIPE) - - if verbose: - print( - f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}" + if not bin: + frappe.throw( + _("{} not found in PATH! This is required to restore the database.").format(bin_name), + exc=frappe.ExecutableNotFound, ) + command.append(bin) + command.append(shlex.join(args)) + command.extend(source) + execute_in_shell(" ".join(command), check_exit_code=True, verbose=verbose) + frappe.cache.delete_keys("") # Delete all keys associated with this site. def get_root_connection(root_login=None, root_password=None): diff --git a/frappe/database/schema.py b/frappe/database/schema.py index 24eea24fa9..1387cbc549 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -3,6 +3,7 @@ import re import frappe from frappe import _ from frappe.utils import cint, cstr, flt +from frappe.utils.defaults import get_not_null_defaults SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE) VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)") @@ -24,6 +25,7 @@ class DBTable: self.add_column: list[DbColumn] = [] self.change_type: list[DbColumn] = [] self.change_name: list[DbColumn] = [] + self.change_nullability: list[DbColumn] = [] self.add_unique: list[DbColumn] = [] self.add_index: list[DbColumn] = [] self.drop_unique: list[DbColumn] = [] @@ -89,15 +91,16 @@ class DBTable: continue self.columns[field.get("fieldname")] = DbColumn( - self, - field.get("fieldname"), - field.get("fieldtype"), - field.get("length"), - field.get("default"), - field.get("search_index"), - field.get("options"), - field.get("unique"), - field.get("precision"), + table=self, + fieldname=field.get("fieldname"), + fieldtype=field.get("fieldtype"), + length=field.get("length"), + default=field.get("default"), + set_index=field.get("search_index"), + options=field.get("options"), + unique=field.get("unique"), + precision=field.get("precision"), + not_nullable=field.get("not_nullable"), ) def validate(self): @@ -175,7 +178,18 @@ class DBTable: class DbColumn: def __init__( - self, table, fieldname, fieldtype, length, default, set_index, options, unique, precision + self, + *, + table, + fieldname, + fieldtype, + length, + default, + set_index, + options, + unique, + precision, + not_nullable, ): self.table = table self.fieldname = fieldname @@ -186,6 +200,7 @@ class DbColumn: self.options = options self.unique = unique self.precision = precision + self.not_nullable = not_nullable def get_definition(self, for_modification=False): column_def = get_definition(self.fieldtype, precision=self.precision, length=self.length) @@ -193,24 +208,43 @@ class DbColumn: if not column_def: return column_def + null = True + default = None + unique = False + if self.fieldtype in ("Check", "Int"): - default_value = cint(self.default) or 0 - column_def += f" not null default {default_value}" + default = cint(self.default) + null = False elif self.fieldtype in ("Currency", "Float", "Percent"): - default_value = flt(self.default) or 0 - column_def += f" not null default {default_value}" + default = flt(self.default) + null = False elif ( self.default and (self.default not in frappe.db.DEFAULT_SHORTCUTS) and not cstr(self.default).startswith(":") ): - column_def += f" default {frappe.db.escape(self.default)}" + default = frappe.db.escape(self.default) + + if self.not_nullable and null: + if default is None: + default = get_not_null_defaults(self.fieldtype) + if isinstance(default, str): + default = frappe.db.escape(default) + null = False if self.unique and not for_modification and (column_def not in ("text", "longtext")): - column_def += " unique" + unique = True + if not null: + column_def += " NOT NULL" + + if default is not None: + column_def += f" DEFAULT {default}" + + if unique: + column_def += " UNIQUE" return column_def def build_for_alter_table(self, current_def): @@ -250,11 +284,15 @@ class DbColumn: ): self.table.set_default.append(self) + # nullability + if self.not_nullable is not None and (self.not_nullable != current_def["not_nullable"]): + self.table.change_nullability.append(self) + # index should be applied or dropped irrespective of type change if (current_def["index"] and not self.set_index) and column_type not in ("text", "longtext"): self.table.drop_index.append(self) - elif (not current_def["index"] and self.set_index) and not (column_type in ("text", "longtext")): + elif (not current_def["index"] and self.set_index) and column_type not in ("text", "longtext"): self.table.add_index.append(self) def default_changed(self, current_def): diff --git a/frappe/database/utils.py b/frappe/database/utils.py index 7cdab76dda..5d1de5792f 100644 --- a/frappe/database/utils.py +++ b/frappe/database/utils.py @@ -1,7 +1,6 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import typing from functools import cached_property from types import NoneType @@ -9,9 +8,6 @@ import frappe from frappe.query_builder.builder import MariaDB, Postgres from frappe.query_builder.functions import Function -if typing.TYPE_CHECKING: - from frappe.query_builder import DocType - Query = str | MariaDB | Postgres QueryValues = tuple | list | dict | NoneType @@ -27,7 +23,7 @@ NestedSetHierarchy = ( ) -def is_query_type(query: str, query_type: str | tuple[str]) -> bool: +def is_query_type(query: str, query_type: str | tuple[str, ...]) -> bool: return query.lstrip().split(maxsplit=1)[0].lower().startswith(query_type) diff --git a/frappe/desk/doctype/bulk_update/bulk_update.py b/frappe/desk/doctype/bulk_update/bulk_update.py index 526543e825..27ffb4ffb8 100644 --- a/frappe/desk/doctype/bulk_update/bulk_update.py +++ b/frappe/desk/doctype/bulk_update/bulk_update.py @@ -46,8 +46,29 @@ class BulkUpdate(Document): @frappe.whitelist() def submit_cancel_or_update_docs(doctype, docnames, action="submit", data=None): - docnames = frappe.parse_json(docnames) + if isinstance(docnames, str): + docnames = frappe.parse_json(docnames) + if len(docnames) < 20: + return _bulk_action(doctype, docnames, action, data) + elif len(docnames) <= 500: + frappe.msgprint(_("Bulk operation is enqueued in background."), alert=True) + frappe.enqueue( + _bulk_action, + doctype=doctype, + docnames=docnames, + action=action, + data=data, + queue="short", + timeout=1000, + ) + else: + frappe.throw( + _("Bulk operations only support up to 500 documents."), title=_("Too Many Documents") + ) + + +def _bulk_action(doctype, docnames, action, data): if data: data = frappe.parse_json(data) @@ -85,5 +106,4 @@ def submit_cancel_or_update_docs(doctype, docnames, action="submit", data=None): def show_progress(docnames, message, i, description): n = len(docnames) - if n >= 10: - frappe.publish_progress(float(i) * 100 / n, title=message, description=description) + frappe.publish_progress(float(i) * 100 / n, title=message, description=description) diff --git a/frappe/desk/doctype/bulk_update/test_bulk_update.py b/frappe/desk/doctype/bulk_update/test_bulk_update.py new file mode 100644 index 0000000000..7611141a0a --- /dev/null +++ b/frappe/desk/doctype/bulk_update/test_bulk_update.py @@ -0,0 +1,48 @@ +# Copyright (c) 2023, Frappe Technologies and Contributors +# See LICENSE + +import time + +import frappe +from frappe.core.doctype.doctype.test_doctype import new_doctype +from frappe.desk.doctype.bulk_update.bulk_update import submit_cancel_or_update_docs +from frappe.tests.utils import FrappeTestCase, timeout + + +class TestBulkUpdate(FrappeTestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.doctype = new_doctype(is_submittable=1, custom=1).insert().name + frappe.db.commit() + for _ in range(50): + frappe.new_doc(cls.doctype, some_fieldname=frappe.mock("name")).insert() + + @timeout() + def wait_for_assertion(self, assertion): + """Wait till an assertion becomes True""" + while True: + if assertion(): + break + time.sleep(0.2) + + def test_bulk_submit_in_background(self): + unsubmitted = frappe.get_all(self.doctype, {"docstatus": 0}, limit=5, pluck="name") + failed = submit_cancel_or_update_docs(self.doctype, unsubmitted, action="submit") + self.assertEqual(failed, []) + + def check_docstatus(docs, status): + frappe.db.rollback() + matching_docs = frappe.get_all( + self.doctype, {"docstatus": status, "name": ("in", docs)}, pluck="name" + ) + return set(matching_docs) == set(docs) + + unsubmitted = frappe.get_all(self.doctype, {"docstatus": 0}, limit=20, pluck="name") + submit_cancel_or_update_docs(self.doctype, unsubmitted, action="submit") + + self.wait_for_assertion(lambda: check_docstatus(unsubmitted, 1)) + + submitted = frappe.get_all(self.doctype, {"docstatus": 1}, limit=20, pluck="name") + submit_cancel_or_update_docs(self.doctype, submitted, action="cancel") + self.wait_for_assertion(lambda: check_docstatus(submitted, 2)) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js index 5d16a6d6d1..3577e9c5ec 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js @@ -30,19 +30,21 @@ frappe.ui.form.on("Dashboard Chart", { frm.disable_form(); } - frm.add_custom_button("Add Chart to Dashboard", () => { - const dialog = frappe.dashboard_utils.get_add_to_dashboard_dialog( - frm.doc.name, - "Dashboard Chart", - "frappe.desk.doctype.dashboard_chart.dashboard_chart.add_chart_to_dashboard" - ); + if (!frm.is_new()) { + frm.add_custom_button("Add Chart to Dashboard", () => { + const dialog = frappe.dashboard_utils.get_add_to_dashboard_dialog( + frm.doc.name, + "Dashboard Chart", + "frappe.desk.doctype.dashboard_chart.dashboard_chart.add_chart_to_dashboard" + ); - if (!frm.doc.chart_name) { - frappe.msgprint(__("Please create chart first")); - } else { - dialog.show(); - } - }); + if (!frm.doc.chart_name) { + frappe.msgprint(__("Please create chart first")); + } else { + dialog.show(); + } + }); + } frm.set_df_property("filters_section", "hidden", 1); frm.set_df_property("dynamic_filters_section", "hidden", 1); diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 1c331a7fe8..059624d28f 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -209,10 +209,10 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): data = frappe.db.get_list( doctype, - fields=[f"{datefield} as _unit", f"SUM({value_field})", "COUNT(*)"], + fields=[datefield, f"SUM({value_field})", "COUNT(*)"], filters=filters, - group_by="_unit", - order_by="_unit asc", + group_by=datefield, + order_by=datefield, as_list=True, ) diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.py b/frappe/desk/doctype/desktop_icon/desktop_icon.py index 7901ef9500..fda9eed7bb 100644 --- a/frappe/desk/doctype/desktop_icon/desktop_icon.py +++ b/frappe/desk/doctype/desktop_icon/desktop_icon.py @@ -37,6 +37,7 @@ class DesktopIcon(Document): reverse: DF.Check standard: DF.Check type: DF.Literal["module", "list", "link", "page", "query-report"] + # end: auto-generated types def validate(self): if not self.label: @@ -225,7 +226,7 @@ def add_user_icon(_doctype, _report=None, label=None, link=None, type="link", st icon_name = new_icon.name - except frappe.UniqueValidationError as e: + except frappe.UniqueValidationError: frappe.throw(_("Desktop Icon already exists")) except Exception as e: raise e @@ -262,7 +263,7 @@ def set_desktop_icons(visible_list, ignore_duplicate=True): an icon for the doctype""" # clear all custom only if setup is not complete - if not int(frappe.defaults.get_defaults().setup_complete or 0): + if not frappe.defaults.get_defaults().get("setup_complete", 0): frappe.db.delete("Desktop Icon", {"standard": 0}) # set standard as blocked and hidden if setting first active domain diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index 5463df6413..bf56498780 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -121,9 +121,7 @@ class Event(Document): ["Communication Link", "link_doctype", "=", participant.reference_doctype], ["Communication Link", "link_name", "=", participant.reference_docname], ] - comms = frappe.get_all("Communication", filters=filters, fields=["name"]) - - if comms: + if comms := frappe.get_all("Communication", filters=filters, fields=["name"], distinct=True): for comm in comms: communication = frappe.get_doc("Communication", comm.name) self.update_communication(participant, communication) diff --git a/frappe/desk/doctype/notification_log/notification_log.js b/frappe/desk/doctype/notification_log/notification_log.js index ea5fdc6400..63776fbf95 100644 --- a/frappe/desk/doctype/notification_log/notification_log.js +++ b/frappe/desk/doctype/notification_log/notification_log.js @@ -11,6 +11,10 @@ frappe.ui.form.on("Notification Log", { }, open_reference_document: function (frm) { + if (frm.doc?.link) { + frappe.set_route(frm.doc.link); + return; + } const dt = frm.doc.document_type; const dn = frm.doc.document_name; frappe.set_route("Form", dt, dn); diff --git a/frappe/desk/doctype/notification_log/notification_log.json b/frappe/desk/doctype/notification_log/notification_log.json index bafe28faf8..9fbe7324d3 100644 --- a/frappe/desk/doctype/notification_log/notification_log.json +++ b/frappe/desk/doctype/notification_log/notification_log.json @@ -15,7 +15,8 @@ "attached_file", "attachment_link", "open_reference_document", - "from_user" + "from_user", + "link" ], "fields": [ { @@ -91,12 +92,18 @@ "fieldname": "attachment_link", "fieldtype": "HTML", "label": "Attachment Link" + }, + { + "fieldname": "link", + "fieldtype": "Data", + "hidden": 1, + "label": "Link" } ], "hide_toolbar": 1, "in_create": 1, "links": [], - "modified": "2023-06-14 21:20:51.197943", + "modified": "2023-11-18 22:40:12.145940", "modified_by": "Administrator", "module": "Desk", "name": "Notification Log", diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py index e459a63ef8..e6e13dafe7 100644 --- a/frappe/desk/doctype/notification_log/notification_log.py +++ b/frappe/desk/doctype/notification_log/notification_log.py @@ -25,6 +25,7 @@ class NotificationLog(Document): email_content: DF.TextEditor | None for_user: DF.Link | None from_user: DF.Link | None + link: DF.Data | None read: DF.Check subject: DF.Text | None type: DF.Literal["Mention", "Energy Point", "Assignment", "Share", "Alert"] @@ -125,21 +126,24 @@ def send_notification_email(doc): if not email: return - doc_link = get_url_to_form(doc.document_type, doc.document_name) header = get_email_header(doc) email_subject = strip_html(doc.subject) + args = { + "body_content": doc.subject, + "description": doc.email_content, + } + if doc.link: + args["doc_link"] = doc.link + else: + args["document_type"] = doc.document_type + args["document_name"] = doc.document_name + args["doc_link"] = get_url_to_form(doc.document_type, doc.document_name) frappe.sendmail( recipients=email, subject=email_subject, template="new_notification", - args={ - "body_content": doc.subject, - "description": doc.email_content, - "document_type": doc.document_type, - "document_name": doc.document_name, - "doc_link": doc_link, - }, + args=args, header=[header, "orange"], now=frappe.flags.in_test, ) diff --git a/frappe/desk/doctype/number_card/number_card.js b/frappe/desk/doctype/number_card/number_card.js index ac881d47cf..ea60e2a194 100644 --- a/frappe/desk/doctype/number_card/number_card.js +++ b/frappe/desk/doctype/number_card/number_card.js @@ -4,8 +4,11 @@ frappe.ui.form.on("Number Card", { refresh: function (frm) { if (!frappe.boot.developer_mode && frm.doc.is_standard) { - frm.disable_form(); + frm.disable_save(); + } else { + frm.enable_save(); } + frm.set_df_property("filters_section", "hidden", 1); frm.set_df_property("dynamic_filters_section", "hidden", 1); frm.trigger("set_options"); @@ -19,12 +22,7 @@ frappe.ui.form.on("Number Card", { } if (frm.doc.type == "Custom") { - if (!frappe.boot.developer_mode) { - frm.disable_form(); - } frm.filters = eval(frm.doc.filters_config); - frm.trigger("set_filters_description"); - frm.trigger("set_method_description"); frm.trigger("render_filters_table"); } frm.trigger("set_parent_document_type"); @@ -68,49 +66,7 @@ frappe.ui.form.on("Number Card", { frm.set_df_property("dynamic_filters_section", "hidden", 1); }, - set_filters_description: function (frm) { - if (frm.doc.type == "Custom") { - frm.fields_dict.filters_config.set_description(` - Set the filters here. For example: -
-
-[{
-	fieldname: "company",
-	label: __("Company"),
-	fieldtype: "Link",
-	options: "Company",
-	default: frappe.defaults.get_user_default("Company"),
-	reqd: 1
-},
-{
-	fieldname: "account",
-	label: __("Account"),
-	fieldtype: "Link",
-	options: "Account",
-	reqd: 1
-}]
-
`); - } - }, - - set_method_description: function (frm) { - if (frm.doc.type == "Custom") { - frm.fields_dict.method.set_description(` - Set the path to a whitelisted function that will return the data for the number card in the format: -
-
-{
-	"value": value,
-	"fieldtype": "Currency",
-	"route_options": {"from_date": "2023-05-23"},
-	"route": ["query-report", "Permitted Documents For User"]
-}
-
`); - } - }, - type: function (frm) { - frm.trigger("set_filters_description"); if (frm.doc.type == "Report") { frm.set_query("report_name", () => { return { diff --git a/frappe/desk/doctype/number_card/number_card.json b/frappe/desk/doctype/number_card/number_card.json index 50447a7bdb..dbe5177bdb 100644 --- a/frappe/desk/doctype/number_card/number_card.json +++ b/frappe/desk/doctype/number_card/number_card.json @@ -117,6 +117,7 @@ "fieldname": "is_standard", "fieldtype": "Check", "label": "Is Standard", + "no_copy": 1, "read_only_depends_on": "eval: !frappe.boot.developer_mode" }, { @@ -165,6 +166,7 @@ }, { "depends_on": "eval: doc.type == 'Custom'", + "description": "Set the path to a whitelisted function that will return the data for the number card in the format:\n\n
\n{\n\t\"value\": value,\n\t\"fieldtype\": \"Currency\",\n\t\"route_options\": {\"from_date\": \"2023-05-23\"},\n\t\"route\": [\"query-report\", \"Permitted Documents For User\"]\n}
", "fieldname": "method", "fieldtype": "Data", "label": "Method", @@ -177,6 +179,7 @@ "label": "Custom Configuration" }, { + "description": "Set the filters here. For example:\n
\n[{\n\tfieldname: \"company\",\n\tlabel: __(\"Company\"),\n\tfieldtype: \"Link\",\n\toptions: \"Company\",\n\tdefault: frappe.defaults.get_user_default(\"Company\"),\n\treqd: 1\n},\n{\n\tfieldname: \"account\",\n\tlabel: __(\"Account\"),\n\tfieldtype: \"Link\",\n\toptions: \"Account\",\n\treqd: 1\n}]\n
", "fieldname": "filters_config", "fieldtype": "Code", "label": "Filters Configuration", @@ -200,7 +203,7 @@ } ], "links": [], - "modified": "2023-08-28 22:23:56.286804", + "modified": "2023-11-09 13:44:00.280846", "modified_by": "Administrator", "module": "Desk", "name": "Number Card", diff --git a/frappe/desk/doctype/system_console/system_console.json b/frappe/desk/doctype/system_console/system_console.json index a851831909..9573e23b52 100644 --- a/frappe/desk/doctype/system_console/system_console.json +++ b/frappe/desk/doctype/system_console/system_console.json @@ -29,7 +29,7 @@ ], "fields": [ { - "description": "To print output use log(text)", + "description": "To print output use print(text)", "fieldname": "console", "fieldtype": "Code", "label": "Console", @@ -86,7 +86,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-04-15 14:15:58.398590", + "modified": "2023-11-03 13:02:00.706806", "modified_by": "Administrator", "module": "Desk", "name": "System Console", diff --git a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json index e494aad152..e47487eaaf 100644 --- a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json +++ b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json @@ -44,7 +44,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "DocType View", - "options": "\nList\nReport Builder\nDashboard\nTree\nNew\nCalendar\nKanban" + "options": "\nList\nReport Builder\nDashboard\nTree\nNew\nCalendar\nKanban\nImage" }, { "fieldname": "column_break_4", @@ -102,8 +102,7 @@ "fieldname": "url", "fieldtype": "Data", "in_list_view": 1, - "label": "URL", - "options": "URL" + "label": "URL" }, { "depends_on": "eval:doc.doc_view == \"Kanban\"", @@ -116,7 +115,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-07-18 16:12:53.546430", + "modified": "2023-11-27 14:13:38.489737", "modified_by": "Administrator", "module": "Desk", "name": "Workspace Shortcut", diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index 8d42b804cd..c9f7929b28 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -196,7 +196,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { this.abort_setup(r.message.fail); } }, - error: () => this.abort_setup("Error in setup"), + error: () => this.abort_setup(), }); } @@ -213,7 +213,11 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { abort_setup(fail_msg) { this.$working_state.find(".state-icon-container").html(""); - fail_msg = fail_msg ? fail_msg : __("Failed to complete setup"); + fail_msg = fail_msg + ? fail_msg + : frappe.last_response.setup_wizard_failure_message + ? frappe.last_response.setup_wizard_failure_message + : __("Failed to complete setup"); this.update_setup_message("Could not start up: " + fail_msg); @@ -463,7 +467,7 @@ frappe.setup.slides_settings = [ fieldtype: "Data", options: "Email", }, - { fieldname: "password", label: __("Password"), fieldtype: "Password" }, + { fieldname: "password", label: __("Password"), fieldtype: "Password", length: 512 }, ], onload: function (slide) { diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index e3df4eec5d..88a53df7b6 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -83,11 +83,14 @@ def process_setup_stages(stages, user_input, is_background_task=False): task.get("fn")(task.get("args")) except Exception: handle_setup_exception(user_input) + message = current_task.get("fail_msg") if current_task else "Failed to complete setup" + frappe.log_error(title=f"Setup failed: {message}") if not is_background_task: - return {"status": "fail", "fail": current_task.get("fail_msg")} + frappe.response["setup_wizard_failure_message"] = message + raise frappe.publish_realtime( "setup_task", - {"status": "fail", "fail_msg": current_task.get("fail_msg")}, + {"status": "fail", "fail_msg": message}, user=frappe.session.user, ) else: diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index fd299af819..8f2f7f8dca 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -215,12 +215,12 @@ def clean_params(data): def parse_json(data): - if isinstance(data.get("filters"), str): - data["filters"] = json.loads(data["filters"]) - if isinstance(data.get("or_filters"), str): - data["or_filters"] = json.loads(data["or_filters"]) - if isinstance(data.get("fields"), str): - data["fields"] = ["*"] if data["fields"] == "*" else json.loads(data["fields"]) + if (filters := data.get("filters")) and isinstance(filters, str): + data["filters"] = json.loads(filters) + if (or_filters := data.get("or_filters")) and isinstance(or_filters, str): + data["or_filters"] = json.loads(or_filters) + if (fields := data.get("fields")) and isinstance(fields, str): + data["fields"] = ["*"] if fields == "*" else json.loads(fields) if isinstance(data.get("docstatus"), str): data["docstatus"] = json.loads(data["docstatus"]) if isinstance(data.get("save_user_settings"), str): diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 90f71bf88f..5eb874633d 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -159,6 +159,15 @@ frappe.ui.form.on("Email Account", { delete frappe.route_flags.delete_user_from_locals; delete locals["User"][frappe.route_flags.linked_user]; } + + if (frappe.boot.developer_mode && !frm.is_dirty() && frm.doc.enable_incoming) { + frm.add_custom_button(__("Pull Emails"), () => { + frm.call({ + method: "pull_emails", + args: { email_account: frm.doc.name }, + }); + }); + } }, authorize_api_access: function (frm) { diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 9bcf328116..d7c75e03a1 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -831,6 +831,14 @@ def pull(now=False): ) +@frappe.whitelist() +def pull_emails(email_account: str) -> None: + """Pull emails from given email account.""" + frappe.has_permission("Email Account", "read", throw=True) + + pull_from_email_account(email_account) + + def pull_from_email_account(email_account): """Runs within a worker process""" email_account = frappe.get_doc("Email Account", email_account) diff --git a/frappe/email/doctype/email_domain/email_domain.py b/frappe/email/doctype/email_domain/email_domain.py index 5b9f38615a..fef6e1b303 100644 --- a/frappe/email/doctype/email_domain/email_domain.py +++ b/frappe/email/doctype/email_domain/email_domain.py @@ -73,6 +73,7 @@ class EmailDomain(Document): use_ssl_for_outgoing: DF.Check use_starttls: DF.Check use_tls: DF.Check + # end: auto-generated types def validate(self): """Validate POP3/IMAP and SMTP connections.""" @@ -120,4 +121,4 @@ class EmailDomain(Document): elif self.use_tls: self.smtp_port = self.smtp_port or 587 - conn_method((self.smtp_server or ""), cint(self.smtp_port) or 0).quit() + conn_method((self.smtp_server or ""), cint(self.smtp_port)).quit() diff --git a/frappe/email/doctype/email_group/email_group.py b/frappe/email/doctype/email_group/email_group.py index 7afd349804..f365fa2fb6 100755 --- a/frappe/email/doctype/email_group/email_group.py +++ b/frappe/email/doctype/email_group/email_group.py @@ -1,6 +1,8 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE +import contextlib + import frappe from frappe import _ from frappe.model.document import Document @@ -41,7 +43,7 @@ class EmailGroup(Document): added = 0 for user in frappe.get_all(doctype, [email_field, unsubscribed_field or "name"]): - try: + with contextlib.suppress(frappe.UniqueValidationError, frappe.InvalidEmailAddressError): email = parse_addr(user.get(email_field))[1] if user.get(email_field) else None if email: frappe.get_doc( @@ -52,10 +54,7 @@ class EmailGroup(Document): "unsubscribed": user.get(unsubscribed_field) if unsubscribed_field else 0, } ).insert(ignore_permissions=True) - added += 1 - except frappe.UniqueValidationError: - pass frappe.msgprint(_("{0} subscribers added").format(added)) @@ -123,6 +122,5 @@ def send_welcome_email(welcome_email, email, email_group): return args = dict(email=email, email_group=email_group) - email_message = welcome_email.response or welcome_email.response_html - message = frappe.render_template(email_message, args) + message = frappe.render_template(welcome_email.response_, args) frappe.sendmail(email, subject=welcome_email.subject, message=message) diff --git a/frappe/email/doctype/email_group_member/email_group_member.json b/frappe/email/doctype/email_group_member/email_group_member.json index 0e32135b72..0d68674101 100644 --- a/frappe/email/doctype/email_group_member/email_group_member.json +++ b/frappe/email/doctype/email_group_member/email_group_member.json @@ -28,6 +28,7 @@ "in_global_search": 1, "in_list_view": 1, "label": "Email", + "options": "Email", "reqd": 1 }, { @@ -40,7 +41,7 @@ } ], "links": [], - "modified": "2022-07-11 16:38:34.165271", + "modified": "2023-11-25 16:54:59.828669", "modified_by": "Administrator", "module": "Email", "name": "Email Group Member", diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index da10ae5d16..828ae2e419 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -687,13 +687,13 @@ class QueueBuilder: mail.set_in_reply_to(self.in_reply_to) return mail - def process(self, send_now=False): + def process(self, send_now=False) -> EmailQueue | None: """Build and return the email queues those are created. Sends email incase if it is requested to send now. """ final_recipients = self.final_recipients() - queue_separately = (final_recipients and self.queue_separately) or len(final_recipients) > 20 + queue_separately = (final_recipients and self.queue_separately) or len(final_recipients) > 100 if not (final_recipients + self.final_cc()): return [] @@ -705,6 +705,7 @@ class QueueBuilder: recipients = list(set(final_recipients + self.final_cc() + self.bcc)) q = EmailQueue.new({**queue_data, **{"recipients": recipients}}, ignore_permissions=True) send_now and q.send() + return q else: if send_now and len(final_recipients) >= 1000: # force queueing if there are too many recipients to avoid timeouts diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 5aabe3d85e..2c79f13541 100644 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -50,10 +50,10 @@ class Newsletter(WebsiteGenerator): total_recipients: DF.Int total_views: DF.Int # end: auto-generated types + def validate(self): self.route = f"newsletters/{self.name}" self.validate_sender_address() - self.validate_recipient_address() self.validate_publishing() self.validate_scheduling_date() @@ -135,7 +135,6 @@ class Newsletter(WebsiteGenerator): def validate_newsletter_recipients(self): if not self.newsletter_recipients: frappe.throw(_("Newsletter should have atleast one recipient"), exc=NoRecipientFoundError) - self.validate_recipient_address() def validate_sender_address(self): """Validate self.send_from is a valid email address or not.""" @@ -145,11 +144,6 @@ class Newsletter(WebsiteGenerator): f"{self.sender_name} <{self.sender_email}>" if self.sender_name else self.sender_email ) - def validate_recipient_address(self): - """Validate if self.newsletter_recipients are all valid email addresses or not.""" - for recipient in self.newsletter_recipients: - frappe.utils.validate_email_address(recipient, throw=True) - def validate_publishing(self): if self.send_webview_link and not self.published: frappe.throw(_("Newsletter must be published to send webview link in email")) @@ -308,11 +302,11 @@ def confirmed_unsubscribe(email, group): @frappe.whitelist(allow_guest=True) @rate_limit(limit=10, seconds=60 * 60) -def subscribe(email, email_group=None): # noqa +def subscribe(email, email_group=None): """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email.""" if email_group is None: - email_group = _("Website") + email_group = get_default_email_group() # build subscription confirmation URL api_endpoint = frappe.utils.get_url( @@ -355,13 +349,16 @@ def subscribe(email, email_group=None): # noqa @frappe.whitelist(allow_guest=True) -def confirm_subscription(email, email_group=_("Website")): # noqa +def confirm_subscription(email, email_group=None): """API endpoint to confirm email subscription. This endpoint is called when user clicks on the link sent to their mail. """ if not verify_request(): return + if email_group is None: + email_group = get_default_email_group() + if not frappe.db.exists("Email Group", email_group): frappe.get_doc({"doctype": "Email Group", "title": email_group}).insert(ignore_permissions=True) @@ -438,3 +435,7 @@ def newsletter_email_read(recipient_email=None, reference_doctype=None, referenc finally: frappe.response.update(frappe.utils.get_imaginary_pixel_response()) + + +def get_default_email_group(): + return _("Website", lang=frappe.db.get_default("language")) diff --git a/frappe/email/doctype/newsletter/newsletter_list.js b/frappe/email/doctype/newsletter/newsletter_list.js index 0921de02b4..71e9423b7e 100644 --- a/frappe/email/doctype/newsletter/newsletter_list.js +++ b/frappe/email/doctype/newsletter/newsletter_list.js @@ -2,11 +2,11 @@ frappe.listview_settings["Newsletter"] = { add_fields: ["subject", "email_sent", "schedule_sending"], get_indicator: function (doc) { if (doc.email_sent) { - return [__("Sent"), "green", "email_sent,=,Yes"]; + return [__("Sent"), "green", "email_sent,=,1"]; } else if (doc.schedule_sending) { - return [__("Scheduled"), "purple", "email_sent,=,No|schedule_sending,=,Yes"]; + return [__("Scheduled"), "purple", "email_sent,=,0|schedule_sending,=,1"]; } else { - return [__("Not Sent"), "gray", "email_sent,=,No"]; + return [__("Not Sent"), "gray", "email_sent,=,0"]; } }, }; diff --git a/frappe/email/doctype/notification/notification.json b/frappe/email/doctype/notification/notification.json index 8b6900a3c9..3626cc29bc 100644 --- a/frappe/email/doctype/notification/notification.json +++ b/frappe/email/doctype/notification/notification.json @@ -36,6 +36,7 @@ "send_to_all_assignees", "recipients", "message_sb", + "message_type", "message", "message_examples", "view_properties", @@ -277,15 +278,24 @@ "fieldname": "send_to_all_assignees", "fieldtype": "Check", "label": "Send To All Assignees" + }, + { + "default": "Markdown", + "depends_on": "is_standard", + "fieldname": "message_type", + "fieldtype": "Select", + "label": "Message Type", + "options": "Markdown\nHTML\nPlain Text" } ], "icon": "fa fa-envelope", "index_web_pages_for_search": 1, "links": [], - "modified": "2021-05-04 11:17:11.882314", + "modified": "2023-11-17 08:48:25.616203", "modified_by": "Administrator", "module": "Email", "name": "Notification", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -301,6 +311,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "subject", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index a8c797a008..d89a2bbfbd 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -12,10 +12,12 @@ from frappe.desk.doctype.notification_log.notification_log import enqueue_create from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message from frappe.model.document import Document from frappe.modules.utils import export_module_json, get_doc_module -from frappe.utils import add_to_date, cast, is_html, nowdate, validate_email_address +from frappe.utils import add_to_date, cast, nowdate, validate_email_address from frappe.utils.jinja import validate_template from frappe.utils.safe_exec import get_safe_globals +FORMATS = {"HTML": ".html", "Markdown": ".md", "Plain Text": ".txt"} + class Notification(Document): # begin: auto-generated types @@ -50,6 +52,7 @@ class Notification(Document): ] is_standard: DF.Check message: DF.Code | None + message_type: DF.Literal["Markdown", "HTML", "Plain Text"] method: DF.Data | None module: DF.Link | None print_format: DF.Link | None @@ -93,11 +96,11 @@ class Notification(Document): def on_update(self): frappe.cache.hdel("notifications", self.document_type) path = export_module_json(self, self.is_standard, self.module) - if path: - # js - if not os.path.exists(path + ".md") and not os.path.exists(path + ".html"): - with open(path + ".md", "w") as f: - f.write(self.message) + if path and self.message: + extension = FORMATS.get(self.message_type, ".md") + file_path = path + extension + with open(file_path, "w") as f: + f.write(self.message) # py if not os.path.exists(path + ".py"): @@ -399,18 +402,26 @@ def get_context(context): } ] - def get_template(self): + def get_template(self, md_as_html=False): module = get_doc_module(self.module, self.doctype, self.name) - def load_template(extn): - template = "" - template_path = os.path.join(os.path.dirname(module.__file__), frappe.scrub(self.name) + extn) - if os.path.exists(template_path): - with open(template_path) as f: - template = f.read() - return template + path = os.path.join(os.path.dirname(module.__file__), frappe.scrub(self.name)) + extension = FORMATS.get(self.message_type, ".md") + file_path = path + extension - return load_template(".html") or load_template(".md") + template = "" + + if os.path.exists(file_path): + with open(file_path) as f: + template = f.read() + + if not template: + return + + if extension == ".md": + return frappe.utils.md_to_html(template) + + return template def load_standard_properties(self, context): """load templates and run get_context""" @@ -421,10 +432,7 @@ def get_context(context): if out: context.update(out) - self.message = self.get_template() - - if not is_html(self.message): - self.message = frappe.utils.md_to_html(self.message) + self.message = self.get_template(md_as_html=True) def on_trash(self): frappe.cache.hdel("notifications", self.document_type) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 0d1e7393c3..19798851fe 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -220,6 +220,9 @@ class EmailServer: ).where(Communication.email_account == self.settings.email_account).run() if self.settings.use_imap: + # Remove {"} quotes that are added to handle spaces in IMAP Folder names + if folder[0] == folder[-1] == '"': + folder = folder[1:-1] # new update for the IMAP Folder DocType IMAPFolder = frappe.qb.DocType("IMAP Folder") frappe.qb.update(IMAPFolder).set(IMAPFolder.uidvalidity, current_uid_validity).set( @@ -233,11 +236,6 @@ class EmailServer: EmailAccount.uidnext, uidnext ).where(EmailAccount.name == self.settings.email_account_name).run() - # uid validity not found pulling emails for first time - if not uid_validity: - self.settings.email_sync_rule = "UNSEEN" - return - sync_count = 100 if uid_validity else int(self.settings.initial_sync_count) from_uid = ( 1 if uidnext < (sync_count + 1) or (uidnext - sync_count) < 1 else uidnext - sync_count diff --git a/frappe/handler.py b/frappe/handler.py index 6db6a7600f..d889c67b23 100644 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -287,7 +287,6 @@ def get_attr(cmd): f"Calling shorthand for {cmd} is deprecated, please specify full path in RPC call." ) method = globals()[cmd] - frappe.log("method:" + cmd) return method diff --git a/frappe/hooks.py b/frappe/hooks.py index 988d0e5ee8..d889b5f7c2 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -31,6 +31,7 @@ app_include_js = [ "report.bundle.js", "telemetry.bundle.js", ] + app_include_css = [ "desk.bundle.css", "report.bundle.css", @@ -436,6 +437,7 @@ after_job = [ extend_bootinfo = [ "frappe.utils.telemetry.add_bootinfo", "frappe.core.doctype.user_permission.user_permission.send_user_permissions", + "frappe.utils.sentry.add_bootinfo", ] export_python_type_annotations = True @@ -516,3 +518,18 @@ standard_help_items = [ "is_standard": 1, }, ] + +# log doctype cleanups to automatically add in log settings +default_log_clearing_doctypes = { + "Error Log": 30, + "Activity Log": 90, + "Email Queue": 30, + "Scheduled Job Log": 90, + "Route History": 90, + "Submission Queue": 30, + "Prepared Report": 30, + "Webhook Request Log": 30, + "Integration Request": 90, + "Unhandled Email": 30, + "Reminder": 30, +} diff --git a/frappe/installer.py b/frappe/installer.py index 93facf2b0e..89b52e1d4e 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -48,6 +48,7 @@ def _new_site( db_type=None, db_host=None, db_port=None, + setup_db=True, ): """Install a new Frappe site""" @@ -66,7 +67,12 @@ def _new_site( if not db_name: import hashlib - db_name = "_" + hashlib.sha1(os.path.realpath(frappe.get_site_path()).encode()).hexdigest()[:16] + db_name = ( + "_" + + hashlib.sha1( + os.path.realpath(frappe.get_site_path()).encode(), usedforsecurity=False + ).hexdigest()[:16] + ) try: # enable scheduler post install? @@ -91,6 +97,7 @@ def _new_site( db_host=db_host, db_port=db_port, no_mariadb_socket=no_mariadb_socket, + setup=setup_db, ) apps_to_install = ( @@ -128,9 +135,10 @@ def install_db( db_host=None, db_port=None, no_mariadb_socket=False, + setup=True, ): import frappe.database - from frappe.database import setup_database + from frappe.database import bootstrap_database, setup_database if not db_type: db_type = frappe.conf.db_type @@ -152,7 +160,15 @@ def install_db( frappe.flags.root_login = root_login frappe.flags.root_password = root_password - setup_database(force, source_sql, verbose, no_mariadb_socket) + + if setup: + setup_database(force, verbose, no_mariadb_socket) + + bootstrap_database( + db_name=frappe.conf.db_name, + verbose=verbose, + source_sql=source_sql, + ) frappe.conf.admin_password = frappe.conf.admin_password or admin_password diff --git a/frappe/integrations/doctype/integration_request/integration_request.py b/frappe/integrations/doctype/integration_request/integration_request.py index c3cd8d061c..a81e702dfc 100644 --- a/frappe/integrations/doctype/integration_request/integration_request.py +++ b/frappe/integrations/doctype/integration_request/integration_request.py @@ -4,7 +4,7 @@ import json import frappe -from frappe.integrations.utils import json_handler +from frappe.integrations.utils import get_json, json_handler from frappe.model.document import Document @@ -45,7 +45,7 @@ class IntegrationRequest(Document): data = json.loads(self.data) data.update(params) - self.data = json.dumps(data) + self.data = get_json(data) self.status = status self.save(ignore_permissions=True) frappe.db.commit() diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.py b/frappe/integrations/doctype/social_login_key/social_login_key.py index 4c1fc8708f..3445bb92e3 100644 --- a/frappe/integrations/doctype/social_login_key/social_login_key.py +++ b/frappe/integrations/doctype/social_login_key/social_login_key.py @@ -222,6 +222,6 @@ def provider_allows_signup(provider: str) -> bool: sign_up_config = frappe.db.get_value("Social Login Key", provider, "sign_ups") - if not (sign_up_config and provider): # fallback to global settings + if not sign_up_config: # fallback to global settings return is_signup_disabled() return sign_up_config == "Allow" diff --git a/frappe/integrations/doctype/token_cache/token_cache.py b/frappe/integrations/doctype/token_cache/token_cache.py index da72335413..5619030499 100644 --- a/frappe/integrations/doctype/token_cache/token_cache.py +++ b/frappe/integrations/doctype/token_cache/token_cache.py @@ -1,7 +1,7 @@ # Copyright (c) 2019, Frappe Technologies and contributors # License: MIT. See LICENSE -from datetime import datetime, timedelta +import datetime import pytz @@ -73,8 +73,8 @@ class TokenCache(Document): system_timezone = pytz.timezone(get_system_timezone()) modified = frappe.utils.get_datetime(self.modified) modified = system_timezone.localize(modified) - expiry_utc = modified.astimezone(pytz.utc) + timedelta(seconds=self.expires_in) - now_utc = datetime.utcnow().replace(tzinfo=pytz.utc) + expiry_utc = modified.astimezone(pytz.utc) + datetime.timedelta(seconds=self.expires_in) + now_utc = datetime.datetime.now(pytz.utc) return cint((expiry_utc - now_utc).total_seconds()) def is_expired(self): diff --git a/frappe/integrations/doctype/webhook/__init__.py b/frappe/integrations/doctype/webhook/__init__.py index dcad1c8b5c..1cd08aeca1 100644 --- a/frappe/integrations/doctype/webhook/__init__.py +++ b/frappe/integrations/doctype/webhook/__init__.py @@ -4,60 +4,45 @@ import frappe +def get_all_webhooks(): + # query webhooks + webhooks_list = frappe.get_all( + "Webhook", + fields=["name", "condition", "webhook_docevent", "webhook_doctype"], + filters={"enabled": True}, + ) + + # make webhooks map + webhooks = {} + for w in webhooks_list: + webhooks.setdefault(w.webhook_doctype, []).append(w) + + return webhooks + + def run_webhooks(doc, method): """Run webhooks for this method""" + + frappe_flags = frappe.local.flags + if ( - frappe.flags.in_import - or frappe.flags.in_patch - or frappe.flags.in_install - or frappe.flags.in_migrate + frappe_flags.in_import + or frappe_flags.in_patch + or frappe_flags.in_install + or frappe_flags.in_migrate ): return - if frappe.flags.webhooks_executed is None: - frappe.flags.webhooks_executed = {} - - # TODO: remove this hazardous unnecessary cache in flags - if frappe.flags.webhooks is None: - # load webhooks from cache - webhooks = frappe.cache.get_value("webhooks") - if webhooks is None: - # query webhooks - webhooks_list = frappe.get_all( - "Webhook", - fields=["name", "condition", "webhook_docevent", "webhook_doctype"], - filters={"enabled": True}, - ) - - # make webhooks map for cache - webhooks = {} - for w in webhooks_list: - webhooks.setdefault(w.webhook_doctype, []).append(w) - frappe.cache.set_value("webhooks", webhooks) - - frappe.flags.webhooks = webhooks + # load all webhooks from cache / DB + webhooks = frappe.cache.get_value("webhooks", get_all_webhooks) # get webhooks for this doctype - webhooks_for_doc = frappe.flags.webhooks.get(doc.doctype, None) + webhooks_for_doc = webhooks.get(doc.doctype, None) if not webhooks_for_doc: # no webhooks, quit return - def _webhook_request(webhook): - if webhook.name not in frappe.flags.webhooks_executed.get(doc.name, []): - frappe.enqueue( - "frappe.integrations.doctype.webhook.webhook.enqueue_webhook", - enqueue_after_commit=True, - doc=doc, - webhook=webhook, - ) - - # keep list of webhooks executed for this doc in this request - # so that we don't run the same webhook for the same document multiple times - # in one request - frappe.flags.webhooks_executed.setdefault(doc.name, []).append(webhook.name) - event_list = ["on_update", "after_insert", "on_submit", "on_cancel", "on_trash"] if not doc.flags.in_insert: @@ -76,4 +61,52 @@ def run_webhooks(doc, method): trigger_webhook = True if trigger_webhook and event and webhook.webhook_docevent == event: - _webhook_request(webhook) + _add_webhook_to_queue(webhook, doc) + + +def _add_webhook_to_queue(webhook, doc): + # Maintain a queue and flush on commit + if not getattr(frappe.local, "_webhook_queue", None): + frappe.local._webhook_queue = [] + frappe.db.after_commit.add(flush_webhook_execution_queue) + + frappe.local._webhook_queue.append(frappe._dict(doc=doc, webhook=webhook)) + + +def flush_webhook_execution_queue(): + """Enqueue all pending webhook executions. + + Each webhook can trigger multiple times on same document or even different instance of same + document. We assume that last enqueued version of document is the final document for this DB + transaction. + """ + if not getattr(frappe.local, "_webhook_queue", None): + return + + uniq_hooks = set() + unique_last_instances = [] + + # reverse + frappe.local._webhook_queue.reverse() + + # deduplicate on (doc.name, webhook.name) + # 'doc' holds the last instance values + for execution in frappe.local._webhook_queue: + key = (execution.webhook.get("name"), execution.doc.get("name")) + if key not in uniq_hooks: + uniq_hooks.add(key) + unique_last_instances.append(execution) + + # Clear original queue so next enqueue computation happens correctly. + del frappe.local._webhook_queue + + # reverse again, to get back the original order on which to execute webhooks + unique_last_instances.reverse() + + for instance in unique_last_instances: + frappe.enqueue( + "frappe.integrations.doctype.webhook.webhook.enqueue_webhook", + doc=instance.doc, + webhook=instance.webhook, + now=frappe.flags.in_test, + ) diff --git a/frappe/integrations/doctype/webhook/test_webhook.py b/frappe/integrations/doctype/webhook/test_webhook.py index d308ec95ab..c0148f5f67 100644 --- a/frappe/integrations/doctype/webhook/test_webhook.py +++ b/frappe/integrations/doctype/webhook/test_webhook.py @@ -7,6 +7,7 @@ import responses from responses.matchers import json_params_matcher import frappe +from frappe.integrations.doctype.webhook import flush_webhook_execution_queue from frappe.integrations.doctype.webhook.webhook import ( enqueue_webhook, get_webhook_data, @@ -96,6 +97,7 @@ class TestWebhook(FrappeTestCase): self.test_user = frappe.new_doc("User") self.test_user.email = "user1@integration.webhooks.test.com" self.test_user.first_name = "user1" + self.test_user.send_welcome_email = False self.responses = responses.RequestsMock() self.responses.start() @@ -112,18 +114,19 @@ class TestWebhook(FrappeTestCase): """Test webhook trigger for enabled webhooks""" frappe.cache.delete_value("webhooks") - frappe.flags.webhooks = None # Insert the user to db self.test_user.insert() - self.assertTrue("User" in frappe.flags.webhooks) + webhooks = frappe.cache.get_value("webhooks") + self.assertTrue("User" in webhooks) + self.assertEqual(len(webhooks.get("User")), 1) + # only 1 hook (enabled) must be queued - self.assertEqual(len(frappe.flags.webhooks.get("User")), 1) - self.assertTrue(self.test_user.email in frappe.flags.webhooks_executed) - self.assertEqual( - frappe.flags.webhooks_executed.get(self.test_user.email)[0], self.sample_webhooks[0].name - ) + self.assertEqual(len(frappe.local._webhook_queue), 1) + execution = frappe.local._webhook_queue[0] + self.assertEqual(execution.webhook.name, self.sample_webhooks[0].name) + self.assertEqual(execution.doc.name, self.test_user.name) def test_validate_doc_events(self): "Test creating a submit-related webhook for a non-submittable DocType" @@ -206,7 +209,7 @@ class TestWebhook(FrappeTestCase): wh_config = { "doctype": "Webhook", "webhook_doctype": "Note", - "webhook_docevent": "after_insert", + "webhook_docevent": "on_change", "enabled": 1, "request_url": "https://httpbin.org/post", "request_method": "POST", @@ -223,8 +226,9 @@ class TestWebhook(FrappeTestCase): doc = frappe.new_doc("Note") doc.title = "Test Webhook Note" + final_title = frappe.generate_hash() - expected_req = [{"title": doc.title} for _ in range(3)] + expected_req = [{"title": final_title} for _ in range(3)] self.responses.add( responses.POST, "https://httpbin.org/post", @@ -233,8 +237,15 @@ class TestWebhook(FrappeTestCase): match=[json_params_matcher(expected_req)], ) - with get_test_webhook(wh_config) as wh: - enqueue_webhook(doc, wh) + with get_test_webhook(wh_config): + # It should only execute once in a transaction + doc.insert() + doc.reload() + doc.save() + doc = frappe.get_doc(doc.doctype, doc.name) + doc.title = final_title + doc.save() + flush_webhook_execution_queue() log = frappe.get_last_doc("Webhook Request Log") self.assertEqual(len(json.loads(log.response)), 3) diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index 9e80a9aa34..86f8b0b1ef 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -43,6 +43,14 @@ def make_put_request(url, **kwargs): return make_request("PUT", url, **kwargs) +def make_patch_request(url, **kwargs): + return make_request("PATCH", url, **kwargs) + + +def make_delete_request(url, **kwargs): + return make_request("DELETE", url, **kwargs) + + def create_request_log( data, integration_type=None, diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 78c20a2eae..42c575371e 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -31,6 +31,7 @@ from frappe.utils import ( sanitize_html, strip_html, ) +from frappe.utils.defaults import get_not_null_defaults from frappe.utils.html_utils import unescape_html if TYPE_CHECKING: @@ -343,18 +344,17 @@ class BaseDocument: if ignore_virtual or fieldname not in self.permitted_fieldnames: continue - if value is None: - if (prop := getattr(type(self), fieldname, None)) and is_a_property(prop): - value = getattr(self, fieldname) + if (prop := getattr(type(self), fieldname, None)) and is_a_property(prop): + value = getattr(self, fieldname) - elif options := getattr(df, "options", None): - from frappe.utils.safe_exec import get_safe_globals + elif options := getattr(df, "options", None): + from frappe.utils.safe_exec import get_safe_globals - value = frappe.safe_eval( - code=options, - eval_globals=get_safe_globals(), - eval_locals={"doc": self}, - ) + value = frappe.safe_eval( + code=options, + eval_globals=get_safe_globals(), + eval_locals={"doc": self}, + ) if isinstance(value, list) and df.fieldtype not in table_fields: frappe.throw(_("Value for {0} cannot be a list").format(_(df.label))) @@ -384,6 +384,13 @@ class BaseDocument: if ignore_nulls and not is_virtual_field and value is None: continue + # If the docfield is not nullable - set a default non-null value + if value is None and getattr(df, "not_nullable", False): + if df.default: + value = df.default + else: + value = get_not_null_defaults(df.fieldtype) + d[fieldname] = value return d diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 62c0538298..76a03f5a76 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -109,7 +109,6 @@ class DatabaseQuery: save_user_settings=False, save_user_settings_fields=False, update=None, - add_total_row=None, user_settings=None, reference_doctype=None, run=True, @@ -734,12 +733,15 @@ class DatabaseQuery: f.update(get_additional_filter_field(additional_filters_config, f, f.value)) meta = frappe.get_meta(f.doctype) + df = meta.get("fields", {"fieldname": f.fieldname}) + df = df[0] if df else None + can_be_null = True + value = None + # prepare in condition if f.operator.lower() in NestedSetHierarchy: - values = f.value or "" - # TODO: handle list and tuple # if not isinstance(values, (list, tuple)): # values = values.split(",") @@ -785,30 +787,33 @@ class DatabaseQuery: "not in" if f.operator.lower() in ("not ancestors of", "not descendants of") else "in" ) - elif f.operator.lower() in ("in", "not in"): + if f.operator.lower() in ("in", "not in"): # if values contain '' or falsy values then only coalesce column # for `in` query this is only required if values contain '' or values are empty. # for `not in` queries we can't be sure as column values might contain null. + can_be_null = not getattr(df, "not_nullable", False) if f.operator.lower() == "in": - can_be_null = not f.value or any(v is None or v == "" for v in f.value) + can_be_null &= not f.value or any(v is None or v == "" for v in f.value) - values = f.value or "" - if isinstance(values, str): - values = values.split(",") + if value is None: + values = f.value or "" + if isinstance(values, str): + values = values.split(",") - fallback = "''" - value = [frappe.db.escape((cstr(v) or "").strip(), percent=False) for v in values] - if len(value): - value = f"({', '.join(value)})" - else: - value = "('')" + fallback = "''" + value = [frappe.db.escape((cstr(v) or "").strip(), percent=False) for v in values] + if len(value): + value = f"({', '.join(value)})" + else: + value = "('')" else: escape = True - df = meta.get("fields", {"fieldname": f.fieldname}) - df = df[0] if df else None - if df and df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent"): + if df and ( + df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent") + or getattr(df, "not_nullable", False) + ): can_be_null = False if f.operator.lower() in ("previous", "next", "timespan"): @@ -841,7 +846,7 @@ class DatabaseQuery: elif f.value == "not set": f.operator = "=" fallback = "''" - can_be_null = True + can_be_null = not getattr(df, "not_nullable", False) value = "" @@ -981,7 +986,6 @@ class DatabaseQuery: ) def add_user_permissions(self, user_permissions): - doctype_link_fields = [] doctype_link_fields = self.doctype_meta.get_link_fields() # append current doctype with fieldname as 'name' as first link field diff --git a/frappe/model/document.py b/frappe/model/document.py index 4d5eec64fc..9ed4e2a3f2 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -343,7 +343,7 @@ class Document(BaseDocument): :param ignore_permissions: Do not check permissions if True. :param ignore_version: Do not save version if True.""" if self.flags.in_print: - return + return self self.flags.notifications_executed = [] diff --git a/frappe/model/dynamic_links.py b/frappe/model/dynamic_links.py index 523d587389..61ed86de46 100644 --- a/frappe/model/dynamic_links.py +++ b/frappe/model/dynamic_links.py @@ -13,7 +13,7 @@ dynamic_link_queries = [ `tabDocField`.fieldname, `tabDocField`.options from `tabDocField`, `tabDocType` where `tabDocField`.fieldtype='Dynamic Link' and - `tabDocType`.`name`=`tabDocField`.parent + `tabDocType`.`name`=`tabDocField`.parent and `tabDocType`.is_virtual = 0 order by `tabDocType`.read_only, `tabDocType`.in_create""", """select `tabCustom Field`.dt as parent, `tabDocType`.read_only, `tabDocType`.in_create, diff --git a/frappe/model/mapper.py b/frappe/model/mapper.py index 3f43f2a5f2..7363bf4583 100644 --- a/frappe/model/mapper.py +++ b/frappe/model/mapper.py @@ -69,9 +69,25 @@ def get_mapped_doc( # main if not target_doc: - target_doc = frappe.new_doc(table_maps[from_doctype]["doctype"]) + target_doctype = table_maps[from_doctype]["doctype"] + if table_maps[from_doctype].get("on_parent"): + target_parent = table_maps[from_doctype].get("on_parent") + if isinstance(target_parent, str): + target_parent = frappe.get_doc(json.loads(target_parent)) + target_parentfield = target_parent.get_parentfield_of_doctype(target_doctype) + target_doc = frappe.new_doc( + target_doctype, parent_doc=target_parent, parentfield=target_parentfield + ) + target_parent.append(target_parentfield, target_doc) + ret_doc = target_parent + else: + target_doc = frappe.new_doc(target_doctype) + ret_doc = target_doc elif isinstance(target_doc, str): target_doc = frappe.get_doc(json.loads(target_doc)) + ret_doc = target_doc + else: + ret_doc = target_doc if ( not apply_strict_user_permissions @@ -136,6 +152,9 @@ def get_mapped_doc( True if target_doc.get(target_parentfield) else False ) + if table_map.get("ignore"): + continue + if table_map.get("add_if_empty") and row_exists_for_parentfield.get(target_parentfield): continue @@ -147,16 +166,15 @@ def get_mapped_doc( if postprocess: postprocess(source_doc, target_doc) - target_doc.set_onload("load_after_mapping", True) + ret_doc.run_method("after_mapping", source_doc) + ret_doc.set_onload("load_after_mapping", True) if ( - apply_strict_user_permissions - and not ignore_permissions - and not target_doc.has_permission("create") + apply_strict_user_permissions and not ignore_permissions and not ret_doc.has_permission("create") ): - target_doc.raise_no_permission_to("create") + ret_doc.raise_no_permission_to("create") - return target_doc + return ret_doc def map_doc(source_doc, target_doc, table_map, source_parent=None): diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 11821ffb19..9bad8f1028 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -201,8 +201,9 @@ def rename_doc( # call after_rename new_doc = frappe.get_doc(doctype, new) - # copy any flags if required - new_doc._local = getattr(old_doc, "_local", None) + if validate: + # copy any flags if required + new_doc._local = getattr(old_doc, "_local", None) new_doc.run_method("after_rename", old, new, merge) @@ -463,11 +464,12 @@ def get_link_fields(doctype: str) -> list[dict]: cf = frappe.qb.DocType("Custom Field") ps = frappe.qb.DocType("Property Setter") - st_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == df.parent).as_("issingle") standard_fields = ( frappe.qb.from_(df) - .select(df.parent, df.fieldname, st_issingle) - .where((df.options == doctype) & (df.fieldtype == "Link")) + .inner_join(dt) + .on(df.parent == dt.name) + .select(df.parent, df.fieldname, dt.issingle.as_("issingle")) + .where((df.options == doctype) & (df.fieldtype == "Link") & (dt.is_virtual == 0)) .run(as_dict=True) ) diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 267a1667b5..d77671808d 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -153,8 +153,11 @@ def remove_orphan_doctypes(): orphan_doctypes = [] clear_controller_cache() + class_overrides = frappe.get_hooks("override_doctype_class", {}) for doctype in doctype_names: + if doctype in class_overrides: + continue try: get_controller(doctype=doctype) except ImportError: diff --git a/frappe/model/utils/rename_field.py b/frappe/model/utils/rename_field.py index c17d01183b..e3b0835ddd 100644 --- a/frappe/model/utils/rename_field.py +++ b/frappe/model/utils/rename_field.py @@ -8,19 +8,21 @@ from frappe.model.utils.user_settings import sync_user_settings, update_user_set from frappe.utils.password import rename_password_field -def rename_field(doctype, old_fieldname, new_fieldname): +def rename_field(doctype, old_fieldname, new_fieldname, validate=True): """This functions assumes that doctype is already synced""" meta = frappe.get_meta(doctype, cached=False) new_field = meta.get_field(new_fieldname) - if not new_field: - print("rename_field: " + (new_fieldname) + " not found in " + doctype) - return - if not meta.issingle and not frappe.db.has_column(doctype, old_fieldname): - print("rename_field: " + (old_fieldname) + " not found in table for: " + doctype) - # never had the field? - return + if validate: + if not new_field: + print("rename_field: " + (new_fieldname) + " not found in " + doctype) + return + + if not meta.issingle and not frappe.db.has_column(doctype, old_fieldname): + print("rename_field: " + (old_fieldname) + " not found in table for: " + doctype) + # never had the field? + return if new_field.fieldtype in table_fields: # change parentfield of table mentioned in options diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 0d7ce13d95..cc51a55d90 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import json +from collections import defaultdict from typing import TYPE_CHECKING, Union import frappe @@ -233,17 +234,30 @@ def get_workflow_field_value(workflow_name, field): @frappe.whitelist() def bulk_workflow_approval(docnames, doctype, action): - from collections import defaultdict + docnames = json.loads(docnames) + if len(docnames) < 20: + _bulk_workflow_action(docnames, doctype, action) + elif len(docnames) <= 500: + frappe.msgprint(_("Bulk {0} is enqueued in background.").format(action), alert=True) + frappe.enqueue( + _bulk_workflow_action, + docnames=docnames, + doctype=doctype, + action=action, + queue="short", + timeout=1000, + ) + else: + frappe.throw(_("Bulk approval only support up to 500 documents."), title=_("Too Many Documents")) + + +def _bulk_workflow_action(docnames, doctype, action): # dictionaries for logging failed_transactions = defaultdict(list) successful_transactions = defaultdict(list) - # WARN: message log is cleared - print("Clearing frappe.message_log...") frappe.clear_messages() - - docnames = json.loads(docnames) for (idx, docname) in enumerate(docnames, 1): message_dict = {} try: @@ -308,7 +322,9 @@ def print_workflow_log(messages, title, doctype, indicator): html = f"
{doc}
" msg += html - frappe.msgprint(msg, title=_("Workflow Status"), indicator=indicator, is_minimizable=True) + frappe.msgprint( + msg, title=_("Workflow Status"), indicator=indicator, is_minimizable=True, realtime=True + ) @frappe.whitelist() diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index 0295fbaaf2..ee8b4cd014 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -20,7 +20,7 @@ def calculate_hash(path: str) -> str: Returns: str: The calculated hash """ - hash_md5 = hashlib.md5() + hash_md5 = hashlib.md5(usedforsecurity=False) with open(path, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): hash_md5.update(chunk) diff --git a/frappe/monitor.py b/frappe/monitor.py index 9b8f500358..c64855676f 100644 --- a/frappe/monitor.py +++ b/frappe/monitor.py @@ -1,12 +1,13 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import datetime import json import os import traceback import uuid -from datetime import datetime +import pytz import rq import frappe @@ -50,7 +51,7 @@ class Monitor: self.data = frappe._dict( { "site": frappe.local.site, - "timestamp": datetime.utcnow(), + "timestamp": datetime.datetime.now(pytz.UTC), "transaction_type": transaction_type, "uuid": str(uuid.uuid4()), } @@ -83,7 +84,7 @@ class Monitor: if job := rq.get_current_job(): self.data.uuid = job.id - waitdiff = self.data.timestamp - job.enqueued_at + waitdiff = self.data.timestamp - job.enqueued_at.replace(tzinfo=pytz.UTC) self.data.job.wait = int(waitdiff.total_seconds() * 1000000) def add_custom_data(self, **kwargs): @@ -92,7 +93,7 @@ class Monitor: def dump(self, response=None): try: - timediff = datetime.utcnow() - self.data.timestamp + timediff = datetime.datetime.now(pytz.UTC) - self.data.timestamp # Obtain duration in microseconds self.data.duration = int(timediff.total_seconds() * 1000000) diff --git a/frappe/oauth.py b/frappe/oauth.py index 1094194348..bf7abeb424 100644 --- a/frappe/oauth.py +++ b/frappe/oauth.py @@ -11,7 +11,7 @@ from oauthlib.openid import RequestValidator import frappe from frappe.auth import LoginManager -from frappe.utils.data import get_system_timezone +from frappe.utils.data import get_system_timezone, now_datetime class OAuthWebRequestValidator(RequestValidator): @@ -240,20 +240,11 @@ class OAuthWebRequestValidator(RequestValidator): def validate_bearer_token(self, token, scopes, request): # Remember to check expiration and scope membership otoken = frappe.get_doc("OAuth Bearer Token", token) - token_expiration_local = otoken.expiration_time.replace( - tzinfo=pytz.timezone(get_system_timezone()) - ) - token_expiration_utc = token_expiration_local.astimezone(pytz.utc) - is_token_valid = ( - frappe.utils.datetime.datetime.utcnow().replace(tzinfo=pytz.utc) < token_expiration_utc - ) and otoken.status != "Revoked" + is_token_valid = (now_datetime() < otoken.expiration_time) and otoken.status != "Revoked" client_scopes = frappe.db.get_value("OAuth Client", otoken.client, "scopes").split( get_url_delimiter() ) - are_scopes_valid = True - for scp in scopes: - are_scopes_valid = are_scopes_valid and True if scp in client_scopes else False - + are_scopes_valid = all(scope in client_scopes for scope in scopes) return is_token_valid and are_scopes_valid # Token refresh request diff --git a/frappe/patches.txt b/frappe/patches.txt index d3f6e30aee..790b5a5e3f 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -231,3 +231,4 @@ execute:frappe.db.set_single_value("Document Naming Settings", "default_amend_na frappe.patches.v15_0.move_event_cancelled_to_status frappe.patches.v15_0.set_file_type frappe.core.doctype.data_import.patches.remove_stale_docfields_from_legacy_version +frappe.patches.v15_0.validate_newsletter_recipients diff --git a/frappe/patches/v15_0/validate_newsletter_recipients.py b/frappe/patches/v15_0/validate_newsletter_recipients.py new file mode 100644 index 0000000000..61749d3df8 --- /dev/null +++ b/frappe/patches/v15_0/validate_newsletter_recipients.py @@ -0,0 +1,9 @@ +import frappe +from frappe.utils import validate_email_address + + +def execute(): + for name, email in frappe.get_all("Email Group Member", fields=["name", "email"], as_list=True): + if not validate_email_address(email, throw=False): + frappe.db.set_value("Email Group Member", name, "unsubscribed", 1) + frappe.db.commit() diff --git a/frappe/public/css/fonts/inter/Inter-Black.woff2 b/frappe/public/css/fonts/inter/Inter-Black.woff2 new file mode 100644 index 0000000000..18b35db75c Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-Black.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-BlackItalic.woff2 b/frappe/public/css/fonts/inter/Inter-BlackItalic.woff2 new file mode 100644 index 0000000000..02c9d8ecc2 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-BlackItalic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-Bold.woff2 b/frappe/public/css/fonts/inter/Inter-Bold.woff2 new file mode 100644 index 0000000000..0f1b157633 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-Bold.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-BoldItalic.woff2 b/frappe/public/css/fonts/inter/Inter-BoldItalic.woff2 new file mode 100644 index 0000000000..bc50f24c87 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-BoldItalic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-ExtraBold.woff2 b/frappe/public/css/fonts/inter/Inter-ExtraBold.woff2 new file mode 100644 index 0000000000..b1133688a4 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-ExtraBold.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-ExtraBoldItalic.woff2 b/frappe/public/css/fonts/inter/Inter-ExtraBoldItalic.woff2 new file mode 100644 index 0000000000..a5b76ca8da Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-ExtraBoldItalic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-ExtraLight.woff2 b/frappe/public/css/fonts/inter/Inter-ExtraLight.woff2 new file mode 100644 index 0000000000..1d77ae8d04 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-ExtraLight.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-ExtraLightItalic.woff2 b/frappe/public/css/fonts/inter/Inter-ExtraLightItalic.woff2 new file mode 100644 index 0000000000..8c6849209d Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-ExtraLightItalic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-Italic.var.woff2 b/frappe/public/css/fonts/inter/Inter-Italic.var.woff2 deleted file mode 100644 index 13778e77a3..0000000000 Binary files a/frappe/public/css/fonts/inter/Inter-Italic.var.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/Inter-Italic.woff2 b/frappe/public/css/fonts/inter/Inter-Italic.woff2 new file mode 100644 index 0000000000..4c24ce2815 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-Italic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-Light.woff2 b/frappe/public/css/fonts/inter/Inter-Light.woff2 new file mode 100644 index 0000000000..dbe61437a1 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-Light.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-LightItalic.woff2 b/frappe/public/css/fonts/inter/Inter-LightItalic.woff2 new file mode 100644 index 0000000000..a40d042158 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-LightItalic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-Medium.woff2 b/frappe/public/css/fonts/inter/Inter-Medium.woff2 new file mode 100644 index 0000000000..0fd2ee7370 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-Medium.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-MediumItalic.woff2 b/frappe/public/css/fonts/inter/Inter-MediumItalic.woff2 new file mode 100644 index 0000000000..96767155d9 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-MediumItalic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-Regular.woff2 b/frappe/public/css/fonts/inter/Inter-Regular.woff2 new file mode 100644 index 0000000000..b8699af29b Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-Regular.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-SemiBold.woff2 b/frappe/public/css/fonts/inter/Inter-SemiBold.woff2 new file mode 100644 index 0000000000..95c48b184e Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-SemiBold.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-SemiBoldItalic.woff2 b/frappe/public/css/fonts/inter/Inter-SemiBoldItalic.woff2 new file mode 100644 index 0000000000..ddfe19e839 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-SemiBoldItalic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-Thin.woff2 b/frappe/public/css/fonts/inter/Inter-Thin.woff2 new file mode 100644 index 0000000000..07909608cd Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-Thin.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter-ThinItalic.woff2 b/frappe/public/css/fonts/inter/Inter-ThinItalic.woff2 new file mode 100644 index 0000000000..a7bf213801 Binary files /dev/null and b/frappe/public/css/fonts/inter/Inter-ThinItalic.woff2 differ diff --git a/frappe/public/css/fonts/inter/Inter.var.woff2 b/frappe/public/css/fonts/inter/Inter.var.woff2 deleted file mode 100644 index 039bfbab24..0000000000 Binary files a/frappe/public/css/fonts/inter/Inter.var.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/InterVariable-Italic.woff2 b/frappe/public/css/fonts/inter/InterVariable-Italic.woff2 new file mode 100644 index 0000000000..f22ec25549 Binary files /dev/null and b/frappe/public/css/fonts/inter/InterVariable-Italic.woff2 differ diff --git a/frappe/public/css/fonts/inter/InterVariable.woff2 b/frappe/public/css/fonts/inter/InterVariable.woff2 new file mode 100644 index 0000000000..22a12b04e1 Binary files /dev/null and b/frappe/public/css/fonts/inter/InterVariable.woff2 differ diff --git a/frappe/public/css/fonts/inter/LICENSE.txt b/frappe/public/css/fonts/inter/LICENSE.txt deleted file mode 100644 index 65ec0f9103..0000000000 --- a/frappe/public/css/fonts/inter/LICENSE.txt +++ /dev/null @@ -1,94 +0,0 @@ -Copyright (c) 2016-2020 The Inter Project Authors. -"Inter" is trademark of Rasmus Andersson. -https://github.com/rsms/inter - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION AND CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/frappe/public/css/fonts/inter/inter.css b/frappe/public/css/fonts/inter/inter.css index 3be525aba0..39166e902e 100644 --- a/frappe/public/css/fonts/inter/inter.css +++ b/frappe/public/css/fonts/inter/inter.css @@ -1,164 +1,33 @@ -/* This file is depricated use Inter.scss instead. */ -/* Backward compatibility */ @font-face { - font-family: 'Inter V'; + font-family: InterVariable; + font-style: normal; font-weight: 100 900; font-display: swap; - font-style: normal; - src: url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2-variations'), - url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2'); - src: url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2') tech('variations'); + src: url("/assets/frappe/css/fonts/inter/InterVariable.woff2") format("woff2"); } @font-face { - font-family: 'Inter V'; + font-family: InterVariable; + font-style: italic; font-weight: 100 900; font-display: swap; - font-style: italic; - src: url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2-variations'), - url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2'); - src: url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2') tech('variations'); + src: url("/assets/frappe/css/fonts/inter/InterVariable-Italic.woff2") format("woff2"); } -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 100; - src: url("/assets/frappe/css/fonts/inter/inter_thinitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_thinitalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 200; - src: url("/assets/frappe/css/fonts/inter/inter_extralight.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_extralight.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 200; - src: url("/assets/frappe/css/fonts/inter/inter_extralightitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_extralightitalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 300; - src: url("/assets/frappe/css/fonts/inter/inter_light.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_light.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 300; - src: url("/assets/frappe/css/fonts/inter/inter_lightitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_lightitalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 400; - src: url("/assets/frappe/css/fonts/inter/inter_regular.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_regular.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 400; - src: url("/assets/frappe/css/fonts/inter/inter_italic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_italic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 500; - src: url("/assets/frappe/css/fonts/inter/inter_medium.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_medium.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 500; - src: url("/assets/frappe/css/fonts/inter/inter_mediumitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_mediumitalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 600; - src: url("/assets/frappe/css/fonts/inter/inter_semibold.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_semibold.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 600; - src: url("/assets/frappe/css/fonts/inter/inter_semibolditalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_semibolditalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 700; - src: url("/assets/frappe/css/fonts/inter/inter_bold.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_bold.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 700; - src: url("/assets/frappe/css/fonts/inter/inter_bolditalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_bolditalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 800; - src: url("/assets/frappe/css/fonts/inter/inter_extrabold.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_extrabold.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 800; - src: url("/assets/frappe/css/fonts/inter/inter_extrabolditalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_extrabolditalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 900; - src: url("/assets/frappe/css/fonts/inter/inter_black.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_black.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 900; - src: url("/assets/frappe/css/fonts/inter/inter_blackitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_blackitalic.woff") format("woff"); -} + /* static fonts */ + @font-face { font-family: "Inter"; font-style: normal; font-weight: 100; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-Thin.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 100; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-ThinItalic.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: normal; font-weight: 200; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-ExtraLight.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 200; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-ExtraLightItalic.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: normal; font-weight: 300; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-Light.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 300; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-LightItalic.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-Regular.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 400; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-Italic.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-Medium.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 500; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-MediumItalic.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-SemiBold.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 600; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-SemiBoldItalic.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-Bold.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 700; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-BoldItalic.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: normal; font-weight: 800; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-ExtraBold.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 800; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-ExtraBoldItalic.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: normal; font-weight: 900; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-Black.woff2") format("woff2"); } + @font-face { font-family: "Inter"; font-style: italic; font-weight: 900; font-display: swap; src: url("/assets/frappe/css/fonts/inter/Inter-BlackItalic.woff2") format("woff2"); } diff --git a/frappe/public/css/fonts/inter/inter.scss b/frappe/public/css/fonts/inter/inter.scss index d3565415a3..8471e042f6 100644 --- a/frappe/public/css/fonts/inter/inter.scss +++ b/frappe/public/css/fonts/inter/inter.scss @@ -1,164 +1 @@ -// TODO instead of making copy of inter.css find a way to import it. -// workaround for css import as it fails for custom website_theme_template -@font-face { - font-family: 'Inter V'; - font-weight: 100 900; - font-display: swap; - font-style: normal; - src: url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2-variations'), - url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2'); - src: url('/assets/frappe/css/fonts/inter/Inter.var.woff2?v=3.19') format('woff2') tech('variations'); - } - @font-face { - font-family: 'Inter V'; - font-weight: 100 900; - font-display: swap; - font-style: italic; - src: url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2-variations'), - url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2'); - src: url('/assets/frappe/css/fonts/inter/Inter-Italic.var.woff2?v=3.19') format('woff2') tech('variations'); - } -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 100; - src: url("/assets/frappe/css/fonts/inter/inter_thinitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_thinitalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 200; - src: url("/assets/frappe/css/fonts/inter/inter_extralight.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_extralight.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 200; - src: url("/assets/frappe/css/fonts/inter/inter_extralightitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_extralightitalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 300; - src: url("/assets/frappe/css/fonts/inter/inter_light.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_light.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 300; - src: url("/assets/frappe/css/fonts/inter/inter_lightitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_lightitalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 400; - src: url("/assets/frappe/css/fonts/inter/inter_regular.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_regular.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 400; - src: url("/assets/frappe/css/fonts/inter/inter_italic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_italic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 500; - src: url("/assets/frappe/css/fonts/inter/inter_medium.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_medium.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 500; - src: url("/assets/frappe/css/fonts/inter/inter_mediumitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_mediumitalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 600; - src: url("/assets/frappe/css/fonts/inter/inter_semibold.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_semibold.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 600; - src: url("/assets/frappe/css/fonts/inter/inter_semibolditalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_semibolditalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 700; - src: url("/assets/frappe/css/fonts/inter/inter_bold.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_bold.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 700; - src: url("/assets/frappe/css/fonts/inter/inter_bolditalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_bolditalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 800; - src: url("/assets/frappe/css/fonts/inter/inter_extrabold.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_extrabold.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 800; - src: url("/assets/frappe/css/fonts/inter/inter_extrabolditalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_extrabolditalic.woff") format("woff"); -} - -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: normal; - font-weight: 900; - src: url("/assets/frappe/css/fonts/inter/inter_black.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_black.woff") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-display: swap; - font-style: italic; - font-weight: 900; - src: url("/assets/frappe/css/fonts/inter/inter_blackitalic.woff2") format("woff2"), - url("/assets/frappe/css/fonts/inter/inter_blackitalic.woff") format("woff"); -} +@import "frappe/public/css/fonts/inter/inter.css"; diff --git a/frappe/public/css/fonts/inter/inter_black.woff b/frappe/public/css/fonts/inter/inter_black.woff deleted file mode 100644 index 52af3fe46a..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_black.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_black.woff2 b/frappe/public/css/fonts/inter/inter_black.woff2 deleted file mode 100644 index 89c204c80c..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_black.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_blackitalic.woff b/frappe/public/css/fonts/inter/inter_blackitalic.woff deleted file mode 100644 index 0ad6c7d2c1..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_blackitalic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_blackitalic.woff2 b/frappe/public/css/fonts/inter/inter_blackitalic.woff2 deleted file mode 100644 index b3f3267352..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_blackitalic.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_bold.woff b/frappe/public/css/fonts/inter/inter_bold.woff deleted file mode 100644 index 80f70f058e..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_bold.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_bold.woff2 b/frappe/public/css/fonts/inter/inter_bold.woff2 deleted file mode 100644 index 622e5f1478..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_bold.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_bolditalic.woff b/frappe/public/css/fonts/inter/inter_bolditalic.woff deleted file mode 100644 index 03238dd48a..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_bolditalic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_bolditalic.woff2 b/frappe/public/css/fonts/inter/inter_bolditalic.woff2 deleted file mode 100644 index 1b2dafb9da..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_bolditalic.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_extrabold.woff b/frappe/public/css/fonts/inter/inter_extrabold.woff deleted file mode 100644 index fbccaecddc..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_extrabold.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_extrabold.woff2 b/frappe/public/css/fonts/inter/inter_extrabold.woff2 deleted file mode 100644 index 0bcf3f3d14..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_extrabold.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_extrabolditalic.woff b/frappe/public/css/fonts/inter/inter_extrabolditalic.woff deleted file mode 100644 index 20c45c44f5..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_extrabolditalic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_extrabolditalic.woff2 b/frappe/public/css/fonts/inter/inter_extrabolditalic.woff2 deleted file mode 100644 index cca9829bb2..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_extrabolditalic.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_extralight.woff b/frappe/public/css/fonts/inter/inter_extralight.woff deleted file mode 100644 index 8c9bb8c8fd..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_extralight.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_extralight.woff2 b/frappe/public/css/fonts/inter/inter_extralight.woff2 deleted file mode 100644 index f927cd91a9..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_extralight.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_extralightitalic.woff b/frappe/public/css/fonts/inter/inter_extralightitalic.woff deleted file mode 100644 index 6136f7e6ba..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_extralightitalic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_extralightitalic.woff2 b/frappe/public/css/fonts/inter/inter_extralightitalic.woff2 deleted file mode 100644 index 8565cd6b48..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_extralightitalic.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_italic.var.woff2 b/frappe/public/css/fonts/inter/inter_italic.var.woff2 deleted file mode 100644 index fe6faaa581..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_italic.var.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_italic.woff b/frappe/public/css/fonts/inter/inter_italic.woff deleted file mode 100644 index 9c21aedc8a..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_italic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_italic.woff2 b/frappe/public/css/fonts/inter/inter_italic.woff2 deleted file mode 100644 index 734944b11b..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_italic.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_light.woff b/frappe/public/css/fonts/inter/inter_light.woff deleted file mode 100644 index 0df2bc7c53..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_light.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_light.woff2 b/frappe/public/css/fonts/inter/inter_light.woff2 deleted file mode 100644 index b09ea9da2e..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_light.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_lightitalic.woff b/frappe/public/css/fonts/inter/inter_lightitalic.woff deleted file mode 100644 index ee7ebb51bb..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_lightitalic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_lightitalic.woff2 b/frappe/public/css/fonts/inter/inter_lightitalic.woff2 deleted file mode 100644 index 3d7774aca3..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_lightitalic.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_medium.woff b/frappe/public/css/fonts/inter/inter_medium.woff deleted file mode 100644 index 1d50f8007e..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_medium.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_medium.woff2 b/frappe/public/css/fonts/inter/inter_medium.woff2 deleted file mode 100644 index ffb4206c2e..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_medium.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_mediumitalic.woff b/frappe/public/css/fonts/inter/inter_mediumitalic.woff deleted file mode 100644 index 9eb5b9d99e..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_mediumitalic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_mediumitalic.woff2 b/frappe/public/css/fonts/inter/inter_mediumitalic.woff2 deleted file mode 100644 index ebee6551da..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_mediumitalic.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_regular.woff b/frappe/public/css/fonts/inter/inter_regular.woff deleted file mode 100644 index 7cb4990b84..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_regular.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_regular.woff2 b/frappe/public/css/fonts/inter/inter_regular.woff2 deleted file mode 100644 index 66691b83a5..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_regular.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_roman.var.woff2 b/frappe/public/css/fonts/inter/inter_roman.var.woff2 deleted file mode 100644 index dbcd4ce78b..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_roman.var.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_semibold.woff b/frappe/public/css/fonts/inter/inter_semibold.woff deleted file mode 100644 index 490bd9d5e4..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_semibold.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_semibold.woff2 b/frappe/public/css/fonts/inter/inter_semibold.woff2 deleted file mode 100644 index 9fd7726ebb..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_semibold.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_semibolditalic.woff b/frappe/public/css/fonts/inter/inter_semibolditalic.woff deleted file mode 100644 index 839fc3d0a1..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_semibolditalic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_semibolditalic.woff2 b/frappe/public/css/fonts/inter/inter_semibolditalic.woff2 deleted file mode 100644 index 24925694cf..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_semibolditalic.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_thin.woff b/frappe/public/css/fonts/inter/inter_thin.woff deleted file mode 100644 index 1b4f86c165..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_thin.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_thin.woff2 b/frappe/public/css/fonts/inter/inter_thin.woff2 deleted file mode 100644 index 16c7b0f4a9..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_thin.woff2 and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_thinitalic.woff b/frappe/public/css/fonts/inter/inter_thinitalic.woff deleted file mode 100644 index 1bcf69399d..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_thinitalic.woff and /dev/null differ diff --git a/frappe/public/css/fonts/inter/inter_thinitalic.woff2 b/frappe/public/css/fonts/inter/inter_thinitalic.woff2 deleted file mode 100644 index a13ba5b571..0000000000 Binary files a/frappe/public/css/fonts/inter/inter_thinitalic.woff2 and /dev/null differ diff --git a/frappe/public/css/octicons/octicons.scss b/frappe/public/css/octicons/octicons.scss index 0902cedce8..81e7d52d78 100755 --- a/frappe/public/css/octicons/octicons.scss +++ b/frappe/public/css/octicons/octicons.scss @@ -1,220 +1,548 @@ $octicons-font-path: "." !default; -$octicons-version: "396334ee3da78f4302d25c758ae3e3ce5dc3c97d"; +$octicons-version: "396334ee3da78f4302d25c758ae3e3ce5dc3c97d"; @font-face { - font-family: 'octicons'; - src: url('#{$octicons-font-path}/octicons.eot?#iefix&v=#{$octicons-version}') format('embedded-opentype'), - url('#{$octicons-font-path}/octicons.woff?v=#{$octicons-version}') format('woff'), - url('#{$octicons-font-path}/octicons.ttf?v=#{$octicons-version}') format('truetype'), - url('#{$octicons-font-path}/octicons.svg?v=#{$octicons-version}#octicons') format('svg'); - font-weight: normal; - font-style: normal; + font-family: "octicons"; + src: url("#{$octicons-font-path}/octicons.eot?#iefix&v=#{$octicons-version}") + format("embedded-opentype"), + url("#{$octicons-font-path}/octicons.woff?v=#{$octicons-version}") format("woff"), + url("#{$octicons-font-path}/octicons.ttf?v=#{$octicons-version}") format("truetype"), + url("#{$octicons-font-path}/octicons.svg?v=#{$octicons-version}#octicons") format("svg"); + font-weight: normal; + font-style: normal; } // .octicon is optimized for 16px. // .mega-octicon is optimized for 32px but can be used larger. -.octicon, .mega-octicon { - font: normal normal normal 16px/1 octicons; - display: inline-block; - text-decoration: none; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; +.octicon, +.mega-octicon { + font: normal normal normal 16px/1 octicons; + display: inline-block; + text-decoration: none; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mega-octicon { + font-size: 32px; } -.mega-octicon { font-size: 32px; } -.octicon-alert:before { content: '\f02d'} /*  */ -.octicon-arrow-down:before { content: '\f03f'} /*  */ -.octicon-arrow-left:before { content: '\f040'} /*  */ -.octicon-arrow-right:before { content: '\f03e'} /*  */ -.octicon-arrow-small-down:before { content: '\f0a0'} /*  */ -.octicon-arrow-small-left:before { content: '\f0a1'} /*  */ -.octicon-arrow-small-right:before { content: '\f071'} /*  */ -.octicon-arrow-small-up:before { content: '\f09f'} /*  */ -.octicon-arrow-up:before { content: '\f03d'} /*  */ +.octicon-alert:before { + content: "\f02d"; +} /*  */ +.octicon-arrow-down:before { + content: "\f03f"; +} /*  */ +.octicon-arrow-left:before { + content: "\f040"; +} /*  */ +.octicon-arrow-right:before { + content: "\f03e"; +} /*  */ +.octicon-arrow-small-down:before { + content: "\f0a0"; +} /*  */ +.octicon-arrow-small-left:before { + content: "\f0a1"; +} /*  */ +.octicon-arrow-small-right:before { + content: "\f071"; +} /*  */ +.octicon-arrow-small-up:before { + content: "\f09f"; +} /*  */ +.octicon-arrow-up:before { + content: "\f03d"; +} /*  */ .octicon-microscope:before, -.octicon-beaker:before { content: '\f0dd'} /*  */ -.octicon-bell:before { content: '\f0de'} /*  */ -.octicon-book:before { content: '\f007'} /*  */ -.octicon-bookmark:before { content: '\f07b'} /*  */ -.octicon-briefcase:before { content: '\f0d3'} /*  */ -.octicon-broadcast:before { content: '\f048'} /*  */ -.octicon-browser:before { content: '\f0c5'} /*  */ -.octicon-bug:before { content: '\f091'} /*  */ -.octicon-calendar:before { content: '\f068'} /*  */ -.octicon-check:before { content: '\f03a'} /*  */ -.octicon-checklist:before { content: '\f076'} /*  */ -.octicon-chevron-down:before { content: '\f0a3'} /*  */ -.octicon-chevron-left:before { content: '\f0a4'} /*  */ -.octicon-chevron-right:before { content: '\f078'} /*  */ -.octicon-chevron-up:before { content: '\f0a2'} /*  */ -.octicon-circle-slash:before { content: '\f084'} /*  */ -.octicon-circuit-board:before { content: '\f0d6'} /*  */ -.octicon-clippy:before { content: '\f035'} /*  */ -.octicon-clock:before { content: '\f046'} /*  */ -.octicon-cloud-download:before { content: '\f00b'} /*  */ -.octicon-cloud-upload:before { content: '\f00c'} /*  */ -.octicon-code:before { content: '\f05f'} /*  */ -.octicon-color-mode:before { content: '\f065'} /*  */ +.octicon-beaker:before { + content: "\f0dd"; +} /*  */ +.octicon-bell:before { + content: "\f0de"; +} /*  */ +.octicon-book:before { + content: "\f007"; +} /*  */ +.octicon-bookmark:before { + content: "\f07b"; +} /*  */ +.octicon-briefcase:before { + content: "\f0d3"; +} /*  */ +.octicon-broadcast:before { + content: "\f048"; +} /*  */ +.octicon-browser:before { + content: "\f0c5"; +} /*  */ +.octicon-bug:before { + content: "\f091"; +} /*  */ +.octicon-calendar:before { + content: "\f068"; +} /*  */ +.octicon-check:before { + content: "\f03a"; +} /*  */ +.octicon-checklist:before { + content: "\f076"; +} /*  */ +.octicon-chevron-down:before { + content: "\f0a3"; +} /*  */ +.octicon-chevron-left:before { + content: "\f0a4"; +} /*  */ +.octicon-chevron-right:before { + content: "\f078"; +} /*  */ +.octicon-chevron-up:before { + content: "\f0a2"; +} /*  */ +.octicon-circle-slash:before { + content: "\f084"; +} /*  */ +.octicon-circuit-board:before { + content: "\f0d6"; +} /*  */ +.octicon-clippy:before { + content: "\f035"; +} /*  */ +.octicon-clock:before { + content: "\f046"; +} /*  */ +.octicon-cloud-download:before { + content: "\f00b"; +} /*  */ +.octicon-cloud-upload:before { + content: "\f00c"; +} /*  */ +.octicon-code:before { + content: "\f05f"; +} /*  */ +.octicon-color-mode:before { + content: "\f065"; +} /*  */ .octicon-comment-add:before, -.octicon-comment:before { content: '\f02b'} /*  */ -.octicon-comment-discussion:before { content: '\f04f'} /*  */ -.octicon-credit-card:before { content: '\f045'} /*  */ -.octicon-dash:before { content: '\f0ca'} /*  */ -.octicon-dashboard:before { content: '\f07d'} /*  */ -.octicon-database:before { content: '\f096'} /*  */ +.octicon-comment:before { + content: "\f02b"; +} /*  */ +.octicon-comment-discussion:before { + content: "\f04f"; +} /*  */ +.octicon-credit-card:before { + content: "\f045"; +} /*  */ +.octicon-dash:before { + content: "\f0ca"; +} /*  */ +.octicon-dashboard:before { + content: "\f07d"; +} /*  */ +.octicon-database:before { + content: "\f096"; +} /*  */ .octicon-clone:before, -.octicon-desktop-download:before { content: '\f0dc'} /*  */ -.octicon-device-camera:before { content: '\f056'} /*  */ -.octicon-device-camera-video:before { content: '\f057'} /*  */ -.octicon-device-desktop:before { content: '\f27c'} /*  */ -.octicon-device-mobile:before { content: '\f038'} /*  */ -.octicon-diff:before { content: '\f04d'} /*  */ -.octicon-diff-added:before { content: '\f06b'} /*  */ -.octicon-diff-ignored:before { content: '\f099'} /*  */ -.octicon-diff-modified:before { content: '\f06d'} /*  */ -.octicon-diff-removed:before { content: '\f06c'} /*  */ -.octicon-diff-renamed:before { content: '\f06e'} /*  */ -.octicon-ellipsis:before { content: '\f09a'} /*  */ +.octicon-desktop-download:before { + content: "\f0dc"; +} /*  */ +.octicon-device-camera:before { + content: "\f056"; +} /*  */ +.octicon-device-camera-video:before { + content: "\f057"; +} /*  */ +.octicon-device-desktop:before { + content: "\f27c"; +} /*  */ +.octicon-device-mobile:before { + content: "\f038"; +} /*  */ +.octicon-diff:before { + content: "\f04d"; +} /*  */ +.octicon-diff-added:before { + content: "\f06b"; +} /*  */ +.octicon-diff-ignored:before { + content: "\f099"; +} /*  */ +.octicon-diff-modified:before { + content: "\f06d"; +} /*  */ +.octicon-diff-removed:before { + content: "\f06c"; +} /*  */ +.octicon-diff-renamed:before { + content: "\f06e"; +} /*  */ +.octicon-ellipsis:before { + content: "\f09a"; +} /*  */ .octicon-eye-unwatch:before, .octicon-eye-watch:before, -.octicon-eye:before { content: '\f04e'} /*  */ -.octicon-file-binary:before { content: '\f094'} /*  */ -.octicon-file-code:before { content: '\f010'} /*  */ -.octicon-file-directory:before { content: '\f016'} /*  */ -.octicon-file-media:before { content: '\f012'} /*  */ -.octicon-file-pdf:before { content: '\f014'} /*  */ -.octicon-file-submodule:before { content: '\f017'} /*  */ -.octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ -.octicon-file-symlink-file:before { content: '\f0b0'} /*  */ -.octicon-file-text:before { content: '\f011'} /*  */ -.octicon-file-zip:before { content: '\f013'} /*  */ -.octicon-flame:before { content: '\f0d2'} /*  */ -.octicon-fold:before { content: '\f0cc'} /*  */ -.octicon-gear:before { content: '\f02f'} /*  */ -.octicon-gift:before { content: '\f042'} /*  */ -.octicon-gist:before { content: '\f00e'} /*  */ -.octicon-gist-secret:before { content: '\f08c'} /*  */ +.octicon-eye:before { + content: "\f04e"; +} /*  */ +.octicon-file-binary:before { + content: "\f094"; +} /*  */ +.octicon-file-code:before { + content: "\f010"; +} /*  */ +.octicon-file-directory:before { + content: "\f016"; +} /*  */ +.octicon-file-media:before { + content: "\f012"; +} /*  */ +.octicon-file-pdf:before { + content: "\f014"; +} /*  */ +.octicon-file-submodule:before { + content: "\f017"; +} /*  */ +.octicon-file-symlink-directory:before { + content: "\f0b1"; +} /*  */ +.octicon-file-symlink-file:before { + content: "\f0b0"; +} /*  */ +.octicon-file-text:before { + content: "\f011"; +} /*  */ +.octicon-file-zip:before { + content: "\f013"; +} /*  */ +.octicon-flame:before { + content: "\f0d2"; +} /*  */ +.octicon-fold:before { + content: "\f0cc"; +} /*  */ +.octicon-gear:before { + content: "\f02f"; +} /*  */ +.octicon-gift:before { + content: "\f042"; +} /*  */ +.octicon-gist:before { + content: "\f00e"; +} /*  */ +.octicon-gist-secret:before { + content: "\f08c"; +} /*  */ .octicon-git-branch-create:before, .octicon-git-branch-delete:before, -.octicon-git-branch:before { content: '\f020'} /*  */ -.octicon-git-commit:before { content: '\f01f'} /*  */ -.octicon-git-compare:before { content: '\f0ac'} /*  */ -.octicon-git-merge:before { content: '\f023'} /*  */ +.octicon-git-branch:before { + content: "\f020"; +} /*  */ +.octicon-git-commit:before { + content: "\f01f"; +} /*  */ +.octicon-git-compare:before { + content: "\f0ac"; +} /*  */ +.octicon-git-merge:before { + content: "\f023"; +} /*  */ .octicon-git-pull-request-abandoned:before, -.octicon-git-pull-request:before { content: '\f009'} /*  */ -.octicon-globe:before { content: '\f0b6'} /*  */ -.octicon-graph:before { content: '\f043'} /*  */ -.octicon-heart:before { content: '\2665'} /* ♥ */ -.octicon-history:before { content: '\f07e'} /*  */ -.octicon-home:before { content: '\f08d'} /*  */ -.octicon-horizontal-rule:before { content: '\f070'} /*  */ -.octicon-hubot:before { content: '\f09d'} /*  */ -.octicon-inbox:before { content: '\f0cf'} /*  */ -.octicon-info:before { content: '\f059'} /*  */ -.octicon-issue-closed:before { content: '\f028'} /*  */ -.octicon-issue-opened:before { content: '\f026'} /*  */ -.octicon-issue-reopened:before { content: '\f027'} /*  */ -.octicon-jersey:before { content: '\f019'} /*  */ -.octicon-key:before { content: '\f049'} /*  */ -.octicon-keyboard:before { content: '\f00d'} /*  */ -.octicon-law:before { content: '\f0d8'} /*  */ -.octicon-light-bulb:before { content: '\f000'} /*  */ -.octicon-link:before { content: '\f05c'} /*  */ -.octicon-link-external:before { content: '\f07f'} /*  */ -.octicon-list-ordered:before { content: '\f062'} /*  */ -.octicon-list-unordered:before { content: '\f061'} /*  */ -.octicon-location:before { content: '\f060'} /*  */ +.octicon-git-pull-request:before { + content: "\f009"; +} /*  */ +.octicon-globe:before { + content: "\f0b6"; +} /*  */ +.octicon-graph:before { + content: "\f043"; +} /*  */ +.octicon-heart:before { + content: "\2665"; +} /* ♥ */ +.octicon-history:before { + content: "\f07e"; +} /*  */ +.octicon-home:before { + content: "\f08d"; +} /*  */ +.octicon-horizontal-rule:before { + content: "\f070"; +} /*  */ +.octicon-hubot:before { + content: "\f09d"; +} /*  */ +.octicon-inbox:before { + content: "\f0cf"; +} /*  */ +.octicon-info:before { + content: "\f059"; +} /*  */ +.octicon-issue-closed:before { + content: "\f028"; +} /*  */ +.octicon-issue-opened:before { + content: "\f026"; +} /*  */ +.octicon-issue-reopened:before { + content: "\f027"; +} /*  */ +.octicon-jersey:before { + content: "\f019"; +} /*  */ +.octicon-key:before { + content: "\f049"; +} /*  */ +.octicon-keyboard:before { + content: "\f00d"; +} /*  */ +.octicon-law:before { + content: "\f0d8"; +} /*  */ +.octicon-light-bulb:before { + content: "\f000"; +} /*  */ +.octicon-link:before { + content: "\f05c"; +} /*  */ +.octicon-link-external:before { + content: "\f07f"; +} /*  */ +.octicon-list-ordered:before { + content: "\f062"; +} /*  */ +.octicon-list-unordered:before { + content: "\f061"; +} /*  */ +.octicon-location:before { + content: "\f060"; +} /*  */ .octicon-gist-private:before, .octicon-mirror-private:before, .octicon-git-fork-private:before, -.octicon-lock:before { content: '\f06a'} /*  */ -.octicon-logo-github:before { content: '\f092'} /*  */ -.octicon-mail:before { content: '\f03b'} /*  */ -.octicon-mail-read:before { content: '\f03c'} /*  */ -.octicon-mail-reply:before { content: '\f051'} /*  */ -.octicon-mark-github:before { content: '\f00a'} /*  */ -.octicon-markdown:before { content: '\f0c9'} /*  */ -.octicon-megaphone:before { content: '\f077'} /*  */ -.octicon-mention:before { content: '\f0be'} /*  */ -.octicon-milestone:before { content: '\f075'} /*  */ +.octicon-lock:before { + content: "\f06a"; +} /*  */ +.octicon-logo-github:before { + content: "\f092"; +} /*  */ +.octicon-mail:before { + content: "\f03b"; +} /*  */ +.octicon-mail-read:before { + content: "\f03c"; +} /*  */ +.octicon-mail-reply:before { + content: "\f051"; +} /*  */ +.octicon-mark-github:before { + content: "\f00a"; +} /*  */ +.octicon-markdown:before { + content: "\f0c9"; +} /*  */ +.octicon-megaphone:before { + content: "\f077"; +} /*  */ +.octicon-mention:before { + content: "\f0be"; +} /*  */ +.octicon-milestone:before { + content: "\f075"; +} /*  */ .octicon-mirror-public:before, -.octicon-mirror:before { content: '\f024'} /*  */ -.octicon-mortar-board:before { content: '\f0d7'} /*  */ -.octicon-mute:before { content: '\f080'} /*  */ -.octicon-no-newline:before { content: '\f09c'} /*  */ -.octicon-octoface:before { content: '\f008'} /*  */ -.octicon-organization:before { content: '\f037'} /*  */ -.octicon-package:before { content: '\f0c4'} /*  */ -.octicon-paintcan:before { content: '\f0d1'} /*  */ -.octicon-pencil:before { content: '\f058'} /*  */ +.octicon-mirror:before { + content: "\f024"; +} /*  */ +.octicon-mortar-board:before { + content: "\f0d7"; +} /*  */ +.octicon-mute:before { + content: "\f080"; +} /*  */ +.octicon-no-newline:before { + content: "\f09c"; +} /*  */ +.octicon-octoface:before { + content: "\f008"; +} /*  */ +.octicon-organization:before { + content: "\f037"; +} /*  */ +.octicon-package:before { + content: "\f0c4"; +} /*  */ +.octicon-paintcan:before { + content: "\f0d1"; +} /*  */ +.octicon-pencil:before { + content: "\f058"; +} /*  */ .octicon-person-add:before, .octicon-person-follow:before, -.octicon-person:before { content: '\f018'} /*  */ -.octicon-pin:before { content: '\f041'} /*  */ -.octicon-plug:before { content: '\f0d4'} /*  */ +.octicon-person:before { + content: "\f018"; +} /*  */ +.octicon-pin:before { + content: "\f041"; +} /*  */ +.octicon-plug:before { + content: "\f0d4"; +} /*  */ .octicon-repo-create:before, .octicon-gist-new:before, .octicon-file-directory-create:before, .octicon-file-add:before, -.octicon-plus:before { content: '\f05d'} /*  */ -.octicon-primitive-dot:before { content: '\f052'} /*  */ -.octicon-primitive-square:before { content: '\f053'} /*  */ -.octicon-pulse:before { content: '\f085'} /*  */ -.octicon-question:before { content: '\f02c'} /*  */ -.octicon-quote:before { content: '\f063'} /*  */ -.octicon-radio-tower:before { content: '\f030'} /*  */ +.octicon-plus:before { + content: "\f05d"; +} /*  */ +.octicon-primitive-dot:before { + content: "\f052"; +} /*  */ +.octicon-primitive-square:before { + content: "\f053"; +} /*  */ +.octicon-pulse:before { + content: "\f085"; +} /*  */ +.octicon-question:before { + content: "\f02c"; +} /*  */ +.octicon-quote:before { + content: "\f063"; +} /*  */ +.octicon-radio-tower:before { + content: "\f030"; +} /*  */ .octicon-repo-delete:before, -.octicon-repo:before { content: '\f001'} /*  */ -.octicon-repo-clone:before { content: '\f04c'} /*  */ -.octicon-repo-force-push:before { content: '\f04a'} /*  */ +.octicon-repo:before { + content: "\f001"; +} /*  */ +.octicon-repo-clone:before { + content: "\f04c"; +} /*  */ +.octicon-repo-force-push:before { + content: "\f04a"; +} /*  */ .octicon-gist-fork:before, -.octicon-repo-forked:before { content: '\f002'} /*  */ -.octicon-repo-pull:before { content: '\f006'} /*  */ -.octicon-repo-push:before { content: '\f005'} /*  */ -.octicon-rocket:before { content: '\f033'} /*  */ -.octicon-rss:before { content: '\f034'} /*  */ -.octicon-ruby:before { content: '\f047'} /*  */ -.octicon-screen-full:before { content: '\f066'} /*  */ -.octicon-screen-normal:before { content: '\f067'} /*  */ +.octicon-repo-forked:before { + content: "\f002"; +} /*  */ +.octicon-repo-pull:before { + content: "\f006"; +} /*  */ +.octicon-repo-push:before { + content: "\f005"; +} /*  */ +.octicon-rocket:before { + content: "\f033"; +} /*  */ +.octicon-rss:before { + content: "\f034"; +} /*  */ +.octicon-ruby:before { + content: "\f047"; +} /*  */ +.octicon-screen-full:before { + content: "\f066"; +} /*  */ +.octicon-screen-normal:before { + content: "\f067"; +} /*  */ .octicon-search-save:before, -.octicon-search:before { content: '\f02e'} /*  */ -.octicon-server:before { content: '\f097'} /*  */ -.octicon-settings:before { content: '\f07c'} /*  */ -.octicon-shield:before { content: '\f0e1'} /*  */ +.octicon-search:before { + content: "\f02e"; +} /*  */ +.octicon-server:before { + content: "\f097"; +} /*  */ +.octicon-settings:before { + content: "\f07c"; +} /*  */ +.octicon-shield:before { + content: "\f0e1"; +} /*  */ .octicon-log-in:before, -.octicon-sign-in:before { content: '\f036'} /*  */ +.octicon-sign-in:before { + content: "\f036"; +} /*  */ .octicon-log-out:before, -.octicon-sign-out:before { content: '\f032'} /*  */ -.octicon-squirrel:before { content: '\f0b2'} /*  */ +.octicon-sign-out:before { + content: "\f032"; +} /*  */ +.octicon-squirrel:before { + content: "\f0b2"; +} /*  */ .octicon-star-add:before, .octicon-star-delete:before, -.octicon-star:before { content: '\f02a'} /*  */ -.octicon-stop:before { content: '\f08f'} /*  */ +.octicon-star:before { + content: "\f02a"; +} /*  */ +.octicon-stop:before { + content: "\f08f"; +} /*  */ .octicon-repo-sync:before, -.octicon-sync:before { content: '\f087'} /*  */ +.octicon-sync:before { + content: "\f087"; +} /*  */ .octicon-tag-remove:before, .octicon-tag-add:before, -.octicon-tag:before { content: '\f015'} /*  */ -.octicon-telescope:before { content: '\f088'} /*  */ -.octicon-terminal:before { content: '\f0c8'} /*  */ -.octicon-three-bars:before { content: '\f05e'} /*  */ -.octicon-thumbsdown:before { content: '\f0db'} /*  */ -.octicon-thumbsup:before { content: '\f0da'} /*  */ -.octicon-tools:before { content: '\f031'} /*  */ -.octicon-trashcan:before { content: '\f0d0'} /*  */ -.octicon-triangle-down:before { content: '\f05b'} /*  */ -.octicon-triangle-left:before { content: '\f044'} /*  */ -.octicon-triangle-right:before { content: '\f05a'} /*  */ -.octicon-triangle-up:before { content: '\f0aa'} /*  */ -.octicon-unfold:before { content: '\f039'} /*  */ -.octicon-unmute:before { content: '\f0ba'} /*  */ -.octicon-versions:before { content: '\f064'} /*  */ -.octicon-watch:before { content: '\f0e0'} /*  */ +.octicon-tag:before { + content: "\f015"; +} /*  */ +.octicon-telescope:before { + content: "\f088"; +} /*  */ +.octicon-terminal:before { + content: "\f0c8"; +} /*  */ +.octicon-three-bars:before { + content: "\f05e"; +} /*  */ +.octicon-thumbsdown:before { + content: "\f0db"; +} /*  */ +.octicon-thumbsup:before { + content: "\f0da"; +} /*  */ +.octicon-tools:before { + content: "\f031"; +} /*  */ +.octicon-trashcan:before { + content: "\f0d0"; +} /*  */ +.octicon-triangle-down:before { + content: "\f05b"; +} /*  */ +.octicon-triangle-left:before { + content: "\f044"; +} /*  */ +.octicon-triangle-right:before { + content: "\f05a"; +} /*  */ +.octicon-triangle-up:before { + content: "\f0aa"; +} /*  */ +.octicon-unfold:before { + content: "\f039"; +} /*  */ +.octicon-unmute:before { + content: "\f0ba"; +} /*  */ +.octicon-versions:before { + content: "\f064"; +} /*  */ +.octicon-watch:before { + content: "\f0e0"; +} /*  */ .octicon-remove-close:before, -.octicon-x:before { content: '\f081'} /*  */ -.octicon-zap:before { content: '\26A1'} /* ⚡ */ +.octicon-x:before { + content: "\f081"; +} /*  */ +.octicon-zap:before { + content: "\26A1"; +} /* ⚡ */ diff --git a/frappe/public/css/octicons/sprockets-octicons.scss b/frappe/public/css/octicons/sprockets-octicons.scss index cef21ae62e..84cd64d260 100755 --- a/frappe/public/css/octicons/sprockets-octicons.scss +++ b/frappe/public/css/octicons/sprockets-octicons.scss @@ -1,217 +1,543 @@ @font-face { - font-family: 'octicons'; - src: font-url('octicons.eot?#iefix') format('embedded-opentype'), - font-url('octicons.woff') format('woff'), - font-url('octicons.ttf') format('truetype'), - font-url('octicons.svg#octicons') format('svg'); - font-weight: normal; - font-style: normal; + font-family: "octicons"; + src: font-url("octicons.eot?#iefix") format("embedded-opentype"), + font-url("octicons.woff") format("woff"), font-url("octicons.ttf") format("truetype"), + font-url("octicons.svg#octicons") format("svg"); + font-weight: normal; + font-style: normal; } // .octicon is optimized for 16px. // .mega-octicon is optimized for 32px but can be used larger. -.octicon, .mega-octicon { - font: normal normal normal 16px/1 octicons; - display: inline-block; - text-decoration: none; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; +.octicon, +.mega-octicon { + font: normal normal normal 16px/1 octicons; + display: inline-block; + text-decoration: none; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mega-octicon { + font-size: 32px; } -.mega-octicon { font-size: 32px; } -.octicon-alert:before { content: '\f02d'} /*  */ -.octicon-arrow-down:before { content: '\f03f'} /*  */ -.octicon-arrow-left:before { content: '\f040'} /*  */ -.octicon-arrow-right:before { content: '\f03e'} /*  */ -.octicon-arrow-small-down:before { content: '\f0a0'} /*  */ -.octicon-arrow-small-left:before { content: '\f0a1'} /*  */ -.octicon-arrow-small-right:before { content: '\f071'} /*  */ -.octicon-arrow-small-up:before { content: '\f09f'} /*  */ -.octicon-arrow-up:before { content: '\f03d'} /*  */ +.octicon-alert:before { + content: "\f02d"; +} /*  */ +.octicon-arrow-down:before { + content: "\f03f"; +} /*  */ +.octicon-arrow-left:before { + content: "\f040"; +} /*  */ +.octicon-arrow-right:before { + content: "\f03e"; +} /*  */ +.octicon-arrow-small-down:before { + content: "\f0a0"; +} /*  */ +.octicon-arrow-small-left:before { + content: "\f0a1"; +} /*  */ +.octicon-arrow-small-right:before { + content: "\f071"; +} /*  */ +.octicon-arrow-small-up:before { + content: "\f09f"; +} /*  */ +.octicon-arrow-up:before { + content: "\f03d"; +} /*  */ .octicon-microscope:before, -.octicon-beaker:before { content: '\f0dd'} /*  */ -.octicon-bell:before { content: '\f0de'} /*  */ -.octicon-book:before { content: '\f007'} /*  */ -.octicon-bookmark:before { content: '\f07b'} /*  */ -.octicon-briefcase:before { content: '\f0d3'} /*  */ -.octicon-broadcast:before { content: '\f048'} /*  */ -.octicon-browser:before { content: '\f0c5'} /*  */ -.octicon-bug:before { content: '\f091'} /*  */ -.octicon-calendar:before { content: '\f068'} /*  */ -.octicon-check:before { content: '\f03a'} /*  */ -.octicon-checklist:before { content: '\f076'} /*  */ -.octicon-chevron-down:before { content: '\f0a3'} /*  */ -.octicon-chevron-left:before { content: '\f0a4'} /*  */ -.octicon-chevron-right:before { content: '\f078'} /*  */ -.octicon-chevron-up:before { content: '\f0a2'} /*  */ -.octicon-circle-slash:before { content: '\f084'} /*  */ -.octicon-circuit-board:before { content: '\f0d6'} /*  */ -.octicon-clippy:before { content: '\f035'} /*  */ -.octicon-clock:before { content: '\f046'} /*  */ -.octicon-cloud-download:before { content: '\f00b'} /*  */ -.octicon-cloud-upload:before { content: '\f00c'} /*  */ -.octicon-code:before { content: '\f05f'} /*  */ -.octicon-color-mode:before { content: '\f065'} /*  */ +.octicon-beaker:before { + content: "\f0dd"; +} /*  */ +.octicon-bell:before { + content: "\f0de"; +} /*  */ +.octicon-book:before { + content: "\f007"; +} /*  */ +.octicon-bookmark:before { + content: "\f07b"; +} /*  */ +.octicon-briefcase:before { + content: "\f0d3"; +} /*  */ +.octicon-broadcast:before { + content: "\f048"; +} /*  */ +.octicon-browser:before { + content: "\f0c5"; +} /*  */ +.octicon-bug:before { + content: "\f091"; +} /*  */ +.octicon-calendar:before { + content: "\f068"; +} /*  */ +.octicon-check:before { + content: "\f03a"; +} /*  */ +.octicon-checklist:before { + content: "\f076"; +} /*  */ +.octicon-chevron-down:before { + content: "\f0a3"; +} /*  */ +.octicon-chevron-left:before { + content: "\f0a4"; +} /*  */ +.octicon-chevron-right:before { + content: "\f078"; +} /*  */ +.octicon-chevron-up:before { + content: "\f0a2"; +} /*  */ +.octicon-circle-slash:before { + content: "\f084"; +} /*  */ +.octicon-circuit-board:before { + content: "\f0d6"; +} /*  */ +.octicon-clippy:before { + content: "\f035"; +} /*  */ +.octicon-clock:before { + content: "\f046"; +} /*  */ +.octicon-cloud-download:before { + content: "\f00b"; +} /*  */ +.octicon-cloud-upload:before { + content: "\f00c"; +} /*  */ +.octicon-code:before { + content: "\f05f"; +} /*  */ +.octicon-color-mode:before { + content: "\f065"; +} /*  */ .octicon-comment-add:before, -.octicon-comment:before { content: '\f02b'} /*  */ -.octicon-comment-discussion:before { content: '\f04f'} /*  */ -.octicon-credit-card:before { content: '\f045'} /*  */ -.octicon-dash:before { content: '\f0ca'} /*  */ -.octicon-dashboard:before { content: '\f07d'} /*  */ -.octicon-database:before { content: '\f096'} /*  */ +.octicon-comment:before { + content: "\f02b"; +} /*  */ +.octicon-comment-discussion:before { + content: "\f04f"; +} /*  */ +.octicon-credit-card:before { + content: "\f045"; +} /*  */ +.octicon-dash:before { + content: "\f0ca"; +} /*  */ +.octicon-dashboard:before { + content: "\f07d"; +} /*  */ +.octicon-database:before { + content: "\f096"; +} /*  */ .octicon-clone:before, -.octicon-desktop-download:before { content: '\f0dc'} /*  */ -.octicon-device-camera:before { content: '\f056'} /*  */ -.octicon-device-camera-video:before { content: '\f057'} /*  */ -.octicon-device-desktop:before { content: '\f27c'} /*  */ -.octicon-device-mobile:before { content: '\f038'} /*  */ -.octicon-diff:before { content: '\f04d'} /*  */ -.octicon-diff-added:before { content: '\f06b'} /*  */ -.octicon-diff-ignored:before { content: '\f099'} /*  */ -.octicon-diff-modified:before { content: '\f06d'} /*  */ -.octicon-diff-removed:before { content: '\f06c'} /*  */ -.octicon-diff-renamed:before { content: '\f06e'} /*  */ -.octicon-ellipsis:before { content: '\f09a'} /*  */ +.octicon-desktop-download:before { + content: "\f0dc"; +} /*  */ +.octicon-device-camera:before { + content: "\f056"; +} /*  */ +.octicon-device-camera-video:before { + content: "\f057"; +} /*  */ +.octicon-device-desktop:before { + content: "\f27c"; +} /*  */ +.octicon-device-mobile:before { + content: "\f038"; +} /*  */ +.octicon-diff:before { + content: "\f04d"; +} /*  */ +.octicon-diff-added:before { + content: "\f06b"; +} /*  */ +.octicon-diff-ignored:before { + content: "\f099"; +} /*  */ +.octicon-diff-modified:before { + content: "\f06d"; +} /*  */ +.octicon-diff-removed:before { + content: "\f06c"; +} /*  */ +.octicon-diff-renamed:before { + content: "\f06e"; +} /*  */ +.octicon-ellipsis:before { + content: "\f09a"; +} /*  */ .octicon-eye-unwatch:before, .octicon-eye-watch:before, -.octicon-eye:before { content: '\f04e'} /*  */ -.octicon-file-binary:before { content: '\f094'} /*  */ -.octicon-file-code:before { content: '\f010'} /*  */ -.octicon-file-directory:before { content: '\f016'} /*  */ -.octicon-file-media:before { content: '\f012'} /*  */ -.octicon-file-pdf:before { content: '\f014'} /*  */ -.octicon-file-submodule:before { content: '\f017'} /*  */ -.octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ -.octicon-file-symlink-file:before { content: '\f0b0'} /*  */ -.octicon-file-text:before { content: '\f011'} /*  */ -.octicon-file-zip:before { content: '\f013'} /*  */ -.octicon-flame:before { content: '\f0d2'} /*  */ -.octicon-fold:before { content: '\f0cc'} /*  */ -.octicon-gear:before { content: '\f02f'} /*  */ -.octicon-gift:before { content: '\f042'} /*  */ -.octicon-gist:before { content: '\f00e'} /*  */ -.octicon-gist-secret:before { content: '\f08c'} /*  */ +.octicon-eye:before { + content: "\f04e"; +} /*  */ +.octicon-file-binary:before { + content: "\f094"; +} /*  */ +.octicon-file-code:before { + content: "\f010"; +} /*  */ +.octicon-file-directory:before { + content: "\f016"; +} /*  */ +.octicon-file-media:before { + content: "\f012"; +} /*  */ +.octicon-file-pdf:before { + content: "\f014"; +} /*  */ +.octicon-file-submodule:before { + content: "\f017"; +} /*  */ +.octicon-file-symlink-directory:before { + content: "\f0b1"; +} /*  */ +.octicon-file-symlink-file:before { + content: "\f0b0"; +} /*  */ +.octicon-file-text:before { + content: "\f011"; +} /*  */ +.octicon-file-zip:before { + content: "\f013"; +} /*  */ +.octicon-flame:before { + content: "\f0d2"; +} /*  */ +.octicon-fold:before { + content: "\f0cc"; +} /*  */ +.octicon-gear:before { + content: "\f02f"; +} /*  */ +.octicon-gift:before { + content: "\f042"; +} /*  */ +.octicon-gist:before { + content: "\f00e"; +} /*  */ +.octicon-gist-secret:before { + content: "\f08c"; +} /*  */ .octicon-git-branch-create:before, .octicon-git-branch-delete:before, -.octicon-git-branch:before { content: '\f020'} /*  */ -.octicon-git-commit:before { content: '\f01f'} /*  */ -.octicon-git-compare:before { content: '\f0ac'} /*  */ -.octicon-git-merge:before { content: '\f023'} /*  */ +.octicon-git-branch:before { + content: "\f020"; +} /*  */ +.octicon-git-commit:before { + content: "\f01f"; +} /*  */ +.octicon-git-compare:before { + content: "\f0ac"; +} /*  */ +.octicon-git-merge:before { + content: "\f023"; +} /*  */ .octicon-git-pull-request-abandoned:before, -.octicon-git-pull-request:before { content: '\f009'} /*  */ -.octicon-globe:before { content: '\f0b6'} /*  */ -.octicon-graph:before { content: '\f043'} /*  */ -.octicon-heart:before { content: '\2665'} /* ♥ */ -.octicon-history:before { content: '\f07e'} /*  */ -.octicon-home:before { content: '\f08d'} /*  */ -.octicon-horizontal-rule:before { content: '\f070'} /*  */ -.octicon-hubot:before { content: '\f09d'} /*  */ -.octicon-inbox:before { content: '\f0cf'} /*  */ -.octicon-info:before { content: '\f059'} /*  */ -.octicon-issue-closed:before { content: '\f028'} /*  */ -.octicon-issue-opened:before { content: '\f026'} /*  */ -.octicon-issue-reopened:before { content: '\f027'} /*  */ -.octicon-jersey:before { content: '\f019'} /*  */ -.octicon-key:before { content: '\f049'} /*  */ -.octicon-keyboard:before { content: '\f00d'} /*  */ -.octicon-law:before { content: '\f0d8'} /*  */ -.octicon-light-bulb:before { content: '\f000'} /*  */ -.octicon-link:before { content: '\f05c'} /*  */ -.octicon-link-external:before { content: '\f07f'} /*  */ -.octicon-list-ordered:before { content: '\f062'} /*  */ -.octicon-list-unordered:before { content: '\f061'} /*  */ -.octicon-location:before { content: '\f060'} /*  */ +.octicon-git-pull-request:before { + content: "\f009"; +} /*  */ +.octicon-globe:before { + content: "\f0b6"; +} /*  */ +.octicon-graph:before { + content: "\f043"; +} /*  */ +.octicon-heart:before { + content: "\2665"; +} /* ♥ */ +.octicon-history:before { + content: "\f07e"; +} /*  */ +.octicon-home:before { + content: "\f08d"; +} /*  */ +.octicon-horizontal-rule:before { + content: "\f070"; +} /*  */ +.octicon-hubot:before { + content: "\f09d"; +} /*  */ +.octicon-inbox:before { + content: "\f0cf"; +} /*  */ +.octicon-info:before { + content: "\f059"; +} /*  */ +.octicon-issue-closed:before { + content: "\f028"; +} /*  */ +.octicon-issue-opened:before { + content: "\f026"; +} /*  */ +.octicon-issue-reopened:before { + content: "\f027"; +} /*  */ +.octicon-jersey:before { + content: "\f019"; +} /*  */ +.octicon-key:before { + content: "\f049"; +} /*  */ +.octicon-keyboard:before { + content: "\f00d"; +} /*  */ +.octicon-law:before { + content: "\f0d8"; +} /*  */ +.octicon-light-bulb:before { + content: "\f000"; +} /*  */ +.octicon-link:before { + content: "\f05c"; +} /*  */ +.octicon-link-external:before { + content: "\f07f"; +} /*  */ +.octicon-list-ordered:before { + content: "\f062"; +} /*  */ +.octicon-list-unordered:before { + content: "\f061"; +} /*  */ +.octicon-location:before { + content: "\f060"; +} /*  */ .octicon-gist-private:before, .octicon-mirror-private:before, .octicon-git-fork-private:before, -.octicon-lock:before { content: '\f06a'} /*  */ -.octicon-logo-github:before { content: '\f092'} /*  */ -.octicon-mail:before { content: '\f03b'} /*  */ -.octicon-mail-read:before { content: '\f03c'} /*  */ -.octicon-mail-reply:before { content: '\f051'} /*  */ -.octicon-mark-github:before { content: '\f00a'} /*  */ -.octicon-markdown:before { content: '\f0c9'} /*  */ -.octicon-megaphone:before { content: '\f077'} /*  */ -.octicon-mention:before { content: '\f0be'} /*  */ -.octicon-milestone:before { content: '\f075'} /*  */ +.octicon-lock:before { + content: "\f06a"; +} /*  */ +.octicon-logo-github:before { + content: "\f092"; +} /*  */ +.octicon-mail:before { + content: "\f03b"; +} /*  */ +.octicon-mail-read:before { + content: "\f03c"; +} /*  */ +.octicon-mail-reply:before { + content: "\f051"; +} /*  */ +.octicon-mark-github:before { + content: "\f00a"; +} /*  */ +.octicon-markdown:before { + content: "\f0c9"; +} /*  */ +.octicon-megaphone:before { + content: "\f077"; +} /*  */ +.octicon-mention:before { + content: "\f0be"; +} /*  */ +.octicon-milestone:before { + content: "\f075"; +} /*  */ .octicon-mirror-public:before, -.octicon-mirror:before { content: '\f024'} /*  */ -.octicon-mortar-board:before { content: '\f0d7'} /*  */ -.octicon-mute:before { content: '\f080'} /*  */ -.octicon-no-newline:before { content: '\f09c'} /*  */ -.octicon-octoface:before { content: '\f008'} /*  */ -.octicon-organization:before { content: '\f037'} /*  */ -.octicon-package:before { content: '\f0c4'} /*  */ -.octicon-paintcan:before { content: '\f0d1'} /*  */ -.octicon-pencil:before { content: '\f058'} /*  */ +.octicon-mirror:before { + content: "\f024"; +} /*  */ +.octicon-mortar-board:before { + content: "\f0d7"; +} /*  */ +.octicon-mute:before { + content: "\f080"; +} /*  */ +.octicon-no-newline:before { + content: "\f09c"; +} /*  */ +.octicon-octoface:before { + content: "\f008"; +} /*  */ +.octicon-organization:before { + content: "\f037"; +} /*  */ +.octicon-package:before { + content: "\f0c4"; +} /*  */ +.octicon-paintcan:before { + content: "\f0d1"; +} /*  */ +.octicon-pencil:before { + content: "\f058"; +} /*  */ .octicon-person-add:before, .octicon-person-follow:before, -.octicon-person:before { content: '\f018'} /*  */ -.octicon-pin:before { content: '\f041'} /*  */ -.octicon-plug:before { content: '\f0d4'} /*  */ +.octicon-person:before { + content: "\f018"; +} /*  */ +.octicon-pin:before { + content: "\f041"; +} /*  */ +.octicon-plug:before { + content: "\f0d4"; +} /*  */ .octicon-repo-create:before, .octicon-gist-new:before, .octicon-file-directory-create:before, .octicon-file-add:before, -.octicon-plus:before { content: '\f05d'} /*  */ -.octicon-primitive-dot:before { content: '\f052'} /*  */ -.octicon-primitive-square:before { content: '\f053'} /*  */ -.octicon-pulse:before { content: '\f085'} /*  */ -.octicon-question:before { content: '\f02c'} /*  */ -.octicon-quote:before { content: '\f063'} /*  */ -.octicon-radio-tower:before { content: '\f030'} /*  */ +.octicon-plus:before { + content: "\f05d"; +} /*  */ +.octicon-primitive-dot:before { + content: "\f052"; +} /*  */ +.octicon-primitive-square:before { + content: "\f053"; +} /*  */ +.octicon-pulse:before { + content: "\f085"; +} /*  */ +.octicon-question:before { + content: "\f02c"; +} /*  */ +.octicon-quote:before { + content: "\f063"; +} /*  */ +.octicon-radio-tower:before { + content: "\f030"; +} /*  */ .octicon-repo-delete:before, -.octicon-repo:before { content: '\f001'} /*  */ -.octicon-repo-clone:before { content: '\f04c'} /*  */ -.octicon-repo-force-push:before { content: '\f04a'} /*  */ +.octicon-repo:before { + content: "\f001"; +} /*  */ +.octicon-repo-clone:before { + content: "\f04c"; +} /*  */ +.octicon-repo-force-push:before { + content: "\f04a"; +} /*  */ .octicon-gist-fork:before, -.octicon-repo-forked:before { content: '\f002'} /*  */ -.octicon-repo-pull:before { content: '\f006'} /*  */ -.octicon-repo-push:before { content: '\f005'} /*  */ -.octicon-rocket:before { content: '\f033'} /*  */ -.octicon-rss:before { content: '\f034'} /*  */ -.octicon-ruby:before { content: '\f047'} /*  */ -.octicon-screen-full:before { content: '\f066'} /*  */ -.octicon-screen-normal:before { content: '\f067'} /*  */ +.octicon-repo-forked:before { + content: "\f002"; +} /*  */ +.octicon-repo-pull:before { + content: "\f006"; +} /*  */ +.octicon-repo-push:before { + content: "\f005"; +} /*  */ +.octicon-rocket:before { + content: "\f033"; +} /*  */ +.octicon-rss:before { + content: "\f034"; +} /*  */ +.octicon-ruby:before { + content: "\f047"; +} /*  */ +.octicon-screen-full:before { + content: "\f066"; +} /*  */ +.octicon-screen-normal:before { + content: "\f067"; +} /*  */ .octicon-search-save:before, -.octicon-search:before { content: '\f02e'} /*  */ -.octicon-server:before { content: '\f097'} /*  */ -.octicon-settings:before { content: '\f07c'} /*  */ -.octicon-shield:before { content: '\f0e1'} /*  */ +.octicon-search:before { + content: "\f02e"; +} /*  */ +.octicon-server:before { + content: "\f097"; +} /*  */ +.octicon-settings:before { + content: "\f07c"; +} /*  */ +.octicon-shield:before { + content: "\f0e1"; +} /*  */ .octicon-log-in:before, -.octicon-sign-in:before { content: '\f036'} /*  */ +.octicon-sign-in:before { + content: "\f036"; +} /*  */ .octicon-log-out:before, -.octicon-sign-out:before { content: '\f032'} /*  */ -.octicon-squirrel:before { content: '\f0b2'} /*  */ +.octicon-sign-out:before { + content: "\f032"; +} /*  */ +.octicon-squirrel:before { + content: "\f0b2"; +} /*  */ .octicon-star-add:before, .octicon-star-delete:before, -.octicon-star:before { content: '\f02a'} /*  */ -.octicon-stop:before { content: '\f08f'} /*  */ +.octicon-star:before { + content: "\f02a"; +} /*  */ +.octicon-stop:before { + content: "\f08f"; +} /*  */ .octicon-repo-sync:before, -.octicon-sync:before { content: '\f087'} /*  */ +.octicon-sync:before { + content: "\f087"; +} /*  */ .octicon-tag-remove:before, .octicon-tag-add:before, -.octicon-tag:before { content: '\f015'} /*  */ -.octicon-telescope:before { content: '\f088'} /*  */ -.octicon-terminal:before { content: '\f0c8'} /*  */ -.octicon-three-bars:before { content: '\f05e'} /*  */ -.octicon-thumbsdown:before { content: '\f0db'} /*  */ -.octicon-thumbsup:before { content: '\f0da'} /*  */ -.octicon-tools:before { content: '\f031'} /*  */ -.octicon-trashcan:before { content: '\f0d0'} /*  */ -.octicon-triangle-down:before { content: '\f05b'} /*  */ -.octicon-triangle-left:before { content: '\f044'} /*  */ -.octicon-triangle-right:before { content: '\f05a'} /*  */ -.octicon-triangle-up:before { content: '\f0aa'} /*  */ -.octicon-unfold:before { content: '\f039'} /*  */ -.octicon-unmute:before { content: '\f0ba'} /*  */ -.octicon-versions:before { content: '\f064'} /*  */ -.octicon-watch:before { content: '\f0e0'} /*  */ +.octicon-tag:before { + content: "\f015"; +} /*  */ +.octicon-telescope:before { + content: "\f088"; +} /*  */ +.octicon-terminal:before { + content: "\f0c8"; +} /*  */ +.octicon-three-bars:before { + content: "\f05e"; +} /*  */ +.octicon-thumbsdown:before { + content: "\f0db"; +} /*  */ +.octicon-thumbsup:before { + content: "\f0da"; +} /*  */ +.octicon-tools:before { + content: "\f031"; +} /*  */ +.octicon-trashcan:before { + content: "\f0d0"; +} /*  */ +.octicon-triangle-down:before { + content: "\f05b"; +} /*  */ +.octicon-triangle-left:before { + content: "\f044"; +} /*  */ +.octicon-triangle-right:before { + content: "\f05a"; +} /*  */ +.octicon-triangle-up:before { + content: "\f0aa"; +} /*  */ +.octicon-unfold:before { + content: "\f039"; +} /*  */ +.octicon-unmute:before { + content: "\f0ba"; +} /*  */ +.octicon-versions:before { + content: "\f064"; +} /*  */ +.octicon-watch:before { + content: "\f0e0"; +} /*  */ .octicon-remove-close:before, -.octicon-x:before { content: '\f081'} /*  */ -.octicon-zap:before { content: '\26A1'} /* ⚡ */ +.octicon-x:before { + content: "\f081"; +} /*  */ +.octicon-zap:before { + content: "\26A1"; +} /* ⚡ */ diff --git a/frappe/public/icons/timeless/icons.svg b/frappe/public/icons/timeless/icons.svg index 856e743bc2..58665bd20b 100644 --- a/frappe/public/icons/timeless/icons.svg +++ b/frappe/public/icons/timeless/icons.svg @@ -815,7 +815,7 @@ + stroke="none" fill="var(--icon-stroke)"> diff --git a/frappe/public/js/form_builder/components/Autocomplete.vue b/frappe/public/js/form_builder/components/Autocomplete.vue index 2ded948e27..7960047b43 100644 --- a/frappe/public/js/form_builder/components/Autocomplete.vue +++ b/frappe/public/js/form_builder/components/Autocomplete.vue @@ -19,7 +19,7 @@
{ - return query.value - ? props.options.filter((option) => { - return option.label.toLowerCase().includes(query.value.toLowerCase()); - }) - : props.options; + if (!query.value) return props.options; + return props.options.filter((option) => { + return option.label.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()); + }); +}); + +const sortedOptions = computed(() => { + return filteredOptions.value.sort((a, b) => { + return a.label.localeCompare(b.label); + }); }); function clear_search() { @@ -113,10 +118,11 @@ watch(showOptions, (val) => { .combo-box-options { width: 100%; - background-color: var(--white); + background-color: var(--fg-color); border-radius: var(--border-radius-lg); box-shadow: var(--shadow-2xl); padding: 0; + border: 1px solid var(--subtle-accent); } .combo-box-option { @@ -125,6 +131,8 @@ watch(showOptions, (val) => { border-radius: var(--border-radius-sm); padding: 6px 10px; width: 100%; + cursor: pointer; + user-select: none; &:hover, &.active { diff --git a/frappe/public/js/form_builder/components/Column.vue b/frappe/public/js/form_builder/components/Column.vue index f6a3cfbe51..3fbc67e115 100644 --- a/frappe/public/js/form_builder/components/Column.vue +++ b/frappe/public/js/form_builder/components/Column.vue @@ -1,113 +1,3 @@ - - -
+
@@ -182,6 +45,30 @@ function move_columns_to_section() {
+ + diff --git a/frappe/public/js/form_builder/components/Field.vue b/frappe/public/js/form_builder/components/Field.vue index 6bae5bd35f..26d488cdf8 100644 --- a/frappe/public/js/form_builder/components/Field.vue +++ b/frappe/public/js/form_builder/components/Field.vue @@ -75,6 +75,116 @@ function duplicate_field() { store.form.selected_field = duplicate_field.df; } +function make_dialog(frm) { + frm.dialog = new frappe.ui.Dialog({ + title: __("Set Filters"), + fields: [ + { + fieldtype: "HTML", + fieldname: "filter_area", + }, + ], + primary_action: () => { + let fieldname = props.field.df.fieldname; + let field_option = props.field.df.options; + let filters = frm.filter_group.get_filters().map((filter) => { + // last element is a boolean which hides the filter hence not required to store in meta + filter.pop(); + + // filter_group component requires options and frm.set_query requires fieldname so storing both + filter[0] = { fieldname, field_option }; + return filter; + }); + + props.field.df.link_filters = JSON.stringify(filters); + frm.dialog.hide(); + }, + primary_action_label: __("Apply"), + }); + + if (frm.doctype === "Customize Form") { + let current_doctype = frm.doc.doc_type; + let fieldname = props.field.df.fieldname; + let property = "link_filters"; + let property_setter_id = current_doctype + "-" + fieldname + "-" + property; + + frappe.db.exists("Property Setter", property_setter_id).then((exits) => { + if (exits) { + frm.dialog.set_secondary_action_label(__("Reset To Default")); + frm.dialog.set_secondary_action(() => { + frappe.call({ + method: "frappe.custom.doctype.customize_form.customize_form.get_link_filters_from_doc_without_customisations", + args: { + doctype: current_doctype, + fieldname: fieldname, + }, + callback: function (r) { + if (r.message) { + props.field.df.link_filters = r.message; + + frm.filter_group.clear_filters(); + add_existing_filter(frm, props.field.df); + // hide the secondary action button + frm.dialog.get_secondary_btn().addClass("hidden"); + } + }, + }); + }); + } + }); + } + + // Setting selected field in store because when we click on the dialog the selected field is set to null + frm.dialog.$wrapper.on("click", () => { + store.form.selected_field = props.field.df; + }); +} + +function make_filter_area(frm, doctype) { + frm.filter_group = new frappe.ui.FilterGroup({ + parent: frm.dialog.get_field("filter_area").$wrapper, + doctype: doctype, + on_change: () => {}, + }); +} + +function add_existing_filter(frm, df) { + if (df.link_filters) { + let filters = JSON.parse(df.link_filters); + filters.map((filter) => { + // filter_group component requires options and frm.set_query requires fieldname + filter[0] = filter[0].field_option; + }); + if (filters) { + frm.filter_group.add_filters_to_filter_group(filters); + } + } +} + +function edit_filters() { + let field_doctype = props.field.df.options; + const { frm } = store; + + make_dialog(frm); + make_filter_area(frm, field_doctype); + frappe.model.with_doctype(field_doctype, () => { + frm.dialog.show(); + add_existing_filter(frm, props.field.df); + }); +} + +function is_filter_applied() { + if (props.field.df.link_filters) { + try { + if (JSON.parse(props.field.df.link_filters).length > 0) { + return "btn-filter-applied"; + } + } catch (error) { + return ""; + } + } +} + onMounted(() => selected.value && label_input.value.focus_on_label()); @@ -111,22 +221,17 @@ onMounted(() => selected.value && label_input.value.focus_on_label());