diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index cb502f68a7..3eefd1ce82 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -137,7 +137,7 @@ jobs: - name: UI Tests if: ${{ steps.check-build.outputs.build == 'strawberry' }} - run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID + run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT env: CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js index 5f1ab86d41..03ab61fac4 100644 --- a/cypress/integration/control_barcode.js +++ b/cypress/integration/control_barcode.js @@ -21,7 +21,6 @@ context('Control Barcode', () => { get_dialog_with_barcode().as('dialog'); cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') - .focus() .type('123456789') .blur(); cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]') @@ -38,7 +37,6 @@ context('Control Barcode', () => { get_dialog_with_barcode().as('dialog'); cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') - .focus() .type('123456789') .blur(); cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js index 5c531a0823..d89eba8840 100644 --- a/cypress/integration/control_icon.js +++ b/cypress/integration/control_icon.js @@ -19,18 +19,18 @@ context('Control Icon', () => { get_dialog_with_icon().as('dialog'); cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').click(); - cy.get('.icon-picker .icon-wrapper[id=active]').first().click(); - cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'active'); + cy.get('.icon-picker .icon-wrapper[id=heart-active]').first().click(); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'heart-active'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); - expect(value).to.equal('active'); + expect(value).to.equal('heart-active'); }); - cy.get('.icon-picker .icon-wrapper[id=resting]').first().click(); - cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'resting'); + cy.get('.icon-picker .icon-wrapper[id=heart]').first().click(); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'heart'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); - expect(value).to.equal('resting'); + expect(value).to.equal('heart'); }); }); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 6d16769b37..bfa70ad338 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -58,6 +58,23 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=link] input').should('have.value', ''); }); + it("should be possible set empty value explicitly", () => { + get_dialog_with_link().as("dialog"); + + cy.intercept("POST", "/api/method/frappe.client.validate_link").as("validate_link"); + + cy.get(".frappe-control[data-fieldname=link] input") + .type(" ", { delay: 100 }) + .blur(); + cy.wait("@validate_link"); + cy.get(".frappe-control[data-fieldname=link] input").should("have.value", ""); + cy.window() + .its("cur_dialog") + .then((dialog) => { + expect(dialog.get_value("link")).to.equal(''); + }); + }); + it('should route to form on arrow click', () => { get_dialog_with_link().as('dialog'); @@ -78,7 +95,7 @@ context('Control Link', () => { }); }); - it('should fetch valid value', () => { + it('should update dependant fields (via fetch_from)', () => { cy.get('@todos').then(todos => { cy.visit(`/app/todo/${todos[0]}`); cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); @@ -89,7 +106,67 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( 'contain', 'Administrator' ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", "Administrator"); + + // invalid input + cy.get('@input').clear().type('invalid input', {delay: 100}).blur(); + cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( + 'contain', '' + ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", null); + + // set valid value again + cy.get('@input').clear().type('Administrator', {delay: 100}).blur(); + cy.wait('@validate_link'); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", "Administrator"); + + // clear input + cy.get('@input').clear().blur(); + cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( + 'contain', '' + ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", ""); }); }); + it("should set default values", () => { + cy.insert_doc("Property Setter", { + "doctype_or_field": "DocField", + "doc_type": "ToDo", + "field_name": "assigned_by", + "property": "default", + "property_type": "Text", + "value": "Administrator" + }, true); + cy.reload(); + cy.new_form("ToDo"); + cy.fill_field("description", "new", "Text Editor"); + cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form"); + cy.findByRole("button", {name: "Save"}).click(); + cy.wait("@save_form"); + cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( + "contain", "Administrator" + ); + // if user clears default value explicitly, system should not reset default again + cy.get_field("assigned_by").clear().blur(); + cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form"); + cy.findByRole("button", {name: "Save"}).click(); + cy.wait("@save_form"); + cy.get_field("assigned_by").should("have.value", ""); + cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( + "contain", "" + ); + }); }); diff --git a/cypress/integration/datetime.js b/cypress/integration/datetime.js index ef1952dc94..4a24faf40b 100644 --- a/cypress/integration/datetime.js +++ b/cypress/integration/datetime.js @@ -103,6 +103,7 @@ context('Control Date, Time and DateTime', () => { input_value: '12-02-2019 11:00' // admin timezone (Asia/Kolkata) } ]; + datetime_formats.forEach(d => { it(`test datetime format ${d.date_format} ${d.time_format}`, () => { cy.set_value('System Settings', 'System Settings', { diff --git a/cypress/integration/multi_select_dialog.js b/cypress/integration/multi_select_dialog.js index 7752ad0f0b..607db506c7 100644 --- a/cypress/integration/multi_select_dialog.js +++ b/cypress/integration/multi_select_dialog.js @@ -77,11 +77,11 @@ context('MultiSelectDialog', () => { it('tests more button', () => { cy.get_open_dialog() - .get(`.frappe-control[data-fieldname="more_btn"]`) + .get(`.frappe-control[data-fieldname="more_child_btn"]`) .should('exist') .as('more-btn'); - cy.get_open_dialog().get('.list-item-container').should(($rows) => { + cy.get_open_dialog().get('.datatable .dt-scrollable .dt-row').should(($rows) => { expect($rows).to.have.length(20); }); @@ -89,7 +89,7 @@ context('MultiSelectDialog', () => { cy.get('@more-btn').find('button').click({force: true}); cy.wait('@get-more-records'); - cy.get_open_dialog().get('.list-item-container').should(($rows) => { + cy.get_open_dialog().get('.datatable .dt-scrollable .dt-row').should(($rows) => { if ($rows.length <= 20) { throw new Error("More button doesn't work"); } diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index 629ae72eb8..0253e8fd43 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -7,8 +7,6 @@ context('Report View', () => { cy.visit('/app/website'); cy.insert_doc('DocType', custom_submittable_doctype, true); cy.clear_cache(); - }); - it('Field with enabled allow_on_submit should be editable.', () => { cy.insert_doc(doctype_name, { 'title': 'Doc 1', 'description': 'Random Text', @@ -16,6 +14,8 @@ context('Report View', () => { // submit document 'docstatus': 1 }, true).as('doc'); + }); + it('Field with enabled allow_on_submit should be editable.', () => { cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update'); cy.visit(`/app/List/${doctype_name}/Report`); // check status column added from docstatus diff --git a/cypress/integration/timeline_email.js b/cypress/integration/timeline_email.js index dfe80e0019..5808bd52ef 100644 --- a/cypress/integration/timeline_email.js +++ b/cypress/integration/timeline_email.js @@ -14,12 +14,12 @@ context('Timeline Email', () => { cy.wait(700); }); - it('Adding email and verifying timeline content for email attachment, deleting attachment and ToDo', () => { + it('Adding email and verifying timeline content for email attachment', () => { cy.visit('/app/todo'); cy.get('.list-row > .level-left > .list-subject').eq(0).click(); //Creating a new email - cy.get('.timeline-actions > .btn').click(); + cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click(); cy.fill_field('recipients', 'test@example.com', 'MultiSelect'); cy.get('.modal.show > .modal-dialog > .modal-content > .modal-body > :nth-child(1) > .form-layout > .form-page > :nth-child(3) > .section-body > .form-column > form > [data-fieldtype="Text Editor"] > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').type('Test Mail'); @@ -43,7 +43,9 @@ context('Timeline Email', () => { cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click(); cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .dropdown-menu > li > .grey-link').eq(9).click(); cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click(); + }); + it('Deleting attachment and ToDo', () => { cy.visit('/app/todo'); cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click(); @@ -57,11 +59,11 @@ context('Timeline Email', () => { cy.wait(500); //To check if the discard button functionality in email is working correctly - cy.get('.timeline-actions > .btn').click(); + cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click(); cy.fill_field('recipients', 'test@example.com', 'MultiSelect'); cy.get('.modal-footer > .standard-actions > .btn-secondary').contains('Discard').click(); cy.wait(500); - cy.get('.timeline-actions > .btn').click(); + cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click(); cy.wait(500); cy.get_field('recipients', 'MultiSelect').should('have.text', ''); cy.get('.modal-header:visible > .modal-actions > .btn-modal-close > .icon').click(); diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js index 65586366e6..9d6eeaff64 100644 --- a/cypress/integration/workspace.js +++ b/cypress/integration/workspace.js @@ -33,44 +33,39 @@ context('Workspace 2.0', () => { }); it('Add New Block', () => { - cy.get('.codex-editor__redactor .ce-block'); - cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); - cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Heading').click(); + cy.get('.ce-block').click().type('{enter}'); + cy.get('.block-list-container .block-list-item').contains('Heading').click(); cy.get(":focus").type('Header'); cy.get(".ce-block:last").find('.ce-header').should('exist'); - cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); - cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Text').click(); + cy.get('.ce-block:last').click().type('{enter}'); + cy.get('.block-list-container .block-list-item').contains('Text').click(); cy.get(":focus").type('Paragraph text'); cy.get(".ce-block:last").find('.ce-paragraph').should('exist'); }); it('Delete A Block', () => { - cy.get(".ce-block:last").find('.delete-paragraph').click(); + cy.get(":focus").click(); + cy.get('.paragraph-control .setting-btn').click(); + cy.get('.paragraph-control .dropdown-item').contains('Delete').click(); cy.get(".ce-block:last").find('.ce-paragraph').should('not.exist'); }); it('Shrink and Expand A Block', () => { - cy.get(".ce-block:last").find('.tune-btn').click(); - cy.get('.ce-settings--opened .ce-shrink-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-11'); - cy.get('.ce-settings--opened .ce-shrink-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-10'); - cy.get('.ce-settings--opened .ce-shrink-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-9'); - cy.get('.ce-settings--opened .ce-expand-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-10'); - cy.get('.ce-settings--opened .ce-expand-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-11'); - cy.get('.ce-settings--opened .ce-expand-button').click(); - cy.get(".ce-block:last").should('have.class', 'col-12'); - }); - - it('Change Header Text Size', () => { - cy.get('.ce-settings--opened .cdx-settings-button[data-level="3"]').click(); - cy.get(".ce-block:last").find('.widget-head h3').should('exist'); - cy.get('.ce-settings--opened .cdx-settings-button[data-level="4"]').click(); - cy.get(".ce-block:last").find('.widget-head h4').should('exist'); + cy.get(":focus").click(); + cy.get('.ce-block:last .setting-btn').click(); + cy.get('.ce-block:last .dropdown-item').contains('Shrink').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-11'); + cy.get('.ce-block:last .dropdown-item').contains('Shrink').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-10'); + cy.get('.ce-block:last .dropdown-item').contains('Shrink').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-9'); + cy.get('.ce-block:last .dropdown-item').contains('Expand').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-10'); + cy.get('.ce-block:last .dropdown-item').contains('Expand').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-11'); + cy.get('.ce-block:last .dropdown-item').contains('Expand').click(); + cy.get(".ce-block:last").should('have.class', 'col-xs-12'); cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); }); @@ -79,7 +74,10 @@ context('Workspace 2.0', () => { cy.get('.codex-editor__redactor .ce-block'); cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); - cy.get('.sidebar-item-container[item-name="Test Private Page"]').find('.sidebar-item-control .delete-page').click(); + cy.get('.sidebar-item-container[item-name="Test Private Page"]') + .find('.sidebar-item-control .setting-btn').click(); + cy.get('.sidebar-item-container[item-name="Test Private Page"]') + .find('.dropdown-item[title="Delete Workspace"]').click({force: true}); cy.wait(300); cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click(); cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 758b3cde2b..4f273af21f 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -110,34 +110,6 @@ Cypress.Commands.add('get_doc', (doctype, name) => { }); }); -Cypress.Commands.add('insert_doc', (doctype, args, ignore_duplicate) => { - return cy - .window() - .its('frappe.csrf_token') - .then(csrf_token => { - return cy - .request({ - method: 'POST', - url: `/api/resource/${doctype}`, - body: args, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Frappe-CSRF-Token': csrf_token - }, - failOnStatusCode: !ignore_duplicate - }) - .then(res => { - let status_codes = [200]; - if (ignore_duplicate) { - status_codes.push(409); - } - expect(res.status).to.be.oneOf(status_codes); - return res.body; - }); - }); -}); - Cypress.Commands.add('remove_doc', (doctype, name) => { return cy .window() diff --git a/frappe/__init__.py b/frappe/__init__.py index 08c0f794b3..3558603454 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -12,6 +12,8 @@ Read the documentation: https://frappeframework.com/docs """ import os, warnings +STANDARD_USERS = ('Guest', 'Administrator') + _dev_server = os.environ.get('DEV_SERVER', False) if _dev_server: @@ -100,7 +102,7 @@ def as_unicode(text, encoding='utf-8'): '''Convert to unicode if required''' if isinstance(text, str): return text - elif text==None: + elif text is None: return '' elif isinstance(text, bytes): return str(text, encoding) @@ -121,6 +123,7 @@ def set_user_lang(user, user_language=None): local.lang = get_user_lang(user) # local-globals + db = local("db") qb = local("qb") conf = local("conf") @@ -140,6 +143,8 @@ lang = local("lang") # This if block is never executed when running the code. It is only used for # telling static code analyzer where to find dynamically defined attributes. if typing.TYPE_CHECKING: + from frappe.utils.redis_wrapper import RedisWrapper + from frappe.database.mariadb.database import MariaDBDatabase from frappe.database.postgres.database import PostgresDatabase from frappe.query_builder.builder import MariaDB, Postgres @@ -147,6 +152,7 @@ if typing.TYPE_CHECKING: db: typing.Union[MariaDBDatabase, PostgresDatabase] qb: typing.Union[MariaDB, Postgres] + # end: static analysis hack def init(site, sites_path=None, new_site=False): @@ -291,7 +297,7 @@ def get_conf(site=None): class init_site: def __init__(self, site=None): - '''If site==None, initialize it for empty site ('') to load common_site_config.json''' + '''If site is None, initialize it for empty site ('') to load common_site_config.json''' self.site = site or '' def __enter__(self): @@ -308,9 +314,8 @@ def destroy(): release_local(local) -# memcache redis_server = None -def cache(): +def cache() -> "RedisWrapper": """Returns redis connection.""" global redis_server if not redis_server: @@ -443,7 +448,7 @@ def throw(msg, exc=ValidationError, title=None, is_minimizable=None, wide=None, msgprint(msg, raise_exception=exc, title=title, indicator='red', is_minimizable=is_minimizable, wide=wide, as_list=as_list) def emit_js(js, user=False, **kwargs): - if user == False: + if user is False: user = session.user publish_realtime('eval_js', js, user=user, **kwargs) @@ -1658,7 +1663,7 @@ def local_cache(namespace, key, generator, regenerate_if_none=False): if key not in local.cache[namespace]: local.cache[namespace][key] = generator() - elif local.cache[namespace][key]==None and regenerate_if_none: + elif local.cache[namespace][key] is None and regenerate_if_none: # if key exists but the previous result was None local.cache[namespace][key] = generator() diff --git a/frappe/app.py b/frappe/app.py index d73dd67983..609a8535d7 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -192,12 +192,7 @@ def make_form_dict(request): if not isinstance(args, dict): frappe.throw(_("Invalid request arguments")) - try: - frappe.local.form_dict = frappe._dict({ - k: v[0] if isinstance(v, (list, tuple)) else v for k, v in args.items() - }) - except IndexError: - frappe.local.form_dict = frappe._dict(args) + 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 diff --git a/frappe/auth.py b/frappe/auth.py index 078a6bb165..d4778eb0c1 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -111,7 +111,8 @@ class LoginManager: self.user_type = None if frappe.local.form_dict.get('cmd')=='login' or frappe.local.request.path=="/api/method/login": - if self.login()==False: return + if self.login() is False: + return self.resume = False # run login triggers @@ -250,8 +251,7 @@ class LoginManager: if not self.user: return - from frappe.core.doctype.user.user import STANDARD_USERS - if self.user in STANDARD_USERS: + if self.user in frappe.STANDARD_USERS: return False reset_pwd_after_days = cint(frappe.db.get_single_value("System Settings", diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index a8c75bffd9..90099eebb6 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -272,7 +272,7 @@ def apply(doc=None, method=None, doctype=None, name=None): for todo in todos_to_close: _todo = frappe.get_doc("ToDo", todo) _todo.status = "Closed" - _todo.save() + _todo.save(ignore_permissions=True) break else: diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json index fa2606dc43..40b265b34f 100644 --- a/frappe/automation/workspace/tools/tools.json +++ b/frappe/automation/workspace/tools/tools.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"ToDo\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Note\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"File\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Assignment Rule\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Auto Repeat\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Tools\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Automation\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Event Streaming\", \"col\": 4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Note\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Auto Repeat\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Event Streaming\",\"col\":4}}]", "creation": "2020-03-02 14:53:24.980279", "docstatus": 0, "doctype": "Workspace", @@ -208,7 +208,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:02.839181", + "modified": "2022-01-13 17:48:48.456763", "modified_by": "Administrator", "module": "Automation", "name": "Tools", @@ -217,7 +217,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 26, + "sequence_id": 26.0, "shortcuts": [ { "label": "ToDo", diff --git a/frappe/boot.py b/frappe/boot.py index e671d8b37d..7f62d96cae 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -7,6 +7,7 @@ bootstrap client session import frappe import frappe.defaults import frappe.desk.desk_page +from frappe.desk.doctype.route_history.route_history import frequently_visited_links from frappe.desk.form.load import get_meta_bundle from frappe.utils.change_log import get_versions from frappe.translate import get_lang_dict @@ -15,9 +16,8 @@ from frappe.social.doctype.energy_point_settings.energy_point_settings import is from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points from frappe.model.base_document import get_controller -from frappe.social.doctype.post.post import frequently_visited_links from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings, get_app_logo -from frappe.utils import get_time_zone +from frappe.utils import get_time_zone, add_user_info def get_bootinfo(): """build and return boot info""" @@ -107,8 +107,8 @@ def load_conf_settings(bootinfo): if key in conf: bootinfo[key] = conf.get(key) def load_desktop_data(bootinfo): - from frappe.desk.desktop import get_wspace_sidebar_items - bootinfo.allowed_workspaces = get_wspace_sidebar_items().get('pages') + from frappe.desk.desktop import get_workspace_sidebar_items + bootinfo.allowed_workspaces = get_workspace_sidebar_items().get('pages') bootinfo.module_page_map = get_controller("Workspace").get_module_page_map() bootinfo.dashboards = frappe.get_all("Dashboard") @@ -222,17 +222,14 @@ def load_translations(bootinfo): bootinfo["__messages"] = messages def get_user_info(): - user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', 'gender', - 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type', 'time_zone'], - filters=dict(enabled=1)) + # get info for current user + user_info = frappe._dict() + add_user_info(frappe.session.user, user_info) - user_info_map = {d.name: d for d in user_info} + if frappe.session.user == 'Administrator' and user_info.Administrator.email: + user_info[user_info.Administrator.email] = user_info.Administrator - admin_data = user_info_map.get('Administrator') - if admin_data: - user_info_map[admin_data.email] = admin_data - - return user_info_map + return user_info def get_user(bootinfo): """get user info""" diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 0df8878da4..94a845639b 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -148,7 +148,7 @@ def build_table_count_cache(): data = ( frappe.qb.from_(information_schema.tables).select(table_name, table_rows) ).run(as_dict=True) - counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data} + counts = {d.get('name').replace('tab', '', 1): d.get('count', None) for d in data} _cache.set_value("information_schema:counts", counts) return counts diff --git a/frappe/client.py b/frappe/client.py index e835e7fee7..7280c29ba4 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -99,7 +99,6 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren if not filters: filters = None - if frappe.get_meta(doctype).issingle: value = frappe.db.get_values_from_single(fields, filters, doctype, as_dict=as_dict, debug=debug) else: diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 677325e02d..62488525b0 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -952,7 +952,7 @@ def trim_database(context, dry_run, format, no_backup): doctype_tables = frappe.get_all("DocType", pluck="name") for x in database_tables: - doctype = x.lstrip("tab") + doctype = x.replace("tab", "", 1) if not (doctype in doctype_tables or x.startswith("__") or x in STANDARD_TABLES): TABLES_TO_DROP.append(x) @@ -966,7 +966,7 @@ def trim_database(context, dry_run, format, no_backup): odb = scheduled_backup( ignore_conf=False, - include_doctypes=",".join(x.lstrip("tab") for x in TABLES_TO_DROP), + include_doctypes=",".join(x.replace("tab", "", 1) for x in TABLES_TO_DROP), ignore_files=True, force=True, ) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 41b607b192..e3379a43aa 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -623,6 +623,7 @@ def transform_database(context, table, engine, row_format, failfast): @click.command('run-tests') @click.option('--app', help="For App") @click.option('--doctype', help="For DocType") +@click.option('--case', help="Select particular TestCase") @click.option('--doctype-list-path', help="Path to .txt file for list of doctypes. Example erpnext/tests/server/agriculture.txt") @click.option('--test', multiple=True, help="Specific test") @click.option('--ui-tests', is_flag=True, default=False, help="Run UI Tests") @@ -636,7 +637,7 @@ def transform_database(context, table, engine, row_format, failfast): @pass_context def run_tests(context, app=None, module=None, doctype=None, test=(), profile=False, coverage=False, junit_xml_output=False, ui_tests = False, doctype_list_path=None, - skip_test_records=False, skip_before_tests=False, failfast=False): + skip_test_records=False, skip_before_tests=False, failfast=False, case=None): with CodeCoverage(coverage, app): import frappe.test_runner @@ -658,7 +659,7 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, force=context.force, profile=profile, junit_xml_output=junit_xml_output, - ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast) + ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast, case=case) if len(ret.failures) == 0 and len(ret.errors) == 0: ret = 0 diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 54ddbce2c4..46ef7bf5d2 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -1,29 +1,32 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe import json -from email.utils import formataddr -from frappe.core.utils import get_parent_doc -from frappe.utils import (get_url, get_formatted_email, cint, list_to_str, - validate_email_address, split_emails, parse_addr, get_datetime) -from frappe.email.email_body import get_message_id +from typing import TYPE_CHECKING, Dict + +import frappe import frappe.email.smtp -import time from frappe import _ -from frappe.utils.background_jobs import enqueue +from frappe.email.email_body import get_message_id +from frappe.utils import (cint, get_datetime, get_formatted_email, + list_to_str, split_emails, validate_email_address) + +if TYPE_CHECKING: + from frappe.core.doctype.communication.communication import Communication + OUTGOING_EMAIL_ACCOUNT_MISSING = _(""" Unable to send mail because of a missing email account. Please setup default Email Account from Setup > Email > Email Account """) + @frappe.whitelist() def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "Sent", sender=None, sender_full_name=None, recipients=None, communication_medium="Email", send_email=False, print_html=None, print_format=None, attachments='[]', send_me_a_copy=False, cc=None, bcc=None, flags=None, read_receipt=None, print_letterhead=True, email_template=None, communication_type=None, - ignore_permissions=False): + ignore_permissions=False) -> Dict[str, str]: """Make a new communication. :param doctype: Reference DocType. @@ -56,7 +59,7 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = cc = list_to_str(cc) if isinstance(cc, list) else cc bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc - comm = frappe.get_doc({ + comm: "Communication" = frappe.get_doc({ "doctype":"Communication", "subject": subject, "content": content, @@ -73,16 +76,13 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "message_id":get_message_id().strip(" <>"), "read_receipt":read_receipt, "has_attachment": 1 if attachments else 0, - "communication_type": communication_type + "communication_type": communication_type, }).insert(ignore_permissions=True) - comm.save(ignore_permissions=True) - - if isinstance(attachments, str): - attachments = json.loads(attachments) - # if not committed, delayed task doesn't find the communication if attachments: + if isinstance(attachments, str): + attachments = json.loads(attachments) add_attachments(comm.name, attachments) if cint(send_email): @@ -93,12 +93,13 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = send_me_a_copy=send_me_a_copy, print_letterhead=print_letterhead) emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy) + return { "name": comm.name, - "emails_not_sent_to": ", ".join(emails_not_sent_to or []) + "emails_not_sent_to": ", ".join(emails_not_sent_to) } -def validate_email(doc): +def validate_email(doc: "Communication") -> None: """Validate Email Addresses of Recipients and CC""" if not (doc.communication_type=="Communication" and doc.communication_medium == "Email") or doc.flags.in_receive: return @@ -114,8 +115,6 @@ def validate_email(doc): for email in split_emails(doc.bcc): validate_email_address(email, throw=True) - # validate sender - def set_incoming_outgoing_accounts(doc): from frappe.email.doctype.email_account.email_account import EmailAccount incoming_email_account = EmailAccount.find_incoming( diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index 0247354d1c..f52ba1b394 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -1,3 +1,4 @@ +from typing import List import frappe from frappe import _ from frappe.core.utils import get_parent_doc @@ -194,14 +195,18 @@ class CommunicationEmailMixin: return _("Leave this conversation") return '' - def exclude_emails_list(self, is_inbound_mail_communcation=False, include_sender=False): + def exclude_emails_list(self, is_inbound_mail_communcation=False, include_sender=False) -> List: """List of mail id's excluded while sending mail. """ all_ids = self.get_all_email_addresses(exclude_displayname=True) - final_ids = self.mail_recipients(is_inbound_mail_communcation = is_inbound_mail_communcation) + \ - self.mail_bcc(is_inbound_mail_communcation = is_inbound_mail_communcation) + \ - self.mail_cc(is_inbound_mail_communcation = is_inbound_mail_communcation, include_sender=include_sender) - return set(all_ids) - set(final_ids) + + final_ids = ( + self.mail_recipients(is_inbound_mail_communcation=is_inbound_mail_communcation) + + self.mail_bcc(is_inbound_mail_communcation=is_inbound_mail_communcation) + + self.mail_cc(is_inbound_mail_communcation=is_inbound_mail_communcation, include_sender=include_sender) + ) + + return list(set(all_ids) - set(final_ids)) def get_assignees(self): """Get owners of the reference document. diff --git a/frappe/core/doctype/data_export/exporter.py b/frappe/core/doctype/data_export/exporter.py index c5cf67ba57..79570d5048 100644 --- a/frappe/core/doctype/data_export/exporter.py +++ b/frappe/core/doctype/data_export/exporter.py @@ -314,7 +314,7 @@ class DataExporter: .where(child_doctype_table.parentfield == c["parentfield"]) .orderby(child_doctype_table.idx) ) - for ci, child in enumerate(data_row.run()): + for ci, child in enumerate(data_row.run(as_dict=True)): self.add_data_row(rows, c['doctype'], c['parentfield'], child, ci) for row in rows: diff --git a/frappe/core/doctype/data_export/test_data_exporter.py b/frappe/core/doctype/data_export/test_data_exporter.py new file mode 100644 index 0000000000..8d05707cf1 --- /dev/null +++ b/frappe/core/doctype/data_export/test_data_exporter.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# License: MIT. See LICENSE +import unittest +import frappe +from frappe.core.doctype.data_export.exporter import DataExporter + +class TestDataExporter(unittest.TestCase): + def setUp(self): + self.doctype_name = 'Test DocType for Export Tool' + self.doc_name = 'Test Data for Export Tool' + self.create_doctype_if_not_exists(doctype_name=self.doctype_name) + self.create_test_data() + + def create_doctype_if_not_exists(self, doctype_name, force=False): + """ + Helper Function for setting up doctypes + """ + if force: + frappe.delete_doc_if_exists('DocType', doctype_name) + frappe.delete_doc_if_exists('DocType', 'Child 1 of ' + doctype_name) + + if frappe.db.exists('DocType', doctype_name): + return + + # Child Table 1 + table_1_name = 'Child 1 of ' + doctype_name + frappe.get_doc({ + 'doctype': 'DocType', + 'name': table_1_name, + 'module': 'Custom', + 'custom': 1, + 'istable': 1, + 'fields': [ + {'label': 'Child Title', 'fieldname': 'child_title', 'reqd': 1, 'fieldtype': 'Data'}, + {'label': 'Child Number', 'fieldname': 'child_number', 'fieldtype': 'Int'}, + ] + }).insert() + + # Main Table + frappe.get_doc({ + 'doctype': 'DocType', + 'name': doctype_name, + 'module': 'Custom', + 'custom': 1, + 'autoname': 'field:title', + 'fields': [ + {'label': 'Title', 'fieldname': 'title', 'reqd': 1, 'fieldtype': 'Data'}, + {'label': 'Number', 'fieldname': 'number', 'fieldtype': 'Int'}, + {'label': 'Table Field 1', 'fieldname': 'table_field_1', 'fieldtype': 'Table', 'options': table_1_name}, + ], + 'permissions': [ + {'role': 'System Manager'} + ] + }).insert() + + def create_test_data(self, force=False): + """ + Helper Function creating test data + """ + if force: + frappe.delete_doc(self.doctype_name, self.doc_name) + + if not frappe.db.exists(self.doctype_name, self.doc_name): + self.doc = frappe.get_doc( + doctype=self.doctype_name, + title=self.doc_name, + number="100", + table_field_1=[ + {"child_title": "Child Title 1", "child_number": "50"}, + {"child_title": "Child Title 2", "child_number": "51"}, + ] + ).insert() + else: + self.doc = frappe.get_doc(self.doctype_name, self.doc_name) + + def test_export_content(self): + exp = DataExporter(doctype=self.doctype_name, file_type='CSV') + exp.build_response() + + self.assertEqual(frappe.response['type'],'csv') + self.assertEqual(frappe.response['doctype'], self.doctype_name) + self.assertTrue(frappe.response['result']) + self.assertIn('Child Title 1\",50',frappe.response['result']) + self.assertIn('Child Title 2\",51',frappe.response['result']) + + def test_export_type(self): + for type in ['csv', 'Excel']: + with self.subTest(type=type): + exp = DataExporter(doctype=self.doctype_name, file_type=type) + exp.build_response() + + self.assertEqual(frappe.response['doctype'], self.doctype_name) + self.assertTrue(frappe.response['result']) + + if type == 'csv': + self.assertEqual(frappe.response['type'],'csv') + elif type == 'Excel': + self.assertEqual(frappe.response['type'],'binary') + self.assertEqual(frappe.response['filename'], self.doctype_name+'.xlsx') # 'Test DocType for Export Tool.xlsx') + self.assertTrue(frappe.response['filecontent']) + + def tearDown(self): + pass + diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 216db53c72..dfc560a98a 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -44,6 +44,7 @@ frappe.ui.form.on('Data Import', { } frm.dashboard.show_progress(__('Import Progress'), percent, message); frm.page.set_indicator(__('In Progress'), 'orange'); + frm.trigger('update_primary_action'); // hide progress when complete if (data.current === data.total) { @@ -80,7 +81,10 @@ frappe.ui.form.on('Data Import', { frm.trigger('show_import_log'); frm.trigger('show_import_warnings'); frm.trigger('toggle_submit_after_import'); - frm.trigger('show_import_status'); + + if (frm.doc.status != 'Pending') + frm.trigger('show_import_status'); + frm.trigger('show_report_error_button'); if (frm.doc.status === 'Partial Success') { @@ -128,40 +132,49 @@ frappe.ui.form.on('Data Import', { }, show_import_status(frm) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); - let successful_records = import_log.filter(log => log.success); - let failed_records = import_log.filter(log => !log.success); - if (successful_records.length === 0) return; + frappe.call({ + 'method': 'frappe.core.doctype.data_import.data_import.get_import_status', + 'args': { + 'data_import_name': frm.doc.name + }, + 'callback': function(r) { + let successful_records = cint(r.message.success); + let failed_records = cint(r.message.failed); + let total_records = cint(r.message.total_records); - let message; - if (failed_records.length === 0) { - let message_args = [successful_records.length]; - if (frm.doc.import_type === 'Insert New Records') { - message = - successful_records.length > 1 - ? __('Successfully imported {0} records.', message_args) - : __('Successfully imported {0} record.', message_args); - } else { - message = - successful_records.length > 1 - ? __('Successfully updated {0} records.', message_args) - : __('Successfully updated {0} record.', message_args); + if (!total_records) return; + + let message; + if (failed_records === 0) { + let message_args = [successful_records]; + if (frm.doc.import_type === 'Insert New Records') { + message = + successful_records > 1 + ? __('Successfully imported {0} records.', message_args) + : __('Successfully imported {0} record.', message_args); + } else { + message = + successful_records > 1 + ? __('Successfully updated {0} records.', message_args) + : __('Successfully updated {0} record.', message_args); + } + } else { + let message_args = [successful_records, total_records]; + if (frm.doc.import_type === 'Insert New Records') { + message = + successful_records > 1 + ? __('Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) + : __('Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); + } else { + message = + successful_records > 1 + ? __('Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) + : __('Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); + } + } + frm.dashboard.set_headline(message); } - } else { - let message_args = [successful_records.length, import_log.length]; - if (frm.doc.import_type === 'Insert New Records') { - message = - successful_records.length > 1 - ? __('Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) - : __('Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); - } else { - message = - successful_records.length > 1 - ? __('Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) - : __('Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); - } - } - frm.dashboard.set_headline(message); + }); }, show_report_error_button(frm) { @@ -275,7 +288,7 @@ frappe.ui.form.on('Data Import', { }, show_import_preview(frm, preview_data) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); + let import_log = preview_data.import_log; if ( frm.import_preview && @@ -316,6 +329,15 @@ frappe.ui.form.on('Data Import', { ); }, + export_import_log(frm) { + open_url_post( + '/api/method/frappe.core.doctype.data_import.data_import.download_import_log', + { + data_import_name: frm.doc.name + } + ); + }, + show_import_warnings(frm, preview_data) { let columns = preview_data.columns; let warnings = JSON.parse(frm.doc.template_warnings || '[]'); @@ -391,92 +413,131 @@ frappe.ui.form.on('Data Import', { frm.trigger('show_import_log'); }, - show_import_log(frm) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); - let logs = import_log; - frm.toggle_display('import_log', false); - frm.toggle_display('import_log_section', logs.length > 0); + render_import_log(frm) { + frappe.call({ + 'method': 'frappe.client.get_list', + 'args': { + 'doctype': 'Data Import Log', + 'filters': { + 'data_import': frm.doc.name + }, + 'fields': ['success', 'docname', 'messages', 'exception', 'row_indexes'], + 'limit_page_length': 5000, + 'order_by': 'log_index' + }, + callback: function(r) { + let logs = r.message; - if (logs.length === 0) { - frm.get_field('import_log_preview').$wrapper.empty(); + if (logs.length === 0) return; + + frm.toggle_display('import_log_section', true); + + let rows = logs + .map(log => { + let html = ''; + if (log.success) { + if (frm.doc.import_type === 'Insert New Records') { + html = __('Successfully imported {0}', [ + `${frappe.utils.get_form_link( + frm.doc.reference_doctype, + log.docname, + true + )}` + ]); + } else { + html = __('Successfully updated {0}', [ + `${frappe.utils.get_form_link( + frm.doc.reference_doctype, + log.docname, + true + )}` + ]); + } + } else { + let messages = (JSON.parse(log.messages || '[]')) + .map(JSON.parse) + .map(m => { + let title = m.title ? `${m.title}` : ''; + let message = m.message ? `
${m.message}
` : ''; + return title + message; + }) + .join(''); + let id = frappe.dom.get_unique_id(); + html = `${messages} + +
+
+
${log.exception}
+
+
`; + } + let indicator_color = log.success ? 'green' : 'red'; + let title = log.success ? __('Success') : __('Failure'); + + if (frm.doc.show_failed_logs && log.success) { + return ''; + } + + return ` + ${JSON.parse(log.row_indexes).join(', ')} + +
${title}
+ + + ${html} + + `; + }) + .join(''); + + if (!rows && frm.doc.show_failed_logs) { + rows = ` + ${__('No failed logs')} + `; + } + + frm.get_field('import_log_preview').$wrapper.html(` + + + + + + + ${rows} +
${__('Row Number')}${__('Status')}${__('Message')}
+ `); + } + }); + }, + + show_import_log(frm) { + frm.toggle_display('import_log_section', false); + + if (frm.import_in_progress) { return; } - let rows = logs - .map(log => { - let html = ''; - if (log.success) { - if (frm.doc.import_type === 'Insert New Records') { - html = __('Successfully imported {0}', [ - `${frappe.utils.get_form_link( - frm.doc.reference_doctype, - log.docname, - true - )}` - ]); - } else { - html = __('Successfully updated {0}', [ - `${frappe.utils.get_form_link( - frm.doc.reference_doctype, - log.docname, - true - )}` - ]); - } + frappe.call({ + 'method': 'frappe.client.get_count', + 'args': { + 'doctype': 'Data Import Log', + 'filters': { + 'data_import': frm.doc.name + } + }, + 'callback': function(r) { + let count = r.message; + if (count < 5000) { + frm.trigger('render_import_log'); } else { - let messages = log.messages - .map(JSON.parse) - .map(m => { - let title = m.title ? `${m.title}` : ''; - let message = m.message ? `
${m.message}
` : ''; - return title + message; - }) - .join(''); - let id = frappe.dom.get_unique_id(); - html = `${messages} - -
-
-
${log.exception}
-
-
`; + frm.toggle_display('import_log_section', false); + frm.add_custom_button(__('Export Import Log'), () => + frm.trigger('export_import_log') + ); } - let indicator_color = log.success ? 'green' : 'red'; - let title = log.success ? __('Success') : __('Failure'); - - if (frm.doc.show_failed_logs && log.success) { - return ''; - } - - return ` - ${log.row_indexes.join(', ')} - -
${title}
- - - ${html} - - `; - }) - .join(''); - - if (!rows && frm.doc.show_failed_logs) { - rows = ` - ${__('No failed logs')} - `; - } - - frm.get_field('import_log_preview').$wrapper.html(` - - - - - - - ${rows} -
${__('Row Number')}${__('Status')}${__('Message')}
- `); + } + }); }, }); diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json index fe6fb90481..9e948dac8c 100644 --- a/frappe/core/doctype/data_import/data_import.json +++ b/frappe/core/doctype/data_import/data_import.json @@ -1,194 +1,197 @@ { - "actions": [], - "autoname": "format:{reference_doctype} Import on {creation}", - "beta": 1, - "creation": "2019-08-04 14:16:08.318714", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "reference_doctype", - "import_type", - "download_template", - "import_file", - "html_5", - "google_sheets_url", - "refresh_google_sheet", - "column_break_5", - "status", - "submit_after_import", - "mute_emails", - "template_options", - "import_warnings_section", - "template_warnings", - "import_warnings", - "section_import_preview", - "import_preview", - "import_log_section", - "import_log", - "show_failed_logs", - "import_log_preview" - ], - "fields": [ - { - "fieldname": "reference_doctype", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Document Type", - "options": "DocType", - "reqd": 1, - "set_only_once": 1 - }, - { - "fieldname": "import_type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Import Type", - "options": "\nInsert New Records\nUpdate Existing Records", - "reqd": 1, - "set_only_once": 1 - }, - { - "depends_on": "eval:!doc.__islocal", - "fieldname": "import_file", - "fieldtype": "Attach", - "in_list_view": 1, - "label": "Import File", - "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" - }, - { - "fieldname": "import_preview", - "fieldtype": "HTML", - "label": "Import Preview" - }, - { - "fieldname": "section_import_preview", - "fieldtype": "Section Break", - "label": "Preview" - }, - { - "fieldname": "column_break_5", - "fieldtype": "Column Break" - }, - { - "fieldname": "template_options", - "fieldtype": "Code", - "hidden": 1, - "label": "Template Options", - "options": "JSON", - "read_only": 1 - }, - { - "fieldname": "import_log", - "fieldtype": "Code", - "label": "Import Log", - "options": "JSON" - }, - { - "fieldname": "import_log_section", - "fieldtype": "Section Break", - "label": "Import Log" - }, - { - "fieldname": "import_log_preview", - "fieldtype": "HTML", - "label": "Import Log Preview" - }, - { - "default": "Pending", - "fieldname": "status", - "fieldtype": "Select", - "hidden": 1, - "label": "Status", - "options": "Pending\nSuccess\nPartial Success\nError", - "read_only": 1 - }, - { - "fieldname": "template_warnings", - "fieldtype": "Code", - "hidden": 1, - "label": "Template Warnings", - "options": "JSON" - }, - { - "default": "0", - "fieldname": "submit_after_import", - "fieldtype": "Check", - "label": "Submit After Import", - "set_only_once": 1 - }, - { - "fieldname": "import_warnings_section", - "fieldtype": "Section Break", - "label": "Import File Errors and Warnings" - }, - { - "fieldname": "import_warnings", - "fieldtype": "HTML", - "label": "Import Warnings" - }, - { - "depends_on": "eval:!doc.__islocal", - "fieldname": "download_template", - "fieldtype": "Button", - "label": "Download Template" - }, - { - "default": "1", - "fieldname": "mute_emails", - "fieldtype": "Check", - "label": "Don't Send Emails", - "set_only_once": 1 - }, - { - "default": "0", - "fieldname": "show_failed_logs", - "fieldtype": "Check", - "label": "Show Failed Logs" - }, - { - "depends_on": "eval:!doc.__islocal && !doc.import_file", - "fieldname": "html_5", - "fieldtype": "HTML", - "options": "
Or
" - }, - { - "depends_on": "eval:!doc.__islocal && !doc.import_file\n", - "description": "Must be a publicly accessible Google Sheets URL", - "fieldname": "google_sheets_url", - "fieldtype": "Data", - "label": "Import from Google Sheets", - "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" - }, - { - "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)", - "fieldname": "refresh_google_sheet", - "fieldtype": "Button", - "label": "Refresh Google Sheet" - } - ], - "hide_toolbar": 1, - "links": [], - "modified": "2021-04-11 01:50:42.074623", - "modified_by": "Administrator", - "module": "Core", - "name": "Data Import", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 + "actions": [], + "autoname": "format:{reference_doctype} Import on {creation}", + "beta": 1, + "creation": "2019-08-04 14:16:08.318714", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_doctype", + "import_type", + "download_template", + "import_file", + "payload_count", + "html_5", + "google_sheets_url", + "refresh_google_sheet", + "column_break_5", + "status", + "submit_after_import", + "mute_emails", + "template_options", + "import_warnings_section", + "template_warnings", + "import_warnings", + "section_import_preview", + "import_preview", + "import_log_section", + "show_failed_logs", + "import_log_preview" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "import_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Import Type", + "options": "\nInsert New Records\nUpdate Existing Records", + "reqd": 1, + "set_only_once": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "import_file", + "fieldtype": "Attach", + "in_list_view": 1, + "label": "Import File", + "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" + }, + { + "fieldname": "import_preview", + "fieldtype": "HTML", + "label": "Import Preview" + }, + { + "fieldname": "section_import_preview", + "fieldtype": "Section Break", + "label": "Preview" + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "template_options", + "fieldtype": "Code", + "hidden": 1, + "label": "Template Options", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "import_log_section", + "fieldtype": "Section Break", + "label": "Import Log" + }, + { + "fieldname": "import_log_preview", + "fieldtype": "HTML", + "label": "Import Log Preview" + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "label": "Status", + "options": "Pending\nSuccess\nPartial Success\nError", + "read_only": 1 + }, + { + "fieldname": "template_warnings", + "fieldtype": "Code", + "hidden": 1, + "label": "Template Warnings", + "options": "JSON" + }, + { + "default": "0", + "fieldname": "submit_after_import", + "fieldtype": "Check", + "label": "Submit After Import", + "set_only_once": 1 + }, + { + "fieldname": "import_warnings_section", + "fieldtype": "Section Break", + "label": "Import File Errors and Warnings" + }, + { + "fieldname": "import_warnings", + "fieldtype": "HTML", + "label": "Import Warnings" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "download_template", + "fieldtype": "Button", + "label": "Download Template" + }, + { + "default": "1", + "fieldname": "mute_emails", + "fieldtype": "Check", + "label": "Don't Send Emails", + "set_only_once": 1 + }, + { + "default": "0", + "fieldname": "show_failed_logs", + "fieldtype": "Check", + "label": "Show Failed Logs" + }, + { + "depends_on": "eval:!doc.__islocal && !doc.import_file", + "fieldname": "html_5", + "fieldtype": "HTML", + "options": "
Or
" + }, + { + "depends_on": "eval:!doc.__islocal && !doc.import_file\n", + "description": "Must be a publicly accessible Google Sheets URL", + "fieldname": "google_sheets_url", + "fieldtype": "Data", + "label": "Import from Google Sheets", + "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" + }, + { + "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)", + "fieldname": "refresh_google_sheet", + "fieldtype": "Button", + "label": "Refresh Google Sheet" + }, + { + "fieldname": "payload_count", + "fieldtype": "Int", + "hidden": 1, + "label": "Payload Count", + "read_only": 1 + } + ], + "hide_toolbar": 1, + "links": [], + "modified": "2022-02-01 20:08:37.624914", + "modified_by": "Administrator", + "module": "Core", + "name": "Data Import", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 5935ddc4ba..5972e79b4d 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -27,6 +27,7 @@ class DataImport(Document): self.validate_import_file() self.validate_google_sheets_url() + self.set_payload_count() def validate_import_file(self): if self.import_file: @@ -38,6 +39,12 @@ class DataImport(Document): return validate_google_sheets_url(self.google_sheets_url) + def set_payload_count(self): + if self.import_file: + i = self.get_importer() + payloads = i.import_file.get_payloads_for_import() + self.payload_count = len(payloads) + @frappe.whitelist() def get_preview_from_template(self, import_file=None, google_sheets_url=None): if import_file: @@ -67,7 +74,7 @@ class DataImport(Document): enqueue( start_import, queue="default", - timeout=6000, + timeout=10000, event="data_import", job_name=self.name, data_import=self.name, @@ -80,6 +87,9 @@ class DataImport(Document): def export_errored_rows(self): return self.get_importer().export_errored_rows() + def download_import_log(self): + return self.get_importer().export_import_log() + def get_importer(self): return Importer(self.reference_doctype, data_import=self) @@ -90,7 +100,6 @@ def get_preview_from_template(data_import, import_file=None, google_sheets_url=N import_file, google_sheets_url ) - @frappe.whitelist() def form_start_import(data_import): return frappe.get_doc("Data Import", data_import).start_import() @@ -145,6 +154,30 @@ def download_errored_template(data_import_name): data_import = frappe.get_doc("Data Import", data_import_name) data_import.export_errored_rows() +@frappe.whitelist() +def download_import_log(data_import_name): + data_import = frappe.get_doc("Data Import", data_import_name) + data_import.download_import_log() + +@frappe.whitelist() +def get_import_status(data_import_name): + import_status = {} + + logs = frappe.get_all('Data Import Log', fields=['count(*) as count', 'success'], + filters={'data_import': data_import_name}, + group_by='success') + + total_payload_count = frappe.db.get_value('Data Import', data_import_name, 'payload_count') + + for log in logs: + if log.get('success'): + import_status['success'] = log.get('count') + else: + import_status['failed'] = log.get('count') + + import_status['total_records'] = total_payload_count + + return import_status def import_file( doctype, file_path, import_type, submit_after_import=False, console=False diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js index 0eb05aa354..6ab750ba25 100644 --- a/frappe/core/doctype/data_import/data_import_list.js +++ b/frappe/core/doctype/data_import/data_import_list.js @@ -24,12 +24,14 @@ frappe.listview_settings['Data Import'] = { 'Error': 'red' }; let status = doc.status; + if (imports_in_progress.includes(doc.name)) { status = 'In Progress'; } if (status == 'Pending') { status = 'Not Started'; } + return [__(status), colors[status], 'status,=,' + doc.status]; }, formatters: { diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index b9b2050763..107c05a66a 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -47,7 +47,13 @@ class Importer: ) def get_data_for_import_preview(self): - return self.import_file.get_data_for_import_preview() + out = self.import_file.get_data_for_import_preview() + + out.import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success"], + filters={"data_import": self.data_import.name}, + order_by="log_index", limit=10) + + return out def before_import(self): # set user lang for translations @@ -58,7 +64,6 @@ class Importer: frappe.flags.in_import = True frappe.flags.mute_emails = self.data_import.mute_emails - self.data_import.db_set("status", "Pending") self.data_import.db_set("template_warnings", "") def import_data(self): @@ -79,20 +84,25 @@ class Importer: return # setup import log - if self.data_import.import_log: - import_log = frappe.parse_json(self.data_import.import_log) - else: - import_log = [] + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"], + filters={"data_import": self.data_import.name}, + order_by="log_index") or [] - # remove previous failures from import log - import_log = [log for log in import_log if log.get("success")] + log_index = 0 + + # Do not remove rows in case of retry after an error or pending data import + if self.data_import.status == "Partial Success" and len(import_log) >= self.data_import.payload_count: + # remove previous failures from import log only in case of retry after partial success + import_log = [log for log in import_log if log.get("success")] # get successfully imported rows imported_rows = [] for log in import_log: log = frappe._dict(log) - if log.success: - imported_rows += log.row_indexes + if log.success or len(import_log) < self.data_import.payload_count: + imported_rows += json.loads(log.row_indexes) + + log_index = log.log_index # start import total_payload_count = len(payloads) @@ -146,25 +156,41 @@ class Importer: }, ) - import_log.append( - frappe._dict(success=True, docname=doc.name, row_indexes=row_indexes) - ) + create_import_log(self.data_import.name, log_index, { + 'success': True, + 'docname': doc.name, + 'row_indexes': row_indexes + }) + + log_index += 1 + + if not self.data_import.status == "Partial Success": + self.data_import.db_set("status", "Partial Success") + # commit after every successful import frappe.db.commit() except Exception: - import_log.append( - frappe._dict( - success=False, - exception=frappe.get_traceback(), - messages=frappe.local.message_log, - row_indexes=row_indexes, - ) - ) + messages = frappe.local.message_log frappe.clear_messages() + # rollback if exception frappe.db.rollback() + create_import_log(self.data_import.name, log_index, { + 'success': False, + 'exception': frappe.get_traceback(), + 'messages': messages, + 'row_indexes': row_indexes + }) + + log_index += 1 + + # Logs are db inserted directly so will have to be fetched again + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"], + filters={"data_import": self.data_import.name}, + order_by="log_index") or [] + # set status failures = [log for log in import_log if not log.get("success")] if len(failures) == total_payload_count: @@ -178,7 +204,6 @@ class Importer: self.print_import_log(import_log) else: self.data_import.db_set("status", status) - self.data_import.db_set("import_log", json.dumps(import_log)) self.after_import() @@ -248,11 +273,14 @@ class Importer: if not self.data_import: return - import_log = frappe.parse_json(self.data_import.import_log or "[]") + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success"], + filters={"data_import": self.data_import.name}, + order_by="log_index") or [] + failures = [log for log in import_log if not log.get("success")] row_indexes = [] for f in failures: - row_indexes.extend(f.get("row_indexes", [])) + row_indexes.extend(json.loads(f.get("row_indexes", []))) # de duplicate row_indexes = list(set(row_indexes)) @@ -264,6 +292,30 @@ class Importer: build_csv_response(rows, _(self.doctype)) + def export_import_log(self): + from frappe.utils.csvutils import build_csv_response + + if not self.data_import: + return + + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "messages", "exception", "docname"], + filters={"data_import": self.data_import.name}, + order_by="log_index") + + header_row = ["Row Numbers", "Status", "Message", "Exception"] + + rows = [header_row] + + for log in import_log: + row_number = json.loads(log.get("row_indexes"))[0] + status = "Success" if log.get('success') else "Failure" + message = "Successfully Imported {0}".format(log.get('docname')) if log.get('success') else \ + log.get("messages") + exception = frappe.utils.cstr(log.get("exception", '')) + rows += [[row_number, status, message, exception]] + + build_csv_response(rows, self.doctype) + def print_import_log(self, import_log): failed_records = [log for log in import_log if not log.success] successful_records = [log for log in import_log if log.success] @@ -1172,3 +1224,17 @@ def df_as_json(df): def get_select_options(df): return [d for d in (df.options or "").split("\n") if d] + +def create_import_log(data_import, log_index, log_details): + frappe.get_doc({ + 'doctype': 'Data Import Log', + 'log_index': log_index, + 'success': log_details.get('success'), + 'data_import': data_import, + 'row_indexes': json.dumps(log_details.get('row_indexes')), + 'docname': log_details.get('docname'), + 'messages': json.dumps(log_details.get('messages', '[]')), + 'exception': log_details.get('exception') + }).db_insert() + + diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index e1bc0e7ca5..3f78594dd2 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -60,15 +60,19 @@ class TestImporter(unittest.TestCase): frappe.local.message_log = [] data_import.start_import() data_import.reload() - import_log = frappe.parse_json(data_import.import_log) - self.assertEqual(import_log[0]['row_indexes'], [2,3]) - expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" - self.assertEqual(frappe.parse_json(import_log[0]['messages'][0])['message'], expected_error) - expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" - self.assertEqual(frappe.parse_json(import_log[0]['messages'][1])['message'], expected_error) - self.assertEqual(import_log[1]['row_indexes'], [4]) - self.assertEqual(frappe.parse_json(import_log[1]['messages'][0])['message'], "Title is required") + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "messages", "exception", "docname"], + filters={"data_import": data_import.name}, + order_by="log_index") + + self.assertEqual(frappe.parse_json(import_log[0]['row_indexes']), [2,3]) + expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[0])['message'], expected_error) + expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[1])['message'], expected_error) + + self.assertEqual(frappe.parse_json(import_log[1]['row_indexes']), [4]) + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[1]['messages'])[0])['message'], "Title is required") def test_data_import_update(self): existing_doc = frappe.get_doc( diff --git a/frappe/social/doctype/post/__init__.py b/frappe/core/doctype/data_import_log/__init__.py similarity index 100% rename from frappe/social/doctype/post/__init__.py rename to frappe/core/doctype/data_import_log/__init__.py diff --git a/frappe/core/doctype/data_import_log/data_import_log.js b/frappe/core/doctype/data_import_log/data_import_log.js new file mode 100644 index 0000000000..c376edeec9 --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Data Import Log', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/data_import_log/data_import_log.json b/frappe/core/doctype/data_import_log/data_import_log.json new file mode 100644 index 0000000000..b1d991f099 --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.json @@ -0,0 +1,84 @@ +{ + "actions": [], + "creation": "2021-12-25 16:12:20.205889", + "doctype": "DocType", + "editable_grid": 1, + "engine": "MyISAM", + "field_order": [ + "data_import", + "row_indexes", + "success", + "docname", + "messages", + "exception", + "log_index" + ], + "fields": [ + { + "fieldname": "data_import", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Data Import", + "options": "Data Import" + }, + { + "fieldname": "docname", + "fieldtype": "Data", + "label": "Reference Name" + }, + { + "fieldname": "exception", + "fieldtype": "Text", + "label": "Exception" + }, + { + "fieldname": "row_indexes", + "fieldtype": "Code", + "label": "Row Indexes", + "options": "JSON" + }, + { + "default": "0", + "fieldname": "success", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Success" + }, + { + "fieldname": "log_index", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Log Index" + }, + { + "fieldname": "messages", + "fieldtype": "Code", + "label": "Messages", + "options": "JSON" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-12-29 11:19:19.646076", + "modified_by": "Administrator", + "module": "Core", + "name": "Data Import Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/frappe/core/doctype/data_import_log/data_import_log.py b/frappe/core/doctype/data_import_log/data_import_log.py new file mode 100644 index 0000000000..a71aefa8bc --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class DataImportLog(Document): + pass diff --git a/frappe/core/doctype/data_import_log/test_data_import_log.py b/frappe/core/doctype/data_import_log/test_data_import_log.py new file mode 100644 index 0000000000..244404936e --- /dev/null +++ b/frappe/core/doctype/data_import_log/test_data_import_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestDataImportLog(unittest.TestCase): + pass diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 3754288145..67c31b704d 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -699,6 +699,13 @@ class DocType(Document): if not name: name = self.name + # a Doctype name is the tablename created in database + # `tab` the length of tablename is limited to 64 characters + max_length = frappe.db.MAX_COLUMN_LENGTH - 3 + if len(name) > max_length: + # length(tab + ) should be equal to 64 characters hence doctype should be 61 characters + frappe.throw(_("Doctype name is limited to {0} characters ({1})").format(max_length, name), frappe.NameError) + flags = {"flags": re.ASCII} # a DocType name should not start or end with an empty space diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 12c227464d..50882f51bd 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -23,6 +23,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(frappe.NameError, new_doctype("_Some DocType").insert) self.assertRaises(frappe.NameError, new_doctype("8Some DocType").insert) self.assertRaises(frappe.NameError, new_doctype("Some (DocType)").insert) + self.assertRaises(frappe.NameError, new_doctype("Some Doctype with a name whose length is more than 61 characters").insert) for name in ("Some DocType", "Some_DocType"): if frappe.db.exists("DocType", name): frappe.delete_doc("DocType", name) @@ -353,7 +354,6 @@ class TestDocType(unittest.TestCase): dump_docs = json.dumps(docs.get('docs')) cancel_all_linked_docs(dump_docs) data_link_doc.cancel() - data_doc.name = '{}-CANC-0'.format(data_doc.name) data_doc.load_from_db() self.assertEqual(data_link_doc.docstatus, 2) self.assertEqual(data_doc.docstatus, 2) @@ -377,7 +377,7 @@ class TestDocType(unittest.TestCase): for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 - link_doc.insert(ignore_if_duplicate=True) + link_doc.insert() #create first parent doctype test_doc_1 = new_doctype('Test Doctype 1') @@ -392,7 +392,7 @@ class TestDocType(unittest.TestCase): for data in test_doc_1.get('permissions'): data.submit = 1 data.cancel = 1 - test_doc_1.insert(ignore_if_duplicate=True) + test_doc_1.insert() #crete second parent doctype doc = new_doctype('Test Doctype 2') @@ -407,7 +407,7 @@ class TestDocType(unittest.TestCase): for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 - doc.insert(ignore_if_duplicate=True) + doc.insert() # create doctype data data_link_doc_1 = frappe.new_doc('Test Linked Doctype 1') @@ -438,7 +438,6 @@ class TestDocType(unittest.TestCase): # checking that doc for Test Doctype 2 is not canceled self.assertRaises(frappe.LinkExistsError, data_link_doc_1.cancel) - data_doc_2.name = '{}-CANC-0'.format(data_doc_2.name) data_doc.load_from_db() data_doc_2.load_from_db() self.assertEqual(data_link_doc_1.docstatus, 2) diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.py b/frappe/core/doctype/document_naming_rule/document_naming_rule.py index 8013f9df6f..5c445fd058 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.py +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.py @@ -5,6 +5,7 @@ import frappe from frappe.model.document import Document from frappe.utils.data import evaluate_filters +from frappe.model.naming import parse_naming_series from frappe import _ class DocumentNamingRule(Document): @@ -27,7 +28,9 @@ class DocumentNamingRule(Document): return counter = frappe.db.get_value(self.doctype, self.name, 'counter', for_update=True) or 0 - doc.name = self.prefix + ('%0'+str(self.prefix_digits)+'d') % (counter + 1) + naming_series = parse_naming_series(self.prefix, doc=doc) + + doc.name = naming_series + ('%0'+str(self.prefix_digits)+'d') % (counter + 1) frappe.db.set_value(self.doctype, self.name, 'counter', counter + 1) @frappe.whitelist() diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index adf10b9a03..ee2c9987b6 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE """ @@ -7,7 +7,6 @@ record of files naming for same name files: file.gif, file-1.gif, file-2.gif etc """ -import base64 import hashlib import imghdr import io @@ -17,9 +16,10 @@ import os import re import shutil import zipfile +from typing import TYPE_CHECKING, Tuple import requests -import requests.exceptions +from requests.exceptions import HTTPError, SSLError from PIL import Image, ImageFile, ImageOps from io import BytesIO from urllib.parse import quote, unquote @@ -31,6 +31,11 @@ from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, g from frappe.utils.image import strip_exif_data, optimize_image from frappe.utils.file_manager import safe_b64decode +if TYPE_CHECKING: + from PIL.ImageFile import ImageFile + from requests.models import Response + + class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -276,7 +281,7 @@ class File(Document): image, filename, extn = get_local_image(self.file_url) else: image, filename, extn = get_web_image(self.file_url) - except (requests.exceptions.HTTPError, requests.exceptions.SSLError, IOError, TypeError): + except (HTTPError, SSLError, IOError, TypeError): return size = width, height @@ -572,12 +577,10 @@ class File(Document): @staticmethod def zip_files(files): - from six import string_types - zip_file = io.BytesIO() zf = zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) for _file in files: - if isinstance(_file, string_types): + if isinstance(_file, str): _file = frappe.get_doc("File", _file) if not isinstance(_file, File): continue @@ -650,9 +653,17 @@ def setup_folder_path(filename, new_parent): from frappe.model.rename_doc import rename_doc rename_doc("File", file.name, file.get_name_based_on_parent_folder(), ignore_permissions=True) -def get_extension(filename, extn, content): +def get_extension(filename, extn, content: bytes = None, response: "Response" = None) -> str: mimetype = None + if response: + content_type = response.headers.get("Content-Type") + + if content_type: + _extn = mimetypes.guess_extension(content_type) + if _extn: + return _extn[1:] + if extn: # remove '?' char and parameters from extn if present if '?' in extn: @@ -695,14 +706,14 @@ def get_local_image(file_url): return image, filename, extn -def get_web_image(file_url): +def get_web_image(file_url: str) -> Tuple["ImageFile", str, str]: # download file_url = frappe.utils.get_url(file_url) r = requests.get(file_url, stream=True) try: r.raise_for_status() - except requests.exceptions.HTTPError as e: - if "404" in e.args[0]: + except HTTPError: + if r.status_code == 404: frappe.msgprint(_("File '{0}' not found").format(file_url)) else: frappe.msgprint(_("Unable to read file format for {0}").format(file_url)) @@ -721,7 +732,10 @@ def get_web_image(file_url): filename = get_random_filename() extn = None - extn = get_extension(filename, extn, r.content) + extn = get_extension(filename, extn, response=r) + if extn == "bin": + extn = get_extension(filename, extn, content=r.content) or "png" + filename = "/files/" + strip(unquote(filename)) return image, filename, extn diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 2c1042e104..ba83dfca19 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -1,15 +1,14 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import base64 import json import frappe import os import unittest + from frappe import _ -from frappe.core.doctype.file.file import get_attached_images, move_file, get_files_in_folder, unzip_file +from frappe.core.doctype.file.file import File, get_attached_images, move_file, get_files_in_folder, unzip_file from frappe.utils import get_files_path -# test_records = frappe.get_test_records('File') test_content1 = 'Hello' test_content2 = 'Hello World' @@ -24,8 +23,6 @@ def make_test_doc(): class TestSimpleFile(unittest.TestCase): - - def setUp(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() self.test_content = test_content1 @@ -38,21 +35,13 @@ class TestSimpleFile(unittest.TestCase): _file.save() self.saved_file_url = _file.file_url - def test_save(self): _file = frappe.get_doc("File", {"file_url": self.saved_file_url}) content = _file.get_content() self.assertEqual(content, self.test_content) - def tearDown(self): - # File gets deleted on rollback, so blank - pass - - class TestBase64File(unittest.TestCase): - - def setUp(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() self.test_content = base64.b64encode(test_content1.encode('utf-8')) @@ -66,18 +55,12 @@ class TestBase64File(unittest.TestCase): _file.save() self.saved_file_url = _file.file_url - def test_saved_content(self): _file = frappe.get_doc("File", {"file_url": self.saved_file_url}) content = _file.get_content() self.assertEqual(content, test_content1) - def tearDown(self): - # File gets deleted on rollback, so blank - pass - - class TestSameFileName(unittest.TestCase): def test_saved_content(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() @@ -130,8 +113,6 @@ class TestSameFileName(unittest.TestCase): class TestSameContent(unittest.TestCase): - - def setUp(self): self.attached_to_doctype1, self.attached_to_docname1 = make_test_doc() self.attached_to_doctype2, self.attached_to_docname2 = make_test_doc() @@ -186,10 +167,6 @@ class TestSameContent(unittest.TestCase): limit_property.delete() frappe.clear_cache(doctype='ToDo') - def tearDown(self): - # File gets deleted on rollback, so blank - pass - class TestFile(unittest.TestCase): def setUp(self): @@ -398,7 +375,7 @@ class TestFile(unittest.TestCase): def test_make_thumbnail(self): # test web image - test_file = frappe.get_doc({ + test_file: File = frappe.get_doc({ "doctype": "File", "file_name": 'logo', "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), @@ -407,6 +384,16 @@ class TestFile(unittest.TestCase): test_file.make_thumbnail() self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg') + # test web image without extension + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": 'logo', + "file_url": frappe.utils.get_url('/_test/assets/image'), + }).insert(ignore_permissions=True) + + test_file.make_thumbnail() + self.assertTrue(test_file.thumbnail_url.endswith("_small.jpeg")) + # test local image test_file.db_set('thumbnail_url', None) test_file.reload() diff --git a/frappe/core/doctype/payment_gateway/payment_gateway.json b/frappe/core/doctype/payment_gateway/payment_gateway.json index b97d72c771..7195b3949e 100644 --- a/frappe/core/doctype/payment_gateway/payment_gateway.json +++ b/frappe/core/doctype/payment_gateway/payment_gateway.json @@ -1,154 +1,55 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:gateway", - "beta": 0, - "creation": "2015-12-15 22:26:45.221162", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "autoname": "field:gateway", + "creation": "2022-01-24 21:09:47.229371", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gateway", + "gateway_settings", + "gateway_controller" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gateway", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Gateway", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "gateway", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Gateway", + "reqd": 1, + "unique": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gateway_settings", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Gateway Settings", - "length": 0, - "no_copy": 0, - "options": "DocType", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "gateway_settings", + "fieldtype": "Link", + "label": "Gateway Settings", + "options": "DocType" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gateway_controller", - "fieldtype": "Dynamic Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Gateway Controller", - "length": 0, - "no_copy": 0, - "options": "gateway_settings", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "gateway_controller", + "fieldtype": "Dynamic Link", + "label": "Gateway Controller", + "options": "gateway_settings" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 1, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-02-05 14:24:33.526645", - "modified_by": "Administrator", - "module": "Core", - "name": "Payment Gateway", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2022-01-24 21:17:03.864719", + "modified_by": "Administrator", + "module": "Core", + "name": "Payment Gateway", + "naming_rule": "By fieldname", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "System Manager", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index d8c945fb6d..b5f3ba7168 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -34,19 +34,7 @@ def run_server_script_for_doc_event(doc, event): if scripts: # run all scripts for this doctype + event for script_name in scripts: - try: - frappe.get_doc('Server Script', script_name).execute_doc(doc) - except Exception as e: - message = frappe._('Error executing Server Script {0}. Open Browser Console to see traceback.').format( - frappe.utils.get_link_to_form('Server Script', script_name) - ) - exception = type(e) - if getattr(frappe, 'request', None): - # all exceptions throw 500 which is internal server error - # however server script error is a user error - # so we should throw 417 which is expectation failed - exception.http_status_code = 417 - frappe.throw(title=frappe._('Server Script Error'), msg=message, exc=exception) + frappe.get_doc('Server Script', script_name).execute_doc(doc) def get_server_script_map(): # fetch cached server script methods diff --git a/frappe/core/doctype/test/test.py b/frappe/core/doctype/test/test.py index 4cb088c117..ab6fcb6de4 100644 --- a/frappe/core/doctype/test/test.py +++ b/frappe/core/doctype/test/test.py @@ -31,4 +31,15 @@ class test(Document): def get_value(self, fields, filters, **kwargs): # return [] with open("data_file.json", "r") as read_file: - return [json.load(read_file)] \ No newline at end of file + return [json.load(read_file)] + + def get_count(self, args): + # return [] + with open("data_file.json", "r") as read_file: + return [json.load(read_file)] + + def get_stats(self, args): + # return [] + with open("data_file.json", "r") as read_file: + return [json.load(read_file)] + diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index d1291acfc4..2d3da791ff 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -355,7 +355,10 @@ class TestUser(unittest.TestCase): test_user.reload() self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/") update_password(old_password, old_password=new_password) - self.assertEqual(json.loads(frappe.message_log[0]), {"message": "Password reset instructions have been sent to your email"}) + self.assertEqual( + json.loads(frappe.message_log[0]).get("message"), + "Password reset instructions have been sent to your email" + ) sendmail.assert_called_once() self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com") diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index ef7845d3b0..d08755f9a8 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -19,7 +19,7 @@ from frappe.core.doctype.user_type.user_type import user_linked_with_permission_ from frappe.query_builder import DocType -STANDARD_USERS = ("Guest", "Administrator") +STANDARD_USERS = frappe.STANDARD_USERS class User(Document): __new_password = None @@ -344,7 +344,7 @@ class User(Document): frappe.sendmail(recipients=self.email, sender=sender, subject=subject, template=template, args=args, header=[subject, "green"], - delayed=(not now) if now!=None else self.flags.delay_emails, retry=3) + delayed=(not now) if now is not None else self.flags.delay_emails, retry=3) def a_system_manager_should_exist(self): if not self.get_other_system_managers(): @@ -756,7 +756,7 @@ def verify_password(password): @frappe.whitelist(allow_guest=True) def sign_up(email, full_name, redirect_to): if is_signup_disabled(): - frappe.throw(_('Sign Up is disabled'), title='Not Allowed') + frappe.throw(_("Sign Up is disabled"), title=_("Not Allowed")) user = frappe.db.get("User", {"email": email}) if user: @@ -810,8 +810,10 @@ def reset_password(user): user.validate_reset_password() user.reset_password(send_email=True) - return frappe.msgprint(_("Password reset instructions have been sent to your email")) - + return frappe.msgprint( + msg=_("Password reset instructions have been sent to your email"), + title=_("Password Email Sent") + ) except frappe.DoesNotExistError: frappe.local.response['http_status_code'] = 400 frappe.clear_messages() diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index cf905c2ce2..d4a9d68fd5 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -3,6 +3,7 @@ from frappe.core.doctype.user_permission.user_permission import add_user_permissions, remove_applicable from frappe.permissions import has_user_permission from frappe.core.doctype.doctype.test_doctype import new_doctype +from frappe.website.doctype.blog_post.test_blog_post import make_test_blog import frappe import unittest @@ -31,6 +32,18 @@ class TestUserPermission(unittest.TestCase): param = get_params(user, 'User', perm_user.name, is_default=1) self.assertRaises(frappe.ValidationError, add_user_permissions, param) + def test_default_user_permission_corectness(self): + user = create_user('test_default_corectness_permission_1@example.com') + param = get_params(user, 'User', user.name, is_default=1, hide_descendants= 1) + add_user_permissions(param) + #create a duplicate entry with default + perm_user = create_user('test_default_corectness2@example.com') + test_blog = make_test_blog() + param = get_params(perm_user, 'Blog Post', test_blog.name, is_default=1, hide_descendants= 1) + add_user_permissions(param) + frappe.db.delete('User Permission', filters={'for_value': test_blog.name}) + frappe.delete_doc('Blog Post', test_blog.name) + def test_default_user_permission(self): frappe.set_user('Administrator') user = create_user('test_user_perm1@example.com', 'Website Manager') diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index 1366ace115..fb658481b2 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -48,7 +48,6 @@ class UserPermission(Document): }, or_filters={ 'applicable_for': cstr(self.applicable_for), 'apply_to_all_doctypes': 1, - 'hide_descendants': cstr(self.hide_descendants) }, limit=1) if overlap_exists: ref_link = frappe.get_desk_link(self.doctype, overlap_exists[0].name) diff --git a/frappe/core/doctype/user_type/test_user_type.py b/frappe/core/doctype/user_type/test_user_type.py index 7080e1830b..6807f8fc9e 100644 --- a/frappe/core/doctype/user_type/test_user_type.py +++ b/frappe/core/doctype/user_type/test_user_type.py @@ -1,8 +1,68 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and Contributors # License: MIT. See LICENSE -# import frappe +import frappe import unittest +from frappe.installer import update_site_config + class TestUserType(unittest.TestCase): - pass + def setUp(self): + create_role() + + def test_add_select_perm_doctypes(self): + user_type = create_user_type('Test User Type') + + # select perms added for all link fields + doc = frappe.get_meta('Contact') + link_fields = doc.get_link_fields() + select_doctypes = frappe.get_all('User Select Document Type', {'parent': user_type.name}, pluck='document_type') + + for entry in link_fields: + self.assertTrue(entry.options in select_doctypes) + + # select perms added for all child table link fields + link_fields = [] + for child_table in doc.get_table_fields(): + child_doc = frappe.get_meta(child_table.options) + link_fields.extend(child_doc.get_link_fields()) + + for entry in link_fields: + self.assertTrue(entry.options in select_doctypes) + + def tearDown(self): + frappe.db.rollback() + + +def create_user_type(user_type): + if frappe.db.exists('User Type', user_type): + frappe.delete_doc('User Type', user_type) + + user_type_limit = {frappe.scrub(user_type): 1} + update_site_config('user_type_doctype_limit', user_type_limit) + + doc = frappe.get_doc({ + 'doctype': 'User Type', + 'name': user_type, + 'role': '_Test User Type', + 'user_id_field': 'user', + 'apply_user_permission_on': 'User' + }) + + doc.append('user_doctypes', { + 'document_type': 'Contact', + 'read': 1, + 'write': 1 + }) + + return doc.insert() + + +def create_role(): + if not frappe.db.exists('Role', '_Test User Type'): + frappe.get_doc({ + 'doctype': 'Role', + 'role_name': '_Test User Type', + 'desk_access': 1, + 'is_custom': 1 + }).insert() \ No newline at end of file diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 661ac932e7..626ab772b8 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -121,7 +121,7 @@ class UserType(Document): for child_table in doc.get_table_fields(): child_doc = frappe.get_meta(child_table.options) - if not child_doc.istable: + if child_doc: self.prepare_select_perm_doctypes(child_doc, user_doctypes, select_doctypes) if select_doctypes: diff --git a/frappe/core/page/dashboard_view/dashboard_view.js b/frappe/core/page/dashboard_view/dashboard_view.js index e8e9cc9502..bf9fb2a286 100644 --- a/frappe/core/page/dashboard_view/dashboard_view.js +++ b/frappe/core/page/dashboard_view/dashboard_view.js @@ -30,6 +30,7 @@ class Dashboard { show() { this.route = frappe.get_route(); + this.set_breadcrumbs(); if (this.route.length > 1) { // from route this.show_dashboard(this.route.slice(-1)[0]); @@ -75,6 +76,10 @@ class Dashboard { frappe.last_dashboard = current_dashboard_name; } + set_breadcrumbs() { + frappe.breadcrumbs.add("Desk", "Dashboard"); + } + refresh() { frappe.run_serially([ () => this.render_cards(), diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json index aabb4f9d1c..c1c506ae3a 100644 --- a/frappe/core/workspace/build/build.json +++ b/frappe/core/workspace/build/build.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"DocType\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Workspace\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Report\",\"col\":4}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Elements\",\"level\":4,\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Modules\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Models\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Views\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Scripting\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"col\":4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"DocType\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Workspace\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Report\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Elements\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Modules\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Models\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Views\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Scripting\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"col\":4}}]", "creation": "2021-01-02 10:51:16.579957", "docstatus": 0, "doctype": "Workspace", @@ -222,7 +222,7 @@ "type": "Link" } ], - "modified": "2021-09-05 21:14:52.384816", + "modified": "2022-01-13 17:26:02.736366", "modified_by": "Administrator", "module": "Core", "name": "Build", @@ -231,7 +231,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 5, + "sequence_id": 5.0, "shortcuts": [ { "doc_view": "", diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json index 917ce2cbdc..5aadbc42d5 100644 --- a/frappe/core/workspace/settings/settings.json +++ b/frappe/core/workspace/settings/settings.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\":\"header\",\"data\": {\"text\":\"Settings\",\"level\": 4,\"col\": 12}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"System Settings\",\"col\": 4}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"Print Settings\",\"col\": 4}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"Website Settings\",\"col\": 4}}, {\"type\":\"spacer\",\"data\": {\"col\": 12}}, {\"type\":\"header\",\"data\": {\"text\":\"Reports & Masters\",\"level\": 4,\"col\": 12}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Data\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Email / Notifications\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Website\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Core\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Printing\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Workflow\",\"col\": 4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Settings\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"System Settings\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Print Settings\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Website Settings\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Email / Notifications\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Website\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Core\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Workflow\",\"col\":4}}]", "creation": "2020-03-02 15:09:40.527211", "docstatus": 0, "doctype": "Workspace", @@ -367,7 +367,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:03.456174", + "modified": "2022-01-13 17:49:59.586909", "modified_by": "Administrator", "module": "Core", "name": "Settings", @@ -376,7 +376,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 29, + "sequence_id": 29.0, "shortcuts": [ { "icon": "setting", diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json index 85c110151b..5741c54eeb 100644 --- a/frappe/core/workspace/users/users.json +++ b/frappe/core/workspace/users/users.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Permission Manager\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Profile\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Type\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Users\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Logs\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Permissions\", \"col\": 4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Role\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Permission Manager\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User Profile\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User Type\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Users\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Logs\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Permissions\",\"col\":4}}]", "creation": "2020-03-02 15:12:16.754449", "docstatus": 0, "doctype": "Workspace", @@ -145,7 +145,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:03.010205", + "modified": "2022-01-13 17:49:08.912772", "modified_by": "Administrator", "module": "Core", "name": "Users", @@ -154,7 +154,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 27, + "sequence_id": 27.0, "shortcuts": [ { "label": "User", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 24a5d1358b..1593ed49a5 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -107,20 +107,26 @@ class CustomizeForm(Document): def set_name_translation(self): '''Create, update custom translation for this doctype''' current = self.get_name_translation() - if current: - if self.label and current.translated_text != self.label: - frappe.db.set_value('Translation', current.name, 'translated_text', self.label) - frappe.translate.clear_cache() - else: + if not self.label: + if current: # clear translation frappe.delete_doc('Translation', current.name) + return - else: - if self.label: - frappe.get_doc(dict(doctype='Translation', - source_text=self.doc_type, - translated_text=self.label, - language_code=frappe.local.lang or 'en')).insert() + if not current: + frappe.get_doc( + { + "doctype": 'Translation', + "source_text": self.doc_type, + "translated_text": self.label, + "language_code": frappe.local.lang or 'en' + } + ).insert() + return + + if self.label != current.translated_text: + frappe.db.set_value('Translation', current.name, 'translated_text', self.label) + frappe.translate.clear_cache() def clear_existing_doc(self): doc_type = self.doc_type @@ -377,7 +383,7 @@ class CustomizeForm(Document): def make_property_setter(self, prop, value, property_type, fieldname=None, apply_on=None, row_name = None): - delete_property_setter(self.doc_type, prop, fieldname) + delete_property_setter(self.doc_type, prop, fieldname, row_name) property_value = self.get_existing_property_value(prop, fieldname) diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index 8a287b17e8..0fe39e0008 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -304,3 +304,25 @@ class TestCustomizeForm(unittest.TestCase): action = [d for d in event.actions if d.label=='Test Action'] self.assertEqual(len(action), 0) + + def test_custom_label(self): + d = self.get_customize_form("Event") + + # add label + d.label = "Test Rename" + d.run_method("save_customization") + self.assertEqual(d.label, "Test Rename") + + # change label + d.label = "Test Rename 2" + d.run_method("save_customization") + self.assertEqual(d.label, "Test Rename 2") + + # saving again to make sure existing label persists + d.run_method("save_customization") + self.assertEqual(d.label, "Test Rename 2") + + # clear label + d.label = "" + d.run_method("save_customization") + self.assertEqual(d.label, "") diff --git a/frappe/custom/doctype/property_setter/property_setter.py b/frappe/custom/doctype/property_setter/property_setter.py index 7f40be9725..0a65aa6f5d 100644 --- a/frappe/custom/doctype/property_setter/property_setter.py +++ b/frappe/custom/doctype/property_setter/property_setter.py @@ -19,7 +19,7 @@ class PropertySetter(Document): def validate(self): self.validate_fieldtype_change() if self.is_new(): - delete_property_setter(self.doc_type, self.property, self.field_name) + delete_property_setter(self.doc_type, self.property, self.field_name, self.row_name) # clear cache frappe.clear_cache(doctype = self.doc_type) @@ -91,11 +91,13 @@ def make_property_setter(doctype, fieldname, property, value, property_type, for property_setter.insert() return property_setter -def delete_property_setter(doc_type, property, field_name=None): +def delete_property_setter(doc_type, property, field_name=None, row_name=None): """delete other property setters on this, if this is new""" - filters = dict(doc_type = doc_type, property=property) + filters = dict(doc_type=doc_type, property=property) if field_name: filters['field_name'] = field_name + if row_name: + filters["row_name"] = row_name frappe.db.delete('Property Setter', filters) diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json index 8938bdec9c..1756abcb1d 100644 --- a/frappe/custom/workspace/customization/customization.json +++ b/frappe/custom/workspace/customization/customization.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Customization\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customize Form\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Custom Role\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Client Script\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Server Script\",\"col\":4}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Dashboards\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Form Customization\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Other\",\"col\":4}}]", + "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Customization\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customize Form\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Custom Role\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Client Script\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Server Script\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Dashboards\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Form Customization\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Other\",\"col\":4}}]", "creation": "2020-03-02 15:15:03.839594", "docstatus": 0, "doctype": "Workspace", @@ -123,7 +123,7 @@ "type": "Link" } ], - "modified": "2021-11-24 16:20:03.500885", + "modified": "2022-01-13 17:28:08.345794", "modified_by": "Administrator", "module": "Custom", "name": "Customization", @@ -132,7 +132,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 8, + "sequence_id": 8.0, "shortcuts": [ { "label": "Customize Form", diff --git a/frappe/database/database.py b/frappe/database/database.py index 65242e0419..8a6b83c5d9 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -10,19 +10,20 @@ import re import string from contextlib import contextmanager from time import time -from typing import Dict, List, Union, Tuple +from typing import Dict, List, Tuple, Union + +from pypika.terms import Criterion, NullValue, PseudoColumn import frappe import frappe.defaults import frappe.model.meta from frappe import _ -from frappe.utils import now, getdate, cast, get_datetime from frappe.model.utils.link_count import flush_local_link_count from frappe.query_builder.functions import Count -from frappe.query_builder.functions import Min, Max, Avg, Sum -from frappe.query_builder.utils import Column +from frappe.query_builder.utils import DocType +from frappe.utils import cast, get_datetime, getdate, now, sbool + from .query import Query -from pypika.terms import Criterion, PseudoColumn class Database(object): @@ -278,7 +279,9 @@ class Database(object): if self.auto_commit_on_many_writes: self.commit() else: - frappe.throw(_("Too many writes in one request. Please send smaller requests"), frappe.ValidationError) + msg = "

" + _("Too many changes to database in single action.") + "
" + msg += _("The changes have been reverted.") + "
" + raise frappe.TooManyWritesError(msg) def check_implicit_commit(self, query): if self.transaction_writes and \ @@ -555,7 +558,21 @@ class Database(object): def get_list(*args, **kwargs): return frappe.get_list(*args, **kwargs) - def get_single_value(self, doctype, fieldname, cache=False): + def set_single_value(self, doctype, fieldname, value, *args, **kwargs): + """Set field value of Single DocType. + + :param doctype: DocType of the single object + :param fieldname: `fieldname` of the property + :param value: `value` of the property + + Example: + + # Update the `deny_multiple_sessions` field in System Settings DocType. + company = frappe.db.set_single_value("System Settings", "deny_multiple_sessions", True) + """ + return self.set_value(doctype, doctype, fieldname, value, *args, **kwargs) + + def get_single_value(self, doctype, fieldname, cache=True): """Get property of Single DocType. Cache locally by default :param doctype: DocType of the single object whose value is requested @@ -570,7 +587,7 @@ class Database(object): if not doctype in self.value_cache: self.value_cache[doctype] = {} - if fieldname in self.value_cache[doctype]: + if cache and fieldname in self.value_cache[doctype]: return self.value_cache[doctype][fieldname] val = self.query.get_sql( @@ -677,53 +694,55 @@ class Database(object): :param debug: Print the query in the developer / js console. :param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit. """ - if not modified: - modified = now() - if not modified_by: - modified_by = frappe.session.user + is_single_doctype = not (dn and dt != dn) + to_update = field if isinstance(field, dict) else {field: val} - to_update = {} if update_modified: - to_update = {"modified": modified, "modified_by": modified_by} + modified = modified or now() + modified_by = modified_by or frappe.session.user + to_update.update({"modified": modified, "modified_by": modified_by}) + + if is_single_doctype: + frappe.db.delete( + "Singles", + filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug + ) + + singles_data = ((dt, key, sbool(value)) for key, value in to_update.items()) + query = ( + frappe.qb.into("Singles") + .columns("doctype", "field", "value") + .insert(*singles_data) + ).run(debug=debug) + frappe.clear_document_cache(dt, dt) - if isinstance(field, dict): - to_update.update(field) else: - to_update.update({field: val}) + table = DocType(dt) - if dn and dt!=dn: - # with table - set_values = [] - for key in to_update: - set_values.append('`{0}`=%({0})s'.format(key)) + if for_update: + docnames = tuple( + self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True) + ) or (NullValue(),) + query = frappe.qb.update(table).where(table.name.isin(docnames)) - for name in self.get_values(dt, dn, 'name', for_update=for_update, debug=debug): - values = dict(name=name[0]) - values.update(to_update) + for docname in docnames: + frappe.clear_document_cache(dt, docname) - self.sql("""update `tab{0}` - set {1} where name=%(name)s""".format(dt, ', '.join(set_values)), - values, debug=debug) + else: + query = self.query.build_conditions(table=dt, filters=dn, update=True) + # TODO: Fix this; doesn't work rn - gavin@frappe.io + # frappe.cache().hdel_keys(dt, "document_cache") + # Workaround: clear all document caches + frappe.cache().delete_value('document_cache') - frappe.clear_document_cache(dt, values['name']) - else: - # for singles - keys = list(to_update) - self.sql(''' - delete from `tabSingles` - where field in ({0}) and - doctype=%s'''.format(', '.join(['%s']*len(keys))), - list(keys) + [dt], debug=debug) - for key, value in to_update.items(): - self.sql('''insert into `tabSingles` (doctype, field, value) values (%s, %s, %s)''', - (dt, key, value), debug=debug) + for column, value in to_update.items(): + query = query.set(column, value) - frappe.clear_document_cache(dt, dn) + query.run(debug=debug) if dt in self.value_cache: del self.value_cache[dt] - @staticmethod def set(doc, field, val): """Set value in document. **Avoid**""" diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 6b827a4e89..de28dad900 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -245,9 +245,16 @@ class MariaDBDatabase(Database): column_name as 'name', column_type as 'type', column_default as 'default', - column_key = 'MUL' as 'index', + COALESCE( + (select 1 + from information_schema.statistics + where table_name="{table_name}" + and column_name=columns.column_name + and NON_UNIQUE=1 + limit 1 + ), 0) as 'index', column_key = 'UNI' as 'unique' - from information_schema.columns + from information_schema.columns as columns where table_name = '{table_name}' '''.format(table_name=table_name), as_dict=1) def has_index(self, table_name, index_name): diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index 5768a2f23d..07bb4d5d7c 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -58,18 +58,34 @@ class MariaDBTable(DBTable): modify_column_query.append("MODIFY `{}` {}".format(col.fieldname, col.get_definition())) for col in self.add_index: - # if index key not exists - if not frappe.db.sql("SHOW INDEX FROM `%s` WHERE key_name = %s" % - (self.table_name, '%s'), col.fieldname): - add_index_query.append("ADD INDEX `{}`(`{}`)".format(col.fieldname, col.fieldname)) + # if index key does not exists + if not frappe.db.has_index(self.table_name, col.fieldname + '_index'): + add_index_query.append("ADD INDEX `{}_index`(`{}`)".format(col.fieldname, col.fieldname)) - for col in self.drop_index: + for col in self.drop_index + self.drop_unique: if col.fieldname != 'name': # primary key + current_column = self.current_columns.get(col.fieldname.lower()) + unique_constraint_changed = current_column.unique != col.unique + if unique_constraint_changed and not col.unique: + # nosemgrep + unique_index_record = frappe.db.sql(""" + SHOW INDEX FROM `{0}` + WHERE Key_name=%s + AND Non_unique=0 + """.format(self.table_name), (col.fieldname), as_dict=1) + if unique_index_record: + drop_index_query.append("DROP INDEX `{}`".format(unique_index_record[0].Key_name)) + index_constraint_changed = current_column.index != col.set_index # if index key exists - if frappe.db.sql("""SHOW INDEX FROM `{0}` - WHERE key_name=%s - AND Non_unique=%s""".format(self.table_name), (col.fieldname, col.unique)): - drop_index_query.append("drop index `{}`".format(col.fieldname)) + if index_constraint_changed and not col.set_index: + # nosemgrep + index_record = frappe.db.sql(""" + SHOW INDEX FROM `{0}` + WHERE Key_name=%s + AND Non_unique=1 + """.format(self.table_name), (col.fieldname + '_index'), as_dict=1) + if index_record: + drop_index_query.append("DROP INDEX `{}`".format(index_record[0].Key_name)) try: for query_parts in [add_column_query, modify_column_query, add_index_query, drop_index_query]: diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 33f07990af..a3266242a5 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -77,11 +77,11 @@ class PostgresDatabase(Database): """Escape quotes and percent in given string.""" if isinstance(s, bytes): s = s.decode('utf-8') - + # MariaDB's driver treats None as an empty string # So Postgres should do the same - if s is None: + if s is None: s = '' if percent: @@ -170,11 +170,11 @@ class PostgresDatabase(Database): @staticmethod def is_primary_key_violation(e): - return e.pgcode == '23505' and '_pkey' in cstr(e.args[0]) + return getattr(e, "pgcode", None) == '23505' and '_pkey' in cstr(e.args[0]) @staticmethod def is_unique_key_violation(e): - return e.pgcode == '23505' and '_key' in cstr(e.args[0]) + return getattr(e, "pgcode", None) == '23505' and '_key' in cstr(e.args[0]) @staticmethod def is_duplicate_fieldname(e): @@ -308,18 +308,20 @@ class PostgresDatabase(Database): WHEN 'timestamp without time zone' THEN 'timestamp' ELSE a.data_type END AS type, - COUNT(b.indexdef) AS Index, + BOOL_OR(b.index) AS index, SPLIT_PART(COALESCE(a.column_default, NULL), '::', 1) AS default, BOOL_OR(b.unique) AS unique FROM information_schema.columns a LEFT JOIN - (SELECT indexdef, tablename, indexdef LIKE '%UNIQUE INDEX%' AS unique + (SELECT indexdef, tablename, + indexdef LIKE '%UNIQUE INDEX%' AS unique, + indexdef NOT LIKE '%UNIQUE INDEX%' AS index FROM pg_indexes WHERE tablename='{table_name}') b - ON SUBSTRING(b.indexdef, '\(.*\)') LIKE CONCAT('%', a.column_name, '%') + 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;''' - .format(table_name=table_name), as_dict=1) + GROUP BY a.column_name, a.data_type, a.column_default, a.character_maximum_length; + '''.format(table_name=table_name), as_dict=1) def get_database_list(self, target): return [d[0] for d in self.sql("SELECT datname FROM pg_database;")] diff --git a/frappe/database/postgres/schema.py b/frappe/database/postgres/schema.py index 58153ca6ce..a2d5be0b70 100644 --- a/frappe/database/postgres/schema.py +++ b/frappe/database/postgres/schema.py @@ -11,8 +11,6 @@ class PostgresTable(DBTable): column_defs = self.get_column_definitions() if column_defs: add_text += ',\n'.join(column_defs) - # index - # index_defs = self.get_index_definitions() # TODO: set docstatus length # create table frappe.db.sql("""create table `%s` ( @@ -28,8 +26,25 @@ class PostgresTable(DBTable): idx bigint not null default '0', %s)""".format(varchar_len=frappe.db.VARCHAR_LEN) % (self.table_name, add_text)) + self.create_indexes() frappe.db.commit() + def create_indexes(self): + create_index_query = "" + for key, col in self.columns.items(): + if (col.set_index + and col.fieldtype in frappe.db.type_map + and frappe.db.type_map.get(col.fieldtype)[0] + not in ('text', 'longtext')): + create_index_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format( + index_name=col.fieldname, + table_name=self.table_name, + field=col.fieldname + ) + if create_index_query: + # nosemgrep + frappe.db.sql(create_index_query) + def alter(self): for col in self.columns.values(): col.build_for_alter_table(self.current_columns.get(col.fieldname.lower())) @@ -52,8 +67,8 @@ class PostgresTable(DBTable): query.append("ALTER COLUMN `{0}` TYPE {1} {2}".format( col.fieldname, get_definition(col.fieldtype, precision=col.precision, length=col.length), - using_clause) - ) + using_clause + )) for col in self.set_default: if col.fieldname=="name": @@ -73,37 +88,54 @@ class PostgresTable(DBTable): query.append("ALTER COLUMN `{}` SET DEFAULT {}".format(col.fieldname, col_default)) - create_index_query = "" + create_contraint_query = "" for col in self.add_index: # if index key not exists - create_index_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format( + create_contraint_query += 'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}`(`{field}`);'.format( index_name=col.fieldname, table_name=self.table_name, field=col.fieldname) - drop_index_query = "" + for col in self.add_unique: + # if index key not exists + create_contraint_query += 'CREATE UNIQUE INDEX IF NOT EXISTS "unique_{index_name}" ON `{table_name}`(`{field}`);'.format( + index_name=col.fieldname, + table_name=self.table_name, + field=col.fieldname + ) + + drop_contraint_query = "" for col in self.drop_index: # primary key if col.fieldname != 'name': # if index key exists - if not frappe.db.has_index(self.table_name, col.fieldname): - drop_index_query += 'DROP INDEX IF EXISTS "{}" ;'.format(col.fieldname) + drop_contraint_query += 'DROP INDEX IF EXISTS "{}" ;'.format(col.fieldname) - if query: - try: + for col in self.drop_unique: + # primary key + if col.fieldname != 'name': + # if index key exists + drop_contraint_query += 'DROP INDEX IF EXISTS "unique_{}" ;'.format(col.fieldname) + try: + if query: final_alter_query = "ALTER TABLE `{}` {}".format(self.table_name, ", ".join(query)) - if final_alter_query: frappe.db.sql(final_alter_query) - if create_index_query: frappe.db.sql(create_index_query) - if drop_index_query: frappe.db.sql(drop_index_query) - except Exception as e: - # sanitize - if frappe.db.is_duplicate_fieldname(e): - frappe.throw(str(e)) - elif frappe.db.is_duplicate_entry(e): - fieldname = str(e).split("'")[-2] - frappe.throw(_("""{0} field cannot be set as unique in {1}, - as there are non-unique existing values""".format( - fieldname, self.table_name))) - raise e - else: - raise e + # nosemgrep + frappe.db.sql(final_alter_query) + if create_contraint_query: + # nosemgrep + frappe.db.sql(create_contraint_query) + if drop_contraint_query: + # nosemgrep + frappe.db.sql(drop_contraint_query) + except Exception as e: + # sanitize + if frappe.db.is_duplicate_fieldname(e): + frappe.throw(str(e)) + elif frappe.db.is_duplicate_entry(e): + fieldname = str(e).split("'")[-2] + frappe.throw( + _("{0} field cannot be set as unique in {1}, as there are non-unique existing values") + .format(fieldname, self.table_name) + ) + else: + raise e diff --git a/frappe/database/query.py b/frappe/database/query.py index 6d2be5fa25..587378b32f 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -308,7 +308,7 @@ class Permission: doctype = [doctype] for dt in doctype: - dt = re.sub("tab", "", dt) + dt = re.sub("^tab", "", dt) if not frappe.has_permission( dt, "select", diff --git a/frappe/database/schema.py b/frappe/database/schema.py index 10582eff8f..9a6dd502dc 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -21,6 +21,7 @@ class DBTable: self.change_name = [] self.add_unique = [] self.add_index = [] + self.drop_unique = [] self.drop_index = [] self.set_default = [] @@ -219,8 +220,10 @@ class DbColumn: self.table.change_type.append(self) # unique - if((self.unique and not current_def['unique']) and column_type not in ('text', 'longtext')): + if ((self.unique and not current_def['unique']) and column_type not in ('text', 'longtext')): self.table.add_unique.append(self) + elif (current_def['unique'] and not self.unique) and column_type not in ('text', 'longtext'): + self.table.drop_unique.append(self) # default if (self.default_changed(current_def) @@ -230,9 +233,7 @@ class DbColumn: self.table.set_default.append(self) # index should be applied or dropped irrespective of type change - if ((current_def['index'] and not self.set_index and not self.unique) - or (current_def['unique'] and not self.unique)): - # to drop unique you have to drop index + 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')): diff --git a/frappe/defaults.py b/frappe/defaults.py index eb98db449f..e249ef2099 100644 --- a/frappe/defaults.py +++ b/frappe/defaults.py @@ -126,7 +126,7 @@ def set_default(key, value, parent, parenttype="__default"): "defkey": key, "parent": parent }) - if value != None: + if value is not None: add_default(key, value, parent) else: _clear_cache(parent) @@ -187,7 +187,7 @@ def get_defaults_for(parent="__default"): """get all defaults""" defaults = frappe.cache().hget("defaults", parent) - if defaults==None: + if defaults is None: # sort descending because first default must get precedence table = DocType("DefaultValue") res = frappe.qb.from_(table).where( diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index e1789852f1..4164db679d 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -56,31 +56,6 @@ class Workspace: self.restricted_doctypes = frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache() self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache() - def is_page_allowed(self): - cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) - shortcuts = self.doc.shortcuts - - for section in cards: - links = loads(section.get('links')) if isinstance(section.get('links'), str) else section.get('links') - for item in links: - if self.is_item_allowed(item.get('link_to'), item.get('link_type')): - return True - - def _in_active_domains(item): - if not item.restrict_to_domain: - return True - else: - return item.restrict_to_domain in frappe.get_active_domains() - - for item in shortcuts: - if self.is_item_allowed(item.link_to, item.type) and _in_active_domains(item): - return True - - if not shortcuts and not self.doc.links: - return True - - return False - def is_permitted(self): """Returns true if Has Role is not set or the user is allowed.""" from frappe.utils import has_common @@ -346,20 +321,20 @@ def get_desktop_page(page): dict: dictionary of cards, charts and shortcuts to be displayed on website """ try: - wspace = Workspace(loads(page)) - wspace.build_workspace() + workspace = Workspace(loads(page)) + workspace.build_workspace() return { - 'charts': wspace.charts, - 'shortcuts': wspace.shortcuts, - 'cards': wspace.cards, - 'onboardings': wspace.onboardings + 'charts': workspace.charts, + 'shortcuts': workspace.shortcuts, + 'cards': workspace.cards, + 'onboardings': workspace.onboardings } except DoesNotExistError: frappe.log_error(frappe.get_traceback()) return {} @frappe.whitelist() -def get_wspace_sidebar_items(): +def get_workspace_sidebar_items(): """Get list of sidebar items for desk""" has_access = "Workspace Manager" in frappe.get_roles() @@ -385,8 +360,8 @@ def get_wspace_sidebar_items(): # Filter Page based on Permission for page in all_pages: try: - wspace = Workspace(page, True) - if wspace.is_permitted() and wspace.is_page_allowed() or has_access: + workspace = Workspace(page, True) + if has_access or workspace.is_permitted(): if page.public: pages.append(page) elif page.for_user == frappe.session.user: @@ -453,25 +428,24 @@ def get_custom_report_list(module): return out def save_new_widget(doc, page, blocks, new_widgets): + if loads(new_widgets): + widgets = _dict(loads(new_widgets)) - widgets = _dict(loads(new_widgets)) - - if widgets.chart: - doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts")) - if widgets.shortcut: - doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts")) - if widgets.card: - doc.build_links_table_from_card(widgets.card) + if widgets.chart: + doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts")) + if widgets.shortcut: + doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts")) + if widgets.card: + doc.build_links_table_from_card(widgets.card) # remove duplicate and unwanted widgets - if widgets: - clean_up(doc, blocks) + clean_up(doc, blocks) try: doc.save(ignore_permissions=True) except (ValidationError, TypeError) as e: # Create a json string to log - json_config = dumps(widgets, sort_keys=True, indent=4) + json_config = widgets and dumps(widgets, sort_keys=True, indent=4) # Error log body log = \ diff --git a/frappe/desk/doctype/bulk_update/bulk_update.py b/frappe/desk/doctype/bulk_update/bulk_update.py index a0523d90cd..20887f8886 100644 --- a/frappe/desk/doctype/bulk_update/bulk_update.py +++ b/frappe/desk/doctype/bulk_update/bulk_update.py @@ -42,13 +42,13 @@ def submit_cancel_or_update_docs(doctype, docnames, action='submit', data=None): doc = frappe.get_doc(doctype, d) try: message = '' - if action == 'submit' and doc.docstatus==0: + if action == 'submit' and doc.docstatus.is_draft(): doc.submit() message = _('Submiting {0}').format(doctype) - elif action == 'cancel' and doc.docstatus==1: + elif action == 'cancel' and doc.docstatus.is_submitted(): doc.cancel() message = _('Cancelling {0}').format(doctype) - elif action == 'update' and doc.docstatus < 2: + elif action == 'update' and not doc.docstatus.is_cancelled(): doc.update(data) doc.save() message = _('Updating {0}').format(doctype) diff --git a/frappe/desk/doctype/route_history/route_history.py b/frappe/desk/doctype/route_history/route_history.py index a49d5d5418..f0aa867c8a 100644 --- a/frappe/desk/doctype/route_history/route_history.py +++ b/frappe/desk/doctype/route_history/route_history.py @@ -52,3 +52,9 @@ def deferred_insert(routes): ] _deferred_insert("Route History", json.dumps(routes)) + +@frappe.whitelist() +def frequently_visited_links(): + return frappe.get_all('Route History', fields=['route', 'count(name) as count'], filters={ + 'user': frappe.session.user + }, group_by="route", order_by="count desc", limit=5) diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index 04975c69e3..211029dfcf 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_rename": 1, "autoname": "field:label", "beta": 1, "creation": "2020-01-23 13:45:59.470592", @@ -141,7 +142,7 @@ }, { "fieldname": "sequence_id", - "fieldtype": "Int", + "fieldtype": "Float", "label": "Sequence Id" }, { @@ -158,7 +159,7 @@ ], "in_create": 1, "links": [], - "modified": "2021-09-16 12:01:06.450622", + "modified": "2021-12-15 19:33:00.805265", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 94114e3918..b40f517350 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.modules.export_file import export_to_files from frappe.model.document import Document +from frappe.model.rename_doc import rename_doc from frappe.desk.desktop import save_new_widget from frappe.desk.utils import validate_route_conflict @@ -121,77 +122,157 @@ def get_report_type(report): report_type = frappe.get_value("Report", report, "report_type") return report_type in ["Query Report", "Script Report", "Custom Report"] +@frappe.whitelist() +def new_page(new_page): + if not loads(new_page): + return + + page = loads(new_page) + + if page.get("public") and not is_workspace_manager(): + return + + doc = frappe.new_doc('Workspace') + doc.title = page.get('title') + doc.icon = page.get('icon') + doc.content = page.get('content') + doc.parent_page = page.get('parent_page') + doc.label = page.get('label') + doc.for_user = page.get('for_user') + doc.public = page.get('public') + doc.sequence_id = last_sequence_id(doc) + 1 + doc.save(ignore_permissions=True) + + return doc @frappe.whitelist() -def save_page(title, icon, parent, public, sb_public_items, sb_private_items, deleted_pages, new_widgets, blocks, save): - save = frappe.parse_json(save) +def save_page(title, public, new_widgets, blocks): public = frappe.parse_json(public) - if save: - doc = frappe.new_doc('Workspace') - doc.title = title - doc.icon = icon - doc.content = blocks - doc.parent_page = parent - if public: - doc.label = title - doc.public = 1 - else: - doc.label = title + "-" + frappe.session.user - doc.for_user = frappe.session.user - doc.save(ignore_permissions=True) - else: - if public: - filters = { - 'public': public, - 'label': title - } - else: - filters = { - 'for_user': frappe.session.user, - 'label': title + "-" + frappe.session.user - } - pages = frappe.get_list("Workspace", filters=filters) - if pages: - doc = frappe.get_doc("Workspace", pages[0]) + filters = { + 'public': public, + 'label': title + } - doc.content = blocks - doc.save(ignore_permissions=True) + if not public: + filters = { + 'for_user': frappe.session.user, + 'label': title + "-" + frappe.session.user + } + pages = frappe.get_list("Workspace", filters=filters) + if pages: + doc = frappe.get_doc("Workspace", pages[0]) - if loads(new_widgets): - save_new_widget(doc, title, blocks, new_widgets) + doc.content = blocks + doc.save(ignore_permissions=True) - if loads(sb_public_items) or loads(sb_private_items): - sort_pages(loads(sb_public_items), loads(sb_private_items)) - - if loads(deleted_pages): - return delete_pages(loads(deleted_pages)) + save_new_widget(doc, title, blocks, new_widgets) return {"name": title, "public": public, "label": doc.label} -def delete_pages(deleted_pages): - for page in deleted_pages: - if page.get("public") and not is_workspace_manager(): - return {"name": page.get("title"), "public": 1, "label": page.get("label")} +@frappe.whitelist() +def update_page(name, title, icon, parent, public): + public = frappe.parse_json(public) - if frappe.db.exists("Workspace", page.get("name")): - frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True) + doc = frappe.get_doc("Workspace", name) - return {"name": "Home", "public": 1, "label": "Home"} + filters = { + 'parent_page': doc.title, + 'public': doc.public + } + child_docs = frappe.get_list("Workspace", filters=filters) + if doc: + doc.title = title + doc.icon = icon + doc.parent_page = parent + if doc.public != public: + doc.sequence_id = frappe.db.count('Workspace', {'public':public}, cache=True) + doc.public = public + doc.for_user = '' if public else doc.for_user or frappe.session.user + doc.label = '{0}-{1}'.format(title, doc.for_user) if doc.for_user else title + doc.save(ignore_permissions=True) + + if name != doc.label: + rename_doc("Workspace", name, doc.label, force=True, ignore_permissions=True) + + # update new name and public in child pages + if child_docs: + for child in child_docs: + child_doc = frappe.get_doc("Workspace", child.name) + child_doc.parent_page = doc.title + child_doc.public = doc.public + child_doc.save(ignore_permissions=True) + + return {"name": doc.title, "public": doc.public, "label": doc.label} + +@frappe.whitelist() +def duplicate_page(page_name, new_page): + if not loads(new_page): + return + + new_page = loads(new_page) + + if new_page.get("is_public") and not is_workspace_manager(): + return + + old_doc = frappe.get_doc("Workspace", page_name) + doc = frappe.copy_doc(old_doc) + doc.title = new_page.get('title') + doc.icon = new_page.get('icon') + doc.parent_page = new_page.get('parent') or '' + doc.public = new_page.get('is_public') + doc.for_user = '' + doc.label = doc.title + if not doc.public: + doc.for_user = doc.for_user or frappe.session.user + doc.label = '{0}-{1}'.format(doc.title, doc.for_user) + doc.name = doc.label + if old_doc.public == doc.public: + doc.sequence_id += 0.1 + else: + doc.sequence_id = last_sequence_id(doc) + 1 + doc.insert(ignore_permissions=True) + + return doc + +@frappe.whitelist() +def delete_page(page): + if not loads(page): + return + + page = loads(page) + + if page.get("public") and not is_workspace_manager(): + return + + if frappe.db.exists("Workspace", page.get("name")): + frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True) + + return {"name": page.get("name"), "public": page.get("public"), "title": page.get("title")} + +@frappe.whitelist() def sort_pages(sb_public_items, sb_private_items): - wspace_public_pages = get_page_list(['name', 'title'], {'public': 1}) - wspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user}) + if not loads(sb_public_items) and not loads(sb_private_items): + return + + sb_public_items = loads(sb_public_items) + sb_private_items = loads(sb_private_items) + + workspace_public_pages = get_page_list(['name', 'title'], {'public': 1}) + workspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user}) if sb_private_items: - sort_page(wspace_private_pages, sb_private_items) + return sort_page(workspace_private_pages, sb_private_items) if sb_public_items and is_workspace_manager(): - sort_page(wspace_public_pages, sb_public_items) + return sort_page(workspace_public_pages, sb_public_items) -def sort_page(wspace_pages, pages): + return False + +def sort_page(workspace_pages, pages): for seq, d in enumerate(pages): - for page in wspace_pages: + for page in workspace_pages: if page.title == d.get('title'): doc = frappe.get_doc('Workspace', page.name) doc.sequence_id = seq + 1 @@ -199,6 +280,27 @@ def sort_page(wspace_pages, pages): doc.save(ignore_permissions=True) break + return True + +def last_sequence_id(doc): + doc_exists = frappe.db.exists({ + 'doctype': 'Workspace', + 'public': doc.public, + 'for_user': doc.for_user + }) + + if not doc_exists: + return 0 + + return frappe.db.get_list('Workspace', + fields=['sequence_id'], + filters={ + 'public': doc.public, + 'for_user': doc.for_user + }, + order_by="sequence_id desc" + )[0].sequence_id + def get_page_list(fields, filters): return frappe.get_list("Workspace", fields=fields, filters=filters, order_by='sequence_id asc') diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 0e644c3cf5..58d5b30103 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -91,33 +91,82 @@ def get_docinfo(doc=None, doctype=None, name=None): raise frappe.PermissionError all_communications = _get_communications(doc.doctype, doc.name) - automated_messages = filter(lambda x: x['communication_type'] == 'Automated Message', all_communications) - communications_except_auto_messages = filter(lambda x: x['communication_type'] != 'Automated Message', all_communications) + automated_messages = [msg for msg in all_communications if msg['communication_type'] == 'Automated Message'] + communications_except_auto_messages = [msg for msg in all_communications if msg['communication_type'] != 'Automated Message'] - frappe.response["docinfo"] = { + docinfo = frappe._dict(user_info = {}) + + add_comments(doc, docinfo) + + docinfo.update({ "attachments": get_attachments(doc.doctype, doc.name), - "attachment_logs": get_comments(doc.doctype, doc.name, 'attachment'), "communications": communications_except_auto_messages, "automated_messages": automated_messages, - 'comments': get_comments(doc.doctype, doc.name), 'total_comments': len(json.loads(doc.get('_comments') or '[]')), 'versions': get_versions(doc), "assignments": get_assignments(doc.doctype, doc.name), - "assignment_logs": get_comments(doc.doctype, doc.name, 'assignment'), "permissions": get_doc_permissions(doc), "shared": frappe.share.get_users(doc.doctype, doc.name), - "info_logs": get_comments(doc.doctype, doc.name, comment_type=['Info', 'Edit', 'Label']), - "share_logs": get_comments(doc.doctype, doc.name, 'share'), - "like_logs": get_comments(doc.doctype, doc.name, 'Like'), - "workflow_logs": get_comments(doc.doctype, doc.name, comment_type="Workflow"), "views": get_view_logs(doc.doctype, doc.name), "energy_point_logs": get_point_logs(doc.doctype, doc.name), "additional_timeline_content": get_additional_timeline_content(doc.doctype, doc.name), "milestones": get_milestones(doc.doctype, doc.name), "is_document_followed": is_document_followed(doc.doctype, doc.name, frappe.session.user), "tags": get_tags(doc.doctype, doc.name), - "document_email": get_document_email(doc.doctype, doc.name) - } + "document_email": get_document_email(doc.doctype, doc.name), + }) + + update_user_info(docinfo) + + frappe.response["docinfo"] = docinfo + return docinfo + +def add_comments(doc, docinfo): + # divide comments into separate lists + docinfo.comments = [] + docinfo.shared = [] + docinfo.assignment_logs = [] + docinfo.attachment_logs = [] + docinfo.info_logs = [] + docinfo.like_logs = [] + docinfo.workflow_logs = [] + + comments = frappe.get_all("Comment", + fields=["name", "creation", "content", "owner", "comment_type"], + filters={ + "reference_doctype": doc.doctype, + "reference_name": doc.name + } + ) + + for c in comments: + if c.comment_type == "Comment": + c.content = frappe.utils.markdown(c.content) + docinfo.comments.append(c) + + elif c.comment_type in ('Shared', 'Unshared'): + docinfo.shared.append(c) + + elif c.comment_type in ('Assignment Completed', 'Assigned'): + docinfo.assignment_logs.append(c) + + elif c.comment_type in ('Attachment', 'Attachment Removed'): + docinfo.attachment_logs.append(c) + + elif c.comment_type in ('Info', 'Edit', 'Label'): + docinfo.info_logs.append(c) + + elif c.comment_type == "Like": + docinfo.like_logs.append(c) + + elif c.comment_type == "Workflow": + docinfo.workflow_logs.append(c) + + frappe.utils.add_user_info(c.owner, docinfo.user_info) + + + return comments + def get_milestones(doctype, name): return frappe.db.get_all('Milestone', fields = ['creation', 'owner', 'track_field', 'value'], @@ -252,7 +301,7 @@ def get_communication_data(doctype, name, start=0, limit=20, after=None, fields= return communications def get_assignments(dt, dn): - cl = frappe.get_all("ToDo", + return frappe.get_all("ToDo", fields=['name', 'allocated_to as owner', 'description', 'status'], filters={ 'reference_type': dt, @@ -260,8 +309,6 @@ def get_assignments(dt, dn): 'status': ('!=', 'Cancelled'), }) - return cl - @frappe.whitelist() def get_badge_info(doctypes, filters): filters = json.loads(filters) @@ -319,3 +366,24 @@ def get_additional_timeline_content(doctype, docname): contents.extend(frappe.get_attr(method)(doctype, docname) or []) return contents + +def update_user_info(docinfo): + for d in docinfo.communications: + frappe.utils.add_user_info(d.sender, docinfo.user_info) + + for d in docinfo.shared: + frappe.utils.add_user_info(d.user, docinfo.user_info) + + for d in docinfo.assignments: + frappe.utils.add_user_info(d.owner, docinfo.user_info) + + for d in docinfo.views: + frappe.utils.add_user_info(d.owner, docinfo.user_info) + +@frappe.whitelist() +def get_user_info_for_viewers(users): + user_info = {} + for user in json.loads(users): + frappe.utils.add_user_info(user, user_info) + + return user_info diff --git a/frappe/desk/moduleview.py b/frappe/desk/moduleview.py index e2e2c4c155..7a9c211c3c 100644 --- a/frappe/desk/moduleview.py +++ b/frappe/desk/moduleview.py @@ -524,7 +524,7 @@ def get_last_modified(doctype): raise # hack: save as -1 so that it is cached - if last_modified==None: + if last_modified is None: last_modified = -1 return last_modified diff --git a/frappe/desk/page/user_profile/user_profile.py b/frappe/desk/page/user_profile/user_profile.py index 73df6d78cb..0d91fd0d91 100644 --- a/frappe/desk/page/user_profile/user_profile.py +++ b/frappe/desk/page/user_profile/user_profile.py @@ -30,7 +30,7 @@ def get_energy_points_percentage_chart_data(user, field): as_list = True) return { - "labels": [r[0] for r in result if r[0] != None], + "labels": [r[0] for r in result if r[0] is not None], "datasets": [{ "values": [r[1] for r in result] }] diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index fb150e4bea..4001d0b9cf 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -12,14 +12,14 @@ from io import StringIO from frappe.core.doctype.access_log.access_log import make_access_log from frappe.utils import cstr, format_duration from frappe.model.base_document import get_controller - +from frappe.utils import add_user_info @frappe.whitelist() @frappe.read_only() def get(): args = get_form_params() # If virtual doctype get data from controller het_list method - if frappe.db.get_value("DocType", filters={"name": args.doctype}, fieldname="is_virtual"): + if is_virtual_doctype(args.doctype): controller = get_controller(args.doctype) data = compress(controller(args.doctype).get_list(args)) else: @@ -29,17 +29,31 @@ def get(): @frappe.whitelist() @frappe.read_only() def get_list(): - # uncompressed (refactored from frappe.model.db_query.get_list) - return execute(**get_form_params()) + args = get_form_params() + + if is_virtual_doctype(args.doctype): + controller = get_controller(args.doctype) + data = controller(args.doctype).get_list(args) + else: + # uncompressed (refactored from frappe.model.db_query.get_list) + data = execute(**args) + + return data @frappe.whitelist() @frappe.read_only() def get_count(): args = get_form_params() - distinct = 'distinct ' if args.distinct=='true' else '' - args.fields = [f"count({distinct}`tab{args.doctype}`.name) as total_count"] - return execute(**args)[0].get('total_count') + if is_virtual_doctype(args.doctype): + controller = get_controller(args.doctype) + data = controller(args.doctype).get_count(args) + else: + distinct = 'distinct ' if args.distinct=='true' else '' + args.fields = [f"count({distinct}`tab{args.doctype}`.name) as total_count"] + data = execute(**args)[0].get('total_count') + + return data def execute(doctype, *args, **kwargs): return DatabaseQuery(doctype).execute(*args, **kwargs) @@ -219,6 +233,8 @@ def compress(data, args=None): """separate keys and values""" from frappe.desk.query_report import add_total_row + user_info = {} + if not data: return data if args is None: args = {} @@ -230,13 +246,19 @@ def compress(data, args=None): new_row.append(row.get(key)) values.append(new_row) + # add user info for assignments (avatar) + if row._assign: + for user in json.loads(row._assign): + add_user_info(user, user_info) + if args.get("add_total_row"): meta = frappe.get_meta(args.doctype) values = add_total_row(values, keys, meta) return { "keys": keys, - "values": values + "values": values, + "user_info": user_info } @frappe.whitelist() @@ -297,7 +319,7 @@ def export_query(): if add_totals_row: ret = append_totals_row(ret) - data = [['Sr'] + get_labels(db_query.fields, doctype)] + data = [[_('Sr')] + get_labels(db_query.fields, doctype)] for i, row in enumerate(ret): data.append([i+1] + list(row)) @@ -356,7 +378,8 @@ def get_labels(fields, doctype): for key in fields: key = key.split(" as ")[0] - if key.startswith(('count(', 'sum(', 'avg(')): continue + if key.startswith(('count(', 'sum(', 'avg(')): + continue if "." in key: parenttype, fieldname = key.split(".")[0][4:-1], key.split(".")[1].strip("`") @@ -364,10 +387,16 @@ def get_labels(fields, doctype): parenttype = doctype fieldname = fieldname.strip("`") - df = frappe.get_meta(parenttype).get_field(fieldname) - label = df.label if df else fieldname.title() - if label in labels: - label = doctype + ": " + label + if parenttype == doctype and fieldname == "name": + label = _("ID", context="Label of name column in report") + else: + df = frappe.get_meta(parenttype).get_field(fieldname) + label = _(df.label if df else fieldname.title()) + if parenttype != doctype: + # If the column is from a child table, append the child doctype. + # For example, "Item Code (Sales Invoice Item)". + label += f" ({ _(parenttype) })" + labels.append(label) return labels @@ -430,7 +459,14 @@ def get_sidebar_stats(stats, doctype, filters=None): if filters is None: filters = [] - return {"stats": get_stats(stats, doctype, filters)} + if is_virtual_doctype(doctype): + controller = get_controller(doctype) + args = {"stats": stats, "filters": filters} + data = controller(doctype).get_stats(args) + else: + data = get_stats(stats, doctype, filters) + + return {"stats": data} @frappe.whitelist() @frappe.read_only() @@ -552,7 +588,7 @@ def get_match_cond(doctype, as_condition=True): return ((' and ' + cond) if cond else "").replace("%", "%%") def build_match_conditions(doctype, user=None, as_condition=True): - match_conditions = DatabaseQuery(doctype, user=user).build_match_conditions(as_condition=as_condition) + match_conditions = DatabaseQuery(doctype, user=user).build_match_conditions(as_condition=as_condition) if as_condition: return match_conditions.replace("%", "%%") else: @@ -590,3 +626,7 @@ def get_filters_cond(doctype, filters, conditions, ignore_permissions=None, with else: cond = '' return cond + +def is_virtual_doctype(doctype): + return frappe.db.get_value("DocType", doctype, "is_virtual") + diff --git a/frappe/desk/search.py b/frappe/desk/search.py index db88e6ec52..95397070ae 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -107,7 +107,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, else: filters.append([doctype, f[0], "=", f[1]]) - if filters==None: + if filters is None: filters = [] or_filters = [] diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 3f8d399c52..a05e20da24 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -1,5 +1,6 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # License: MIT. See LICENSE + import email.utils import functools import imaplib @@ -7,6 +8,7 @@ import socket import time from datetime import datetime, timedelta from poplib import error_proto +from typing import List import frappe from frappe import _, are_emails_muted, safe_encode @@ -82,9 +84,6 @@ class EmailAccount(Document): if frappe.local.flags.in_patch or frappe.local.flags.in_test: return - #if self.enable_incoming and not self.append_to: - # frappe.throw(_("Append To is mandatory for incoming mails")) - if (not self.awaiting_password and not frappe.local.flags.in_install and not frappe.local.flags.in_patch): if self.password or self.smtp_server in ('127.0.0.1', 'localhost'): @@ -442,7 +441,7 @@ class EmailAccount(Document): frappe.db.rollback() except Exception: frappe.db.rollback() - frappe.log_error('email_account.receive') + frappe.log_error(title="EmailAccount.receive") if self.use_imap: self.handle_bad_emails(mail.uid, mail.raw_message, frappe.get_traceback()) exceptions.append(frappe.get_traceback()) @@ -458,7 +457,7 @@ class EmailAccount(Document): if exceptions: raise Exception(frappe.as_json(exceptions)) - def get_inbound_mails(self, test_mails=None): + def get_inbound_mails(self, test_mails=None) -> List[InboundMail]: """retrive and return inbound mails. """ @@ -625,7 +624,6 @@ class EmailAccount(Document): if frappe.db.exists("Email Account", {"enable_automatic_linking": 1, "name": ('!=', self.name)}): frappe.throw(_("Automatic Linking can be activated only for one Email Account.")) - def append_email_to_sent_folder(self, message): email_server = None try: @@ -643,7 +641,8 @@ class EmailAccount(Document): message = safe_encode(message) email_server.imap.append("Sent", "\\Seen", imaplib.Time2Internaldate(time.time()), message) except Exception: - frappe.log_error() + frappe.log_error(title="EmailAccount.append_email_to_sent_folder") + @frappe.whitelist() def get_append_to(doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None): diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 9730004065..4da83bd0d2 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -11,7 +11,6 @@ import quopri from email.parser import Parser from email.policy import SMTPUTF8 from html2text import html2text -from six.moves import html_parser as HTMLParser import frappe from frappe import _, safe_encode, task @@ -20,6 +19,7 @@ from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message from frappe.email.email_body import add_attachment, get_formatted_html, get_email from frappe.utils import cint, split_emails, add_days, nowdate, cstr, get_hook_method from frappe.email.doctype.email_account.email_account import EmailAccount +from frappe.query_builder.utils import DocType MAX_RETRY_COUNT = 3 @@ -444,7 +444,7 @@ class QueueBuilder: try: text_content = html2text(self._message) - except HTMLParser.HTMLParseError: + except Exception: text_content = "See html attachment" return text_content + unsubscribe_text_message @@ -477,18 +477,24 @@ class QueueBuilder: all_ids = list(set(self.recipients + self.cc)) - EmailUnsubscribe = frappe.qb.DocType("Email Unsubscribe") + EmailUnsubscribe = DocType("Email Unsubscribe") + + unsubscribed = ( + frappe.qb.from_(EmailUnsubscribe).select( + EmailUnsubscribe.email + ).where( + EmailUnsubscribe.email.isin(all_ids) + & ( + ( + (EmailUnsubscribe.reference_doctype == self.reference_doctype) + & (EmailUnsubscribe.reference_name == self.reference_name) + ) | ( + EmailUnsubscribe.global_unsubscribe == 1 + ) + ) + ).distinct() + ).run(pluck=True) - unsubscribed = (frappe.qb.from_(EmailUnsubscribe) - .select(EmailUnsubscribe.email) - .where(EmailUnsubscribe.email.isin(all_ids) & - ( - ( - (EmailUnsubscribe.reference_doctype == self.reference_doctype) & (EmailUnsubscribe.reference_name == self.reference_name) - ) | EmailUnsubscribe.global_unsubscribe == 1 - ) - ).distinct() - ).run(pluck=True) self._unsubscribed_user_emails = unsubscribed or [] return self._unsubscribed_user_emails diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 77979f9735..3fd96bdb6b 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -137,7 +137,7 @@ def get_context(context): if self.set_property_after_alert: allow_update = True - if doc.docstatus == 1 and not doc.meta.get_field(self.set_property_after_alert).allow_on_submit: + if doc.docstatus.is_submitted() and not doc.meta.get_field(self.set_property_after_alert).allow_on_submit: allow_update = False try: if allow_update and not doc.flags.in_notification_update: diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 8145091f69..098a2e6c80 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -104,6 +104,7 @@ class DocumentAlreadyRestored(ValidationError): pass class AttachmentLimitReached(ValidationError): pass class QueryTimeoutError(Exception): pass class QueryDeadlockError(Exception): pass +class TooManyWritesError(Exception): pass # OAuth exceptions class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass diff --git a/frappe/installer.py b/frappe/installer.py old mode 100755 new mode 100644 index b50fa4a3b5..d892ff4ddc --- a/frappe/installer.py +++ b/frappe/installer.py @@ -154,7 +154,7 @@ def install_app(name, verbose=False, set_as_patched=True): for before_install in app_hooks.before_install or []: out = frappe.get_attr(before_install)() - if out==False: + if out is False: return if name != "frappe": @@ -346,14 +346,15 @@ def post_install(rebuild_website=False): def set_all_patches_as_completed(app): - patch_path = os.path.join(frappe.get_pymodule_path(app), "patches.txt") - if os.path.exists(patch_path): - for patch in frappe.get_file_items(patch_path): - frappe.get_doc({ - "doctype": "Patch Log", - "patch": patch - }).insert(ignore_permissions=True) - frappe.db.commit() + from frappe.modules.patch_handler import get_patches_from_app + + patches = get_patches_from_app(app) + for patch in patches: + frappe.get_doc({ + "doctype": "Patch Log", + "patch": patch + }).insert(ignore_permissions=True) + frappe.db.commit() def init_singles(): diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py index 416d656d90..4242676d94 100644 --- a/frappe/integrations/offsite_backup_utils.py +++ b/frappe/integrations/offsite_backup_utils.py @@ -65,10 +65,7 @@ def get_latest_backup_file(with_files=False): return database, config -def get_file_size(file_path, unit): - if not unit: - unit = "MB" - +def get_file_size(file_path, unit='MB'): file_size = os.path.getsize(file_path) memory_size_unit_mapper = {"KB": 1, "MB": 2, "GB": 3, "TB": 4} @@ -99,7 +96,7 @@ def get_chunk_site(file_size): def validate_file_size(): frappe.flags.create_new_backup = True latest_file, site_config = get_latest_backup_file() - file_size = get_file_size(latest_file, unit="GB") + file_size = get_file_size(latest_file, unit="GB") if latest_file else 0 if file_size > 1: frappe.flags.create_new_backup = False diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json index b85056e3ef..bbd2e1199f 100644 --- a/frappe/integrations/workspace/integrations/integrations.json +++ b/frappe/integrations/workspace/integrations/integrations.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Backup\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Google Services\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Authentication\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Payments\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Settings\", \"col\": 4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Backup\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Google Services\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Authentication\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Payments\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]", "creation": "2020-03-02 15:16:18.714190", "docstatus": 0, "doctype": "Workspace", @@ -260,7 +260,7 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:00.355268", + "modified": "2022-01-13 17:39:01.292154", "modified_by": "Administrator", "module": "Integrations", "name": "Integrations", @@ -269,7 +269,7 @@ "public": 1, "restrict_to_domain": "", "roles": [], - "sequence_id": 15, + "sequence_id": 15.0, "shortcuts": [], "title": "Integrations" } \ No newline at end of file diff --git a/frappe/migrate.py b/frappe/migrate.py index 6abc38796f..d13fe858f7 100644 --- a/frappe/migrate.py +++ b/frappe/migrate.py @@ -19,6 +19,8 @@ from frappe.modules.utils import sync_customizations from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.search.website_search import build_index_for_all_routes from frappe.database.schema import add_column +from frappe.modules.patch_handler import PatchType + def migrate(verbose=True, skip_failing=False, skip_search_index=False): @@ -59,16 +61,13 @@ Otherwise, check the server logs and ensure that all the required services are r clear_global_cache() - #run before_migrate hooks for app in frappe.get_installed_apps(): for fn in frappe.get_hooks('before_migrate', app_name=app): frappe.get_attr(fn)() - # run patches - frappe.modules.patch_handler.run_all(skip_failing) - - # sync + frappe.modules.patch_handler.run_all(skip_failing=skip_failing, patch_type=PatchType.pre_model_sync) frappe.model.sync.sync_all() + frappe.modules.patch_handler.run_all(skip_failing=skip_failing, patch_type=PatchType.post_model_sync) frappe.translate.clear_cache() sync_jobs() sync_fixtures() @@ -78,18 +77,16 @@ Otherwise, check the server logs and ensure that all the required services are r frappe.get_doc('Portal Settings', 'Portal Settings').sync_menu() - # syncs statics + # syncs static files clear_website_cache() # updating installed applications data frappe.get_single('Installed Applications').update_versions() - #run after_migrate hooks for app in frappe.get_installed_apps(): for fn in frappe.get_hooks('after_migrate', app_name=app): frappe.get_attr(fn)() - # build web_routes index if not skip_search_index: # Run this last as it updates the current session print('Building search index for {}'.format(frappe.local.site)) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 26a4658c36..94f2c5ea18 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -1,5 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE + import frappe import datetime from frappe import _ @@ -11,6 +12,7 @@ from frappe.model import display_fieldtypes from frappe.utils import (cint, flt, now, cstr, strip_html, sanitize_html, sanitize_email, cast_fieldtype) from frappe.utils.html_utils import unescape_html +from frappe.model.docstatus import DocStatus max_positive_value = { 'smallint': 2 ** 15, @@ -20,6 +22,7 @@ max_positive_value = { DOCTYPES_FOR_DOCTYPE = ('DocType', 'DocField', 'DocPerm', 'DocType Action', 'DocType Link') + def get_controller(doctype): """Returns the **class** object of the given DocType. For `custom` type, returns `frappe.model.document.Document`. @@ -172,7 +175,7 @@ class BaseDocument(object): ... }) """ - if value==None: + if value is None: value={} if isinstance(value, (dict, BaseDocument)): if not self.__dict__.get(key): @@ -224,7 +227,7 @@ class BaseDocument(object): value.parentfield = key if value.docstatus is None: - value.docstatus = 0 + value.docstatus = DocStatus.draft() if not getattr(value, "idx", None): value.idx = len(self.get(key) or []) + 1 @@ -272,7 +275,7 @@ class BaseDocument(object): )): d[fieldname] = str(d[fieldname]) - if d[fieldname] == None and ignore_nulls: + if d[fieldname] is None and ignore_nulls: del d[fieldname] return d @@ -282,8 +285,11 @@ class BaseDocument(object): if key not in self.__dict__: self.__dict__[key] = None - if key in ("idx", "docstatus") and self.__dict__[key] is None: - self.__dict__[key] = 0 + if self.__dict__[key] is None: + if key == "docstatus": + self.docstatus = DocStatus.draft() + elif key == "idx": + self.__dict__[key] = 0 for key in self.get_valid_columns(): if key not in self.__dict__: @@ -304,6 +310,14 @@ class BaseDocument(object): def is_new(self): return self.get("__islocal") + @property + def docstatus(self): + return DocStatus(self.get("docstatus")) + + @docstatus.setter + def docstatus(self, value): + self.__dict__["docstatus"] = DocStatus(cint(value)) + def as_dict(self, no_nulls=False, no_default_fields=False, convert_dates_to_str=False): doc = self.get_valid_dict(convert_dates_to_str=convert_dates_to_str) doc["doctype"] = self.doctype @@ -492,7 +506,7 @@ class BaseDocument(object): self.set(df.fieldname, flt(self.get(df.fieldname))) if self.docstatus is not None: - self.docstatus = cint(self.docstatus) + self.docstatus = DocStatus(cint(self.docstatus)) def _get_missing_mandatory_fields(self): """Get mandatory fields that do not have any values""" @@ -581,7 +595,7 @@ class BaseDocument(object): setattr(self, df.fieldname, values.name) for _df in fields_to_fetch: - if self.is_new() or self.docstatus != 1 or _df.allow_on_submit: + if self.is_new() or not self.docstatus.is_submitted() or _df.allow_on_submit: self.set_fetch_from_value(doctype, _df, values) notify_link_count(doctype, docname) @@ -591,7 +605,7 @@ class BaseDocument(object): elif (df.fieldname != "amended_from" and (is_submittable or self.meta.is_submittable) and frappe.get_meta(doctype).is_submittable - and cint(frappe.db.get_value(doctype, docname, "docstatus"))==2): + and cint(frappe.db.get_value(doctype, docname, "docstatus")) == DocStatus.cancelled()): cancelled_links.append((df.fieldname, docname, get_msg(df, docname))) @@ -646,8 +660,6 @@ class BaseDocument(object): value, comma_options)) def _validate_data_fields(self): - from frappe.core.doctype.user.user import STANDARD_USERS - # data_field options defined in frappe.model.data_field_options for data_field in self.meta.get_data_fields(): data = self.get(data_field.fieldname) @@ -658,7 +670,7 @@ class BaseDocument(object): continue if data_field_options == "Email": - if (self.owner in STANDARD_USERS) and (data in STANDARD_USERS): + if (self.owner in frappe.STANDARD_USERS) and (data in frappe.STANDARD_USERS): continue for email_address in frappe.utils.split_emails(data): frappe.utils.validate_email_address(email_address, throw=True) @@ -807,8 +819,8 @@ class BaseDocument(object): or df.get("fieldtype") in ("Attach", "Attach Image", "Barcode", "Code") # cancelled and submit but not update after submit should be ignored - or self.docstatus==2 - or (self.docstatus==1 and not df.get("allow_on_submit"))): + or self.docstatus.is_cancelled() + or (self.docstatus.is_submitted() and not df.get("allow_on_submit"))): continue else: diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 51d53c69a5..79be261981 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -545,7 +545,7 @@ class DatabaseQuery(object): elif f.operator.lower() in ("like", "not like") or (isinstance(f.value, str) and (not df or df.fieldtype not in ["Float", "Int", "Currency", "Percent", "Check"])): - value = "" if f.value==None else f.value + value = "" if f.value is None else f.value fallback = "''" if f.operator.lower() in ("like", "not like") and isinstance(value, str): diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index 2fddcf9e33..afe01d9106 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -212,7 +212,7 @@ def check_permission_and_not_submitted(doc): .format(doc.doctype, doc.name), raise_exception=frappe.PermissionError) # check if submitted - if doc.docstatus == 1: + if doc.docstatus.is_submitted(): frappe.msgprint(_("{0} {1}: Submitted Record cannot be deleted. You must {2} Cancel {3} it first.").format(_(doc.doctype), doc.name, "", ""), raise_exception=True) diff --git a/frappe/model/docstatus.py b/frappe/model/docstatus.py new file mode 100644 index 0000000000..01aab1e491 --- /dev/null +++ b/frappe/model/docstatus.py @@ -0,0 +1,25 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + + +class DocStatus(int): + def is_draft(self): + return self == self.draft() + + def is_submitted(self): + return self == self.submitted() + + def is_cancelled(self): + return self == self.cancelled() + + @classmethod + def draft(cls): + return cls(0) + + @classmethod + def submitted(cls): + return cls(1) + + @classmethod + def cancelled(cls): + return cls(2) diff --git a/frappe/model/document.py b/frappe/model/document.py index 6b27049d08..b686135d15 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1,13 +1,16 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe +import hashlib +import json import time +from werkzeug.exceptions import NotFound + +import frappe from frappe import _, msgprint, is_whitelisted from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff from frappe.model.base_document import BaseDocument, get_controller -from frappe.model.naming import set_new_name, gen_new_name_for_cancelled_doc -from werkzeug.exceptions import NotFound, Forbidden -import hashlib, json +from frappe.model.naming import set_new_name +from frappe.model.docstatus import DocStatus from frappe.model import optional_fields, table_fields from frappe.model.workflow import validate_workflow from frappe.model.workflow import set_workflow_state_on_action @@ -17,6 +20,7 @@ from frappe.desk.form.document_follow import follow_document from frappe.core.doctype.server_script.server_script_utils import run_server_script_for_doc_event from frappe.utils.data import get_absolute_url + # once_only validation # methods @@ -188,6 +192,8 @@ class Document(BaseDocument): is not set. :param permtype: one of `read`, `write`, `submit`, `cancel`, `delete`""" + import frappe.permissions + if self.flags.ignore_permissions: return True return frappe.permissions.has_permission(self.doctype, permtype, self, verbose=verbose) @@ -209,13 +215,13 @@ class Document(BaseDocument): self.flags.notifications_executed = [] - if ignore_permissions!=None: + if ignore_permissions is not None: self.flags.ignore_permissions = ignore_permissions - if ignore_links!=None: + if ignore_links is not None: self.flags.ignore_links = ignore_links - if ignore_mandatory!=None: + if ignore_mandatory is not None: self.flags.ignore_mandatory = ignore_mandatory self.set("__islocal", True) @@ -295,7 +301,7 @@ class Document(BaseDocument): self.flags.notifications_executed = [] - if ignore_permissions!=None: + if ignore_permissions is not None: self.flags.ignore_permissions = ignore_permissions self.flags.ignore_version = frappe.flags.in_test if ignore_version is None else ignore_version @@ -305,9 +311,6 @@ class Document(BaseDocument): self.check_permission("write", "save") - if self.docstatus == 2: - self._rename_doc_on_cancel() - self.set_user_and_timestamp() self.set_docstatus() self.check_if_latest() @@ -439,7 +442,7 @@ class Document(BaseDocument): values = self.as_dict() # format values for key, value in values.items(): - if value==None: + if value is None: values[key] = "" return values @@ -472,7 +475,7 @@ class Document(BaseDocument): # We'd probably want the creation and owner to be set via API # or Data import at some point, that'd have to be handled here - if self.is_new(): + if self.is_new() and not (frappe.flags.in_patch or frappe.flags.in_migrate): self.creation = self.modified self.owner = self.modified_by @@ -487,8 +490,8 @@ class Document(BaseDocument): frappe.flags.currently_saving.append((self.doctype, self.name)) def set_docstatus(self): - if self.docstatus==None: - self.docstatus=0 + if self.docstatus is None: + self.docstatus = DocStatus.draft() for d in self.get_all_children(): d.docstatus = self.docstatus @@ -718,6 +721,7 @@ class Document(BaseDocument): else: tmp = frappe.db.sql("""select modified, docstatus from `tab{0}` where name = %s for update""".format(self.doctype), self.name, as_dict=True) + if not tmp: frappe.throw(_("Record does not exist")) else: @@ -738,7 +742,7 @@ class Document(BaseDocument): else: self.check_docstatus_transition(0) - def check_docstatus_transition(self, docstatus): + def check_docstatus_transition(self, to_docstatus): """Ensures valid `docstatus` transition. Valid transitions are (number in brackets is `docstatus`): @@ -749,31 +753,32 @@ class Document(BaseDocument): """ if not self.docstatus: - self.docstatus = 0 - if docstatus==0: - if self.docstatus==0: + self.docstatus = DocStatus.draft() + + if to_docstatus == DocStatus.draft(): + if self.docstatus.is_draft(): self._action = "save" - elif self.docstatus==1: + elif self.docstatus.is_submitted(): self._action = "submit" self.check_permission("submit") - elif self.docstatus==2: + elif self.docstatus.is_cancelled(): raise frappe.DocstatusTransitionError(_("Cannot change docstatus from 0 (Draft) to 2 (Cancelled)")) else: raise frappe.ValidationError(_("Invalid docstatus"), self.docstatus) - elif docstatus==1: - if self.docstatus==1: + elif to_docstatus == DocStatus.submitted(): + if self.docstatus.is_submitted(): self._action = "update_after_submit" self.check_permission("submit") - elif self.docstatus==2: + elif self.docstatus.is_cancelled(): self._action = "cancel" self.check_permission("cancel") - elif self.docstatus==0: + elif self.docstatus.is_draft(): raise frappe.DocstatusTransitionError(_("Cannot change docstatus from 1 (Submitted) to 0 (Draft)")) else: raise frappe.ValidationError(_("Invalid docstatus"), self.docstatus) - elif docstatus==2: + elif to_docstatus == DocStatus.cancelled(): raise frappe.ValidationError(_("Cannot edit cancelled document")) def set_parent_in_children(self): @@ -885,14 +890,14 @@ class Document(BaseDocument): if (frappe.flags.in_import and frappe.flags.mute_emails) or frappe.flags.in_patch or frappe.flags.in_install: return - if self.flags.notifications_executed==None: + if self.flags.notifications_executed is None: self.flags.notifications_executed = [] from frappe.email.doctype.notification.notification import evaluate_alert - if self.flags.notifications == None: + if self.flags.notifications is None: alerts = frappe.cache().hget('notifications', self.doctype) - if alerts==None: + if alerts is None: alerts = frappe.get_all('Notification', fields=['name', 'event', 'method'], filters={'enabled': 1, 'document_type': self.doctype}) frappe.cache().hset('notifications', self.doctype, alerts) @@ -927,14 +932,14 @@ class Document(BaseDocument): @whitelist.__func__ def _submit(self): """Submit the document. Sets `docstatus` = 1, then saves.""" - self.docstatus = 1 + self.docstatus = DocStatus.submitted() return self.save() @whitelist.__func__ def _cancel(self): """Cancel the document. Sets `docstatus` = 2, then saves. """ - self.docstatus = 2 + self.docstatus = DocStatus.cancelled() return self.save() @whitelist.__func__ @@ -952,7 +957,7 @@ class Document(BaseDocument): frappe.delete_doc(self.doctype, self.name, ignore_permissions = ignore_permissions, flags=self.flags) def run_before_save_methods(self): - """Run standard methods before `INSERT` or `UPDATE`. Standard Methods are: + """Run standard methods before `INSERT` or `UPDATE`. Standard Methods are: - `validate`, `before_save` for **Save**. - `validate`, `before_submit` for **Submit**. @@ -1378,11 +1383,6 @@ class Document(BaseDocument): from frappe.desk.doctype.tag.tag import DocTags return DocTags(self.doctype).get_tags(self.name).split(",")[1:] - def _rename_doc_on_cancel(self): - new_name = gen_new_name_for_cancelled_doc(self) - frappe.rename_doc(self.doctype, self.name, new_name, force=True, show_alert=False) - self.name = new_name - def __repr__(self): name = self.name or "unsaved" doctype = self.__class__.__name__ diff --git a/frappe/model/dynamic_links.py b/frappe/model/dynamic_links.py index 7311b39b30..03f616ef60 100644 --- a/frappe/model/dynamic_links.py +++ b/frappe/model/dynamic_links.py @@ -32,7 +32,7 @@ def get_dynamic_link_map(for_delete=False): Note: Will not map single doctypes ''' - if getattr(frappe.local, 'dynamic_link_map', None)==None or frappe.flags.in_test: + if getattr(frappe.local, 'dynamic_link_map', None) is None or frappe.flags.in_test: # Build from scratch dynamic_link_map = {} for df in get_dynamic_links(): diff --git a/frappe/model/naming.py b/frappe/model/naming.py index f3d68f3715..b2d11a4cfc 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -1,14 +1,3 @@ -"""utilities to generate a document name based on various rules defined. - -NOTE: -Till version 13, whenever a submittable document is amended it's name is set to orig_name-X, -where X is a counter and it increments when amended again and so on. - -From Version 14, The naming pattern is changed in a way that amended documents will -have the original name `orig_name` instead of `orig_name-X`. To make this happen -the cancelled document naming pattern is changed to 'orig_name-CANC-X'. -""" - # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE @@ -40,7 +29,7 @@ def set_new_name(doc): doc.name = None if getattr(doc, "amended_from", None): - doc.name = _get_amended_name(doc) + _set_amended_name(doc) return elif getattr(doc.meta, "issingle", False): @@ -256,18 +245,6 @@ def revert_series_if_last(key, name, doc=None): * prefix = #### and hashes = 2021 (hash doesn't exist) * will search hash in key then accordingly get prefix = "" """ - if hasattr(doc, 'amended_from'): - # Do not revert the series if the document is amended. - if doc.amended_from: - return - - # Get document name by parsing incase of fist cancelled document - if doc.docstatus == 2 and not doc.amended_from: - if doc.name.endswith('-CANC'): - name, _ = NameParser.parse_docname(doc.name, sep='-CANC') - else: - name, _ = NameParser.parse_docname(doc.name, sep='-CANC-') - if ".#" in key: prefix, hashes = key.rsplit(".", 1) if "#" not in hashes: @@ -356,9 +333,16 @@ def append_number_if_name_exists(doctype, value, fieldname="name", separator="-" return value -def _get_amended_name(doc): - name, _ = NameParser(doc).parse_amended_from() - return name +def _set_amended_name(doc): + am_id = 1 + am_prefix = doc.amended_from + if frappe.db.get_value(doc.doctype, doc.amended_from, "amended_from"): + am_id = cint(doc.amended_from.split("-")[-1]) + 1 + am_prefix = "-".join(doc.amended_from.split("-")[:-1]) # except the last hyphen + + doc.name = am_prefix + "-" + str(am_id) + return doc.name + def _field_autoname(autoname, doc, skip_slicing=None): """ @@ -399,83 +383,3 @@ def _format_autoname(autoname, doc): name = re.sub(r"(\{[\w | #]+\})", get_param_value_for_match, autoname_value) return name - -class NameParser: - """Parse document name and return parts of it. - - NOTE: It handles cancellend and amended doc parsing for now. It can be expanded. - """ - def __init__(self, doc): - self.doc = doc - - def parse_amended_from(self): - """ - Cancelled document naming will be in one of these formats - - * original_name-X-CANC - This is introduced to migrate old style naming to new style - * original_name-CANC - This is introduced to migrate old style naming to new style - * original_name-CANC-X - This is the new style naming - - New style naming: In new style naming amended documents will have original name. That says, - when a document gets cancelled we need rename the document by adding `-CANC-X` to the end - so that amended documents can use the original name. - - Old style naming: cancelled documents stay with original name and when amended, amended one - gets a new name as `original_name-X`. To bring new style naming we had to change the existing - cancelled document names and that is done by adding `-CANC` to cancelled documents through patch. - """ - if not getattr(self.doc, 'amended_from', None): - return (None, None) - - # Handle old style cancelled documents (original_name-X-CANC, original_name-CANC) - if self.doc.amended_from.endswith('-CANC'): - name, _ = self.parse_docname(self.doc.amended_from, '-CANC') - amended_from_doc = frappe.get_all( - self.doc.doctype, - filters = {'name': self.doc.amended_from}, - fields = ['amended_from'], - limit=1) - - # Handle format original_name-X-CANC. - if amended_from_doc and amended_from_doc[0].amended_from: - return self.parse_docname(name, '-') - return name, None - - # Handle new style cancelled documents - return self.parse_docname(self.doc.amended_from, '-CANC-') - - @classmethod - def parse_docname(cls, name, sep='-'): - split_list = name.rsplit(sep, 1) - - if len(split_list) == 1: - return (name, None) - return (split_list[0], split_list[1]) - -def get_cancelled_doc_latest_counter(tname, docname): - """Get the latest counter used for cancelled docs of given docname. - """ - name_prefix = f'{docname}-CANC-' - - rows = frappe.db.sql(""" - select - name - from `tab{tname}` - where - name like %(name_prefix)s and docstatus=2 - """.format(tname=tname), {'name_prefix': name_prefix+'%'}, as_dict=1) - - if not rows: - return -1 - return max([int(row.name.replace(name_prefix, '') or -1) for row in rows]) - -def gen_new_name_for_cancelled_doc(doc): - """Generate a new name for cancelled document. - """ - if getattr(doc, "amended_from", None): - name, _ = NameParser(doc).parse_amended_from() - else: - name = doc.name - - counter = get_cancelled_doc_latest_counter(doc.doctype, name) - return f'{name}-CANC-{counter+1}' diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 2cc5818414..6ffaadc5eb 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -80,6 +80,7 @@ def rename_doc( if doctype=='DocType': rename_doctype(doctype, old, new, force) + update_customizations(old, new) update_attachments(doctype, old, new) @@ -174,6 +175,8 @@ def update_user_settings(old, new, link_fields): else: continue +def update_customizations(old: str, new: str) -> None: + frappe.db.set_value("Custom DocPerm", {"parent": old}, "parent", new, update_modified=False) def update_attachments(doctype, old, new): try: diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index e74d88c0f2..1b26cc2c3a 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -1,10 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import json import frappe -from frappe.utils import cint from frappe import _ -import json +from frappe.utils import cint +from frappe.model.docstatus import DocStatus class WorkflowStateError(frappe.ValidationError): pass class WorkflowTransitionError(frappe.ValidationError): pass @@ -102,13 +103,13 @@ def apply_workflow(doc, action): doc.set(next_state.update_field, next_state.update_value) new_docstatus = cint(next_state.doc_status) - if doc.docstatus == 0 and new_docstatus == 0: + if doc.docstatus.is_draft() and new_docstatus == DocStatus.draft(): doc.save() - elif doc.docstatus == 0 and new_docstatus == 1: + elif doc.docstatus.is_draft() and new_docstatus == DocStatus.submitted(): doc.submit() - elif doc.docstatus == 1 and new_docstatus == 1: + elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.submitted(): doc.save() - elif doc.docstatus == 1 and new_docstatus == 2: + elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.cancelled(): doc.cancel() else: frappe.throw(_('Illegal Document Status for {0}').format(next_state.state)) @@ -212,10 +213,10 @@ def bulk_workflow_approval(docnames, doctype, action): frappe.db.commit() except Exception as e: if not frappe.message_log: - # Exception is raised manually and not from msgprint or throw + # Exception is raised manually and not from msgprint or throw message = "{0}".format(e.__class__.__name__) if e.args: - message += " : {0}".format(e.args[0]) + message += " : {0}".format(e.args[0]) message_dict = {"docname": docname, "message": message} failed_transactions[docname].append(message_dict) diff --git a/frappe/modules/patch_handler.py b/frappe/modules/patch_handler.py index 8dfb27c0b8..7b635ac940 100644 --- a/frappe/modules/patch_handler.py +++ b/frappe/modules/patch_handler.py @@ -1,37 +1,76 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +""" Patch Handler. + +This file manages execution of manaully written patches. Patches are script +that apply changes in database schema or data to accomodate for changes in the +code. + +Ways to specify patches: + +1. patches.txt file specifies patches that run before doctype schema +migration. Each line represents one patch (old format). +2. patches.txt can alternatively also separate pre and post model sync +patches by using INI like file format: + ```patches.txt + [pre_model_sync] + app.module.patch1 + app.module.patch2 + + + [post_model_sync] + app.module.patch3 + ``` + + When different sections are specified patches are executed in this order: + 1. Run pre_model_sync patches + 2. Reload/resync all doctype schema + 3. Run post_model_sync patches + + Hence any patch that just needs to modify data but doesn't depend on + old schema should be added to post_model_sync section of file. + +3. simple python commands can be added by starting line with `execute:` +`execute:` example: `execute:print("hello world")` """ - Execute Patch Files - To run directly +import configparser +import time +from enum import Enum +from typing import List, Optional - python lib/wnf.py patch patch1, patch2 etc - python lib/wnf.py patch -f patch1, patch2 etc +import frappe - where patch1, patch2 is module name -""" -import frappe, frappe.permissions, time -class PatchError(Exception): pass +class PatchError(Exception): + pass -def run_all(skip_failing=False): + +class PatchType(Enum): + pre_model_sync = "pre_model_sync" + post_model_sync = "post_model_sync" + + +def run_all(skip_failing: bool = False, patch_type: Optional[PatchType] = None) -> None: """run all pending patches""" - executed = [p[0] for p in frappe.db.sql("""select patch from `tabPatch Log`""")] + executed = set(frappe.get_all("Patch Log", fields="patch", pluck="patch")) frappe.flags.final_patches = [] def run_patch(patch): try: if not run_single(patchmodule = patch): - log(patch + ': failed: STOPPED') + print(patch + ': failed: STOPPED') raise PatchError(patch) except Exception: if not skip_failing: raise else: - log('Failed to execute patch') + print('Failed to execute patch') - for patch in get_all_patches(): + patches = get_all_patches(patch_type=patch_type) + + for patch in patches: if patch and (patch not in executed): run_patch(patch) @@ -40,18 +79,57 @@ def run_all(skip_failing=False): patch = patch.replace('finally:', '') run_patch(patch) -def get_all_patches(): +def get_all_patches(patch_type: Optional[PatchType] = None) -> List[str]: + + if patch_type and not isinstance(patch_type, PatchType): + frappe.throw(f"Unsupported patch type specified: {patch_type}") + patches = [] for app in frappe.get_installed_apps(): - if app == "shopping_cart": - continue - # 3-to-4 fix - if app=="webnotes": - app="frappe" - patches.extend(frappe.get_file_items(frappe.get_pymodule_path(app, "patches.txt"))) + patches.extend(get_patches_from_app(app, patch_type=patch_type)) return patches +def get_patches_from_app(app: str, patch_type: Optional[PatchType] = None) -> List[str]: + """ Get patches from an app's patches.txt + + patches.txt can be: + 1. ini like file with section for different patch_type + 2. plain text file with each line representing a patch. + """ + + patches_txt = frappe.get_pymodule_path(app, "patches.txt") + + try: + # Attempt to parse as ini file with pre/post patches + # allow_no_value: patches are not key value pairs + # delimiters = '\n' to avoid treating default `:` and `=` in execute as k:v delimiter + parser = configparser.ConfigParser(allow_no_value=True, delimiters="\n") + # preserve case + parser.optionxform = str + parser.read(patches_txt) + + # empty file + if not parser.sections(): + return [] + + if not patch_type: + return [patch for patch in parser[PatchType.pre_model_sync.value]] + \ + [patch for patch in parser[PatchType.post_model_sync.value]] + + if patch_type.value in parser.sections(): + return [patch for patch in parser[patch_type.value]] + else: + frappe.throw(frappe._("Patch type {} not found in patches.txt").format(patch_type)) + + except configparser.MissingSectionHeaderError: + # treat as old format with each line representing a single patch + # backward compatbility with old patches.txt format + if not patch_type or patch_type == PatchType.pre_model_sync: + return frappe.get_file_items(patches_txt) + + return [] + def reload_doc(args): import frappe.modules run_single(method = frappe.modules.reload_doc, methodargs = args) @@ -73,7 +151,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): frappe.db.begin() start_time = time.time() try: - log('Executing {patch} in {site} ({db})'.format(patch=patchmodule or str(methodargs), + print('Executing {patch} in {site} ({db})'.format(patch=patchmodule or str(methodargs), site=frappe.local.site, db=frappe.db.cur_db_name)) if patchmodule: if patchmodule.startswith("finally:"): @@ -96,7 +174,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): frappe.db.commit() end_time = time.time() block_user(False) - log('Success: Done in {time}s'.format(time = round(end_time - start_time, 3))) + print('Success: Done in {time}s'.format(time = round(end_time - start_time, 3))) return True @@ -109,10 +187,7 @@ def executed(patchmodule): if patchmodule.startswith('finally:'): # patches are saved without the finally: tag patchmodule = patchmodule.replace('finally:', '') - done = frappe.db.get_value("Patch Log", {"patch": patchmodule}) - # if done: - # print "Patch %s already executed in %s" % (patchmodule, frappe.db.cur_db_name) - return done + return frappe.db.get_value("Patch Log", {"patch": patchmodule}) def block_user(block, msg=None): """stop/start execution till patch is run""" @@ -128,6 +203,3 @@ def check_session_stopped(): if frappe.db.get_global("__session_status")=='stop': frappe.msgprint(frappe.db.get_global("__session_status_message")) raise frappe.SessionStopped('Session Stopped') - -def log(msg): - print (msg) diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index bbfd63a277..13b52d2020 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -257,6 +257,12 @@ def make_boilerplate(template, doc, opts=None): pass def get_list(self, args): + pass + + def get_count(self, args): + pass + + def get_stats(self, args): pass""" with open(target_file_path, 'w') as target: diff --git a/frappe/patches.txt b/frappe/patches.txt index af7e4d6e3f..7c2c6d5dc5 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -1,3 +1,4 @@ +[pre_model_sync] frappe.patches.v12_0.remove_deprecated_fields_from_doctype #3 execute:frappe.utils.global_search.setup_global_search_table() execute:frappe.reload_doc('core', 'doctype', 'doctype_action', force=True) #2019-09-23 @@ -87,7 +88,6 @@ frappe.patches.v11_0.set_missing_creation_and_modified_value_for_user_permission frappe.patches.v11_0.set_default_letter_head_source frappe.patches.v12_0.set_primary_key_in_series execute:frappe.delete_doc("Page", "modules", ignore_missing=True) -frappe.patches.v11_0.set_default_letter_head_source frappe.patches.v12_0.setup_comments_from_communications frappe.patches.v12_0.replace_null_values_in_tables frappe.patches.v12_0.reset_home_settings @@ -123,6 +123,9 @@ frappe.patches.v12_0.remove_parent_and_parenttype_from_print_formats frappe.patches.v12_0.remove_example_email_thread_notify execute:from frappe.desk.page.setup_wizard.install_fixtures import update_genders;update_genders() frappe.patches.v12_0.set_correct_url_in_files +execute:frappe.reload_doc('core', 'doctype', 'doctype') +execute:frappe.reload_doc('custom', 'doctype', 'property_setter') +frappe.patches.v13_0.remove_invalid_options_for_data_fields frappe.patches.v13_0.website_theme_custom_scss frappe.patches.v13_0.make_user_type frappe.patches.v13_0.set_existing_dashboard_charts_as_public @@ -153,7 +156,6 @@ frappe.patches.v13_0.rename_notification_fields frappe.patches.v13_0.remove_duplicate_navbar_items frappe.patches.v13_0.set_social_icons frappe.patches.v12_0.set_default_password_reset_limit -execute:frappe.reload_doc('core', 'doctype', 'doctype', force=True) frappe.patches.v13_0.set_route_for_blog_category frappe.patches.v13_0.enable_custom_script frappe.patches.v13_0.update_newsletter_content_type @@ -173,22 +175,24 @@ execute:frappe.delete_doc_if_exists('Page', 'workspace') execute:frappe.delete_doc_if_exists('Page', 'dashboard', force=1) frappe.core.doctype.page.patches.drop_unused_pages execute:frappe.get_doc('Role', 'Guest').save() # remove desk access +frappe.patches.v13_0.remove_chat frappe.patches.v13_0.rename_desk_page_to_workspace # 02.02.2021 frappe.patches.v13_0.delete_package_publish_tool frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings frappe.patches.v13_0.remove_twilio_settings frappe.patches.v12_0.rename_uploaded_files_with_proper_name frappe.patches.v13_0.queryreport_columns -execute:frappe.reload_doc('core', 'doctype', 'doctype') frappe.patches.v13_0.jinja_hook frappe.patches.v13_0.update_notification_channel_if_empty frappe.patches.v13_0.set_first_day_of_the_week -frappe.patches.v14_0.drop_data_import_legacy -frappe.patches.v14_0.rename_cancelled_documents -frappe.patches.v14_0.copy_mail_data #08.03.21 frappe.patches.v14_0.update_workspace2 # 20.09.2021 +frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 +frappe.patches.v14_0.transform_todo_schema +frappe.patches.v14_0.remove_post_and_post_comment + +[post_model_sync] +frappe.patches.v14_0.drop_data_import_legacy +frappe.patches.v14_0.copy_mail_data #08.03.21 frappe.patches.v14_0.update_github_endpoints #08-11-2021 frappe.patches.v14_0.remove_db_aggregation -frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 frappe.patches.v14_0.update_color_names_in_kanban_board_column -frappe.patches.v14_0.transform_todo_schema diff --git a/frappe/patches/v11_0/remove_skip_for_doctype.py b/frappe/patches/v11_0/remove_skip_for_doctype.py index 1bbe74bb6d..6e66c75f68 100644 --- a/frappe/patches/v11_0/remove_skip_for_doctype.py +++ b/frappe/patches/v11_0/remove_skip_for_doctype.py @@ -33,7 +33,7 @@ def execute(): continue skip_for_doctype = user_permission.skip_for_doctype.split('\n') else: # while migrating from v10 -> v11 - if skip_for_doctype_map.get((user_permission.allow, user_permission.user)) == None: + if skip_for_doctype_map.get((user_permission.allow, user_permission.user)) is None: skip_for_doctype = get_doctypes_to_skip(user_permission.allow, user_permission.user) # cache skip for doctype for same user and doctype skip_for_doctype_map[(user_permission.allow, user_permission.user)] = skip_for_doctype diff --git a/frappe/patches/v13_0/remove_chat.py b/frappe/patches/v13_0/remove_chat.py new file mode 100644 index 0000000000..1804c7693f --- /dev/null +++ b/frappe/patches/v13_0/remove_chat.py @@ -0,0 +1,17 @@ +import frappe +import click + +def execute(): + frappe.delete_doc_if_exists("DocType", "Chat Message") + frappe.delete_doc_if_exists("DocType", "Chat Message Attachment") + frappe.delete_doc_if_exists("DocType", "Chat Profile") + frappe.delete_doc_if_exists("DocType", "Chat Token") + frappe.delete_doc_if_exists("DocType", "Chat Room User") + frappe.delete_doc_if_exists("DocType", "Chat Room") + frappe.delete_doc_if_exists("Module Def", "Chat") + + click.secho( + "Chat Module is moved to a separate app and is removed from Frappe in version-13.\n" + "Please install the app to continue using the chat feature: https://github.com/frappe/chat", + fg="yellow", + ) \ No newline at end of file diff --git a/frappe/patches/v13_0/remove_invalid_options_for_data_fields.py b/frappe/patches/v13_0/remove_invalid_options_for_data_fields.py new file mode 100644 index 0000000000..90e4b3c5c6 --- /dev/null +++ b/frappe/patches/v13_0/remove_invalid_options_for_data_fields.py @@ -0,0 +1,17 @@ +# Copyright (c) 2022, Frappe and Contributors +# License: MIT. See LICENSE + + +import frappe +from frappe.model import data_field_options + + +def execute(): + custom_field = frappe.qb.DocType('Custom Field') + (frappe.qb + .update(custom_field) + .set(custom_field.options, None) + .where( + (custom_field.fieldtype == "Data") + & (custom_field.options.notin(data_field_options))) + ).run() diff --git a/frappe/patches/v14_0/copy_mail_data.py b/frappe/patches/v14_0/copy_mail_data.py index d3a5c59209..8ef9cfaf1f 100644 --- a/frappe/patches/v14_0/copy_mail_data.py +++ b/frappe/patches/v14_0/copy_mail_data.py @@ -3,9 +3,6 @@ import frappe def execute(): - frappe.reload_doc("email", "doctype", "imap_folder") - frappe.reload_doc("email", "doctype", "email_account") - # patch for all Email Account with the flag use_imap for email_account in frappe.get_list("Email Account", filters={"enable_incoming": 1, "use_imap": 1}): # get all data from Email Account diff --git a/frappe/patches/v14_0/remove_post_and_post_comment.py b/frappe/patches/v14_0/remove_post_and_post_comment.py new file mode 100644 index 0000000000..3a93139961 --- /dev/null +++ b/frappe/patches/v14_0/remove_post_and_post_comment.py @@ -0,0 +1,5 @@ +import frappe + +def execute(): + frappe.delete_doc_if_exists("DocType", "Post") + frappe.delete_doc_if_exists("DocType", "Post Comment") diff --git a/frappe/patches/v14_0/rename_cancelled_documents.py b/frappe/patches/v14_0/rename_cancelled_documents.py deleted file mode 100644 index 4b565d4f76..0000000000 --- a/frappe/patches/v14_0/rename_cancelled_documents.py +++ /dev/null @@ -1,213 +0,0 @@ -import functools -import traceback - -import frappe - -def execute(): - """Rename cancelled documents by adding a postfix. - """ - rename_cancelled_docs() - -def get_submittable_doctypes(): - """Returns list of submittable doctypes in the system. - """ - return frappe.db.get_all('DocType', filters={'is_submittable': 1}, pluck='name') - -def get_cancelled_doc_names(doctype): - """Return names of cancelled document names those are in old format. - """ - docs = frappe.db.get_all(doctype, filters={'docstatus': 2}, pluck='name') - return [each for each in docs if not (each.endswith('-CANC') or ('-CANC-' in each))] - -@functools.lru_cache() -def get_linked_doctypes(): - """Returns list of doctypes those are linked with given doctype using 'Link' fieldtype. - """ - filters=[['fieldtype','=', 'Link']] - links = frappe.get_all("DocField", - fields=["parent", "fieldname", "options as linked_to"], - filters=filters, - as_list=1) - - links+= frappe.get_all("Custom Field", - fields=["dt as parent", "fieldname", "options as linked_to"], - filters=filters, - as_list=1) - - links_by_doctype = {} - for doctype, fieldname, linked_to in links: - links_by_doctype.setdefault(linked_to, []).append((doctype, fieldname)) - return links_by_doctype - -@functools.lru_cache() -def get_single_doctypes(): - return frappe.get_all("DocType", filters={'issingle': 1}, pluck='name') - -@functools.lru_cache() -def get_dynamic_linked_doctypes(): - filters=[['fieldtype','=', 'Dynamic Link']] - - # find dynamic links of parents - links = frappe.get_all("DocField", - fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], - filters=filters, - as_list=1) - links+= frappe.get_all("Custom Field", - fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], - filters=filters, - as_list=1) - return links - -@functools.lru_cache() -def get_child_tables(): - """ - """ - filters =[['fieldtype', 'in', ('Table', 'Table MultiSelect')]] - links = frappe.get_all("DocField", - fields=["parent as doctype", "options as child_table"], - filters=filters, - as_list=1) - - links+= frappe.get_all("Custom Field", - fields=["dt as doctype", "options as child_table"], - filters=filters, - as_list=1) - - map = {} - for doctype, child_table in links: - map.setdefault(doctype, []).append(child_table) - return map - -def update_cancelled_document_names(doctype, cancelled_doc_names): - return frappe.db.sql(""" - update - `tab{doctype}` - set - name=CONCAT(name, '-CANC') - where - docstatus=2 - and - name in %(cancelled_doc_names)s; - """.format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names}) - -def update_amended_field(doctype, cancelled_doc_names): - return frappe.db.sql(""" - update - `tab{doctype}` - set - amended_from=CONCAT(amended_from, '-CANC') - where - amended_from in %(cancelled_doc_names)s; - """.format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names}) - -def update_attachments(doctype, cancelled_doc_names): - frappe.db.sql(""" - update - `tabFile` - set - attached_to_name=CONCAT(attached_to_name, '-CANC') - where - attached_to_doctype=%(dt)s and attached_to_name in %(cancelled_doc_names)s - """, {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) - -def update_versions(doctype, cancelled_doc_names): - frappe.db.sql(""" - UPDATE - `tabVersion` - SET - docname=CONCAT(docname, '-CANC') - WHERE - ref_doctype=%(dt)s AND docname in %(cancelled_doc_names)s - """, {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) - -def update_linked_doctypes(doctype, cancelled_doc_names): - single_doctypes = get_single_doctypes() - - for linked_dt, field in get_linked_doctypes().get(doctype, []): - if linked_dt not in single_doctypes: - frappe.db.sql(""" - update - `tab{linked_dt}` - set - `{column}`=CONCAT(`{column}`, '-CANC') - where - `{column}` in %(cancelled_doc_names)s; - """.format(linked_dt=linked_dt, column=field), - {'cancelled_doc_names': cancelled_doc_names}) - else: - doc = frappe.get_single(linked_dt) - if getattr(doc, field) in cancelled_doc_names: - setattr(doc, field, getattr(doc, field)+'-CANC') - doc.flags.ignore_mandatory=True - doc.flags.ignore_validate=True - doc.save(ignore_permissions=True) - -def update_dynamic_linked_doctypes(doctype, cancelled_doc_names): - single_doctypes = get_single_doctypes() - - for linked_dt, fieldname, doctype_fieldname in get_dynamic_linked_doctypes(): - if linked_dt not in single_doctypes: - frappe.db.sql(""" - update - `tab{linked_dt}` - set - `{column}`=CONCAT(`{column}`, '-CANC') - where - `{column}` in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s; - """.format(linked_dt=linked_dt, column=fieldname, doctype_fieldname=doctype_fieldname), - {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) - else: - doc = frappe.get_single(linked_dt) - if getattr(doc, doctype_fieldname) == doctype and getattr(doc, fieldname) in cancelled_doc_names: - setattr(doc, fieldname, getattr(doc, fieldname)+'-CANC') - doc.flags.ignore_mandatory=True - doc.flags.ignore_validate=True - doc.save(ignore_permissions=True) - -def update_child_tables(doctype, cancelled_doc_names): - child_tables = get_child_tables().get(doctype, []) - single_doctypes = get_single_doctypes() - - for table in child_tables: - if table not in single_doctypes: - frappe.db.sql(""" - update - `tab{table}` - set - parent=CONCAT(parent, '-CANC') - where - parenttype=%(dt)s and parent in %(cancelled_doc_names)s; - """.format(table=table), {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) - else: - doc = frappe.get_single(table) - if getattr(doc, 'parenttype')==doctype and getattr(doc, 'parent') in cancelled_doc_names: - setattr(doc, 'parent', getattr(doc, 'parent')+'-CANC') - doc.flags.ignore_mandatory=True - doc.flags.ignore_validate=True - doc.save(ignore_permissions=True) - -def rename_cancelled_docs(): - submittable_doctypes = get_submittable_doctypes() - - for dt in submittable_doctypes: - for retry in range(2): - try: - cancelled_doc_names = tuple(get_cancelled_doc_names(dt)) - if not cancelled_doc_names: - break - update_cancelled_document_names(dt, cancelled_doc_names) - update_amended_field(dt, cancelled_doc_names) - update_child_tables(dt, cancelled_doc_names) - update_linked_doctypes(dt, cancelled_doc_names) - update_dynamic_linked_doctypes(dt, cancelled_doc_names) - update_attachments(dt, cancelled_doc_names) - update_versions(dt, cancelled_doc_names) - print(f"Renaming cancelled records of {dt} doctype") - frappe.db.commit() - break - except Exception: - if retry == 1: - print(f"Failed to rename the cancelled records of {dt} doctype, moving on!") - traceback.print_exc() - frappe.db.rollback() - diff --git a/frappe/patches/v14_0/update_color_names_in_kanban_board_column.py b/frappe/patches/v14_0/update_color_names_in_kanban_board_column.py index ea8a10e43a..ff03604754 100644 --- a/frappe/patches/v14_0/update_color_names_in_kanban_board_column.py +++ b/frappe/patches/v14_0/update_color_names_in_kanban_board_column.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals import frappe def execute(): - frappe.reload_doc("desk", "doctype", "kanban_board_column") indicator_map = { 'blue': 'Blue', 'orange': 'Orange', diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py index 82076c4328..a4b057b989 100644 --- a/frappe/patches/v14_0/update_workspace2.py +++ b/frappe/patches/v14_0/update_workspace2.py @@ -5,10 +5,10 @@ from frappe import _ def execute(): frappe.reload_doc('desk', 'doctype', 'workspace', force=True) - for seq, wspace in enumerate(frappe.get_all('Workspace', order_by='name asc')): - doc = frappe.get_doc('Workspace', wspace.name) + for seq, workspace in enumerate(frappe.get_all('Workspace', order_by='name asc')): + doc = frappe.get_doc('Workspace', workspace.name) content = create_content(doc) - update_wspace(doc, seq, content) + update_workspace(doc, seq, content) frappe.db.commit() def create_content(doc): @@ -49,7 +49,7 @@ def create_content(doc): del doc.links[doc.links.index(l)] return content -def update_wspace(doc, seq, content): +def update_workspace(doc, seq, content): if not doc.title and not doc.content and not doc.is_standard and not doc.public: doc.sequence_id = seq + 1 doc.content = json.dumps(content) diff --git a/frappe/permissions.py b/frappe/permissions.py index 5faaf7dcfb..af17faba01 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -23,7 +23,7 @@ def print_has_permission_check_logs(func): frappe.flags['has_permission_check_logs'] = [] result = func(*args, **kwargs) self_perm_check = True if not kwargs.get('user') else kwargs.get('user') == frappe.session.user - raise_exception = False if kwargs.get('raise_exception') == False else True + raise_exception = False if kwargs.get('raise_exception') is False else True # print only if access denied # and if user is checking his own permission @@ -559,7 +559,9 @@ def filter_allowed_docs_for_doctype(user_permissions, doctype, with_default_doc= return (allowed_doc, default_doc) if with_default_doc else allowed_doc def push_perm_check_log(log): - if frappe.flags.get('has_permission_check_logs') == None: return + if frappe.flags.get('has_permission_check_logs') is None: + return + frappe.flags.get('has_permission_check_logs').append(_(log)) def has_child_table_permission(child_doctype, ptype="read", child_doc=None, diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index dcba913836..4a515d03f9 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -1,128 +1,309 @@ diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index cac02c7a68..e056a34be2 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -96,6 +96,7 @@ import "./frappe/ui/sort_selector.js"; import "./frappe/change_log.html"; import "./frappe/ui/workspace_loading_skeleton.html"; +import "./frappe/ui/workspace_sidebar_loading_skeleton.html"; import "./frappe/desk.js"; import "./frappe/query_string.js"; diff --git a/frappe/public/js/frappe-web.bundle.js b/frappe/public/js/frappe-web.bundle.js index c962457964..b8d4006090 100644 --- a/frappe/public/js/frappe-web.bundle.js +++ b/frappe/public/js/frappe-web.bundle.js @@ -2,6 +2,7 @@ import "./jquery-bootstrap"; import "./frappe/class.js"; import "./frappe/polyfill.js"; import "./lib/md5.min.js"; +import "./lib/moment.js"; import "./frappe/provide.js"; import "./frappe/format.js"; import "./frappe/utils/number_format.js"; diff --git a/frappe/public/js/frappe/data_import/import_preview.js b/frappe/public/js/frappe/data_import/import_preview.js index 2264042539..b153718c70 100644 --- a/frappe/public/js/frappe/data_import/import_preview.js +++ b/frappe/public/js/frappe/data_import/import_preview.js @@ -331,7 +331,7 @@ frappe.data_import.ImportPreview = class ImportPreview { is_row_imported(row) { let serial_no = row[0].content; return this.import_log.find(log => { - return log.success && log.row_indexes.includes(serial_no); + return log.success && JSON.parse(log.row_indexes || '[]').includes(serial_no); }); } }; diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 202cee645a..51ada70948 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -214,19 +214,20 @@ frappe.Application = class Application { email_password_prompt(email_account,user,i) { var me = this; + const email_id = email_account[i]["email_id"]; let d = new frappe.ui.Dialog({ title: __('Password missing in Email Account'), fields: [ { 'fieldname': 'password', 'fieldtype': 'Password', - 'label': __('Please enter the password for: {0}', [email_account[i]["email_id"]]), + 'label': __('Please enter the password for: {0}', [email_id], "Email Account"), 'reqd': 1 }, { "fieldname": "submit", "fieldtype": "Button", - "label": __("Submit") + "label": __("Submit", null, "Submit password for Email Account") } ] }); diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index 167b4955fa..1b30726a7a 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -534,22 +534,21 @@ export default { }); }, show_google_drive_picker() { - let dialog = cur_dialog; - dialog.hide(); + this.close_dialog = true; let google_drive = new GoogleDrivePicker({ - pickerCallback: data => this.google_drive_callback(data, dialog), + pickerCallback: data => this.google_drive_callback(data), ...this.google_drive_settings }); google_drive.loadPicker(); }, - google_drive_callback(data, dialog) { + google_drive_callback(data) { if (data.action == google.picker.Action.PICKED) { this.upload_file({ file_url: data.docs[0].url, file_name: data.docs[0].name }); } else if (data.action == google.picker.Action.CANCEL) { - dialog.show(); + cur_frm.attachments.new_attachment() } }, url_to_file(url, filename, mime_type) { diff --git a/frappe/public/js/frappe/form/controls/date_range.js b/frappe/public/js/frappe/form/controls/date_range.js index 727e9d55c2..170404f575 100644 --- a/frappe/public/js/frappe/form/controls/date_range.js +++ b/frappe/public/js/frappe/form/controls/date_range.js @@ -11,7 +11,8 @@ frappe.ui.form.ControlDateRange = class ControlDateRange extends frappe.ui.form. language: "en", range: true, autoClose: true, - toggleSelected: false + toggleSelected: false, + firstDay: frappe.datetime.get_first_day_of_the_week_index() }; this.datepicker_options.dateFormat = (frappe.boot.sysdefaults.date_format || 'yyyy-mm-dd'); diff --git a/frappe/public/js/frappe/form/controls/dynamic_link.js b/frappe/public/js/frappe/form/controls/dynamic_link.js index 2c5661ca87..ea9ceb35f3 100644 --- a/frappe/public/js/frappe/form/controls/dynamic_link.js +++ b/frappe/public/js/frappe/form/controls/dynamic_link.js @@ -2,7 +2,7 @@ frappe.ui.form.ControlDynamicLink = class ControlDynamicLink extends frappe.ui.f get_options() { let options = ''; if (this.df.get_options) { - options = this.df.get_options(); + options = this.df.get_options(this); } else if (this.docname==null && cur_dialog) { //for dialog box options = cur_dialog.get_value(this.df.options); diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index ed355cf8b4..9f02485a9e 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -374,10 +374,22 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } set_custom_query(args) { - var set_nulls = function(obj) { - $.each(obj, function(key, value) { - if(value!==undefined) { - obj[key] = value; + const is_valid_value = (value, key) => { + if (value) return true; + // check if empty value is valid + if (this.frm) { + let field = frappe.meta.get_docfield(this.frm.doctype, key); + // empty value link fields is invalid + return !field || !["Link", "Dynamic Link"].includes(field.fieldtype); + } else { + return value !== undefined; + } + } + + const set_nulls = (obj) => { + $.each(obj, (key, value) => { + if (!is_valid_value(value, key)) { + delete obj[key]; } }); return obj; @@ -458,7 +470,6 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat validate_link_and_fetch(df, options, docname, value) { if (!options) return; - let field_value = ""; const fetch_map = this.fetch_map; const columns_to_fetch = Object.values(fetch_map); @@ -467,16 +478,10 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat return value; } - return frappe.xcall("frappe.client.validate_link", { - doctype: options, - docname: value, - fields: columns_to_fetch, - }).then((response) => { - if (!docname || !columns_to_fetch.length) return response.name; - + function update_dependant_fields(response) { + let field_value = ""; for (const [target_field, source_field] of Object.entries(fetch_map)) { if (value) field_value = response[source_field]; - frappe.model.set_value( df.parent, docname, @@ -485,9 +490,23 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat df.fieldtype, ); } + } - return response.name; - }); + // to avoid unnecessary request + if (value) { + return frappe.xcall("frappe.client.validate_link", { + doctype: options, + docname: value, + fields: columns_to_fetch, + }).then((response) => { + if (!docname || !columns_to_fetch.length) return response.name; + update_dependant_fields(response); + return response.name; + }); + } else { + update_dependant_fields({}); + return value; + } } get fetch_map() { diff --git a/frappe/public/js/frappe/form/footer/base_timeline.js b/frappe/public/js/frappe/form/footer/base_timeline.js index beeba16459..ba7a4eb565 100644 --- a/frappe/public/js/frappe/form/footer/base_timeline.js +++ b/frappe/public/js/frappe/form/footer/base_timeline.js @@ -12,8 +12,11 @@ class BaseTimeline { this.wrapper = this.timeline_wrapper; this.timeline_items_wrapper = $(`
`); this.timeline_actions_wrapper = $(` -
-
+
+
+
+
+
`); @@ -37,7 +40,7 @@ class BaseTimeline { ${label} `); action_btn.click(action); - this.timeline_actions_wrapper.append(action_btn); + this.timeline_actions_wrapper.find('.action-buttons').append(action_btn); return action_btn; } diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index f278d1b64b..d440874f36 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -77,12 +77,14 @@ class FormTimeline extends BaseTimeline { const message = __("Add to this activity by mailing to {0}", [link.bold()]); this.document_email_link_wrapper = $(` - +
`); } @@ -119,8 +110,10 @@ frappe.views.Workspace = class Workspace { }); // Scroll sidebar to selected page if it is not in viewport. - !frappe.dom.is_element_in_viewport(this.sidebar.find('.selected')) + this.sidebar.find('.selected').length && !frappe.dom.is_element_in_viewport(this.sidebar.find('.selected')) && this.sidebar.find('.selected')[0].scrollIntoView(); + + this.remove_sidebar_skeleton(); } build_sidebar_section(title, root_pages) { @@ -164,7 +157,8 @@ frappe.views.Workspace = class Workspace { let child_items = pages.filter(page => page.parent_page == item.title); if (child_items.length > 0) { - let child_container = $(``); + let child_container = $item_container.find('.sidebar-child-item'); + child_container.addClass('hidden'); this.prepare_sidebar(child_items, child_container, $item_container); } @@ -179,18 +173,23 @@ frappe.views.Workspace = class Workspace { } add_drop_icon(item, sidebar_control, item_container) { + let drop_icon = 'small-down'; + if (item_container.find(`[item-name="${this.current_page.name}"]`).length) { + drop_icon = 'small-up'; + } + let $child_item_section = item_container.find('.sidebar-child-item'); - let $drop_icon = $(``) + let $drop_icon = $(``) .appendTo(sidebar_control); let pages = item.public ? this.public_pages : this.private_pages; if (pages.some(e => e.parent_page == item.title)) { $drop_icon.removeClass('hidden'); - $drop_icon.on('click', () => { - let icon = $drop_icon.find("use").attr("href")==="#icon-small-down" ? "#icon-small-up" : "#icon-small-down"; - $drop_icon.find("use").attr("href", icon); - $child_item_section.toggleClass("hidden"); - }); } + $drop_icon.on('click', () => { + let icon = $drop_icon.find("use").attr("href")==="#icon-small-down" ? "#icon-small-up" : "#icon-small-down"; + $drop_icon.find("use").attr("href", icon); + $child_item_section.toggleClass("hidden"); + }); } show() { @@ -203,21 +202,48 @@ frappe.views.Workspace = class Workspace { let page = this.get_page_to_show(); this.page.set_title(`${__(page.name)}`); + this.update_selected_sidebar(this.current_page, false); //remove selected from old page + this.update_selected_sidebar(page, true); //add selected on new page + this.show_page(page); } + update_selected_sidebar(page, add) { + let section = page.public ? 'public' : 'private'; + if (this.sidebar && this.sidebar_items[section] && this.sidebar_items[section][page.name]) { + let $sidebar = this.sidebar_items[section][page.name]; + let pages = page.public ? this.public_pages : this.private_pages; + let sidebar_page = pages.find(p => p.title == page.name); + + if (add) { + $sidebar[0].firstElementChild.classList.add("selected"); + if (sidebar_page) sidebar_page.selected = true; + + // open child sidebar section if closed + $sidebar.parent().hasClass('hidden') && + $sidebar.parent().removeClass('hidden'); + + this.current_page = { name: page.name, public: page.public }; + localStorage.current_page = page.name; + localStorage.is_current_page_public = page.public; + } else { + $sidebar[0].firstElementChild.classList.remove("selected"); + if (sidebar_page) sidebar_page.selected = false; + } + } + } + get_data(page) { - return frappe.xcall("frappe.desk.desktop.get_desktop_page", { + return frappe.call("frappe.desk.desktop.get_desktop_page", { page: page }).then(data => { - this.page_data = data; + this.page_data = data.message; // caching page data this.pages[page.name] && delete this.pages[page.name]; - this.pages[page.name] = data; + this.pages[page.name] = data.message; if (!this.page_data || Object.keys(this.page_data).length === 0) return; - if (this.page_data.charts && this.page_data.charts.items.length === 0) return; return frappe.dashboard_utils.get_dashboard_settings().then(settings => { @@ -249,49 +275,35 @@ frappe.views.Workspace = class Workspace { } async show_page(page) { - let section = this.current_page.public ? 'public' : 'private'; - if (this.sidebar_items && this.sidebar_items[section] && this.sidebar_items[section][this.current_page.name]) { - this.sidebar_items[section][this.current_page.name][0].firstElementChild.classList.remove("selected"); - this.sidebar_items[page.public ? 'public':'private'][page.name][0].firstElementChild.classList.add("selected"); - - if (this.sidebar_items[page.public ? 'public':'private'][page.name].parents('.sidebar-item-container')[0]) { - this.sidebar_items[page.public ? 'public':'private'][page.name] - .parents('.sidebar-item-container') - .find('.drop-icon use') - .attr("href", "#icon-small-up"); - } - } - - this.current_page = { name: page.name, public: page.public }; - localStorage.current_page = page.name; - localStorage.is_current_page_public = page.public; - if (!this.body.find('#editorjs')[0]) { this.$page = $(`
`).appendTo(this.body); } - this.create_skeleton(); if (this.all_pages) { + this.create_page_skeleton(); + let pages = page.public ? this.public_pages : this.private_pages; - let this_page = pages.filter(p => p.title == page.name)[0]; - this.setup_actions(page); - this.content = this_page && JSON.parse(this_page.content); + let current_page = pages.filter(p => p.title == page.name)[0]; + this.content = current_page && JSON.parse(current_page.content); this.add_custom_cards_in_content(); $('.item-anchor').addClass('disable-click'); - if (this.pages && this.pages[this_page.name]) { - this.page_data = this.pages[this_page.name]; + if (this.pages && this.pages[current_page.name]) { + this.page_data = this.pages[current_page.name]; } else { - await this.get_data(this_page); + await frappe.after_ajax(() => this.get_data(current_page)); } + this.setup_actions(page); + this.prepare_editorjs(); $('.item-anchor').removeClass('disable-click'); - this.remove_skeleton(); + + this.remove_page_skeleton(); } } @@ -329,9 +341,7 @@ frappe.views.Workspace = class Workspace { return; } - this.page.clear_primary_action(); - this.page.clear_secondary_action(); - this.page.clear_inner_toolbar(); + this.clear_page_actions(); current_page.is_editable && this.page.set_secondary_action(__("Edit"), async () => { if (!this.editor || !this.editor.readOnly) return; @@ -341,7 +351,6 @@ frappe.views.Workspace = class Workspace { this.initialize_editorjs_undo(); this.setup_customization_buttons(current_page); this.show_sidebar_actions(); - this.make_sidebar_sortable(); this.make_blocks_sortable(); }); }); @@ -357,22 +366,25 @@ frappe.views.Workspace = class Workspace { this.undo.readOnly = false; } - setup_customization_buttons(page) { - let me = this; + clear_page_actions() { this.page.clear_primary_action(); this.page.clear_secondary_action(); this.page.clear_inner_toolbar(); + } + + setup_customization_buttons(page) { + this.clear_page_actions(); page.is_editable && this.page.set_primary_action( __("Save Customizations"), () => { - this.page.clear_primary_action(); - this.page.clear_secondary_action(); - this.page.clear_inner_toolbar(); - this.undo.readOnly = true; - this.save_page(); - this.editor.readOnly.toggle(); - this.is_read_only = true; + this.clear_page_actions(); + this.save_page(page).then((saved) => { + if (!saved) return; + this.undo.readOnly = true; + this.editor.readOnly.toggle(); + this.is_read_only = true; + }); }, null, __("Saving") @@ -382,11 +394,10 @@ frappe.views.Workspace = class Workspace { __("Discard"), async () => { this.discard = true; - this.page.clear_primary_action(); - this.page.clear_secondary_action(); - this.page.clear_inner_toolbar(); + this.clear_page_actions(); await this.editor.readOnly.toggle(); this.is_read_only = true; + this.sidebar_pages = this.cached_pages; this.reload(); frappe.show_alert({ message: __("Customizations Discarded"), indicator: "info" }); } @@ -395,34 +406,30 @@ frappe.views.Workspace = class Workspace { page.name && this.page.add_inner_button(__("Settings"), () => { frappe.set_route(`workspace/${page.name}`); }); - - Object.keys(this.blocks).forEach(key => { - this.page.add_inner_button(` - ${this.blocks[key].toolbox.icon} - ${__(this.blocks[key].toolbox.title)} - `, function() { - const index = me.editor.blocks.getBlocksCount() + 1; - me.editor.blocks.insert(key, {}, {}, index, true); - me.editor.caret.setToLastBlock('start', 0); - $('.ce-block:last-child')[0].scrollIntoView(); - }, __('Add Block')); - }); } show_sidebar_actions() { this.sidebar.find('.standard-sidebar-section').addClass('show-control'); + this.make_sidebar_sortable(); } - add_sidebar_actions(item, sidebar_control) { + add_sidebar_actions(item, sidebar_control, is_new) { if (!item.is_editable) { - $(`${frappe.utils.icon("lock", "sm")}`) - .appendTo(sidebar_control); sidebar_control.parent().click(() => { !this.is_read_only && frappe.show_alert({ message: __("Only Workspace Manager can sort or edit this page"), indicator: 'info' }, 5); }); + + frappe.utils.add_custom_button( + frappe.utils.icon('duplicate', 'sm'), + () => this.duplicate_page(item), + "duplicate-page", + `${__('Duplicate Workspace')}`, + null, + sidebar_control + ); } else { frappe.utils.add_custom_button( frappe.utils.icon('drag', 'xs'), @@ -432,22 +439,378 @@ frappe.views.Workspace = class Workspace { null, sidebar_control ); - frappe.utils.add_custom_button( - frappe.utils.icon('delete', 'xs'), - () => this.delete_page(item), - "delete-page", - `${__('Delete')}`, - null, - sidebar_control - ); + + !is_new && this.add_settings_button(item, sidebar_control); } } - delete_page(item) { - frappe.confirm(__("Are you sure you want to delete page {0}?", [item.title]), () => { - this.deleted_sidebar_items.push(item); - this.sidebar.find(`.standard-sidebar-section [item-name="${item.title}"][item-public="${item.public}"]`).addClass('hidden'); + get_parent_pages(page) { + this.public_parent_pages = ['', ...this.public_pages.filter(p => !p.parent_page).map(p => p.title)]; + this.private_parent_pages = ['', ...this.private_pages.filter(p => !p.parent_page).map(p => p.title)]; + + if (page) { + return page.public ? this.public_parent_pages : this.private_parent_pages; + } + } + + edit_page(item) { + var me = this; + let old_item = item; + let parent_pages = this.get_parent_pages(item); + let idx = parent_pages.findIndex(x => x == item.title); + if (idx !== -1) parent_pages.splice(idx, 1); + const d = new frappe.ui.Dialog({ + title: __('Update Details'), + fields: [ + { + label: __('Title'), + fieldtype: 'Data', + fieldname: 'title', + reqd: 1, + default: item.title + }, + { + label: __('Parent'), + fieldtype: 'Select', + fieldname: 'parent', + options: parent_pages, + default: item.parent_page + }, + { + label: __('Public'), + fieldtype: 'Check', + fieldname: 'is_public', + depends_on: `eval:${this.has_access}`, + default: item.public, + onchange: function() { + d.set_df_property('parent', 'options', + this.get_value() ? me.public_parent_pages : me.private_parent_pages); + } + }, + { + fieldtype: 'Column Break' + }, + { + label: __('Icon'), + fieldtype: 'Icon', + fieldname: 'icon', + default: item.icon + }, + ], + primary_action_label: __('Update'), + primary_action: (values) => { + let is_title_changed = values.title != old_item.title; + let is_section_changed = values.is_public != old_item.public; + if ((is_title_changed || is_section_changed) && !this.validate_page(values, old_item)) return; + d.hide(); + + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.update_page", + args: { + name: old_item.name, + title: values.title, + icon: values.icon || '', + parent: values.parent || '', + public: values.is_public || 0, + }, + callback: function(res) { + if (res.message) { + let message = `Workspace ${old_item.title} Edited Successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + + this.update_sidebar(old_item, values); + + if (this.make_page_selected) { + let pre_url = values.is_public ? '' : 'private/'; + let route = pre_url + frappe.router.slug(values.title); + frappe.set_route(route); + + this.make_page_selected = false; + } + + this.make_sidebar(); + this.show_sidebar_actions(); + } }); + d.show(); + } + + update_sidebar(old_item, new_item) { + let is_section_changed = old_item.public != (new_item.is_public || 0); + let is_title_changed = old_item.title != new_item.title; + let new_updated_item = {...old_item}; + + let pages = old_item.public ? this.public_pages : this.private_pages; + + let child_items = pages.filter(page => page.parent_page == old_item.title); + + this.make_page_selected = old_item.selected; + + new_updated_item.title = new_item.title; + new_updated_item.icon = new_item.icon; + new_updated_item.parent_page = new_item.parent || ""; + new_updated_item.public = new_item.is_public; + + if (is_title_changed || is_section_changed) { + if (new_item.is_public) { + new_updated_item.name = new_item.title; + new_updated_item.label = new_item.title; + new_updated_item.for_user = ""; + } else { + let user = frappe.session.user; + new_updated_item.name = `${new_item.title}-${user}`; + new_updated_item.label = `${new_item.title}-${user}`; + new_updated_item.for_user = user; + } + } + this.update_cached_values(old_item, new_updated_item); + + if (child_items.length) { + child_items.forEach(child => { + child.parent_page = new_item.title; + is_section_changed && this.update_child_sidebar(child, new_item); + }); + } + } + + update_child_sidebar(child, new_item) { + let old_child = {...child}; + this.make_page_selected = child.selected; + + child.public = new_item.is_public; + if (new_item.is_public) { + child.name = child.title; + child.label = child.title; + child.for_user = ""; + } else { + let user = frappe.session.user; + child.name = `${child.title}-${user}`; + child.label = `${child.title}-${user}`; + child.for_user = user; + } + + this.update_cached_values(old_child, child); + } + + update_cached_values(old_item, new_item, duplicate, new_page) { + let [from_pages, to_pages] = old_item.public ? + [this.public_pages, this.private_pages] : [this.private_pages, this.public_pages]; + + let old_item_index = from_pages.findIndex(page => page.title == old_item.title); + duplicate && old_item_index++; + + // update frappe.workspaces + if (frappe.workspaces[frappe.router.slug(old_item.name)] || new_page) { + !duplicate && delete frappe.workspaces[frappe.router.slug(old_item.name)]; + if (new_item) { + frappe.workspaces[frappe.router.slug(new_item.name)] = {'title': new_item.title}; + } + } + + // update page block data + if (this.pages && this.pages[old_item.name] || new_page) { + if (new_item) { + this.pages[new_item.name] = this.pages[old_item.name] || {}; + } + !duplicate && delete this.pages[old_item.name]; + } + + // update public and private pages + if (new_item) { + let is_section_changed = old_item.public != (new_item.is_public || new_item.public || 0); + + if (is_section_changed) { + !duplicate && from_pages.splice(old_item_index, 1); + to_pages.push(new_item); + } else if (new_page) { + from_pages.push(new_item); + } else { + from_pages.splice(old_item_index, duplicate ? 0 : 1, new_item); + } + } else { + from_pages.splice(old_item_index, 1); + } + + this.sidebar_pages.pages = [...this.public_pages, ...this.private_pages]; + this.cached_pages = this.sidebar_pages; + } + + add_settings_button(item, sidebar_control) { + this.dropdown_list = [ + { + label: 'Edit', + title: 'Edit Workspace', + icon: frappe.utils.icon('edit', 'sm'), + action: () => this.edit_page(item) + }, + { + label: 'Duplicate', + title: 'Duplicate Workspace', + icon: frappe.utils.icon('duplicate', 'sm'), + action: () => this.duplicate_page(item) + }, + { + label: 'Delete', + title: 'Delete Workspace', + icon: frappe.utils.icon('delete-active', 'sm'), + action: () => this.delete_page(item) + } + ]; + + let $button = $(` + + + `); + + let dropdown_item = function(label, title, icon, action) { + let html = $(` + + `); + + html.click(event => { + event.stopPropagation(); + action && action(); + }); + + return html; + }; + + $button.filter('.dropdown-btn').click(event => { + event.stopPropagation(); + if ($button.filter('.dropdown-list.hidden').length) { + $('.dropdown-list:not(.hidden)').addClass('hidden'); + } + $button.filter('.dropdown-list').toggleClass('hidden'); + }); + + $(document).click(event => { + event.stopPropagation(); + $('.dropdown-list:not(.hidden)').addClass('hidden'); + }); + + sidebar_control.append($button); + + this.dropdown_list.forEach((i) => { + $button.filter('.dropdown-list').append(dropdown_item(i.label, i.title, i.icon, i.action)); + }); + } + + delete_page(page) { + frappe.confirm(__("Are you sure you want to delete page {0}?", [page.title]), () => { + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.delete_page", + args: { page: page }, + callback: function(res) { + if (res.message) { + let page = res.message; + let message = `Workspace ${page.title} Deleted Successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + + this.page.clear_primary_action(); + this.update_cached_values(page); + + if (this.current_page.name == page.title && this.current_page.public == page.public) { + frappe.set_route('/'); + } + + this.make_sidebar(); + this.show_sidebar_actions(); + }); + } + + duplicate_page(page) { + var me = this; + let parent_pages = this.get_parent_pages(page); + const d = new frappe.ui.Dialog({ + title: __('Create Duplicate'), + fields: [ + { + label: __('Title'), + fieldtype: 'Data', + fieldname: 'title', + reqd: 1 + }, + { + label: __('Parent'), + fieldtype: 'Select', + fieldname: 'parent', + options: parent_pages, + default: page.parent_page + }, + { + label: __('Public'), + fieldtype: 'Check', + fieldname: 'is_public', + depends_on: `eval:${this.has_access}`, + default: page.public, + onchange: function() { + d.set_df_property('parent', 'options', + this.get_value() ? me.public_parent_pages : me.private_parent_pages); + } + }, + { + fieldtype: 'Column Break' + }, + { + label: __('Icon'), + fieldtype: 'Icon', + fieldname: 'icon', + default: page.icon + }, + ], + primary_action_label: __('Duplicate'), + primary_action: (values) => { + if (!this.validate_page(values)) return; + d.hide(); + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.duplicate_page", + args: { + page_name: page.name, + new_page: values + }, + callback: function(res) { + if (res.message) { + let new_page = res.message; + let message = `Duplicate of ${page.title} named as ${new_page.title} is created successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + + let new_page = {...page}; + + new_page.title = values.title; + new_page.public = values.is_public || 0; + new_page.name = values.title + (new_page.public ? '' : '-' + frappe.session.user); + new_page.label = new_page.name; + new_page.icon = values.icon; + new_page.parent_page = values.parent || ''; + new_page.for_user = new_page.public ? '' : frappe.session.user; + new_page.is_editable = !new_page.public; + new_page.selected = true; + + this.update_cached_values(page, new_page, true); + + let pre_url = values.is_public ? '' : 'private/'; + let route = pre_url + frappe.router.slug(values.title); + frappe.set_route(route); + + me.make_sidebar(); + me.show_sidebar_actions(); + } + }); + d.show(); } make_sidebar_sortable() { @@ -463,35 +826,75 @@ frappe.views.Workspace = class Workspace { onEnd: function (evt) { let is_public = $(evt.item).attr('item-public') == '1'; me.prepare_sorted_sidebar(is_public); + me.update_sorted_sidebar(); } }); }); } prepare_sorted_sidebar(is_public) { + let pages = is_public ? this.public_pages : this.private_pages; if (is_public) { - this.sorted_public_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').last()); + this.sorted_public_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').last(), pages); } else { - this.sorted_private_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').first()); + this.sorted_private_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').first(), pages); } + + this.sidebar_pages.pages = [...this.public_pages, ...this.private_pages]; + this.cached_pages = this.sidebar_pages; } - sort_sidebar($sidebar_section) { + sort_sidebar($sidebar_section, pages) { let sorted_items = []; - for (let page of $sidebar_section.find('.sidebar-item-container')) { + Array.from($sidebar_section.find('.sidebar-item-container')).forEach((page, i) => { let parent_page = ""; + if (page.closest('.nested-container').classList.contains('sidebar-child-item')) { parent_page = page.parentElement.parentElement.attributes["item-name"].value; } + sorted_items.push({ title: page.attributes['item-name'].value, parent_page: parent_page, public: page.attributes['item-public'].value }); - } + + let $drop_icon = $(page).find('.sidebar-item-control .drop-icon').first(); + if ($(page).find('.sidebar-child-item > *').length != 0) { + $drop_icon.removeClass('hidden'); + } else { + $drop_icon.addClass('hidden'); + } + + let from_index = pages.findIndex(p => p.title == page.attributes['item-name'].value); + let element = pages[from_index]; + element.parent_page = parent_page; + if (from_index != i) { + pages.splice(from_index, 1); + pages.splice(i, 0, element); + } + }); return sorted_items; } + update_sorted_sidebar() { + if (this.sorted_public_items || this.sorted_private_items) { + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.sort_pages", + args: { + sb_public_items: this.sorted_public_items, + sb_private_items: this.sorted_private_items, + }, + callback: function(res) { + if (res.message) { + let message = `Sidebar Updated Successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + } + } + make_blocks_sortable() { let me = this; this.page_sortable = Sortable.create(this.page.main.find(".codex-editor__redactor").get(0), { @@ -508,11 +911,10 @@ frappe.views.Workspace = class Workspace { } initialize_new_page() { - this.public_parent_pages = ['', ...this.public_pages.filter(page => !page.parent_page).map(page => page.title)]; - this.private_parent_pages = ['', ...this.private_pages.filter(page => !page.parent_page).map(page => page.title)]; var me = this; + this.get_parent_pages(); const d = new frappe.ui.Dialog({ - title: __('Set Title'), + title: __('New Workspace'), fields: [ { label: __('Title'), @@ -551,81 +953,115 @@ frappe.views.Workspace = class Workspace { d.hide(); this.initialize_editorjs_undo(); this.setup_customization_buttons({is_editable: true}); - this.title = values.title; - this.icon = values.icon; - this.parent = values.parent; - this.public = values.is_public; + + let name = values.title + (values.is_public ? '' : '-' + frappe.session.user); + let blocks = [{ + type: "header", + data: { text: values.title } + }]; + + let new_page = { + content: JSON.stringify(blocks), + name: name, + label: name, + title: values.title, + public: values.is_public || 0, + for_user: values.is_public ? '' : frappe.session.user, + icon: values.icon, + parent_page: values.parent || '', + is_editable: true, + selected: true + }; + this.editor.render({ - blocks: [ - { - type: "header", - data: { - text: this.title, - level: 4 - } - } - ] + blocks: blocks }).then(async () => { if (this.editor.configuration.readOnly) { this.is_read_only = false; await this.editor.readOnly.toggle(); } - this.add_page_to_sidebar(values); + + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.new_page", + args: { + new_page: new_page + }, + callback: function(res) { + if (res.message) { + let message = `Workspace ${new_page.title} Created Successfully`; + frappe.show_alert({ message: __(message), indicator: "green" }); + } + } + }); + + this.update_cached_values(new_page, new_page, true, true); + + let pre_url = new_page.public ? '' : 'private/'; + let route = pre_url + frappe.router.slug(new_page.title); + frappe.set_route(route); + + this.make_sidebar(); this.show_sidebar_actions(); - this.make_sidebar_sortable(); - this.make_blocks_sortable(); - this.prepare_sorted_sidebar(values.is_public); }); } }); d.show(); } - validate_page(values) { + validate_page(new_page, old_page) { let message = ""; - let pages = values.is_public ? this.public_pages : this.private_pages; + let [from_pages, to_pages] = new_page.is_public ? + [this.private_pages, this.public_pages] : [this.public_pages, this.private_pages]; - if (pages && pages.filter(p => p.title == values.title)[0]) { - message = "Page with title '{0}' already exist."; - } else if (frappe.router.doctype_route_exist(frappe.router.slug(values.title))) { + let section = this.sidebar_categories[new_page.is_public]; + + if (to_pages && to_pages.filter(p => p.title == new_page.title)[0]) { + message = `Page with title ${new_page.title} already exist.`; + } + + if (frappe.router.doctype_route_exist(frappe.router.slug(new_page.title))) { message = "Doctype with same route already exist. Please choose different title."; } + let child_pages = old_page && from_pages.filter(p => p.parent_page == old_page.title); + if (child_pages) { + child_pages.every(child_page => { + if (to_pages && to_pages.find(p => p.title == child_page.title)) { + message = `One of the child page with name ${child_page.title} already exist in ${section} Section. Please update the name of the child page first before moving`; + cur_dialog.hide(); + return false; + } + return true; + }); + } + if (message) { - frappe.throw(__(message, [__(values.title)])); + frappe.throw(__(message)); return false; } return true; } - add_page_to_sidebar({title, icon, parent, is_public}) { + add_page_to_sidebar(page) { let $sidebar = $('.standard-sidebar-section'); - let item = { - title: title, - icon: icon, - parent_page: parent, - public: is_public - }; - let $sidebar_item = this.sidebar_item_container(item); - $sidebar_item.addClass('is-draggable'); + let item = {...page}; + + item.selected = true; + item.is_editable = true; + + let $sidebar_item = this.sidebar_item_container(item); + + this.add_sidebar_actions(item, $sidebar_item.find('.sidebar-item-control'), true); - frappe.utils.add_custom_button( - frappe.utils.icon('drag', 'xs'), - null, - "drag-handle", - `${__('Drag')}`, - null, - $sidebar_item.find('.sidebar-item-control') - ); $sidebar_item.find('.sidebar-item-control .drag-handle').css('margin-right', '8px'); - let $sidebar_section = is_public ? $sidebar[1] : $sidebar[0]; + let sidebar_section = item.is_public ? $sidebar[1] : $sidebar[0]; - if (!parent) { - !is_public && $sidebar.first().removeClass('hidden'); - $sidebar_item.appendTo($sidebar_section); + if (!item.parent) { + !item.is_public && $sidebar.first().removeClass('hidden'); + $sidebar_item.appendTo(sidebar_section); } else { - let $item_container = $($sidebar_section).find(`[item-name="${parent}"]`); + let $item_container = $(sidebar_section).find(`[item-name="${item.parent}"]`); let $child_section = $item_container.find('.sidebar-child-item'); let $drop_icon = $item_container.find('.drop-icon'); if (!$child_section[0]) { @@ -635,22 +1071,31 @@ frappe.views.Workspace = class Workspace { } $sidebar_item.appendTo($child_section); $child_section.removeClass('hidden'); + $item_container.find('.drop-icon.hidden').removeClass('hidden'); $item_container.find('.drop-icon use').attr("href", "#icon-small-up"); } + + let section = item.is_public ? 'public' : 'private'; + if (this.sidebar_items && this.sidebar_items[section] && !this.sidebar_items[section][item.title]) { + this.sidebar_items[section][item.title] = $sidebar_item; + } } initialize_editorjs(blocks) { this.tools = { header: { class: this.blocks['header'], - inlineToolbar: true, + inlineToolbar: ['HeaderSize', 'bold', 'italic', 'link'], config: { - defaultLevel: 4 + default_size: 4 } }, paragraph: { class: this.blocks['paragraph'], - inlineToolbar: true + inlineToolbar: ['HeaderSize', 'bold', 'italic', 'link'], + config: { + placeholder: 'Choose a block or continue typing' + } }, chart: { class: this.blocks['chart'], @@ -677,7 +1122,7 @@ frappe.views.Workspace = class Workspace { } }, spacer: this.blocks['spacer'], - spacingTune: frappe.wspace_block.tunes['spacing_tune'], + HeaderSize: frappe.workspace_block.tunes['header_size'], }; this.editor = new EditorJS({ data: { @@ -685,27 +1130,18 @@ frappe.views.Workspace = class Workspace { }, tools: this.tools, autofocus: false, - tunes: ['spacingTune'], readOnly: true, logLevel: 'ERROR' }); } - save_page() { - frappe.dom.freeze(); - this.create_skeleton(); - let save = true; - if (!this.title && this.current_page) { - let pages = this.current_page.public ? this.public_pages : this.private_pages; - this.title = this.current_page.name; - this.public = pages.filter(p => p.title == this.title)[0].public; - save = false; - } else { - this.current_page = { name: this.title, public: this.public }; - } + save_page(page) { let me = this; - this.editor.save().then((outputData) => { + this.current_page = { name: page.title, public: page.public }; + + return this.editor.save().then((outputData) => { let new_widgets = {}; + outputData.blocks.forEach(item => { if (item.data.new) { if (!new_widgets[item.type]) { @@ -718,34 +1154,36 @@ frappe.views.Workspace = class Workspace { let blocks = outputData.blocks.filter( item => item.type != 'card' || - (item.data.card_name !== 'Custom Documents' && - item.data.card_name !== 'Custom Reports') + (item.data.card_name !== 'Custom Documents' && + item.data.card_name !== 'Custom Reports') ); + if (page.content == JSON.stringify(blocks)) { + this.setup_customization_buttons(page); + frappe.show_alert({ message: __("No changes made on the page"), indicator: "warning" }); + return false; + } + + this.create_page_skeleton(); + page.content = JSON.stringify(blocks); frappe.call({ method: "frappe.desk.doctype.workspace.workspace.save_page", args: { - title: me.title, - icon: me.icon || '', - parent: me.parent || '', - public: me.public || 0, - sb_public_items: me.sorted_public_items, - sb_private_items: me.sorted_private_items, - deleted_pages: me.deleted_sidebar_items, + title: page.title, + public: page.public || 0, new_widgets: new_widgets, - blocks: JSON.stringify(blocks), - save: save + blocks: JSON.stringify(blocks) }, callback: function(res) { - frappe.dom.unfreeze(); if (res.message) { - me.new_page = res.message; - me.pages[res.message.label] && delete me.pages[res.message.label]; + me.discard = true; + me.update_cached_values(page, page); me.reload(); frappe.show_alert({ message: __("Page Saved Successfully"), indicator: "green" }); } } }); + return true; }).catch((error) => { error; // console.log('Saving failed: ', error); @@ -753,26 +1191,34 @@ frappe.views.Workspace = class Workspace { } reload() { - this.title = ''; - this.icon = ''; - this.parent = ''; - this.public = false; this.sorted_public_items = []; this.sorted_private_items = []; - this.deleted_sidebar_items = []; - this.create_skeleton(); this.setup_pages(true); this.discard = false; this.undo.readOnly = true; } - create_skeleton() { - this.$page.prepend(frappe.render_template('workspace_loading_skeleton')); - this.$page.find('.codex-editor').addClass('hidden'); + create_page_skeleton() { + if ($('.layout-main-section').find('.workspace-skeleton').length) return; + + $('.layout-main-section').prepend(frappe.render_template('workspace_loading_skeleton')); + $('.layout-main-section').find('.codex-editor').addClass('hidden'); } - remove_skeleton() { - this.$page.find('.codex-editor').removeClass('hidden'); - this.$page.find('.workspace-skeleton').remove(); + remove_page_skeleton() { + $('.layout-main-section').find('.codex-editor').removeClass('hidden'); + $('.layout-main-section').find('.workspace-skeleton').remove(); + } + + create_sidebar_skeleton() { + if ($('.list-sidebar').find('.workspace-sidebar-skeleton').length) return; + + $('.list-sidebar').prepend(frappe.render_template('workspace_sidebar_loading_skeleton')); + $('.desk-sidebar').addClass('hidden'); + } + + remove_sidebar_skeleton() { + $('.desk-sidebar').removeClass('hidden'); + $('.list-sidebar').find('.workspace-sidebar-skeleton').remove(); } }; diff --git a/frappe/public/js/frappe/web_form/web_form.js b/frappe/public/js/frappe/web_form/web_form.js index 1f540958df..a45fc941d3 100644 --- a/frappe/public/js/frappe/web_form/web_form.js +++ b/frappe/public/js/frappe/web_form/web_form.js @@ -160,17 +160,17 @@ export default class WebForm extends frappe.ui.FieldGroup { } setup_primary_action() { - this.add_button_to_header(this.button_label || "Save", "primary", () => + this.add_button_to_header(this.button_label || __("Save", null, "Button in web form"), "primary", () => this.save() ); - this.add_button_to_footer(this.button_label || "Save", "primary", () => + this.add_button_to_footer(this.button_label || __("Save", null, "Button in web form"), "primary", () => this.save() ); } setup_cancel_button() { - this.add_button_to_header(__("Cancel"), "light", () => this.cancel()); + this.add_button_to_header(__("Cancel", null, "Button in web form"), "light", () => this.cancel()); } setup_delete_button() { @@ -216,16 +216,18 @@ export default class WebForm extends frappe.ui.FieldGroup { let message = ''; if (invalid_values.length) { - message += __('Invalid values for fields:') + '

  • ' + invalid_values.join('
  • ') + '
'; + message += __('Invalid values for fields:', null, 'Error message in web form'); + message += '

  • ' + invalid_values.join('
  • ') + '
'; } if (errors.length) { - message += __('Mandatory fields required:') + '

  • ' + errors.join('
  • ') + '
'; + message += __('Mandatory fields required:', null, 'Error message in web form'); + message += '

  • ' + errors.join('
  • ') + '
'; } if (invalid_values.length || errors.length) { frappe.msgprint({ - title: __('Error'), + title: __('Error', null, 'Title of error message in web form'), message: message, indicator: 'orange' }); diff --git a/frappe/public/js/frappe/widgets/base_widget.js b/frappe/public/js/frappe/widgets/base_widget.js index e6ae64d9dc..aabb3526b0 100644 --- a/frappe/public/js/frappe/widgets/base_widget.js +++ b/frappe/public/js/frappe/widgets/base_widget.js @@ -34,16 +34,6 @@ export default class Widget { this.action_area ); - options.allow_delete && - frappe.utils.add_custom_button( - frappe.utils.icon('delete', 'xs'), - () => this.delete(), - "", - `${__('Delete')}`, - null, - this.action_area - ); - if (options.allow_hiding) { if (this.hidden) { this.widget.removeClass("hidden"); @@ -71,27 +61,11 @@ export default class Widget { frappe.utils.add_custom_button( frappe.utils.icon("edit", "xs"), () => this.edit(), - null, + "edit-button", `${__('Edit')}`, null, this.action_area ); - - if (options.allow_resize) { - const title = this.width == 'Full'? `${__('Collapse')}` : `${__('Expand')}`; - frappe.utils.add_custom_button( - '', - () => this.toggle_width(), - "resize-button", - title, - null, - this.action_area - ); - - this.resize_button = this.action_area.find( - ".resize-button" - ); - } } make() { @@ -100,9 +74,7 @@ export default class Widget { } make_widget() { - this.widget = $(`
+ this.widget = $(`
@@ -110,10 +82,8 @@ export default class Widget {
-
-
- +
+
`); this.title_field = this.widget.find(".widget-title"); diff --git a/frappe/public/js/frappe/widgets/chart_widget.js b/frappe/public/js/frappe/widgets/chart_widget.js index ec602b8522..6c34fac45a 100644 --- a/frappe/public/js/frappe/widgets/chart_widget.js +++ b/frappe/public/js/frappe/widgets/chart_widget.js @@ -28,7 +28,7 @@ export default class ChartWidget extends Widget { } set_chart_title() { - const max_chars = this.widget.width() < 600 ? 20 : 60; + const max_chars = this.widget.width() < 600 ? 40 : 60; this.set_title(max_chars); } diff --git a/frappe/public/js/frappe/widgets/links_widget.js b/frappe/public/js/frappe/widgets/links_widget.js index cc771b96b5..3320e88bfb 100644 --- a/frappe/public/js/frappe/widgets/links_widget.js +++ b/frappe/public/js/frappe/widgets/links_widget.js @@ -80,7 +80,9 @@ export default class LinksWidget extends Widget { return $(` + } ${disabled_dependent(item)}" type="${item.type}" title="${ + item.label ? item.label : item.name + }"> ${get_link_for_item(item)} `); diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index 5676a834fe..01d41a0cf9 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -9,6 +9,7 @@ class WidgetDialog { this.setup_dialog_events(); this.dialog.show(); + window.cur_dialog = this.dialog; this.editing && this.set_default_values(); } @@ -181,19 +182,16 @@ class CardDialog extends WidgetDialog { fieldtype: "Select", in_list_view: 1, label: "Link Type", - options: ["DocType", "Page", "Report"], - onchange: (e) => { - me.link_to = e.currentTarget.value; - } + options: ["DocType", "Page", "Report"] }, { fieldname: "link_to", fieldtype: "Dynamic Link", in_list_view: 1, label: "Link To", - options: "link_type", - get_options: () => { - return me.link_to; + get_options: (df) => { + return df.doc.link_type; + } }, { @@ -506,7 +504,7 @@ class NumberCardDialog extends WidgetDialog { setup_dialog_events() { if (!this.document_type) { - if (this.default_values['doctype']) { + if (this.default_values && this.default_values['doctype']) { this.document_type = this.default_values['doctype']; this.setup_filter(this.default_values['doctype']); this.set_aggregate_function_fields(); @@ -518,7 +516,7 @@ class NumberCardDialog extends WidgetDialog { set_aggregate_function_fields() { let aggregate_function_fields = []; - if (this.document_type) { + if (this.document_type && frappe.get_meta(this.document_type)) { frappe.get_meta(this.document_type).fields.map(df => { if (frappe.model.numeric_fieldtypes.includes(df.fieldtype)) { if (df.fieldtype == 'Currency') { @@ -537,7 +535,7 @@ class NumberCardDialog extends WidgetDialog { if (data.new_or_existing == 'Existing Card') { data.name = data.card; } - data.stats_filter = JSON.stringify(this.filter_group.get_filters()); + data.stats_filter = this.filter_group && JSON.stringify(this.filter_group.get_filters()); data.document_type = this.document_type; return data; diff --git a/frappe/public/js/integrations/google_drive_picker.js b/frappe/public/js/integrations/google_drive_picker.js index 9d7971e75c..1e4f1dca7c 100644 --- a/frappe/public/js/integrations/google_drive_picker.js +++ b/frappe/public/js/integrations/google_drive_picker.js @@ -44,9 +44,16 @@ export default class GoogleDrivePicker { } handleAuthResult(authResult) { + let error_map = { + "popup_closed_by_user": __("Google Authentication was closed abruptly by the user") + }; + if (authResult && !authResult.error) { frappe.boot.user.google_drive_token = authResult.access_token; this.createPicker(); + } else { + let error = error_map[authResult.error] || __("Google Authentication Error"); + frappe.throw(error); } } @@ -58,20 +65,34 @@ export default class GoogleDrivePicker { createPicker() { // Create and render a Picker object for searching images. if (this.pickerApiLoaded && frappe.boot.user.google_drive_token) { - var view = new google.picker.DocsView(google.picker.ViewId.DOCS) + this.view = new google.picker.DocsView(google.picker.ViewId.DOCS) .setParent('root') // show the root folder by default .setIncludeFolders(true); // also show folders, not just files - var picker = new google.picker.PickerBuilder() + this.picker = new google.picker.PickerBuilder() .setAppId(this.appId) .setDeveloperKey(this.developerKey) .setOAuthToken(frappe.boot.user.google_drive_token) - .addView(view) + .addView(this.view) .setLocale(frappe.boot.lang) .setCallback(this.pickerCallback) .build(); - picker.setVisible(true); + this.picker.setVisible(true); + this.setupHide(); + } + } + + setupHide() { + let bg = $(".picker-dialog-bg"); + + for (let el of bg) { + el.onclick = () => { + this.picker.setVisible(false); + this.picker.Ob({ + action: google.picker.Action.CANCEL + }); + }; } } } diff --git a/frappe/public/js/lib/moment.js b/frappe/public/js/lib/moment.js new file mode 100644 index 0000000000..7a817a36cd --- /dev/null +++ b/frappe/public/js/lib/moment.js @@ -0,0 +1,5 @@ +// This file is used to make sure that `moment` is bound to the window +// before the bundle finishes loading, due to imports (datetime.js) in the bundle +// that depend on `moment`. +import momentTimezone from "moment-timezone/builds/moment-timezone-with-data.js"; +window.moment = momentTimezone; diff --git a/frappe/public/js/libs.bundle.js b/frappe/public/js/libs.bundle.js index 876d76875b..b71cc592a0 100644 --- a/frappe/public/js/libs.bundle.js +++ b/frappe/public/js/libs.bundle.js @@ -1,15 +1,12 @@ import "./jquery-bootstrap"; import Vue from "vue/dist/vue.esm.js"; -import moment from "moment/min/moment-with-locales.js"; -import momentTimezone from "moment-timezone/builds/moment-timezone-with-data.js"; +import "./lib/moment"; import io from "socket.io-client/dist/socket.io.slim.js"; import Sortable from "./lib/Sortable.min.js"; // TODO: esbuild // Don't think jquery.hotkeys is being used anywhere. Will remove this after being sure. // import "./lib/jquery/jquery.hotkeys.js"; -window.moment = moment; -window.moment = momentTimezone; window.Vue = Vue; window.Sortable = Sortable; window.io = io; diff --git a/frappe/public/js/print_format_builder/print_format_builder.bundle.js b/frappe/public/js/print_format_builder/print_format_builder.bundle.js index b2d3372daf..c8c03d209a 100644 --- a/frappe/public/js/print_format_builder/print_format_builder.bundle.js +++ b/frappe/public/js/print_format_builder/print_format_builder.bundle.js @@ -21,7 +21,7 @@ class PrintFormatBuilder { this.$component.toggle_preview(); } ); - this.page.add_button(__("Reset Changes"), () => + let $reset_changes_btn = this.page.add_button(__("Reset Changes"), () => this.$component.$store.reset_changes() ); this.page.add_menu_item(__("Edit Print Format"), () => { @@ -46,9 +46,11 @@ class PrintFormatBuilder { if (value) { this.page.set_indicator("Not Saved", "orange"); $toggle_preview_btn.hide(); + $reset_changes_btn.show(); } else { this.page.clear_indicator(); $toggle_preview_btn.show(); + $reset_changes_btn.hide(); } }); this.$component.$watch("show_preview", value => { diff --git a/frappe/public/js/web_form.bundle.js b/frappe/public/js/web_form.bundle.js index 01969a489c..ffb7b824bd 100644 --- a/frappe/public/js/web_form.bundle.js +++ b/frappe/public/js/web_form.bundle.js @@ -1,2 +1,3 @@ +import "./lib/moment.js"; import "./frappe/utils/datetime.js"; import "./frappe/web_form/webform_script.js"; diff --git a/frappe/public/scss/common/alert.scss b/frappe/public/scss/common/alert.scss new file mode 100644 index 0000000000..ba026fa48a --- /dev/null +++ b/frappe/public/scss/common/alert.scss @@ -0,0 +1,12 @@ +// Color overrides for https://getbootstrap.com/docs/4.0/components/alerts +$alert-types: info, success, warning, danger; + +.alert { + @each $alert-type in $alert-types { + &.alert-#{$alert-type} { + color: var(--alert-text-#{$alert-type}); + background-color: var(--alert-bg-#{$alert-type}); + border: none; + } + } +} \ No newline at end of file diff --git a/frappe/public/scss/common/buttons.scss b/frappe/public/scss/common/buttons.scss index de3a4cfc20..62479e7a7a 100644 --- a/frappe/public/scss/common/buttons.scss +++ b/frappe/public/scss/common/buttons.scss @@ -62,7 +62,7 @@ background-color: var(--control-bg); color: var(--text-color); &:hover, &:active { - background-color: var(--gray-300); + background-color: var(--gray-400); color: var(--text-color); } } diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss index 7ddc53f187..6508fa2f7c 100644 --- a/frappe/public/scss/common/css_variables.scss +++ b/frappe/public/scss/common/css_variables.scss @@ -189,6 +189,16 @@ --text-on-pink: var(--pink-500); --text-on-cyan: var(--cyan-600); + // alert colors + --alert-text-danger: var(--red-600); + --alert-text-warning: var(--yellow-700); + --alert-text-info: var(--blue-700); + --alert-text-success: var(--green-700); + --alert-bg-danger: var(--red-50); + --alert-bg-warning: var(--yellow-50); + --alert-bg-info: var(--blue-50); + --alert-bg-success: var(--green-50); + // Layout Colors --bg-color: var(--gray-50); --fg-color: white; @@ -221,6 +231,10 @@ --highlight-shadow: 1px 1px 10px var(--blue-50), 0px 0px 4px var(--blue-600); + // code block + --code-block-bg: var(--gray-900); + --code-block-text: var(--gray-400); + // Border Sizes --border-radius-sm: 4px; --border-radius: 6px; @@ -235,6 +249,7 @@ --checkbox-right-margin: var(--margin-xs); --checkbox-size: 14px; --checkbox-focus-shadow: 0 0 0 2px var(--gray-300); + --checkbox-gradient: linear-gradient(180deg, #4AC3F8 -124.51%, var(--primary) 100%); --right-arrow-svg: url("data: image/svg+xml;utf8, "); --left-arrow-svg: url("data: image/svg+xml;utf8, "); diff --git a/frappe/public/scss/common/global.scss b/frappe/public/scss/common/global.scss index 0324b75bfb..8a849ab51a 100644 --- a/frappe/public/scss/common/global.scss +++ b/frappe/public/scss/common/global.scss @@ -54,7 +54,7 @@ input[type="radio"] { } &:checked::before { - background-color: var(--blue-500); + background-color: var(--primary); border-radius: 16px; box-shadow: inset 0 0 0 2px white; } @@ -85,8 +85,8 @@ input[type="checkbox"] { } &:checked { - background-color: var(--blue-500); - background-image: $check-icon, linear-gradient(180deg, #4AC3F8 -124.51%, #2490EF 100%); + background-color: var(--primary); + background-image: $check-icon, var(--checkbox-gradient); background-size: 57%, 100%; box-shadow: none; border: none; diff --git a/frappe/public/scss/common/quill.scss b/frappe/public/scss/common/quill.scss index a243acba5f..3d5ce61c15 100644 --- a/frappe/public/scss/common/quill.scss +++ b/frappe/public/scss/common/quill.scss @@ -42,6 +42,7 @@ height: 300px; border-bottom-left-radius: var(--border-radius); border-bottom-right-radius: var(--border-radius); + resize: vertical; } .ql-stroke { stroke: var(--icon-stroke); @@ -85,10 +86,22 @@ margin-bottom: 8px; } +.ql-code-block-container { + background-color: var(--code-block-bg); + color: var(--code-block-text); + padding: var(--padding-xs) var(--padding-sm) !important; + margin-bottom: var(--margin-xs) !important; + margin-top: var(--margin-xs)!important; + border-radius: var(--border-radius-sm); +} + .ql-bubble .ql-editor { min-height: 100px; max-height: 300px; border-radius: var(--border-radius-sm); + .ql-code-block-container { + @extend .ql-code-block-container; + } } .ql-mention-list-container { diff --git a/frappe/public/scss/desk/css_variables.scss b/frappe/public/scss/desk/css_variables.scss index 0912cb278b..a06ba3e9b0 100644 --- a/frappe/public/scss/desk/css_variables.scss +++ b/frappe/public/scss/desk/css_variables.scss @@ -54,4 +54,6 @@ $input-height: 28px !default; // skeleton --skeleton-bg: var(--gray-100); + // progress bar + --progress-bar-bg: var(--primary); } diff --git a/frappe/public/scss/desk/dark.scss b/frappe/public/scss/desk/dark.scss index f894704ca2..6e5ebdb694 100644 --- a/frappe/public/scss/desk/dark.scss +++ b/frappe/public/scss/desk/dark.scss @@ -63,6 +63,16 @@ --text-on-light-gray: var(--gray-100); --text-on-purple: var(--purple-100); + // alert colors + --alert-text-danger: var(--red-300); + --alert-text-warning: var(--yellow-300); + --alert-text-info: var(--blue-300); + --alert-text-success: var(--green-300); + --alert-bg-danger: var(--red-900); + --alert-bg-warning: var(--yellow-900); + --alert-bg-info: var(--blue-900); + --alert-bg-success: var(--green-900); + --sidebar-select-color: var(--gray-800); --scrollbar-thumb-color: var(--gray-600); @@ -77,6 +87,8 @@ --highlight-shadow: 1px 1px 10px var(--blue-900), 0px 0px 4px var(--blue-500); + --shadow-base: 0px 4px 8px rgba(114, 176, 233, 0.06), 0px 0px 4px rgba(112, 172, 228, 0.12); + // input --input-disabled-bg: none; diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss index 6ab01a744c..549ed6eee9 100644 --- a/frappe/public/scss/desk/desktop.scss +++ b/frappe/public/scss/desk/desktop.scss @@ -107,13 +107,20 @@ body { } } +.divider { + height: 30%; + position: absolute; + top: 18px; + right: 0; + border-right: 1px solid var(--gray-400); +} + .widget { @include flex(flex, null, null, column); min-height: 1px; - padding: 15px; + padding: 7px; border-radius: var(--border-radius-md); height: 100%; - box-shadow: var(--card-shadow); background-color: var(--card-bg); .btn { @@ -143,6 +150,7 @@ body { font-weight: 500; line-height: 1.3em; color: var(--heading-color); + cursor: default; svg { flex: none; @@ -329,9 +337,28 @@ body { } &.onboarding-widget-box { - margin-top: var(--margin-xs); margin-bottom: var(--margin-2xl); - padding: var(--padding-lg); + padding: var(--padding-lg) !important; + background-color: var(--bg-color); + + &.edit-mode:hover { + background-color: var(--fg-color); + + .onboarding-step { + &.active, + &:hover { + background-color: var(--bg-color); + + .step-index.step-pending { + background-color: var(--fg-color); + } + } + + .step-index { + background-color: var(--bg-color); + } + } + } .widget-head { display: flex; @@ -390,12 +417,6 @@ body { .step-index.step-pending { display: flex; } - - &.active { - .step-index.step-pending { - background-color: var(--fg-color); - } - } } &.complete { @@ -418,7 +439,11 @@ body { &.active, &:hover { - background-color: var(--bg-light-gray); + background-color: var(--fg-color); + + .step-index { + background-color: var(--bg-color); + } .step-skip { visibility: visible; @@ -434,7 +459,7 @@ body { height: 20px; width: 20px; color: var(--text-on-light-gray); - background-color: var(--bg-light-gray); + background-color: var(--fg-color); margin-right: var(--margin-sm); border-radius: var(--border-radius-full); @@ -447,7 +472,7 @@ body { display: none; background-color: var(--primary); .icon use { - stroke: var(--white); + stroke: var(--var(--fg-color)); } } @@ -496,7 +521,7 @@ body { } } - @media (max-width: map-get($grid-breakpoints, "sm")) { + @media (max-width: map-get($grid-breakpoints, "md")) { .widget-body { flex-direction: column; .onboarding-steps-wrapper { @@ -513,9 +538,19 @@ body { &.shortcut-widget-box { cursor: pointer; - .widget-head { - margin-top: var(--margin-xs); - margin-bottom: 5px; + &:hover { + .widget-title { + color: var(--blue-500) !important; + } + + svg.icon-xs { + stroke: var(--blue-500) !important; + } + } + + .widget-title { + cursor: pointer !important; + font-size: var(--text-base) !important; } .indicator-pill { @@ -631,8 +666,8 @@ body { width: 18px; .icon-xs { - width: 8px; - height: 7px; + width: 10px; + height: 10px; } } @@ -757,6 +792,25 @@ body { } } +.workspace-sidebar-skeleton { + transition: ease; + .sidebar-box { + height: 40px; + margin-bottom: 10px; + margin-left: 10px; + background-color: var(--skeleton-bg); + + &.child { + margin-left: 30px; + } + + &.section { + height: 25px; + margin-left: 0px; + } + } +} + [data-page-route="Workspaces"] { @media (min-width: map-get($grid-breakpoints, "lg")) { .layout-main { @@ -764,7 +818,6 @@ body { .layout-side-section, .layout-main-section-wrapper { height: 100%; overflow-y: auto; - padding-right: 25px; scrollbar-color: var(--gray-200) transparent; [data-theme="dark"] & { scrollbar-color: var(--gray-800) transparent; @@ -783,7 +836,12 @@ body { } .layout-side-section { - margin-right: 20px; + padding-right: 15px; + } + + .layout-main-section { + padding: var(--padding-md); + margin-bottom: var(--margin-sm); } .desk-sidebar { @@ -792,9 +850,15 @@ body { } } + .layout-main-section { + background-color: var(--fg-color); + padding: var(--padding-sm); + border-radius: var(--border-radius-lg); + } + .block-menu-item-icon svg{ - width: 12px; - height: 12px; + width: 18px; + height: 18px; margin-right: 5px; } @@ -803,7 +867,6 @@ body { padding: 0px; .sidebar-item-control { - > * { align-self: center; margin-left: 3px; @@ -816,7 +879,7 @@ body { display: none; } - .delete-page { + .setting-btn, .duplicate-page { display: none; } @@ -824,13 +887,13 @@ body { padding: 10px 12px 10px 2px; } - .sidebar-info { - display: none; - } - svg { margin-right: 0; } + + .dropdown-list { + top: 42px; + } } .sidebar-item-label { @@ -846,6 +909,7 @@ body { } .sidebar-item-container { + position: relative; .sidebar-item-container{ margin-left: 10px; @@ -863,19 +927,14 @@ body { display: inline-block; } - .delete-page { - display: inline-block; - margin-right: 8px; - } - - .sidebar-info { + .setting-btn, .duplicate-page { display: inline-block; margin-right: 8px; } .drop-icon { padding: 10px 8px 10px 2px; - margin-left: -4px; + margin-left: -8px; } } @@ -899,11 +958,81 @@ body { margin: 0px -7px; padding-bottom: 20px !important; - .ce-block{ + .ce-block { width: 100%; padding-left: 0; padding-right: 0; + .ce-header b { + font-weight: 600 !important; + } + + .new-block-button { + position: absolute; + top: 14px; + left: -22px; + cursor: pointer; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0.5s ease-in-out; + } + + .edit-mode { + .widget-control > *, .paragraph-control > * { + width: 0px; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0.5s ease-in-out; + } + + .link-item { + pointer-events: none; + } + } + + &:hover { + .widget-control > *, .new-block-button { + width: auto; + visibility: visible; + opacity: 1; + } + } + + &.ce-block--focused { + .widget { + box-shadow: var(--shadow-base) !important; + + .widget-control > * { + width: auto; + visibility: visible; + opacity: 1; + } + + &.shortcut, &.header { + background-color: var(--fg-color) !important; + } + + &.onboarding { + background-color: var(--fg-color); + + .onboarding-step { + &.active, + &:hover { + background-color: var(--bg-color); + + .step-index.step-pending { + background-color: var(--fg-color); + } + } + + .step-index { + background-color: var(--bg-color); + } + } + } + } + } + &.ce-block--selected { .ce-block__content { background-color: inherit; @@ -923,50 +1052,125 @@ body { pointer-events: none; } - .ce-header { - padding: 0 !important; - margin-bottom: 0 !important; - flex: 1; + .resizer { + width: 10px; + height: 100%; + position:absolute; + right: 0; + bottom: 0; + cursor: col-resize; + border-color: transparent; + transition: border-color 0.3s ease-in-out; + + &:hover { + border-right: 3px solid var(--gray-400) !important; + } } - .widget{ - &.header { - display: flex; - justify-content: center; - flex: 1; - padding-left: 15px !important; - padding-right: 15px !important; - min-height: 50px; - box-shadow: none; - background-color: var(--control-bg); - color: var(--text-muted); + .ce-header { + padding-left: 7px !important; + margin-bottom: 0 !important; + flex: 1; + + &:focus { + outline: none; + } + } + + .block-list-container { + left: 20px; + top: 55px !important; + width: 200px !important; + } + + .dropdown-title { + padding: 6px 10px; + font-size: smaller; + cursor: default; + } + + .ce-paragraph[data-placeholder]:empty::before { + opacity: 1; + } + + .widget { + &.edit-mode { + padding: 7px 12px; + + &:hover { + box-shadow: var(--shadow-base); + background-color: var(--fg-color); + } + + &.spacer { + align-items: inherit; + color: var(--text-muted); + border: 1px dashed var(--gray-400); + cursor: pointer; + + .widget-control > * { + width: auto; + } + + .spacer-left { + min-width: 74px; + } + } } - &:focus { - outline: none; - } - - &.new-widget { - align-items: inherit; + &.spacer { + height: 18px !important; } &.ce-paragraph { display: block; } - .paragraph-control { - display: flex; - flex-direction: row-reverse; - position: absolute; - right: 20px; - gap: 5px; - background-color: var(--card-bg); - padding-left: 5px; + &.paragraph { + cursor: text; - .drag-handle { - cursor: all-scroll; - cursor: grabbing; + .ce-paragraph { + padding: 2px; } + + .paragraph-control { + display: flex; + flex-direction: row-reverse; + position: absolute; + right: 20px; + gap: 5px; + background-color: var(--card-bg); + padding-left: 5px; + + .drag-handle { + cursor: all-scroll; + cursor: grabbing; + } + } + } + + &.header { + display: flex; + justify-content: center; + flex: 1; + padding-left: 0px !important; + min-height: 40px; + box-shadow: none; + background-color: var(--control-bg); + color: var(--text-muted); + cursor: text; + + .ce-header { + padding-left: 14px !important; + } + } + + &.shortcut { + background-color: var(--control-bg); + } + + &:focus { + outline: none; } } } @@ -978,14 +1182,21 @@ body { } .ce-toolbar { + + &.ce-toolbar--opened { + display: none; + } + svg { fill: currentColor; } .icon { stroke: none; - width: fit-content; - height: fit-content; + + &.icon--plus { + width: 14px; + } } .ce-settings { @@ -993,6 +1204,10 @@ body { .ce-settings__button, .cdx-settings-button { color: #707684; + + .icon { + width: 14px; + } } .cdx-settings-button--active { @@ -1024,6 +1239,10 @@ body { .icon { fill: currentColor; } + + svg { + stroke: none; + } } @media (min-width: 1199px) { @@ -1037,25 +1256,63 @@ body { } } - @media (max-width: 1199px) { - .ce-block.col-4 { - flex: 0 0 50%; - max-width: 50%; - } - } - - @media (max-width: 750px) { - .ce-block.col-4 { - flex: 0 0 100%; - max-width: 100%; - } - } - @media (max-width: 750px) { - .ce-block.col-6 { - flex: 0 0 100%; - max-width: 100%; - } - } - } + + .cdx-marker { + background: rgba(245,235,111,0.29); + padding: 3px 0; + } + + .header-inline-tool { + border: none; + background-color: transparent; + margin-bottom: 2px; + } + + .header-level-select { + display: flex; + flex-direction: column; + padding: 6px; + } + + .header-level-select .header-level { + border: none; + background-color: transparent; + border-radius: var(--border-radius-sm); + padding: 6px; + margin: 2px 0px; + + &:hover { + background-color: var(--fg-hover-color); + } + } + + .dropdown-btn { + position: relative; + } + + .dropdown-list { + position: absolute; + background-color: var(--fg-color); + box-shadow: var(--shadow-base) !important; + border-radius: var(--border-radius-sm); + padding: 6px; + top: 30px; + right: 0; + width: 150px; + z-index: 1; + } + + .dropdown-list .dropdown-item { + cursor: pointer; + padding: 6px 10px; + font-size: small; + border-radius: var(--border-radius-sm); + margin: 1px 0px; + } + + .dropdown-item-icon { + margin-right: 5px; + } + } diff --git a/frappe/public/scss/desk/frappe_datatable.scss b/frappe/public/scss/desk/frappe_datatable.scss index 48645bbbfc..fb7d795ced 100644 --- a/frappe/public/scss/desk/frappe_datatable.scss +++ b/frappe/public/scss/desk/frappe_datatable.scss @@ -87,6 +87,11 @@ } } + .dt-cell__resize-handle { + right: -3px !important; + left: unset !important; + } + .dt-row.dt-row-totalRow { font-weight: bold; } diff --git a/frappe/public/scss/desk/index.scss b/frappe/public/scss/desk/index.scss index 1d1124bd58..5d712f7ad6 100644 --- a/frappe/public/scss/desk/index.scss +++ b/frappe/public/scss/desk/index.scss @@ -2,6 +2,7 @@ @import "../common/mixins.scss"; @import "../common/global.scss"; @import "../common/icons.scss"; +@import "../common/alert.scss"; @import "~bootstrap/scss/bootstrap"; @import "global"; diff --git a/frappe/public/scss/desk/page.scss b/frappe/public/scss/desk/page.scss index 91bd942889..f0a9152cfb 100644 --- a/frappe/public/scss/desk/page.scss +++ b/frappe/public/scss/desk/page.scss @@ -164,7 +164,7 @@ // To compensate for perceived centering .null-state { - height: 85px; + height: 60px; width: auto; margin-bottom: var(--margin-md); img { diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index 7430044878..82bd1cdf13 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -107,7 +107,7 @@ body[data-route^="Module"] .main-menu { cursor: pointer; .sidebar-image { - width: 100%; + width: min(100%, 170px); height: auto; max-height: 170px; object-fit: cover; diff --git a/frappe/public/scss/desk/timeline.scss b/frappe/public/scss/desk/timeline.scss index 1861ee018b..a99f2648e8 100644 --- a/frappe/public/scss/desk/timeline.scss +++ b/frappe/public/scss/desk/timeline.scss @@ -57,35 +57,6 @@ $threshold: 34; } } } - .timeline-actions { - display: inline-flex; - width: 100%; - margin-bottom: var(--timeline-item-bottom-margin); - padding: var(--padding-sm); - position: relative; - .action-btn { - margin-left: var(--margin-md); - display: flex; - align-items: center; - line-height: var(--text-xl); - .icon { - margin-right: var(--margin-xs); - } - } - .action-btn:first-of-type { - margin-left: var(--timeline-item-left-margin); - } - } - .document-email-link-container { - @extend .ellipsis; - position: relative; - padding: var(--padding-sm); - font-size: var(--text-sm); - margin-bottom: var(--timeline-item-bottom-margin); - span:first-of-type { - margin-left: var(--timeline-item-left-margin); - } - } .timeline-item { font-size: var(--text-md); position: relative; @@ -94,6 +65,23 @@ $threshold: 34; color: var(--text-color); font-weight: var(--text-bold); } + .action-buttons { + display: inline-flex; + white-space: nowrap; + overflow: auto; + .action-btn { + margin-left: var(--margin-md); + display: flex; + align-items: center; + line-height: var(--text-xl); + .icon { + margin-right: var(--margin-xs); + } + } + .action-btn:first-of-type { + margin-left: 0; + } + } .timeline-content { max-width: var(--timeline-content-max-width); padding: var(--padding-sm); diff --git a/frappe/public/scss/desk/toast.scss b/frappe/public/scss/desk/toast.scss index d10cfd871e..862b42cd76 100644 --- a/frappe/public/scss/desk/toast.scss +++ b/frappe/public/scss/desk/toast.scss @@ -9,7 +9,27 @@ } } -#alert-container .desk-alert { +.desk-alert { + &.red { + --toast-bg: var(--alert-bg-danger); + } + + &.yellow { + --toast-bg: var(--alert-bg-warning); + } + + &.orange { + --toast-bg: var(--alert-bg-warning); + } + + &.blue { + --toast-bg: var(--alert-bg-info); + } + + &.green { + --toast-bg: var(--alert-bg-success); + } + box-shadow: var(--modal-shadow); width: 400px; min-height: 50px; @@ -46,7 +66,7 @@ .alert-subtitle { font-size: var(--text-md); padding-left: 34px; - color: var(--text-muted); + color: var(--text-light); } } diff --git a/frappe/public/scss/desk/variables.scss b/frappe/public/scss/desk/variables.scss index 2855277ccd..abc63cd637 100644 --- a/frappe/public/scss/desk/variables.scss +++ b/frappe/public/scss/desk/variables.scss @@ -118,6 +118,9 @@ $custom-control-label-color: var(--text-color); $custom-switch-indicator-size: 8px; $custom-control-indicator-border-width: 2px; +// progress bar +$progress-bar-bg: var(--progress-bar-bg); + $navbar-nav-link-padding-x: 1rem !default; $navbar-padding-y: 1rem !default; $card-border-radius: 0.75rem !default; diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss index 69a7b205c4..dcd11a6c76 100644 --- a/frappe/public/scss/website/index.scss +++ b/frappe/public/scss/website/index.scss @@ -4,6 +4,7 @@ @import "../common/mixins"; @import "../common/global"; @import "../common/icons"; +@import "../common/alert"; @import 'base'; @import "../common/flex"; @import "../common/buttons"; @@ -30,12 +31,6 @@ @import 'my_account'; -body { - @include media-breakpoint-up(sm) { - background-color: var(--bg-color); - } -} - .ql-editor.read-mode { padding: 0; line-height: 1.6; @@ -137,7 +132,7 @@ body { a { color: var(--text-color) } - + li.disabled { a { color: var(--text-muted); diff --git a/frappe/public/scss/website/my_account.scss b/frappe/public/scss/website/my_account.scss index bdc52588aa..22b29cc3ec 100644 --- a/frappe/public/scss/website/my_account.scss +++ b/frappe/public/scss/website/my_account.scss @@ -1,7 +1,8 @@ //styles for my account and edit-profile page @include media-breakpoint-up(sm) { body[data-path="me"], - body[data-path="list"] { + body[data-path="list"], + body[data-path="update-profile"] { background-color: var(--bg-color); } } diff --git a/frappe/public/scss/website/web_form.scss b/frappe/public/scss/website/web_form.scss index cb79f88266..8f55bf8104 100644 --- a/frappe/public/scss/website/web_form.scss +++ b/frappe/public/scss/website/web_form.scss @@ -6,7 +6,7 @@ .breadcrumb-container.container { @include media-breakpoint-up(sm) { - padding-left: var(--padding-sm); + padding-left: 0; } } diff --git a/frappe/query_builder/__init__.py b/frappe/query_builder/__init__.py index bf7be84c51..5b58e70c4e 100644 --- a/frappe/query_builder/__init__.py +++ b/frappe/query_builder/__init__.py @@ -1,8 +1,21 @@ -from frappe.query_builder.terms import ParameterizedValueWrapper, ParameterizedFunction -import pypika +import pypika.terms +from pypika import * +from pypika import Field +from pypika.utils import ignore_copy + +from frappe.query_builder.terms import ParameterizedFunction, ParameterizedValueWrapper +from frappe.query_builder.utils import ( + Column, + DocType, + get_query_builder, + patch_query_aggregation, + patch_query_execute, +) pypika.terms.ValueWrapper = ParameterizedValueWrapper pypika.terms.Function = ParameterizedFunction -from pypika import * -from frappe.query_builder.utils import Column, DocType, get_query_builder, patch_query_execute, patch_query_aggregation +# * Overrides the field() method and replaces it with the a `PseudoColumn` 'field' for consistency +pypika.queries.Selectable.__getattr__ = ignore_copy(lambda table, x: Field(x, table=table)) +pypika.queries.Selectable.__getitem__ = ignore_copy(lambda table, x: Field(x, table=table)) +pypika.queries.Selectable.field = pypika.terms.PseudoColumn("field") diff --git a/frappe/query_builder/builder.py b/frappe/query_builder/builder.py index a65d50fdeb..d2fdeab324 100644 --- a/frappe/query_builder/builder.py +++ b/frappe/query_builder/builder.py @@ -1,8 +1,12 @@ from pypika import MySQLQuery, Order, PostgreSQLQuery, terms -from pypika.queries import Schema, Table -from frappe.utils import get_table_name +from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder +from pypika.queries import QueryBuilder, Schema, Table from pypika.terms import Function +from frappe.query_builder.terms import ParameterizedValueWrapper +from frappe.utils import get_table_name + + class Base: terms = terms desc = Order.desc @@ -19,13 +23,13 @@ class Base: return Table(table_name, *args, **kwargs) @classmethod - def into(cls, table, *args, **kwargs): + def into(cls, table, *args, **kwargs) -> QueryBuilder: if isinstance(table, str): table = cls.DocType(table) return super().into(table, *args, **kwargs) @classmethod - def update(cls, table, *args, **kwargs): + def update(cls, table, *args, **kwargs) -> QueryBuilder: if isinstance(table, str): table = cls.DocType(table) return super().update(table, *args, **kwargs) @@ -34,6 +38,10 @@ class Base: class MariaDB(Base, MySQLQuery): Field = terms.Field + @classmethod + def _builder(cls, *args, **kwargs) -> "MySQLQueryBuilder": + return super()._builder(*args, wrapper_cls=ParameterizedValueWrapper, **kwargs) + @classmethod def from_(cls, table, *args, **kwargs): if isinstance(table, str): @@ -53,6 +61,10 @@ class Postgres(Base, PostgreSQLQuery): # they are two different objects. The quick fix used here is to replace the # Field names in the "Field" function. + @classmethod + def _builder(cls, *args, **kwargs) -> "PostgreSQLQueryBuilder": + return super()._builder(*args, wrapper_cls=ParameterizedValueWrapper, **kwargs) + @classmethod def Field(cls, field_name, *args, **kwargs): if field_name in cls.field_translation: diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index 2032cd8497..205f1f9dcd 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -1,33 +1,77 @@ +from datetime import timedelta from typing import Any, Dict, Optional +from frappe.utils.data import format_timedelta from pypika.terms import Function, ValueWrapper from pypika.utils import format_alias_sql -class NamedParameterWrapper(): - def __init__(self, parameters: Dict[str, Any]): - self.parameters = parameters +class NamedParameterWrapper: + """Utility class to hold parameter values and keys""" - def update_parameters(self, param_key: Any, param_value: Any, **kwargs): + def __init__(self) -> None: + self.parameters = {} + + def get_sql(self, param_value: Any, **kwargs) -> str: + """returns SQL for a parameter, while adding the real value in a dict + + Args: + param_value (Any): Value of the parameter + + Returns: + str: parameter used in the SQL query + """ + param_key = f"%(param{len(self.parameters) + 1})s" self.parameters[param_key[2:-2]] = param_value + return param_key - def get_sql(self, **kwargs): - return f'%(param{len(self.parameters) + 1})s' + def get_parameters(self) -> Dict[str, Any]: + """get dict with parameters and values + + Returns: + Dict[str, Any]: parameter dict + """ + return self.parameters class ParameterizedValueWrapper(ValueWrapper): - def get_sql(self, quote_char: Optional[str] = None, secondary_quote_char: str = "'", param_wrapper= None, **kwargs: Any) -> str: - if param_wrapper is None: - sql = self.get_value_sql(quote_char=quote_char, secondary_quote_char=secondary_quote_char, **kwargs) - return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) + """ + Class to monkey patch ValueWrapper + + Adds functionality to parameterize queries when a `param wrapper` is passed in get_sql() + """ + + def get_sql( + self, + quote_char: Optional[str] = None, + secondary_quote_char: str = "'", + param_wrapper: Optional[NamedParameterWrapper] = None, + **kwargs: Any, + ) -> str: + if param_wrapper and isinstance(self.value, str): + # add quotes if it's a string value + value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) + sql = param_wrapper.get_sql(param_value=value_sql, **kwargs) else: - value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) if not isinstance(self.value,int) else self.value - param_sql = param_wrapper.get_sql(**kwargs) - param_wrapper.update_parameters(param_key=param_sql, param_value=value_sql, **kwargs) - return format_alias_sql(param_sql, self.alias, quote_char=quote_char, **kwargs) + # * BUG: pypika doesen't parse timedeltas + if isinstance(self.value, timedelta): + self.value = format_timedelta(self.value) + sql = self.get_value_sql( + quote_char=quote_char, + secondary_quote_char=secondary_quote_char, + param_wrapper=param_wrapper, + **kwargs, + ) + return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) class ParameterizedFunction(Function): + """ + Class to monkey patch pypika.terms.Functions + + Only to pass `param_wrapper` in `get_function_sql`. + """ + def get_sql(self, **kwargs: Any) -> str: with_alias = kwargs.pop("with_alias", False) with_namespace = kwargs.pop("with_namespace", False) @@ -35,15 +79,24 @@ class ParameterizedFunction(Function): dialect = kwargs.pop("dialect", None) param_wrapper = kwargs.pop("param_wrapper", None) - function_sql = self.get_function_sql(with_namespace=with_namespace, quote_char=quote_char, param_wrapper=param_wrapper, dialect=dialect) + function_sql = self.get_function_sql( + with_namespace=with_namespace, + quote_char=quote_char, + param_wrapper=param_wrapper, + dialect=dialect, + ) if self.schema is not None: function_sql = "{schema}.{function}".format( - schema=self.schema.get_sql(quote_char=quote_char, dialect=dialect, **kwargs), + schema=self.schema.get_sql( + quote_char=quote_char, dialect=dialect, **kwargs + ), function=function_sql, ) if with_alias: - return format_alias_sql(function_sql, self.alias, quote_char=quote_char, **kwargs) + return format_alias_sql( + function_sql, self.alias, quote_char=quote_char, **kwargs + ) return function_sql diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index 2767e90242..cbd6147e01 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -1,17 +1,17 @@ from enum import Enum -from typing import Any, Callable, Dict, Union, get_type_hints from importlib import import_module +from typing import Any, Callable, Dict, Union, get_type_hints from pypika import Query from pypika.queries import Column - -import frappe - -from .builder import MariaDB, Postgres from pypika.terms import PseudoColumn +import frappe from frappe.query_builder.terms import NamedParameterWrapper +from .builder import MariaDB, Postgres + + class db_type_is(Enum): MARIADB = "mariadb" POSTGRES = "postgres" @@ -59,11 +59,11 @@ def patch_query_execute(): return frappe.db.sql(query, params, *args, **kwargs) # nosemgrep def prepare_query(query): - params = {} - query = query.get_sql(param_wrapper = NamedParameterWrapper(params)) + param_collector = NamedParameterWrapper() + query = query.get_sql(param_wrapper=param_collector) if frappe.flags.in_safe_exec and not query.lower().strip().startswith("select"): raise frappe.PermissionError('Only SELECT SQL allowed in scripting') - return query, params + return query, param_collector.get_parameters() query_class = get_attr(str(frappe.qb).split("'")[1]) builder_class = get_type_hints(query_class._builder).get('return') @@ -78,7 +78,7 @@ def patch_query_execute(): def patch_query_aggregation(): """Patch aggregation functions to frappe.qb """ - from frappe.query_builder.functions import _max, _min, _avg, _sum + from frappe.query_builder.functions import _avg, _max, _min, _sum frappe.qb.max = _max frappe.qb.min = _min diff --git a/frappe/search/full_text_search.py b/frappe/search/full_text_search.py index 1d4f3fef32..79ccd3c6d5 100644 --- a/frappe/search/full_text_search.py +++ b/frappe/search/full_text_search.py @@ -66,7 +66,7 @@ class FullTextSearch: ix = self.get_index() with ix.searcher(): - writer = ix.writer() + writer = AsyncWriter(ix) writer.delete_by_term(self.id, doc_name) writer.commit(optimize=True) @@ -98,7 +98,7 @@ class FullTextSearch: def build_index(self): """Build index for all parsed documents""" ix = self.create_index() - writer = ix.writer() + writer = AsyncWriter(ix) for i, document in enumerate(self.documents): if document: diff --git a/frappe/sessions.py b/frappe/sessions.py index 6c9acdba13..6a5771b617 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -112,7 +112,7 @@ def get_expired_sessions(): frappe.db.get_values( sessions, filters=( - PseudoColumn(f"({Now() - sessions.lastupdate})") + PseudoColumn(f"({Now()} - {sessions.lastupdate.get_sql()})") > get_expiry_period_for_query(device) ) & (sessions.device == device), @@ -334,7 +334,7 @@ class Session: sessions, filters=(sessions.sid == self.sid) & ( - PseudoColumn(f"({Now() - sessions.lastupdate})") + PseudoColumn(f"({Now()} - {sessions.lastupdate.get_sql()})") < get_expiry_period_for_query(self.device) ), fieldname=["user", "sessiondata"], @@ -374,7 +374,7 @@ class Session: # database persistence is secondary, don't update it too often updated_in_db = False - if force or (time_diff==None) or (time_diff > 600): + if force or (time_diff is None) or (time_diff > 600): # update sessions table frappe.db.sql("""update `tabSessions` set sessiondata=%s, lastupdate=NOW() where sid=%s""" , (str(self.data['data']), diff --git a/frappe/social/doctype/energy_point_rule/energy_point_rule.py b/frappe/social/doctype/energy_point_rule/energy_point_rule.py index b48ec396de..55bf55a3b0 100644 --- a/frappe/social/doctype/energy_point_rule/energy_point_rule.py +++ b/frappe/social/doctype/energy_point_rule/energy_point_rule.py @@ -57,11 +57,11 @@ class EnergyPointRule(Document): def rule_condition_satisfied(self, doc): if self.for_doc_event == 'New': # indicates that this was a new doc - return doc.get_doc_before_save() == None + return doc.get_doc_before_save() is None if self.for_doc_event == 'Submit': - return doc.docstatus == 1 + return doc.docstatus.is_submitted() if self.for_doc_event == 'Cancel': - return doc.docstatus == 2 + return doc.docstatus.is_cancelled() if self.for_doc_event == 'Value Change': field_to_check = self.field_to_check if not field_to_check: return False @@ -96,7 +96,7 @@ def process_energy_points(doc, state): old_doc = doc.get_doc_before_save() # check if doc has been cancelled - if old_doc and old_doc.docstatus == 1 and doc.docstatus == 2: + if old_doc and old_doc.docstatus.is_submitted() and doc.docstatus.is_cancelled(): return revert_points_for_cancelled_doc(doc) for d in frappe.cache_manager.get_doctype_map('Energy Point Rule', doc.doctype, diff --git a/frappe/social/doctype/post/post.js b/frappe/social/doctype/post/post.js deleted file mode 100644 index 0294691812..0000000000 --- a/frappe/social/doctype/post/post.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Post', { - refresh: function() { - - } -}); diff --git a/frappe/social/doctype/post/post.json b/frappe/social/doctype/post/post.json deleted file mode 100644 index af70ce0793..0000000000 --- a/frappe/social/doctype/post/post.json +++ /dev/null @@ -1,343 +0,0 @@ -{ - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-09-25 11:39:04.533626", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "content", - "fieldtype": "Text Editor", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Content", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "comments", - "fieldtype": "Table", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "comments", - "length": 0, - "no_copy": 0, - "options": "Post Comment", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "liked_by", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Liked By", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "is_pinned", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Is Pinned", - "length": 0, - "no_copy": 0, - "permlevel": 2, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "is_globally_pinned", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Is Globally Pinned", - "length": 0, - "no_copy": 0, - "permlevel": 1, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2019-03-11 16:32:20.638805", - "modified_by": "Administrator", - "module": "Social", - "name": "Post", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 - }, - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 1, - "import": 0, - "permlevel": 2, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 2, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 - }, - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 1, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 1 -} \ No newline at end of file diff --git a/frappe/social/doctype/post/post.py b/frappe/social/doctype/post/post.py deleted file mode 100644 index a00f647b55..0000000000 --- a/frappe/social/doctype/post/post.py +++ /dev/null @@ -1,104 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors -# License: MIT. See LICENSE - -import frappe -import requests -from bs4 import BeautifulSoup -from frappe.model.document import Document - -class Post(Document): - def on_update(self): - if (self.is_globally_pinned): - frappe.publish_realtime('global_pin', after_commit=True) - - def after_insert(self): - frappe.publish_realtime('new_post', self.owner, after_commit=True) - -@frappe.whitelist() -def toggle_like(post_name, user=None): - liked_by = frappe.db.get_value('Post', post_name, 'liked_by') - liked_by = liked_by.split('\n') if liked_by else [] - user = user or frappe.session.user - - if user in liked_by: - liked_by.remove(user) - else: - liked_by.append(user) - - liked_by = '\n'.join(liked_by) - frappe.db.set_value('Post', post_name, 'liked_by', liked_by) - frappe.publish_realtime('update_liked_by' + post_name, liked_by, after_commit=True) - -@frappe.whitelist() -def frequently_visited_links(): - return frappe.get_all('Route History', fields=['route', 'count(name) as count'], filters={ - 'user': frappe.session.user - }, group_by="route", order_by="count desc", limit=5) - -@frappe.whitelist() -def get_link_info(url): - cached_link_info = frappe.cache().hget("link_info", url) - if cached_link_info: - return cached_link_info - - try: - page = requests.get(url) - except (requests.exceptions.MissingSchema, requests.exceptions.ConnectionError): - frappe.cache().hset("link_info", url, {}) - return {} - - soup = BeautifulSoup(page.text) - - meta_obj = {} - for meta in soup.findAll('meta'): - meta_name = meta.get('property') or meta.get('name', '').lower() - if meta_name: - meta_obj[meta_name] = meta.get('content') - - frappe.cache().hset("link_info", url, meta_obj) - - return meta_obj - -@frappe.whitelist() -def delete_post(post_name): - post = frappe.get_doc('Post', post_name) - post.delete() - frappe.publish_realtime('delete_post' + post_name, after_commit=True) - -def get_unseen_post_count(): - post_count = frappe.db.count('Post') - view_post_count = get_viewed_posts(True) - - return post_count - view_post_count - -@frappe.whitelist() -def get_posts(filters=None, limit_start=0): - filters = frappe.utils.get_safe_filters(filters) - posts = frappe.get_list('Post', - fields= ['name', 'content', 'owner', 'creation', 'liked_by', 'is_pinned', 'is_globally_pinned'], - filters=filters, - limit_start=limit_start, - limit=20, - order_by= 'is_globally_pinned desc, creation desc') - viewed_posts = get_viewed_posts() - for post in posts: - post['seen'] = post.name in viewed_posts - return posts - -def get_viewed_posts(only_count=False): - view_logs = frappe.db.get_all('View Log', filters={ - 'reference_doctype': 'Post', - 'viewed_by': frappe.session.user - }, fields=['reference_name']) - - return len(view_logs) if only_count else [log.reference_name for log in view_logs] - -@frappe.whitelist() -def set_seen(post_name): - frappe.get_doc({ - 'doctype': 'View Log', - 'reference_doctype': 'Post', - 'reference_name': post_name, - 'viewed_by': frappe.session.user - }).insert(ignore_permissions=True) diff --git a/frappe/social/doctype/post_comment/__init__.py b/frappe/social/doctype/post_comment/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frappe/social/doctype/post_comment/post_comment.js b/frappe/social/doctype/post_comment/post_comment.js deleted file mode 100644 index f03e12d977..0000000000 --- a/frappe/social/doctype/post_comment/post_comment.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Post Comment', { - refresh: function() { - - } -}); diff --git a/frappe/social/doctype/post_comment/post_comment.json b/frappe/social/doctype/post_comment/post_comment.json deleted file mode 100644 index 882bf1fb5f..0000000000 --- a/frappe/social/doctype/post_comment/post_comment.json +++ /dev/null @@ -1,133 +0,0 @@ -{ - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-10-14 10:16:22.852930", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "content", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Content", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-11-01 10:16:03.433920", - "modified_by": "Administrator", - "module": "Social", - "name": "Post Comment", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - }, - { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 - }, - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 1, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 -} \ No newline at end of file diff --git a/frappe/social/doctype/post_comment/post_comment.py b/frappe/social/doctype/post_comment/post_comment.py deleted file mode 100644 index a3394f3ac9..0000000000 --- a/frappe/social/doctype/post_comment/post_comment.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors -# License: MIT. See LICENSE - -import frappe -from frappe.model.document import Document -from frappe.core.doctype.user.user import extract_mentions - -class PostComment(Document): - def after_insert(self): - mentions = extract_mentions(self.content) - for mention in mentions: - if mention == self.owner: continue - frappe.publish_realtime('mention', """{} mentioned you! -
Check Social""" - .format(frappe.utils.get_fullname(self.owner)), - user=mention, - after_commit=True) - frappe.publish_realtime('new_post_comment' + self.parent, self, after_commit=True) diff --git a/frappe/templates/base.html b/frappe/templates/base.html index bc1f802cf7..8d892b5de6 100644 --- a/frappe/templates/base.html +++ b/frappe/templates/base.html @@ -105,8 +105,6 @@ // for backward compatibility of some libs frappe.sys_defaults = frappe.boot.sysdefaults; - - {{ include_script('frappe-web.bundle.js') }} {% endblock %} diff --git a/frappe/templates/includes/navbar/navbar_items.html b/frappe/templates/includes/navbar/navbar_items.html index 34cc24fe1a..dbc086f1f4 100644 --- a/frappe/templates/includes/navbar/navbar_items.html +++ b/frappe/templates/includes/navbar/navbar_items.html @@ -70,7 +70,7 @@ {% endif %} {% if show_sidebar and sidebar_items %} -
+

{% for item in sidebar_items -%}