diff --git a/.eslintrc b/.eslintrc index cc7f555669..937f11586c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -148,6 +148,7 @@ "context": true, "before": true, "beforeEach": true, + "after": true, "qz": true, "localforage": true, "extend_cscript": true diff --git a/.github/workflows/docs-checker.yml b/.github/workflows/docs-checker.yml index 02a01bf4e4..5e91063698 100644 --- a/.github/workflows/docs-checker.yml +++ b/.github/workflows/docs-checker.yml @@ -12,7 +12,7 @@ jobs: - name: 'Setup Environment' uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: 3.8 - name: 'Clone repo' uses: actions/checkout@v2 diff --git a/cypress.json b/cypress.json index ae4495cfa8..15f8f230fa 100644 --- a/cypress.json +++ b/cypress.json @@ -9,5 +9,7 @@ "retries": { "runMode": 2, "openMode": 2 - } + }, + "integrationFolder": ".", + "testFiles": ["cypress/integration/*.js", "**/ui_test_*.js"] } diff --git a/cypress/integration/first_day_of_the_week.js b/cypress/integration/first_day_of_the_week.js new file mode 100644 index 0000000000..1e65b78990 --- /dev/null +++ b/cypress/integration/first_day_of_the_week.js @@ -0,0 +1,45 @@ +context("First Day of the Week", () => { + before(() => { + cy.login(); + }); + + beforeEach(() => { + cy.visit('/app/system-settings'); + cy.findByText('Date and Number Format').click(); + }); + + it("Date control starts with same day as selected in System Settings", () => { + cy.intercept('POST', '/api/method/frappe.core.doctype.system_settings.system_settings.load').as("load_settings"); + cy.fill_field('first_day_of_the_week', 'Tuesday', 'Select'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.wait("@load_settings"); + cy.dialog({ + title: 'Date', + fields: [ + { + label: 'Date', + fieldname: 'date', + fieldtype: 'Date' + } + ] + }); + cy.get_field('date').click(); + cy.get('.datepicker--day-name').eq(0).should('have.text', 'Tu'); + }); + + it("Calendar view starts with same day as selected in System Settings", () => { + cy.intercept('POST', '/api/method/frappe.core.doctype.system_settings.system_settings.load').as("load_settings"); + cy.fill_field('first_day_of_the_week', 'Monday', 'Select'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.wait("@load_settings"); + cy.visit("app/todo/view/calendar/default"); + cy.get('.fc-day-header > span').eq(0).should('have.text', 'Mon'); + }); + + after(() => { + cy.visit('/app/system-settings'); + cy.findByText('Date and Number Format').click(); + cy.fill_field('first_day_of_the_week', 'Sunday', 'Select'); + cy.findByRole('button', {name: 'Save'}).click(); + }); +}); \ No newline at end of file diff --git a/cypress/integration/grid_keyboard_shortcut.js b/cypress/integration/grid_keyboard_shortcut.js index dee056e03e..9cf39165ad 100644 --- a/cypress/integration/grid_keyboard_shortcut.js +++ b/cypress/integration/grid_keyboard_shortcut.js @@ -1,48 +1,39 @@ context('Grid Keyboard Shortcut', () => { let total_count = 0; - beforeEach(() => { - cy.login(); - cy.visit('/app/doctype/User'); - }); before(() => { cy.login(); - cy.visit('/app/doctype/User'); - return cy.window().its('frappe').then(frappe => { - frappe.db.count('DocField', { - filters: { - 'parent': 'User', 'parentfield': 'fields', 'parenttype': 'DocType' - } - }).then((r) => { - total_count = r; - }); - }); + }); + beforeEach(() => { + cy.reload(); + cy.visit('/app/contact/new-contact-1'); + cy.get('.frappe-control[data-fieldname="email_ids"]').find(".grid-add-row").click(); }); it('Insert new row at the end', () => { cy.add_new_row_in_grid('{ctrl}{shift}{downarrow}', (cy, total_count) => { - cy.get('[data-name="new-docfield-1"]').should('have.attr', 'data-idx', `${total_count+1}`); + cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', `${total_count+1}`); }, total_count); }); it('Insert new row at the top', () => { cy.add_new_row_in_grid('{ctrl}{shift}{uparrow}', (cy) => { - cy.get('[data-name="new-docfield-1"]').should('have.attr', 'data-idx', '1'); + cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '2'); }); }); it('Insert new row below', () => { cy.add_new_row_in_grid('{ctrl}{downarrow}', (cy) => { - cy.get('[data-name="new-docfield-1"]').should('have.attr', 'data-idx', '2'); + cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '1'); }); }); it('Insert new row above', () => { cy.add_new_row_in_grid('{ctrl}{uparrow}', (cy) => { - cy.get('[data-name="new-docfield-1"]').should('have.attr', 'data-idx', '1'); + cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '2'); }); }); }); Cypress.Commands.add('add_new_row_in_grid', (shortcut_keys, callbackFn, total_count) => { - cy.get('.frappe-control[data-fieldname="fields"]').as('table'); - cy.get('@table').find('.grid-body .col-xs-2').first().click(); - cy.get('@table').find('.grid-body .col-xs-2') + cy.get('.frappe-control[data-fieldname="email_ids"]').as('table'); + cy.get('@table').find('.grid-body [data-fieldname="email_id"]').first().click(); + cy.get('@table').find('.grid-body [data-fieldname="email_id"]') .first().type(shortcut_keys); callbackFn(cy, total_count); diff --git a/cypress/integration/web_form.js b/cypress/integration/web_form.js new file mode 100644 index 0000000000..8346c96313 --- /dev/null +++ b/cypress/integration/web_form.js @@ -0,0 +1,29 @@ +context('Web Form', () => { + before(() => { + cy.login(); + }); + + it('Navigate and Submit a WebForm', () => { + cy.visit('/update-profile'); + cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200); + cy.get('.web-form-actions .btn-primary').click(); + cy.wait(500); + cy.get('.modal.show > .modal-dialog').should('be.visible'); + }); + + it('Navigate and Submit a MultiStep WebForm', () => { + cy.call('frappe.tests.ui_test_helpers.update_webform_to_multistep').then(() => { + cy.visit('/update-profile-duplicate'); + cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200); + cy.get('.btn-next').should('be.visible'); + cy.get('.web-form-footer .btn-primary').should('not.be.visible'); + cy.get('.btn-next').click(); + cy.get('.btn-previous').should('be.visible'); + cy.get('.btn-next').should('not.be.visible'); + cy.get('.web-form-footer .btn-primary').should('be.visible'); + cy.get('.web-form-actions .btn-primary').click(); + cy.wait(500); + cy.get('.modal.show > .modal-dialog').should('be.visible'); + }); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 933f6a1758..758b3cde2b 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -30,7 +30,7 @@ Cypress.Commands.add('login', (email, password) => { email = 'Administrator'; } if (!password) { - password = Cypress.config('adminPassword'); + password = Cypress.env('adminPassword'); } cy.request({ url: '/api/method/login', @@ -161,7 +161,7 @@ Cypress.Commands.add('remove_doc', (doctype, name) => { Cypress.Commands.add('create_records', doc => { return cy - .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc}) + .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc: JSON.stringify(doc)}) .then(r => r.message); }); @@ -193,7 +193,8 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => { }); Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => { - let selector = `[data-fieldname="${fieldname}"] input:visible`; + let field_element = fieldtype === 'Select' ? 'select': 'input'; + let selector = `[data-fieldname="${fieldname}"] ${field_element}:visible`; if (fieldtype === 'Text Editor') { selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]:visible`; diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index a3e27d4da5..a8c75bffd9 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -1,32 +1,47 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and contributors +# Copyright (c) 2022, Frappe Technologies and contributors # License: MIT. See LICENSE +from typing import Dict, Iterable, List + import frappe -from frappe.model.document import Document -from frappe.desk.form import assign_to -import frappe.cache_manager from frappe import _ +from frappe.cache_manager import clear_doctype_map, get_doctype_map +from frappe.desk.form import assign_to from frappe.model import log_types +from frappe.model.document import Document -class AssignmentRule(Document): +class AssignmentRule(Document): def validate(self): + self.validate_document_types() + self.validate_assignment_days() + + def clear_cache(self): + super().clear_cache() + clear_doctype_map(self.doctype, self.document_type) + clear_doctype_map(self.doctype, f"due_date_rules_for_{self.document_type}") + + def validate_document_types(self): + if self.document_type == "ToDo": + frappe.throw( + _('Assignment Rule is not allowed on {0} document type').format( + frappe.bold("ToDo") + ) + ) + + def validate_assignment_days(self): assignment_days = self.get_assignment_days() - if not len(set(assignment_days)) == len(assignment_days): - repeated_days = get_repeated(assignment_days) - frappe.throw(_("Assignment Day {0} has been repeated.").format(frappe.bold(repeated_days))) - if self.document_type == 'ToDo': - frappe.throw(_('Assignment Rule is not allowed on {0} document type').format(frappe.bold("ToDo"))) - def on_update(self): - clear_assignment_rule_cache(self) - - def after_rename(self, old, new, merge): - clear_assignment_rule_cache(self) + if len(set(assignment_days)) != len(assignment_days): + repeated_days = get_repeated(assignment_days) + plural = "s" if len(repeated_days) > 1 else "" - def on_trash(self): - clear_assignment_rule_cache(self) + frappe.throw( + _("Assignment Day{0} {1} has been repeated.").format( + plural, + frappe.bold(", ".join(repeated_days)) + ) + ) def apply_unassign(self, doc, assignments): if (self.unassign_condition and @@ -35,7 +50,6 @@ class AssignmentRule(Document): return False - def apply_assign(self, doc): if self.safe_eval('assign_condition', doc): return self.do_assignment(doc) @@ -109,7 +123,7 @@ class AssignmentRule(Document): user = d.user, count = frappe.db.count('ToDo', dict( reference_type = self.document_type, - owner = d.user, + allocated_to = d.user, status = "Open")) )) @@ -141,65 +155,68 @@ class AssignmentRule(Document): def is_rule_not_applicable_today(self): today = frappe.flags.assignment_day or frappe.utils.get_weekday() assignment_days = self.get_assignment_days() - if assignment_days and not today in assignment_days: - return True + return assignment_days and today not in assignment_days - return False -def get_assignments(doc): +def get_assignments(doc) -> List[Dict]: return frappe.get_all('ToDo', fields = ['name', 'assignment_rule'], filters = dict( reference_type = doc.get('doctype'), reference_name = doc.get('name'), status = ('!=', 'Cancelled') - ), limit = 5) + ), limit=5) + @frappe.whitelist() def bulk_apply(doctype, docnames): - import json - docnames = json.loads(docnames) - + docnames = frappe.parse_json(docnames) background = len(docnames) > 5 + for name in docnames: if background: frappe.enqueue('frappe.automation.doctype.assignment_rule.assignment_rule.apply', doc=None, doctype=doctype, name=name) else: - apply(None, doctype=doctype, name=name) + apply(doctype=doctype, name=name) + def reopen_closed_assignment(doc): - todo_list = frappe.db.get_all('ToDo', filters = dict( - reference_type = doc.doctype, - reference_name = doc.name, - status = 'Closed' - )) - if not todo_list: - return False + todo_list = frappe.get_all("ToDo", filters={ + "reference_type": doc.doctype, + "reference_name": doc.name, + "status": "Closed", + }, pluck="name") + for todo in todo_list: - todo_doc = frappe.get_doc('ToDo', todo.name) + todo_doc = frappe.get_doc('ToDo', todo) todo_doc.status = 'Open' todo_doc.save(ignore_permissions=True) - return True -def apply(doc, method=None, doctype=None, name=None): - if not doctype: - doctype = doc.doctype + return bool(todo_list) - if (frappe.flags.in_patch + +def apply(doc=None, method=None, doctype=None, name=None): + doctype = doctype or doc.doctype + + skip_assignment_rules = ( + frappe.flags.in_patch or frappe.flags.in_install or frappe.flags.in_setup_wizard - or doctype in log_types): + or doctype in log_types + ) + + if skip_assignment_rules: return if not doc and doctype and name: doc = frappe.get_doc(doctype, name) - assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', doc.doctype, dict( - document_type = doc.doctype, disabled = 0), order_by = 'priority desc') - - assignment_rule_docs = [] + assignment_rules = get_doctype_map("Assignment Rule", doc.doctype, filters={ + "document_type": doc.doctype, "disabled": 0 + }, order_by="priority desc") # multiple auto assigns - for d in assignment_rules: - assignment_rule_docs.append(frappe.get_cached_doc('Assignment Rule', d.get('name'))) + assignment_rule_docs: List[AssignmentRule] = [ + frappe.get_cached_doc("Assignment Rule", d.get('name')) for d in assignment_rules + ] if not assignment_rule_docs: return @@ -235,6 +252,7 @@ def apply(doc, method=None, doctype=None, name=None): # apply close rule only if assignments exists assignments = get_assignments(doc) + if assignments: for assignment_rule in assignment_rule_docs: if assignment_rule.is_rule_not_applicable_today(): @@ -242,38 +260,74 @@ def apply(doc, method=None, doctype=None, name=None): if not new_apply: # only reopen if close condition is not satisfied - if not assignment_rule.safe_eval('close_condition', doc): - reopen = reopen_closed_assignment(doc) - if reopen: + to_close_todos = assignment_rule.safe_eval('close_condition', doc) + + if to_close_todos: + # close todo status + todos_to_close = frappe.get_all("ToDo", filters={ + "reference_type": doc.doctype, + "reference_name": doc.name, + }, pluck="name") + + for todo in todos_to_close: + _todo = frappe.get_doc("ToDo", todo) + _todo.status = "Closed" + _todo.save() + break + + else: + reopened = reopen_closed_assignment(doc) + if reopened: break + + # print(f"Rule:{assignment_rule}\nDoc: {doc}\nReOpened: {reopened}") + assignment_rule.close_assignments(doc) + def update_due_date(doc, state=None): - # called from hook - if (frappe.flags.in_patch - or frappe.flags.in_install - or frappe.flags.in_migrate + """Run on_update on every Document (via hooks.py) + """ + skip_document_update = ( + frappe.flags.in_migrate + or frappe.flags.in_patch or frappe.flags.in_import - or frappe.flags.in_setup_wizard): + or frappe.flags.in_setup_wizard + or frappe.flags.in_install + ) + + if skip_document_update: return - assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', 'due_date_rules_for_' + doc.doctype, dict( - document_type = doc.doctype, - disabled = 0, - due_date_based_on = ['is', 'set'] - )) + + assignment_rules = get_doctype_map( + doctype="Assignment Rule", + name=f"due_date_rules_for_{doc.doctype}", + filters={ + "due_date_based_on": ["is", "set"], + "document_type": doc.doctype, + "disabled": 0, + } + ) + for rule in assignment_rules: - rule_doc = frappe.get_cached_doc('Assignment Rule', rule.get('name')) + rule_doc = frappe.get_cached_doc("Assignment Rule", rule.get("name")) due_date_field = rule_doc.due_date_based_on - if doc.meta.has_field(due_date_field) and \ - doc.has_value_changed(due_date_field) and rule.get('name'): - assignment_todos = frappe.get_all('ToDo', { - 'assignment_rule': rule.get('name'), - 'status': 'Open', - 'reference_type': doc.doctype, - 'reference_name': doc.name - }) + field_updated = ( + doc.meta.has_field(due_date_field) + and doc.has_value_changed(due_date_field) + and rule.get("name") + ) + + if field_updated: + assignment_todos = frappe.get_all("ToDo", filters={ + "assignment_rule": rule.get("name"), + "reference_type": doc.doctype, + "reference_name": doc.name, + "status": "Open", + }, pluck="name") + for todo in assignment_todos: - todo_doc = frappe.get_doc('ToDo', todo.name) + todo_doc = frappe.get_doc('ToDo', todo) todo_doc.date = doc.get(due_date_field) todo_doc.flags.updater_reference = { 'doctype': 'Assignment Rule', @@ -282,20 +336,19 @@ def update_due_date(doc, state=None): } todo_doc.save(ignore_permissions=True) -def get_assignment_rules(): - return [d.document_type for d in frappe.db.get_all('Assignment Rule', fields=['document_type'], filters=dict(disabled = 0))] -def get_repeated(values): - unique_list = [] - diff = [] +def get_assignment_rules() -> List[str]: + return frappe.get_all("Assignment Rule", filters={"disabled": 0}, pluck="document_type") + + +def get_repeated(values: Iterable) -> List: + unique = set() + repeated = set() + for value in values: - if value not in unique_list: - unique_list.append(str(value)) + if value in unique: + repeated.add(value) else: - if value not in diff: - diff.append(str(value)) - return " ".join(diff) + unique.add(value) -def clear_assignment_rule_cache(rule): - frappe.cache_manager.clear_doctype_map('Assignment Rule', rule.document_type) - frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type) + return [str(x) for x in repeated] diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py index 1c9e177f94..63dbf69d3b 100644 --- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py @@ -1,12 +1,22 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and Contributors +# Copyright (c) 2021, Frappe Technologies and Contributors # License: MIT. See LICENSE -import frappe + import unittest -from frappe.utils import random_string + +import frappe from frappe.test_runner import make_test_records +from frappe.utils import random_string + class TestAutoAssign(unittest.TestCase): + @classmethod + def setUpClass(cls): + frappe.db.delete("Assignment Rule") + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + def setUp(self): make_test_records("User") days = [ @@ -30,7 +40,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test@example.com') + ), 'allocated_to'), 'test@example.com') note = make_note(dict(public=1)) @@ -39,7 +49,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test1@example.com') + ), 'allocated_to'), 'test1@example.com') clear_assignments() @@ -51,7 +61,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test2@example.com') + ), 'allocated_to'), 'test2@example.com') # check loop back to first user note = make_note(dict(public=1)) @@ -60,7 +70,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test@example.com') + ), 'allocated_to'), 'test@example.com') def test_load_balancing(self): self.assignment_rule.rule = 'Load Balancing' @@ -71,11 +81,11 @@ class TestAutoAssign(unittest.TestCase): # check if each user has 10 assignments (?) for user in ('test@example.com', 'test1@example.com', 'test2@example.com'): - self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10) + self.assertEqual(len(frappe.get_all('ToDo', dict(allocated_to = user, reference_type = 'Note'))), 10) # clear 5 assignments for first user # can't do a limit in "delete" since postgres does not support it - for d in frappe.get_all('ToDo', dict(reference_type = 'Note', owner = 'test@example.com'), limit=5): + for d in frappe.get_all('ToDo', dict(reference_type = 'Note', allocated_to = 'test@example.com'), limit=5): frappe.db.delete("ToDo", {"name": d.name}) # add 5 more assignments @@ -84,7 +94,7 @@ class TestAutoAssign(unittest.TestCase): # check if each user still has 10 assignments for user in ('test@example.com', 'test1@example.com', 'test2@example.com'): - self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10) + self.assertEqual(len(frappe.get_all('ToDo', dict(allocated_to = user, reference_type = 'Note'))), 10) def test_based_on_field(self): self.assignment_rule.rule = 'Based on Field' @@ -119,7 +129,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), None) + ), 'allocated_to'), None) def test_clear_assignment(self): note = make_note(dict(public=1)) @@ -129,10 +139,10 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ))[0] + ), limit=1)[0] todo = frappe.get_doc('ToDo', todo['name']) - self.assertEqual(todo.owner, 'test@example.com') + self.assertEqual(todo.allocated_to, 'test@example.com') # test auto unassign note.public = 0 @@ -151,10 +161,10 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ))[0] + ), limit=1)[0] todo = frappe.get_doc('ToDo', todo['name']) - self.assertEqual(todo.owner, 'test@example.com') + self.assertEqual(todo.allocated_to, 'test@example.com') note.content="Closed" note.save() @@ -164,7 +174,7 @@ class TestAutoAssign(unittest.TestCase): # check if todo is closed self.assertEqual(todo.status, 'Closed') # check if closed todo retained assignment - self.assertEqual(todo.owner, 'test@example.com') + self.assertEqual(todo.allocated_to, 'test@example.com') def check_multiple_rules(self): note = make_note(dict(public=1, notify_on_login=1)) @@ -174,7 +184,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), 'test@example.com') + ), 'allocated_to'), 'test@example.com') def check_assignment_rule_scheduling(self): frappe.db.delete("Assignment Rule") @@ -192,7 +202,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), ['test@example.com', 'test1@example.com', 'test2@example.com']) + ), 'allocated_to'), ['test@example.com', 'test1@example.com', 'test2@example.com']) frappe.flags.assignment_day = "Friday" note = make_note(dict(public=1)) @@ -201,7 +211,7 @@ class TestAutoAssign(unittest.TestCase): reference_type = 'Note', reference_name = note.name, status = 'Open' - ), 'owner'), ['test3@example.com']) + ), 'allocated_to'), ['test3@example.com']) def test_assignment_rule_condition(self): frappe.db.delete("Assignment Rule") diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 5ab6c86c00..0277b8e402 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -96,7 +96,15 @@ class AutoRepeat(Document): auto_repeat_days = self.get_auto_repeat_days() if not len(set(auto_repeat_days)) == len(auto_repeat_days): repeated_days = get_repeated(auto_repeat_days) - frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days))) + plural = "s" if len(repeated_days) > 1 else "" + + frappe.throw( + _("Auto Repeat Day{0} {1} has been repeated.").format( + plural, + frappe.bold(", ".join(repeated_days)) + ) + ) + def update_auto_repeat_id(self): #check if document is already on auto repeat diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 3a78a6a599..1ab07d92e4 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -12,7 +12,7 @@ from frappe.core.utils import get_parent_doc from frappe.utils.bot import BotReply from frappe.utils import parse_addr, split_emails from frappe.core.doctype.comment.comment import update_comment_in_doc -from email.utils import parseaddr +from email.utils import getaddresses from urllib.parse import unquote from frappe.utils.user import is_system_user from frappe.contacts.doctype.contact.contact import get_contact_name @@ -372,10 +372,9 @@ def get_contacts(email_strings, auto_create_contact=False): for email_string in email_strings: if email_string: - for email in email_string.split(","): - parsed_email = parseaddr(email)[1] - if parsed_email: - email_addrs.append(parsed_email) + result = getaddresses([email_string]) + for email in result: + email_addrs.append(email[1]) contacts = [] for email in email_addrs: @@ -488,10 +487,12 @@ def update_parent_document_on_communication(doc): def update_first_response_time(parent, communication): if parent.meta.has_field("first_response_time") and not parent.get("first_response_time"): if is_system_user(communication.sender): - first_responded_on = communication.creation - if parent.meta.has_field("first_responded_on") and communication.sent_or_received == "Sent": - parent.db_set("first_responded_on", first_responded_on) - parent.db_set("first_response_time", round(time_diff_in_seconds(first_responded_on, parent.creation), 2)) + if communication.sent_or_received == "Sent": + first_responded_on = communication.creation + if parent.meta.has_field("first_responded_on"): + parent.db_set("first_responded_on", first_responded_on) + first_response_time = round(time_diff_in_seconds(first_responded_on, parent.creation), 2) + parent.db_set("first_response_time", first_response_time) def set_avg_response_time(parent, communication): if parent.meta.has_field("avg_response_time") and communication.sent_or_received == "Sent": diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 6910d615d3..26ddce7d35 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -1,543 +1,553 @@ { - "actions": [], - "autoname": "hash", - "creation": "2013-02-22 01:27:33", - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "label_and_type", - "label", - "fieldtype", - "fieldname", - "precision", - "length", - "non_negative", - "hide_days", - "hide_seconds", - "reqd", - "search_index", - "column_break_18", - "options", - "defaults_section", - "default", - "column_break_6", - "fetch_from", - "fetch_if_empty", - "visibility_section", - "hidden", - "bold", - "allow_in_quick_entry", - "translatable", - "print_hide", - "print_hide_if_no_value", - "report_hide", - "column_break_28", - "depends_on", - "collapsible", - "collapsible_depends_on", - "hide_border", - "list__search_settings_section", - "in_list_view", - "in_standard_filter", - "in_preview", - "column_break_35", - "in_filter", - "in_global_search", - "permissions", - "read_only", - "allow_on_submit", - "ignore_user_permissions", - "allow_bulk_edit", - "column_break_13", - "permlevel", - "ignore_xss_filter", - "constraints_section", - "unique", - "no_copy", - "set_only_once", - "remember_last_selected_value", - "column_break_38", - "mandatory_depends_on", - "read_only_depends_on", - "display", - "print_width", - "width", - "max_height", - "columns", - "column_break_22", - "description", - "oldfieldname", - "oldfieldtype" - ], - "fields": [{ - "fieldname": "label_and_type", - "fieldtype": "Section Break" - }, - { - "bold": 1, - "fieldname": "label", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Label", - "oldfieldname": "label", - "oldfieldtype": "Data", - "print_width": "163", - "search_index": 1, - "width": "163" - }, - { - "bold": 1, - "default": "Data", - "fieldname": "fieldtype", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Type", - "oldfieldname": "fieldtype", - "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break", - "reqd": 1, - "search_index": 1 - }, - { - "bold": 1, - "fieldname": "fieldname", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Name", - "oldfieldname": "fieldname", - "oldfieldtype": "Data", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:!in_list([\"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)", - "fieldname": "reqd", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Mandatory", - "oldfieldname": "reqd", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", - "description": "Set non-standard precision for a Float or Currency field", - "fieldname": "precision", - "fieldtype": "Select", - "label": "Precision", - "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9", - "print_hide": 1 - }, - { - "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", - "fieldname": "length", - "fieldtype": "Int", - "label": "Length" - }, - { - "default": "0", - "fieldname": "search_index", - "fieldtype": "Check", - "label": "Index", - "oldfieldname": "search_index", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "in_list_view", - "fieldtype": "Check", - "label": "In List View", - "print_width": "70px", - "width": "70px" - }, - { - "default": "0", - "fieldname": "in_standard_filter", - "fieldtype": "Check", - "label": "In List Filter" - }, - { - "default": "0", - "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", - "fieldname": "in_global_search", - "fieldtype": "Check", - "label": "In Global Search" - }, - { - "default": "0", - "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", - "fieldname": "in_preview", - "fieldtype": "Check", - "label": "In Preview" - }, - { - "default": "0", - "fieldname": "allow_in_quick_entry", - "fieldtype": "Check", - "label": "Allow in Quick Entry" - }, - { - "default": "0", - "fieldname": "bold", - "fieldtype": "Check", - "label": "Bold" - }, - { - "default": "0", - "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", - "fieldname": "translatable", - "fieldtype": "Check", - "label": "Translatable" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype===\"Section Break\"", - "fieldname": "collapsible", - "fieldtype": "Check", - "label": "Collapsible", - "length": 255 - }, - { - "depends_on": "eval:doc.fieldtype==\"Section Break\" && doc.collapsible", - "fieldname": "collapsible_depends_on", - "fieldtype": "Code", - "label": "Collapsible Depends On (JS)", - "max_height": "3rem", - "options": "JS" - }, - { - "fieldname": "column_break_6", - "fieldtype": "Column Break" - }, - { - "description": "For Links, enter the DocType as range.\nFor Select, enter list of Options, each on a new line.", - "fieldname": "options", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Options", - "oldfieldname": "options", - "oldfieldtype": "Text" - }, - { - "fieldname": "default", - "fieldtype": "Small Text", - "label": "Default", - "max_height": "3rem", - "oldfieldname": "default", - "oldfieldtype": "Text" - }, - { - "fieldname": "fetch_from", - "fieldtype": "Small Text", - "label": "Fetch From" - }, - { - "default": "0", - "fieldname": "fetch_if_empty", - "fieldtype": "Check", - "label": "Fetch only if value is not set" - }, - { - "fieldname": "permissions", - "fieldtype": "Section Break", - "label": "Permissions" - }, - { - "fieldname": "depends_on", - "fieldtype": "Code", - "label": "Display Depends On (JS)", - "length": 255, - "max_height": "3rem", - "oldfieldname": "depends_on", - "oldfieldtype": "Data", - "options": "JS" - }, - { - "default": "0", - "fieldname": "hidden", - "fieldtype": "Check", - "label": "Hidden", - "oldfieldname": "hidden", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "Read Only", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "unique", - "fieldtype": "Check", - "label": "Unique" - }, - { - "default": "0", - "fieldname": "set_only_once", - "fieldtype": "Check", - "label": "Set only once" - }, - { - "default": "0", - "depends_on": "eval: doc.fieldtype == \"Table\"", - "fieldname": "allow_bulk_edit", - "fieldtype": "Check", - "label": "Allow Bulk Edit" - }, - { - "fieldname": "column_break_13", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "permlevel", - "fieldtype": "Int", - "label": "Perm Level", - "oldfieldname": "permlevel", - "oldfieldtype": "Int", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "ignore_user_permissions", - "fieldtype": "Check", - "label": "Ignore User Permissions" - }, - { - "default": "0", - "depends_on": "eval: parent.is_submittable", - "fieldname": "allow_on_submit", - "fieldtype": "Check", - "label": "Allow on Submit", - "oldfieldname": "allow_on_submit", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "report_hide", - "fieldtype": "Check", - "label": "Report Hide", - "oldfieldname": "report_hide", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "depends_on": "eval:(doc.fieldtype == 'Link')", - "fieldname": "remember_last_selected_value", - "fieldtype": "Check", - "label": "Remember Last Selected Value" - }, - { - "default": "0", - "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", - "fieldname": "ignore_xss_filter", - "fieldtype": "Check", - "label": "Ignore XSS Filter" - }, - { - "fieldname": "display", - "fieldtype": "Section Break", - "label": "Display" - }, - { - "default": "0", - "fieldname": "in_filter", - "fieldtype": "Check", - "label": "In Filter", - "oldfieldname": "in_filter", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "no_copy", - "fieldtype": "Check", - "label": "No Copy", - "oldfieldname": "no_copy", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "print_hide", - "fieldtype": "Check", - "label": "Print Hide", - "oldfieldname": "print_hide", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", - "fieldname": "print_hide_if_no_value", - "fieldtype": "Check", - "label": "Print Hide If No Value" - }, - { - "fieldname": "print_width", - "fieldtype": "Data", - "label": "Print Width", - "length": 10 - }, - { - "fieldname": "width", - "fieldtype": "Data", - "label": "Width", - "length": 10, - "oldfieldname": "width", - "oldfieldtype": "Data", - "print_width": "50px", - "width": "50px" - }, - { - "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", - "fieldname": "columns", - "fieldtype": "Int", - "label": "Columns" - }, - { - "fieldname": "column_break_22", - "fieldtype": "Column Break" - }, - { - "fieldname": "description", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Description", - "oldfieldname": "description", - "oldfieldtype": "Text", - "print_width": "300px", - "width": "300px" - }, - { - "fieldname": "oldfieldname", - "fieldtype": "Data", - "hidden": 1, - "oldfieldname": "oldfieldname", - "oldfieldtype": "Data" - }, - { - "fieldname": "oldfieldtype", - "fieldtype": "Data", - "hidden": 1, - "oldfieldname": "oldfieldtype", - "oldfieldtype": "Data" - }, - { - "fieldname": "mandatory_depends_on", - "fieldtype": "Code", - "label": "Mandatory Depends On (JS)", - "max_height": "3rem", - "options": "JS" - }, - { - "fieldname": "read_only_depends_on", - "fieldtype": "Code", - "label": "Read Only Depends On (JS)", - "max_height": "3rem", - "options": "JS" - }, - { - "fieldname": "column_break_38", - "fieldtype": "Column Break" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_days", - "fieldtype": "Check", - "label": "Hide Days" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_seconds", - "fieldtype": "Check", - "label": "Hide Seconds" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Section Break'", - "fieldname": "hide_border", - "fieldtype": "Check", - "label": "Hide Border" - }, - { - "default": "0", - "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", - "fieldname": "non_negative", - "fieldtype": "Check", - "label": "Non Negative" - }, - { - "fieldname": "column_break_18", - "fieldtype": "Column Break" - }, - { - "fieldname": "defaults_section", - "fieldtype": "Section Break", - "label": "Defaults", - "max_height": "2rem" - }, - { - "fieldname": "visibility_section", - "fieldtype": "Section Break", - "label": "Visibility" - }, - { - "fieldname": "column_break_28", - "fieldtype": "Column Break" - }, - { - "fieldname": "constraints_section", - "fieldtype": "Section Break", - "label": "Constraints" - }, - { - "fieldname": "max_height", - "fieldtype": "Data", - "label": "Max Height", - "length": 10 - }, - { - "fieldname": "list__search_settings_section", - "fieldtype": "Section Break", - "label": "List / Search Settings" - }, - { - "fieldname": "column_break_35", - "fieldtype": "Column Break" - } - ], - "idx": 1, - "index_web_pages_for_search": 1, - "istable": 1, - "links": [], - "modified": "2021-09-04 19:41:23.684094", - "modified_by": "Administrator", - "module": "Core", - "name": "DocField", - "naming_rule": "Random", - "owner": "Administrator", - "permissions": [], - "sort_field": "modified", - "sort_order": "ASC" + "actions": [], + "autoname": "hash", + "creation": "2013-02-22 01:27:33", + "doctype": "DocType", + "document_type": "Setup", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label_and_type", + "label", + "fieldtype", + "fieldname", + "precision", + "length", + "non_negative", + "hide_days", + "hide_seconds", + "reqd", + "search_index", + "column_break_18", + "options", + "show_dashboard", + "defaults_section", + "default", + "column_break_6", + "fetch_from", + "fetch_if_empty", + "visibility_section", + "hidden", + "bold", + "allow_in_quick_entry", + "translatable", + "print_hide", + "print_hide_if_no_value", + "report_hide", + "column_break_28", + "depends_on", + "collapsible", + "collapsible_depends_on", + "hide_border", + "list__search_settings_section", + "in_list_view", + "in_standard_filter", + "in_preview", + "column_break_35", + "in_filter", + "in_global_search", + "permissions", + "read_only", + "allow_on_submit", + "ignore_user_permissions", + "allow_bulk_edit", + "column_break_13", + "permlevel", + "ignore_xss_filter", + "constraints_section", + "unique", + "no_copy", + "set_only_once", + "remember_last_selected_value", + "column_break_38", + "mandatory_depends_on", + "read_only_depends_on", + "display", + "print_width", + "width", + "max_height", + "columns", + "column_break_22", + "description", + "oldfieldname", + "oldfieldtype" + ], + "fields": [ + { + "fieldname": "label_and_type", + "fieldtype": "Section Break" + }, + { + "bold": 1, + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "oldfieldname": "label", + "oldfieldtype": "Data", + "print_width": "163", + "search_index": 1, + "width": "163" + }, + { + "bold": 1, + "default": "Data", + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "oldfieldname": "fieldtype", + "oldfieldtype": "Select", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "reqd": 1, + "search_index": 1 + }, + { + "bold": 1, + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Name", + "oldfieldname": "fieldname", + "oldfieldtype": "Data", + "search_index": 1 + }, + { + "default": "0", + "depends_on": "eval:!in_list([\"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)", + "fieldname": "reqd", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Mandatory", + "oldfieldname": "reqd", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", + "description": "Set non-standard precision for a Float or Currency field", + "fieldname": "precision", + "fieldtype": "Select", + "label": "Precision", + "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9", + "print_hide": 1 + }, + { + "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", + "fieldname": "length", + "fieldtype": "Int", + "label": "Length" + }, + { + "default": "0", + "fieldname": "search_index", + "fieldtype": "Check", + "label": "Index", + "oldfieldname": "search_index", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "in_list_view", + "fieldtype": "Check", + "label": "In List View", + "print_width": "70px", + "width": "70px" + }, + { + "default": "0", + "fieldname": "in_standard_filter", + "fieldtype": "Check", + "label": "In List Filter" + }, + { + "default": "0", + "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", + "fieldname": "in_global_search", + "fieldtype": "Check", + "label": "In Global Search" + }, + { + "default": "0", + "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", + "fieldname": "in_preview", + "fieldtype": "Check", + "label": "In Preview" + }, + { + "default": "0", + "fieldname": "allow_in_quick_entry", + "fieldtype": "Check", + "label": "Allow in Quick Entry" + }, + { + "default": "0", + "fieldname": "bold", + "fieldtype": "Check", + "label": "Bold" + }, + { + "default": "0", + "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", + "fieldname": "translatable", + "fieldtype": "Check", + "label": "Translatable" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype===\"Section Break\"", + "fieldname": "collapsible", + "fieldtype": "Check", + "label": "Collapsible", + "length": 255 + }, + { + "depends_on": "eval:doc.fieldtype==\"Section Break\" && doc.collapsible", + "fieldname": "collapsible_depends_on", + "fieldtype": "Code", + "label": "Collapsible Depends On (JS)", + "max_height": "3rem", + "options": "JS" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "description": "For Links, enter the DocType as range.\nFor Select, enter list of Options, each on a new line.", + "fieldname": "options", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Options", + "oldfieldname": "options", + "oldfieldtype": "Text" + }, + { + "fieldname": "default", + "fieldtype": "Small Text", + "label": "Default", + "max_height": "3rem", + "oldfieldname": "default", + "oldfieldtype": "Text" + }, + { + "fieldname": "fetch_from", + "fieldtype": "Small Text", + "label": "Fetch From" + }, + { + "default": "0", + "fieldname": "fetch_if_empty", + "fieldtype": "Check", + "label": "Fetch only if value is not set" + }, + { + "fieldname": "permissions", + "fieldtype": "Section Break", + "label": "Permissions" + }, + { + "fieldname": "depends_on", + "fieldtype": "Code", + "label": "Display Depends On (JS)", + "length": 255, + "max_height": "3rem", + "oldfieldname": "depends_on", + "oldfieldtype": "Data", + "options": "JS" + }, + { + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden", + "oldfieldname": "hidden", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "Read Only", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "unique", + "fieldtype": "Check", + "label": "Unique" + }, + { + "default": "0", + "fieldname": "set_only_once", + "fieldtype": "Check", + "label": "Set only once" + }, + { + "default": "0", + "depends_on": "eval: doc.fieldtype == \"Table\"", + "fieldname": "allow_bulk_edit", + "fieldtype": "Check", + "label": "Allow Bulk Edit" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "permlevel", + "fieldtype": "Int", + "label": "Perm Level", + "oldfieldname": "permlevel", + "oldfieldtype": "Int", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "ignore_user_permissions", + "fieldtype": "Check", + "label": "Ignore User Permissions" + }, + { + "default": "0", + "depends_on": "eval: parent.is_submittable", + "fieldname": "allow_on_submit", + "fieldtype": "Check", + "label": "Allow on Submit", + "oldfieldname": "allow_on_submit", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "report_hide", + "fieldtype": "Check", + "label": "Report Hide", + "oldfieldname": "report_hide", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "depends_on": "eval:(doc.fieldtype == 'Link')", + "fieldname": "remember_last_selected_value", + "fieldtype": "Check", + "label": "Remember Last Selected Value" + }, + { + "default": "0", + "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", + "fieldname": "ignore_xss_filter", + "fieldtype": "Check", + "label": "Ignore XSS Filter" + }, + { + "fieldname": "display", + "fieldtype": "Section Break", + "label": "Display" + }, + { + "default": "0", + "fieldname": "in_filter", + "fieldtype": "Check", + "label": "In Filter", + "oldfieldname": "in_filter", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "no_copy", + "fieldtype": "Check", + "label": "No Copy", + "oldfieldname": "no_copy", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "print_hide", + "fieldtype": "Check", + "label": "Print Hide", + "oldfieldname": "print_hide", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", + "fieldname": "print_hide_if_no_value", + "fieldtype": "Check", + "label": "Print Hide If No Value" + }, + { + "fieldname": "print_width", + "fieldtype": "Data", + "label": "Print Width", + "length": 10 + }, + { + "fieldname": "width", + "fieldtype": "Data", + "label": "Width", + "length": 10, + "oldfieldname": "width", + "oldfieldtype": "Data", + "print_width": "50px", + "width": "50px" + }, + { + "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", + "fieldname": "columns", + "fieldtype": "Int", + "label": "Columns" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "300px", + "width": "300px" + }, + { + "fieldname": "oldfieldname", + "fieldtype": "Data", + "hidden": 1, + "oldfieldname": "oldfieldname", + "oldfieldtype": "Data" + }, + { + "fieldname": "oldfieldtype", + "fieldtype": "Data", + "hidden": 1, + "oldfieldname": "oldfieldtype", + "oldfieldtype": "Data" + }, + { + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On (JS)", + "max_height": "3rem", + "options": "JS" + }, + { + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On (JS)", + "max_height": "3rem", + "options": "JS" + }, + { + "fieldname": "column_break_38", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_days", + "fieldtype": "Check", + "label": "Hide Days" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_seconds", + "fieldtype": "Check", + "label": "Hide Seconds" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "fieldname": "hide_border", + "fieldtype": "Check", + "label": "Hide Border" + }, + { + "default": "0", + "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", + "fieldname": "non_negative", + "fieldtype": "Check", + "label": "Non Negative" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "defaults_section", + "fieldtype": "Section Break", + "label": "Defaults", + "max_height": "2rem" + }, + { + "fieldname": "visibility_section", + "fieldtype": "Section Break", + "label": "Visibility" + }, + { + "fieldname": "column_break_28", + "fieldtype": "Column Break" + }, + { + "fieldname": "constraints_section", + "fieldtype": "Section Break", + "label": "Constraints" + }, + { + "fieldname": "max_height", + "fieldtype": "Data", + "label": "Max Height", + "length": 10 + }, + { + "fieldname": "list__search_settings_section", + "fieldtype": "Section Break", + "label": "List / Search Settings" + }, + { + "fieldname": "column_break_35", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype===\"Tab Break\"", + "fieldname": "show_dashboard", + "fieldtype": "Check", + "label": "Show Dashboard" + } + ], + "idx": 1, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-01-03 11:56:19.812863", + "modified_by": "Administrator", + "module": "Core", + "name": "DocField", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index ad0c3e8e6f..3754288145 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1283,7 +1283,7 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): roles = [p.role for p in doc.get("permissions") or []] + default_roles for role in list(set(roles)): - if not frappe.db.exists("Role", role): + if frappe.db.table_exists("Role", cached=False) and not frappe.db.exists("Role", role): r = frappe.get_doc(dict(doctype= "Role", role_name=role, desk_access=1)) r.flags.ignore_mandatory = r.flags.ignore_permissions = True r.insert() diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 4362a52c34..12c227464d 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -15,6 +15,10 @@ from frappe.core.doctype.doctype.doctype import (UniqueFieldnameError, # test_records = frappe.get_test_records('DocType') class TestDocType(unittest.TestCase): + + def tearDown(self): + frappe.db.rollback() + def test_validate_name(self): self.assertRaises(frappe.NameError, new_doctype("_Some DocType").insert) self.assertRaises(frappe.NameError, new_doctype("8Some DocType").insert) @@ -42,6 +46,7 @@ class TestDocType(unittest.TestCase): doc1.insert() self.assertRaises(frappe.UniqueValidationError, doc2.insert) + frappe.db.rollback() dt.fields[0].unique = 0 dt.save() diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 9a758b53f5..2c1042e104 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -18,6 +18,7 @@ test_content2 = 'Hello World' def make_test_doc(): d = frappe.new_doc('ToDo') d.description = 'Test' + d.assigned_by = frappe.session.user d.save() return d.doctype, d.name diff --git a/frappe/core/doctype/module_def/module_def.json b/frappe/core/doctype/module_def/module_def.json index 7ddc55fce5..12830c8b4f 100644 --- a/frappe/core/doctype/module_def/module_def.json +++ b/frappe/core/doctype/module_def/module_def.json @@ -10,7 +10,8 @@ "custom", "package", "app_name", - "restrict_to_domain" + "restrict_to_domain", + "connections_tab" ], "fields": [ { @@ -50,6 +51,12 @@ "fieldtype": "Link", "label": "Package", "options": "Package" + }, + { + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 } ], "icon": "fa fa-sitemap", @@ -116,7 +123,7 @@ "link_fieldname": "module" } ], - "modified": "2021-09-05 21:58:40.253909", + "modified": "2022-01-03 13:56:52.817954", "modified_by": "Administrator", "module": "Core", "name": "Module Def", @@ -154,5 +161,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js index 4eeab0274b..5128ae24cb 100644 --- a/frappe/core/doctype/system_settings/system_settings.js +++ b/frappe/core/doctype/system_settings/system_settings.js @@ -10,6 +10,10 @@ frappe.ui.form.on("System Settings", { frm.set_value(key, val); frappe.sys_defaults[key] = val; }); + if (frm.re_setup_moment) { + frappe.app.setup_moment(); + delete frm.re_setup_moment; + } } }); }, @@ -38,5 +42,8 @@ frappe.ui.form.on("System Settings", { // Clear cache after saving to refresh the values of boot. frappe.ui.toolbar.clear_cache(); } - } + }, + first_day_of_the_week(frm) { + frm.re_setup_moment = true; + }, }); diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 3e04643256..61410fb1a8 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -17,10 +17,11 @@ "date_and_number_format", "date_format", "time_format", - "column_break_7", "number_format", + "column_break_7", "float_precision", "currency_precision", + "first_day_of_the_week", "sec_backup_limit", "backup_limit", "encrypt_backup", @@ -477,12 +478,19 @@ "fieldname": "disable_system_update_notification", "fieldtype": "Check", "label": "Disable System Update Notification" + }, + { + "default": "Sunday", + "fieldname": "first_day_of_the_week", + "fieldtype": "Select", + "label": "First Day of the Week", + "options": "Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2021-11-29 18:09:53.601629", + "modified": "2022-01-04 11:28:34.881192", "modified_by": "Administrator", "module": "Core", "name": "System Settings", @@ -499,5 +507,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/translation/translation.json b/frappe/core/doctype/translation/translation.json index e91ffc2941..560f3b2ce2 100644 --- a/frappe/core/doctype/translation/translation.json +++ b/frappe/core/doctype/translation/translation.json @@ -43,8 +43,7 @@ { "fieldname": "context", "fieldtype": "Data", - "label": "Context", - "read_only": 1 + "label": "Context" }, { "default": "0", @@ -83,7 +82,7 @@ } ], "links": [], - "modified": "2020-03-12 13:28:48.223409", + "modified": "2021-12-31 10:19:52.541055", "modified_by": "Administrator", "module": "Core", "name": "Translation", diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index b3c85b22a1..d1291acfc4 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -70,7 +70,7 @@ class TestUser(unittest.TestCase): delete_contact("_test@example.com") delete_doc("User", "_test@example.com") - self.assertTrue(not frappe.db.sql("""select * from `tabToDo` where owner=%s""", + self.assertTrue(not frappe.db.sql("""select * from `tabToDo` where allocated_to=%s""", ("_test@example.com",))) from frappe.core.doctype.role.test_role import test_records as role_records diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index cf05ce0c15..a47f539466 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -10,15 +10,15 @@ "enabled", "section_break_3", "email", + "first_name", + "middle_name", "last_name", - "language", "column_break0", - "first_name", "full_name", - "time_zone", - "column_break_11", - "middle_name", "username", + "column_break_11", + "language", + "time_zone", "send_welcome_email", "unsubscribed", "user_image", @@ -660,7 +660,7 @@ { "group": "Activity", "link_doctype": "ToDo", - "link_fieldname": "owner" + "link_fieldname": "allocated_to" }, { "group": "Integrations", @@ -669,7 +669,7 @@ } ], "max_attachments": 5, - "modified": "2021-11-17 17:17:16.098457", + "modified": "2022-01-03 11:53:25.250822", "modified_by": "Administrator", "module": "Core", "name": "User", @@ -702,6 +702,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "full_name", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 2d2ad1fed9..ef7845d3b0 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -363,7 +363,7 @@ class User(Document): frappe.local.login_manager.logout(user=self.name) # delete todos - frappe.db.delete("ToDo", {"owner": self.name}) + frappe.db.delete("ToDo", {"allocated_to": self.name}) todo_table = DocType("ToDo") ( frappe.qb.update(todo_table) diff --git a/frappe/core/doctype/user_permission/user_permission.js b/frappe/core/doctype/user_permission/user_permission.js index 4c3f5b4eb8..8d5c5c1a23 100644 --- a/frappe/core/doctype/user_permission/user_permission.js +++ b/frappe/core/doctype/user_permission/user_permission.js @@ -44,7 +44,7 @@ frappe.ui.form.on('User Permission', { set_applicable_for_constraint: frm => { frm.toggle_reqd('applicable_for', !frm.doc.apply_to_all_doctypes); - if (frm.doc.apply_to_all_doctypes) { + if (frm.doc.apply_to_all_doctypes && frm.doc.applicable_for) { frm.set_value('applicable_for', null); } }, diff --git a/frappe/core/doctype/user_permission/user_permission.json b/frappe/core/doctype/user_permission/user_permission.json index 9cea0856c9..60b6779bfd 100644 --- a/frappe/core/doctype/user_permission/user_permission.json +++ b/frappe/core/doctype/user_permission/user_permission.json @@ -8,8 +8,8 @@ "field_order": [ "user", "allow", - "column_break_3", "for_value", + "column_break_3", "is_default", "advanced_control_section", "apply_to_all_doctypes", @@ -37,10 +37,6 @@ "options": "DocType", "reqd": 1 }, - { - "fieldname": "column_break_3", - "fieldtype": "Column Break" - }, { "fieldname": "for_value", "fieldtype": "Dynamic Link", @@ -87,10 +83,14 @@ "fieldtype": "Check", "hidden": 1, "label": "Hide Descendants" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" } ], "links": [], - "modified": "2021-01-21 18:14:10.839381", + "modified": "2022-01-03 11:25:03.726150", "modified_by": "Administrator", "module": "Core", "name": "User Permission", @@ -111,6 +111,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "user", "track_changes": 1 } \ 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 c1fd678141..661ac932e7 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -37,16 +37,14 @@ class UserType(Document): return modules = frappe.get_all("DocType", - fields=["module"], filters={"name": ("in", [d.document_type for d in self.user_doctypes])}, distinct=True, + pluck="module", ) - self.set('user_type_modules', []) - for row in modules: - self.append('user_type_modules', { - 'module': row.module - }) + self.set("user_type_modules", []) + for module in modules: + self.append("user_type_modules", {"module": module}) def validate_document_type_limit(self): limit = frappe.conf.get('user_type_doctype_limit', {}).get(frappe.scrub(self.name)) diff --git a/frappe/core/notifications.py b/frappe/core/notifications.py index 939cf52911..be3e723af6 100644 --- a/frappe/core/notifications.py +++ b/frappe/core/notifications.py @@ -23,7 +23,7 @@ def get_things_todo(as_list=False): data = frappe.get_list("ToDo", fields=["name", "description"] if as_list else "count(*)", filters=[["ToDo", "status", "=", "Open"]], - or_filters=[["ToDo", "owner", "=", frappe.session.user], + or_filters=[["ToDo", "allocated_to", "=", frappe.session.user], ["ToDo", "assigned_by", "=", frappe.session.user]], as_list=True) diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 0b17200c6f..24a5d1358b 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -516,6 +516,7 @@ docfield_properties = { 'options': 'Text', 'fetch_from': 'Small Text', 'fetch_if_empty': 'Check', + 'show_dashboard': 'Check', 'permlevel': 'Int', 'width': 'Data', 'print_width': 'Data', diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 986b99a7af..a545cd9fe1 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -28,6 +28,7 @@ "options", "fetch_from", "fetch_if_empty", + "show_dashboard", "permissions", "depends_on", "permlevel", @@ -82,7 +83,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nTab Break", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -422,18 +423,27 @@ "fieldname": "non_negative", "fieldtype": "Check", "label": "Non Negative" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Tab Break'", + "fieldname": "show_dashboard", + "fieldtype": "Check", + "label": "Show Dashboard" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-07-11 21:57:24.479749", + "modified": "2022-01-03 14:50:32.035768", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/frappe/database/__init__.py b/frappe/database/__init__.py index b0e3183d4f..7b26ac31b3 100644 --- a/frappe/database/__init__.py +++ b/frappe/database/__init__.py @@ -4,6 +4,8 @@ # Database Module # -------------------- +from frappe.database.database import savepoint + def setup_database(force, source_sql=None, verbose=None, no_mariadb_socket=False): import frappe if frappe.conf.db_type == 'postgres': diff --git a/frappe/database/database.py b/frappe/database/database.py index 7c147cd1d0..65242e0419 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -4,16 +4,18 @@ # Database Module # -------------------- +import datetime +import random import re -import time -from typing import Dict, List, Union +import string +from contextlib import contextmanager +from time import time +from typing import Dict, List, Union, Tuple + import frappe -import datetime import frappe.defaults import frappe.model.meta - from frappe import _ -from time import time 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 @@ -162,10 +164,7 @@ class Database(object): frappe.errprint(("Execution time: {0} sec").format(round(time_end - time_start, 2))) except Exception as e: - if frappe.conf.db_type == 'postgres': - self.rollback() - - elif self.is_syntax_error(e): + if self.is_syntax_error(e): # only for mariadb frappe.errprint('Syntax error in query:') frappe.errprint(query) @@ -176,6 +175,9 @@ class Database(object): elif self.is_timedout(e): raise frappe.QueryTimeoutError(e) + elif frappe.conf.db_type == 'postgres': + raise + if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)): pass else: @@ -265,9 +267,7 @@ class Database(object): """Raises exception if more than 20,000 `INSERT`, `UPDATE` queries are executed in one transaction. This is to ensure that writes are always flushed otherwise this could cause the system to hang.""" - if self.transaction_writes and \ - query and query.strip().split()[0].lower() in ['start', 'alter', 'drop', 'create', "begin", "truncate"]: - raise Exception('This statement can cause implicit commit') + self.check_implicit_commit(query) if query and query.strip().lower() in ('commit', 'rollback'): self.transaction_writes = 0 @@ -280,6 +280,11 @@ class Database(object): else: frappe.throw(_("Too many writes in one request. Please send smaller requests"), frappe.ValidationError) + def check_implicit_commit(self, query): + if self.transaction_writes and \ + query and query.strip().split()[0].lower() in ['start', 'alter', 'drop', 'create', "begin", "truncate"]: + raise Exception('This statement can cause implicit commit') + def fetch_as_dict(self, formatted=0, as_utf8=0): """Internal. Converts results to dict.""" result = self._cursor.fetchall() @@ -699,6 +704,8 @@ class Database(object): self.sql("""update `tab{0}` set {1} where name=%(name)s""".format(dt, ', '.join(set_values)), values, debug=debug) + + frappe.clear_document_cache(dt, values['name']) else: # for singles keys = list(to_update) @@ -711,10 +718,11 @@ class Database(object): self.sql('''insert into `tabSingles` (doctype, field, value) values (%s, %s, %s)''', (dt, key, value), debug=debug) + frappe.clear_document_cache(dt, dn) + if dt in self.value_cache: del self.value_cache[dt] - frappe.clear_document_cache(dt, dn) @staticmethod def set(doc, field, val): @@ -811,6 +819,9 @@ class Database(object): Avoid using savepoints when writing to filesystem.""" self.sql(f"savepoint {save_point}") + def release_savepoint(self, save_point): + self.sql(f"release savepoint {save_point}") + def rollback(self, *, save_point=None): """`ROLLBACK` current transaction. Optionally rollback to a known save_point.""" if save_point: @@ -830,9 +841,9 @@ class Database(object): 'parent': dt }) - def table_exists(self, doctype): + def table_exists(self, doctype, cached=True): """Returns True if table for given doctype exists.""" - return ("tab" + doctype) in self.get_tables() + return ("tab" + doctype) in self.get_tables(cached=cached) def has_table(self, doctype): return self.table_exists(doctype) @@ -1097,3 +1108,28 @@ def enqueue_jobs_after_commit(): q.enqueue_call(execute_job, timeout=job.get("timeout"), kwargs=job.get("queue_args")) frappe.flags.enqueue_after_commit = [] + +@contextmanager +def savepoint(catch: Union[type, Tuple[type, ...]] = Exception): + """ Wrapper for wrapping blocks of DB operations in a savepoint. + + as contextmanager: + + for doc in docs: + with savepoint(catch=DuplicateError): + doc.insert() + + as decorator (wraps FULL function call): + + @savepoint(catch=DuplicateError) + def process_doc(doc): + doc.insert() + """ + try: + savepoint = ''.join(random.sample(string.ascii_lowercase, 10)) + frappe.db.savepoint(savepoint) + yield # control back to calling function + except catch: + frappe.db.rollback(save_point=savepoint) + else: + frappe.db.release_savepoint(savepoint) diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index 73b98f0ff3..cfb4e243a2 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -25,6 +25,7 @@ CREATE TABLE `tabDocField` ( `oldfieldtype` varchar(255) DEFAULT NULL, `options` text, `search_index` int(1) NOT NULL DEFAULT 0, + `show_dashboard` int(1) NOT NULL DEFAULT 0, `hidden` int(1) NOT NULL DEFAULT 0, `set_only_once` int(1) NOT NULL DEFAULT 0, `allow_in_quick_entry` int(1) NOT NULL DEFAULT 0, diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 008635b1b3..33f07990af 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -3,7 +3,7 @@ from typing import List, Tuple, Union import psycopg2 import psycopg2.extensions -from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +from psycopg2.extensions import ISOLATION_LEVEL_REPEATABLE_READ from psycopg2.errorcodes import STRING_DATA_RIGHT_TRUNCATION import frappe @@ -69,14 +69,20 @@ class PostgresDatabase(Database): conn = psycopg2.connect("host='{}' dbname='{}' user='{}' password='{}' port={}".format( self.host, self.user, self.user, self.password, self.port )) - conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) # TODO: Remove this + conn.set_isolation_level(ISOLATION_LEVEL_REPEATABLE_READ) return conn def escape(self, s, percent=True): - """Excape quotes and percent in given string.""" + """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: + s = '' if percent: s = s.replace("%", "%%") @@ -103,7 +109,7 @@ class PostgresDatabase(Database): return super(PostgresDatabase, self).sql(*args, **kwargs) - def get_tables(self): + def get_tables(self, cached=True): return [d[0] for d in self.sql("""select table_name from information_schema.tables where table_catalog='{0}' @@ -138,6 +144,10 @@ class PostgresDatabase(Database): # http://initd.org/psycopg/docs/extensions.html?highlight=datatype#psycopg2.extensions.QueryCanceledError return isinstance(e, psycopg2.extensions.QueryCanceledError) + @staticmethod + def is_syntax_error(e): + return isinstance(e, psycopg2.errors.SyntaxError) + @staticmethod def is_table_missing(e): return getattr(e, 'pgcode', None) == '42P01' @@ -255,8 +265,8 @@ class PostgresDatabase(Database): key=key ) - def check_transaction_status(self, query): - pass + def check_implicit_commit(self, query): + pass # postgres can run DDL in transactions without implicit commits def has_index(self, table_name, index_name): return self.sql("""SELECT 1 FROM pg_indexes WHERE tablename='{table_name}' diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index e8e047f194..f911e34650 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -27,6 +27,7 @@ CREATE TABLE "tabDocField" ( "search_index" smallint NOT NULL DEFAULT 0, "hidden" smallint NOT NULL DEFAULT 0, "set_only_once" smallint NOT NULL DEFAULT 0, + "show_dashboard" smallint NOT NULL DEFAULT 0, "allow_in_quick_entry" smallint NOT NULL DEFAULT 0, "print_hide" smallint NOT NULL DEFAULT 0, "report_hide" smallint NOT NULL DEFAULT 0, diff --git a/frappe/database/schema.py b/frappe/database/schema.py index ce9fcb4147..10582eff8f 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -206,6 +206,12 @@ class DbColumn: if not current_def: self.fieldname = validate_column_name(self.fieldname) self.table.add_column.append(self) + + if column_type not in ('text', 'longtext'): + if self.unique: + self.table.add_unique.append(self) + if self.set_index: + self.table.add_index.append(self) return # type diff --git a/frappe/desk/doctype/bulk_update/bulk_update.py b/frappe/desk/doctype/bulk_update/bulk_update.py index b512ca175c..a0523d90cd 100644 --- a/frappe/desk/doctype/bulk_update/bulk_update.py +++ b/frappe/desk/doctype/bulk_update/bulk_update.py @@ -7,6 +7,7 @@ from frappe.model.document import Document from frappe import _ from frappe.utils import cint + class BulkUpdate(Document): pass @@ -22,7 +23,7 @@ def update(doctype, field, value, condition='', limit=500): frappe.throw(_('; not allowed in condition')) docnames = frappe.db.sql_list( - '''select name from `tab{0}`{1} limit 0, {2}'''.format(doctype, condition, limit) + '''select name from `tab{0}`{1} limit {2} offset 0'''.format(doctype, condition, limit) ) data = {} data[field] = value diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py index 0dfd458a37..ac62796dc2 100644 --- a/frappe/desk/doctype/dashboard/dashboard.py +++ b/frappe/desk/doctype/dashboard/dashboard.py @@ -1,23 +1,33 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and contributors +# Copyright (c) 2022, Frappe Technologies and contributors # License: MIT. See LICENSE -from frappe.model.document import Document -from frappe.modules.export_file import export_to_files -from frappe.config import get_modules_from_all_apps_for_user +import json + import frappe from frappe import _ -import json +from frappe.config import get_modules_from_all_apps_for_user +from frappe.model.document import Document +from frappe.modules.export_file import export_to_files +from frappe.query_builder import DocType + class Dashboard(Document): def on_update(self): if self.is_default: # make all other dashboards non-default - frappe.db.sql('''update - tabDashboard set is_default = 0 where name != %s''', self.name) + DashBoard = DocType("Dashboard") + + frappe.qb.update(DashBoard).set( + DashBoard.is_default, 0 + ).where( + DashBoard.name != self.name + ).run() if frappe.conf.developer_mode and self.is_standard: - export_to_files(record_list=[['Dashboard', self.name, self.module + ' Dashboard']], record_module=self.module) + export_to_files( + record_list=[["Dashboard", self.name, f"{self.module} Dashboard"]], + record_module=self.module + ) def validate(self): if not frappe.conf.developer_mode and self.is_standard: diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 5562f2fc92..5c986b5b7c 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -8,6 +8,7 @@ from frappe.desk.doctype.dashboard_chart.dashboard_chart import get from datetime import datetime from dateutil.relativedelta import relativedelta +from unittest.mock import patch class TestDashboardChart(unittest.TestCase): def test_period_ending(self): @@ -15,8 +16,9 @@ class TestDashboardChart(unittest.TestCase): getdate('2019-04-10')) # week starts on monday - self.assertEqual(get_period_ending('2019-04-10', 'Weekly'), - getdate('2019-04-14')) + with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"): + self.assertEqual(get_period_ending('2019-04-10', 'Weekly'), + getdate('2019-04-14')) self.assertEqual(get_period_ending('2019-04-10', 'Monthly'), getdate('2019-04-30')) @@ -200,13 +202,14 @@ class TestDashboardChart(unittest.TestCase): timeseries = 1 )).insert() - result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) + with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"): + result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) - self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0]) - self.assertEqual( - result.get('labels'), - ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] - ) + self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0]) + self.assertEqual( + result.get('labels'), + ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] + ) frappe.db.rollback() @@ -231,13 +234,13 @@ class TestDashboardChart(unittest.TestCase): timeseries = 1 )).insert() - result = get(chart_name='Test Average Dashboard Chart', refresh = 1) - - self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 150.0, 266.6666666666667, 0.0]) - self.assertEqual( - result.get('labels'), - ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] - ) + with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"): + result = get(chart_name='Test Average Dashboard Chart', refresh = 1) + self.assertEqual( + result.get('labels'), + ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] + ) + self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 150.0, 266.6666666666667, 0.0]) frappe.db.rollback() diff --git a/frappe/desk/doctype/event/test_event.py b/frappe/desk/doctype/event/test_event.py index 6b7f6ee471..b0269a80cc 100644 --- a/frappe/desk/doctype/event/test_event.py +++ b/frappe/desk/doctype/event/test_event.py @@ -93,7 +93,7 @@ class TestEvent(unittest.TestCase): # Remove an assignment todo = frappe.get_doc("ToDo", {"reference_type": ev.doctype, "reference_name": ev.name, - "owner": self.test_user}) + "allocated_to": self.test_user}) todo.status = "Cancelled" todo.save() diff --git a/frappe/desk/doctype/system_console/system_console.js b/frappe/desk/doctype/system_console/system_console.js index 0fe3932671..fc83069fd2 100644 --- a/frappe/desk/doctype/system_console/system_console.js +++ b/frappe/desk/doctype/system_console/system_console.js @@ -100,5 +100,5 @@ frappe.ui.form.on('System Console', { ${rows}`); }); - } + }, }); diff --git a/frappe/desk/doctype/todo/todo.json b/frappe/desk/doctype/todo/todo.json index 15e0e4abe1..518ca00374 100644 --- a/frappe/desk/doctype/todo/todo.json +++ b/frappe/desk/doctype/todo/todo.json @@ -13,7 +13,7 @@ "column_break_2", "color", "date", - "owner", + "allocated_to", "description_section", "description", "section_break_6", @@ -69,15 +69,6 @@ "oldfieldname": "date", "oldfieldtype": "Date" }, - { - "fieldname": "owner", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "in_global_search": 1, - "in_standard_filter": 1, - "label": "Allocated To", - "options": "User" - }, { "fieldname": "description_section", "fieldtype": "Section Break" @@ -153,12 +144,21 @@ "label": "Assignment Rule", "options": "Assignment Rule", "read_only": 1 + }, + { + "fieldname": "allocated_to", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_global_search": 1, + "in_standard_filter": 1, + "label": "Allocated To", + "options": "User" } ], "icon": "fa fa-check", "idx": 2, "links": [], - "modified": "2020-01-14 17:04:36.971002", + "modified": "2021-09-16 11:36:34.586898", "modified_by": "Administrator", "module": "Desk", "name": "ToDo", diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 6f3f4160e6..e689faafbe 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -16,10 +16,10 @@ class ToDo(Document): self._assignment = None if self.is_new(): - if self.assigned_by == self.owner: + if self.assigned_by == self.allocated_to: assignment_message = frappe._("{0} self assigned this task: {1}").format(get_fullname(self.assigned_by), self.description) else: - assignment_message = frappe._("{0} assigned {1}: {2}").format(get_fullname(self.assigned_by), get_fullname(self.owner), self.description) + assignment_message = frappe._("{0} assigned {1}: {2}").format(get_fullname(self.assigned_by), get_fullname(self.allocated_to), self.description) self._assignment = { "text": assignment_message, @@ -29,12 +29,12 @@ class ToDo(Document): else: # NOTE the previous value is only available in validate method if self.get_db_value("status") != self.status: - if self.owner == frappe.session.user: + if self.allocated_to == frappe.session.user: removal_message = frappe._("{0} removed their assignment.").format( get_fullname(frappe.session.user)) else: removal_message = frappe._("Assignment of {0} removed by {1}").format( - get_fullname(self.owner), get_fullname(frappe.session.user)) + get_fullname(self.allocated_to), get_fullname(frappe.session.user)) self._assignment = { "text": removal_message, @@ -69,15 +69,13 @@ class ToDo(Document): return try: - assignments = [d[0] for d in frappe.get_all("ToDo", - filters={ - "reference_type": self.reference_type, - "reference_name": self.reference_name, - "status": ("!=", "Cancelled") - }, - fields=["owner"], as_list=True)] - + assignments = frappe.get_all("ToDo", filters={ + "reference_type": self.reference_type, + "reference_name": self.reference_name, + "status": ("!=", "Cancelled") + }, pluck="allocated_to") assignments.reverse() + frappe.db.set_value(self.reference_type, self.reference_name, "_assign", json.dumps(assignments), update_modified=False) @@ -98,8 +96,8 @@ class ToDo(Document): def get_owners(cls, filters=None): """Returns list of owners after applying filters on todo's. """ - rows = frappe.get_all(cls.DocType, filters=filters or {}, fields=['owner']) - return [parse_addr(row.owner)[1] for row in rows if row.owner] + rows = frappe.get_all(cls.DocType, filters=filters or {}, fields=['allocated_to']) + return [parse_addr(row.allocated_to)[1] for row in rows if row.allocated_to] # NOTE: todo is viewable if a user is an owner, or set as assigned_to value, or has any role that is allowed to access ToDo doctype. def on_doctype_update(): @@ -115,7 +113,7 @@ def get_permission_query_conditions(user): if any(check in todo_roles for check in frappe.get_roles(user)): return None else: - return """(`tabToDo`.owner = {user} or `tabToDo`.assigned_by = {user})"""\ + return """(`tabToDo`.allocated_to = {user} or `tabToDo`.assigned_by = {user})"""\ .format(user=frappe.db.escape(user)) def has_permission(doc, ptype="read", user=None): @@ -127,7 +125,7 @@ def has_permission(doc, ptype="read", user=None): if any(check in todo_roles for check in frappe.get_roles(user)): return True else: - return doc.owner==user or doc.assigned_by==user + return doc.allocated_to==user or doc.assigned_by==user @frappe.whitelist() def new_todo(description): diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index bf77170eeb..049d33c1ec 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -19,11 +19,11 @@ def get(args=None): if not args: args = frappe.local.form_dict - return frappe.get_all('ToDo', fields=['owner', 'name'], filters=dict( - reference_type = args.get('doctype'), - reference_name = args.get('name'), - status = ('!=', 'Cancelled') - ), limit=5) + return frappe.get_all("ToDo", fields=["allocated_to as owner", "name"], filters={ + "reference_type": args.get("doctype"), + "reference_name": args.get("name"), + "status": ("!=", "Cancelled") + }, limit=5) @frappe.whitelist() def add(args=None): @@ -48,7 +48,7 @@ def add(args=None): "reference_type": args['doctype'], "reference_name": args['name'], "status": "Open", - "owner": assign_to + "allocated_to": assign_to } if frappe.get_all("ToDo", filters=filters): @@ -61,7 +61,7 @@ def add(args=None): d = frappe.get_doc({ "doctype": "ToDo", - "owner": assign_to, + "allocated_to": assign_to, "reference_type": args['doctype'], "reference_name": args['name'], "description": args.get('description'), @@ -87,7 +87,7 @@ def add(args=None): follow_document(args['doctype'], args['name'], assign_to) # notify - notify_assignment(d.assigned_by, d.owner, d.reference_type, d.reference_name, action='ASSIGN', + notify_assignment(d.assigned_by, d.allocated_to, d.reference_type, d.reference_name, action='ASSIGN', description=args.get("description")) if shared_with_users: @@ -112,13 +112,13 @@ def add_multiple(args=None): add(args) def close_all_assignments(doctype, name): - assignments = frappe.db.get_all('ToDo', fields=['owner'], filters = + assignments = frappe.db.get_all('ToDo', fields=['allocated_to'], filters = dict(reference_type = doctype, reference_name = name, status=('!=', 'Cancelled'))) if not assignments: return False for assign_to in assignments: - set_status(doctype, name, assign_to.owner, status="Closed") + set_status(doctype, name, assign_to.allocated_to, status="Closed") return True @@ -130,13 +130,13 @@ def set_status(doctype, name, assign_to, status="Cancelled"): """remove from todo""" try: todo = frappe.db.get_value("ToDo", {"reference_type":doctype, - "reference_name":name, "owner":assign_to, "status": ('!=', status)}) + "reference_name":name, "allocated_to":assign_to, "status": ('!=', status)}) if todo: todo = frappe.get_doc("ToDo", todo) todo.status = status todo.save(ignore_permissions=True) - notify_assignment(todo.assigned_by, todo.owner, todo.reference_type, todo.reference_name) + notify_assignment(todo.assigned_by, todo.allocated_to, todo.reference_type, todo.reference_name) except frappe.DoesNotExistError: pass @@ -150,25 +150,26 @@ def clear(doctype, name): ''' Clears assignments, return False if not assigned. ''' - assignments = frappe.db.get_all('ToDo', fields=['owner'], filters = + assignments = frappe.db.get_all('ToDo', fields=['allocated_to'], filters = dict(reference_type = doctype, reference_name = name)) if not assignments: return False for assign_to in assignments: - set_status(doctype, name, assign_to.owner, "Cancelled") + set_status(doctype, name, assign_to.allocated_to, "Cancelled") return True -def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE', +def notify_assignment(assigned_by, allocated_to, doc_type, doc_name, action='CLOSE', description=None): """ Notify assignee that there is a change in assignment """ - if not (assigned_by and owner and doc_type and doc_name): return + if not (assigned_by and allocated_to and doc_type and doc_name): + return # return if self assigned or user disabled - if assigned_by == owner or not frappe.db.get_value('User', owner, 'enabled'): + if assigned_by == allocated_to or not frappe.db.get_value('User', allocated_to, 'enabled'): return # Search for email address in description -- i.e. assignee @@ -194,7 +195,7 @@ def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE', 'email_content': description_html } - enqueue_create_notification(owner, notification_doc) + enqueue_create_notification(allocated_to, notification_doc) def format_message_for_assign_to(users): return "

" + "
".join(users) \ No newline at end of file diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 89e6598859..0e644c3cf5 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -253,7 +253,7 @@ def get_communication_data(doctype, name, start=0, limit=20, after=None, fields= def get_assignments(dt, dn): cl = frappe.get_all("ToDo", - fields=['name', 'owner', 'description', 'status'], + fields=['name', 'allocated_to as owner', 'description', 'status'], filters={ 'reference_type': dt, 'reference_name': dn, diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index 43ad104f0d..3d6f1254a2 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -29,16 +29,16 @@ def get_group_by_count(doctype, current_filters, field): subquery = frappe.get_all(doctype, filters=current_filters, run=False) if field == 'assigned_to': subquery_condition = ' and `tabToDo`.reference_name in ({subquery})'.format(subquery = subquery) - return frappe.db.sql("""select `tabToDo`.owner as name, count(*) as count + return frappe.db.sql("""select `tabToDo`.allocated_to as name, count(*) as count from `tabToDo`, `tabUser` where `tabToDo`.status!='Cancelled' and - `tabToDo`.owner = `tabUser`.name and + `tabToDo`.allocated_to = `tabUser`.name and `tabUser`.user_type = 'System User' {subquery_condition} group by - `tabToDo`.owner + `tabToDo`.allocated_to order by count desc limit 50""".format(subquery_condition = subquery_condition), as_dict=True) diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index b5f0c5043c..b42d8c58b7 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -388,7 +388,6 @@ def make_records(records, debug=False): # LOG every success and failure for record in records: - doctype = record.get("doctype") condition = record.get('__condition') @@ -405,6 +404,7 @@ def make_records(records, debug=False): try: doc.insert(ignore_permissions=True) + frappe.db.commit() except frappe.DuplicateEntryError as e: # print("Failed to insert duplicate {0} {1}".format(doctype, doc.name)) @@ -417,6 +417,7 @@ def make_records(records, debug=False): raise except Exception as e: + frappe.db.rollback() exception = record.get('__exception') if exception: config = _dict(exception) diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py index f40c135653..7e3efb5d48 100644 --- a/frappe/desk/treeview.py +++ b/frappe/desk/treeview.py @@ -4,6 +4,7 @@ import frappe from frappe import _ + @frappe.whitelist() def get_all_nodes(doctype, label, parent, tree_method, **filters): '''Recursively gets all data from tree nodes''' @@ -40,8 +41,8 @@ def get_children(doctype, parent='', **filters): def _get_children(doctype, parent='', ignore_permissions=False): parent_field = 'parent_' + doctype.lower().replace(' ', '_') - filters = [['ifnull(`{0}`,"")'.format(parent_field), '=', parent], - ['docstatus', '<' ,'2']] + filters = [["ifnull(`{0}`,'')".format(parent_field), '=', parent], + ['docstatus', '<' ,2]] meta = frappe.get_meta(doctype) diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index d89a3d83be..9730004065 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -475,28 +475,20 @@ class QueueBuilder: if self._unsubscribed_user_emails is not None: return self._unsubscribed_user_emails - all_ids = tuple(set(self.recipients + self.cc)) - - unsubscribed = frappe.db.sql_list(''' - SELECT - distinct email - from - `tabEmail Unsubscribe` - where - email in %(all_ids)s - and ( - ( - reference_doctype = %(reference_doctype)s - and reference_name = %(reference_name)s - ) - or global_unsubscribe = 1 - ) - ''', { - 'all_ids': all_ids, - 'reference_doctype': self.reference_doctype, - 'reference_name': self.reference_name, - }) - + all_ids = list(set(self.recipients + self.cc)) + + EmailUnsubscribe = frappe.qb.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) self._unsubscribed_user_emails = unsubscribed or [] return self._unsubscribed_user_emails diff --git a/frappe/email/doctype/email_template/email_template.json b/frappe/email/doctype/email_template/email_template.json index dc73acacc1..c6ec971da4 100644 --- a/frappe/email/doctype/email_template/email_template.json +++ b/frappe/email/doctype/email_template/email_template.json @@ -12,7 +12,6 @@ "use_html", "response_html", "response", - "owner", "section_break_4", "email_reply_help" ], @@ -32,14 +31,6 @@ "label": "Response", "mandatory_depends_on": "eval:!doc.use_html" }, - { - "default": "user", - "fieldname": "owner", - "fieldtype": "Link", - "hidden": 1, - "label": "Owner", - "options": "User" - }, { "fieldname": "section_break_4", "fieldtype": "Section Break" @@ -66,7 +57,7 @@ ], "icon": "fa fa-comment", "links": [], - "modified": "2020-11-30 14:12:50.321633", + "modified": "2022-01-04 14:12:50.321633", "modified_by": "Administrator", "module": "Email", "name": "Email Template", diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 6b4ee92043..77979f9735 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -435,8 +435,8 @@ def get_context(doc): def get_assignees(doc): assignees = [] assignees = frappe.get_all('ToDo', filters={'status': 'Open', 'reference_name': doc.name, - 'reference_type': doc.doctype}, fields=['owner']) + 'reference_type': doc.doctype}, fields=['allocated_to']) - recipients = [d.owner for d in assignees] + recipients = [d.allocated_to for d in assignees] return recipients diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 4f4ed6d48e..dd64d0df80 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -27,11 +27,7 @@ from frappe.utils.html_utils import clean_email_html # fix due to a python bug in poplib that limits it to 2048 poplib._MAXLINE = 20480 -imaplib._MAXLINE = 20480 -# fix due to a python bug in poplib that limits it to 2048 -poplib._MAXLINE = 20480 -imaplib._MAXLINE = 20480 class EmailSizeExceededError(frappe.ValidationError): pass diff --git a/frappe/frappeclient.py b/frappe/frappeclient.py index ab58979203..59db38584c 100644 --- a/frappe/frappeclient.py +++ b/frappe/frappeclient.py @@ -1,11 +1,13 @@ -import requests -import json -import frappe -import base64 - ''' FrappeClient is a library that helps you connect with other frappe systems ''' +import base64 +import json + +import requests + +import frappe + class AuthError(Exception): pass @@ -46,7 +48,7 @@ class FrappeClient(object): def _login(self, username, password): '''Login/start a sesion. Called internally on init''' - r = self.session.post(self.url, data={ + r = self.session.post(self.url, params={ 'cmd': 'login', 'usr': username, 'pwd': password @@ -289,14 +291,14 @@ class FrappeClient(object): def get_api(self, method, params=None): if params is None: params = {} - res = self.session.get(self.url + "/api/method/" + method + "/", + res = self.session.get(f"{self.url}/api/method/{method}", params=params, verify=self.verify, headers=self.headers) return self.post_process(res) def post_api(self, method, params=None): if params is None: params = {} - res = self.session.post(self.url + "/api/method/" + method + "/", + res = self.session.post(f"{self.url}/api/method/{method}", params=params, verify=self.verify, headers=self.headers) return self.post_process(res) diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index 1c5abb454c..7c9c64ba3c 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -58,9 +58,9 @@ class LDAPSettings(Document): import ssl if self.require_trusted_certificate == 'Yes': - tls_configuration = ldap3.Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLSv1) + tls_configuration = ldap3.Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLS_CLIENT) else: - tls_configuration = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1) + tls_configuration = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLS_CLIENT) if self.local_private_key_file: tls_configuration.private_key_file = self.local_private_key_file diff --git a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py index 7b0638876b..41997fb4c7 100644 --- a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py @@ -296,7 +296,7 @@ class LDAP_TestCase(): if local_doc['require_trusted_certificate'] == 'Yes': tls_validate = ssl.CERT_REQUIRED - tls_version = ssl.PROTOCOL_TLSv1 + tls_version = ssl.PROTOCOL_TLS_CLIENT tls_configuration = ldap3.Tls(validate=tls_validate, version=tls_version) self.assertTrue(kwargs['auto_bind'] == ldap3.AUTO_BIND_TLS_BEFORE_BIND, @@ -304,7 +304,7 @@ class LDAP_TestCase(): else: tls_validate = ssl.CERT_NONE - tls_version = ssl.PROTOCOL_TLSv1 + tls_version = ssl.PROTOCOL_TLS_CLIENT tls_configuration = ldap3.Tls(validate=tls_validate, version=tls_version) self.assertTrue(kwargs['auto_bind'], diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 1fd3784fcc..26a4658c36 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -101,13 +101,10 @@ class BaseDocument(object): "balance": 42000 }) """ - if "doctype" in d: - self.set("doctype", d.get("doctype")) - # first set default field values of base document for key in default_fields: if key in d: - self.set(key, d.get(key)) + self.set(key, d[key]) for key, value in d.items(): self.set(key, value) @@ -771,7 +768,9 @@ class BaseDocument(object): else: self_value = self.get_value(key) - + # Postgres stores values as `datetime.time`, MariaDB as `timedelta` + if isinstance(self_value, datetime.timedelta) and isinstance(db_value, datetime.time): + db_value = datetime.timedelta(hours=db_value.hour, minutes=db_value.minute, seconds=db_value.second, microseconds=db_value.microsecond) if self_value != db_value: frappe.throw(_("Not allowed to change {0} after submission").format(df.label), frappe.UpdateAfterSubmitError) @@ -1011,15 +1010,12 @@ def _filter(data, filters, limit=None): _filters[f] = fval for d in data: - add = True for f, fval in _filters.items(): if not frappe.compare(getattr(d, f, None), fval[0], fval[1]): - add = False break - - if add: + else: out.append(d) - if limit and (len(out)-1)==limit: + if limit and len(out) >= limit: break return out diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index cb2c2af898..51d53c69a5 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -130,6 +130,11 @@ class DatabaseQuery(object): args.fields = 'distinct ' + args.fields args.order_by = '' # TODO: recheck for alternative + # Postgres requires any field that appears in the select clause to also + # appear in the order by and group by clause + if frappe.db.db_type == 'postgres' and args.order_by and args.group_by: + args = self.prepare_select_args(args) + query = """select %(fields)s from %(tables)s %(conditions)s @@ -203,6 +208,19 @@ class DatabaseQuery(object): return args + def prepare_select_args(self, args): + order_field = re.sub(r"\ order\ by\ |\ asc|\ ASC|\ desc|\ DESC", "", args.order_by) + + if order_field not in args.fields: + extracted_column = order_column = order_field.replace("`", "") + if "." in extracted_column: + extracted_column = extracted_column.split(".")[1] + + args.fields += f", MAX({extracted_column}) as `{order_column}`" + args.order_by = args.order_by.replace(order_field, f"`{order_column}`") + + return args + def parse_args(self): """Convert fields and filters from strings to list, dicts""" if isinstance(self.fields, str): diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index ac976e976c..2fddcf9e33 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -117,7 +117,8 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa doctype=doc.doctype, name=doc.name, is_async=False if frappe.flags.in_test else True) - + # clear cache for Document + doc.clear_cache() # delete global search entry delete_for_document(doc) # delete tag link entry diff --git a/frappe/model/document.py b/frappe/model/document.py index 1f079feedc..e25469c68a 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -396,6 +396,7 @@ class Document(BaseDocument): "parenttype": self.doctype, "parentfield": fieldname }) + def get_doc_before_save(self): return getattr(self, '_doc_before_save', None) @@ -468,9 +469,11 @@ class Document(BaseDocument): self._original_modified = self.modified self.modified = now() self.modified_by = frappe.session.user - if not self.creation: + + # 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(): self.creation = self.modified - if not self.owner: self.owner = self.modified_by for d in self.get_all_children(): @@ -562,8 +565,12 @@ class Document(BaseDocument): fail = value != original_value if fail: - frappe.throw(_("Value cannot be changed for {0}").format(self.meta.get_label(field.fieldname)), - frappe.CannotChangeConstantError) + frappe.throw( + _("Value cannot be changed for {0}").format( + frappe.bold(self.meta.get_label(field.fieldname)) + ), + exc=frappe.CannotChangeConstantError + ) return False @@ -1341,15 +1348,15 @@ class Document(BaseDocument): ), frappe.exceptions.InvalidDates) def get_assigned_users(self): - assignments = frappe.get_all('ToDo', - fields=['owner'], + assigned_users = frappe.get_all('ToDo', + fields=['allocated_to'], filters={ 'reference_type': self.doctype, 'reference_name': self.name, 'status': ('!=', 'Cancelled'), - }) + }, pluck='allocated_to') - users = set([assignment.owner for assignment in assignments]) + users = set(assigned_users) return users def add_tag(self, tag): diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 252c463d3d..a483f3f2d6 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -67,6 +67,10 @@ class Meta(Document): _metaclass = True default_fields = list(default_fields)[1:] special_doctypes = ("DocField", "DocPerm", "DocType", "Module Def", 'DocType Action', 'DocType Link', 'DocType State') + standard_set_once_fields = [ + frappe._dict(fieldname="creation", fieldtype="Datetime"), + frappe._dict(fieldname="owner", fieldtype="Data"), + ] def __init__(self, doctype): self._fields = {} @@ -154,6 +158,12 @@ class Meta(Document): '''Return fields with `set_only_once` set''' if not hasattr(self, "_set_only_once_fields"): self._set_only_once_fields = self.get("fields", {"set_only_once": 1}) + fieldnames = [d.fieldname for d in self._set_only_once_fields] + + for df in self.standard_set_once_fields: + if df.fieldname not in fieldnames: + self._set_only_once_fields.append(df) + return self._set_only_once_fields def get_table_fields(self): diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 651153876a..2cc5818414 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -110,6 +110,7 @@ def rename_doc( if merge: frappe.delete_doc(doctype, old) + new_doc.clear_cache() frappe.clear_cache() if rebuild_search: frappe.enqueue('frappe.utils.global_search.rebuild_for_doctype', doctype=doctype) @@ -292,7 +293,7 @@ def update_link_field_values(link_fields, old, new, doctype): if parent == new and doctype == "DocType": parent = old - frappe.db.set_value(parent, {docfield: old}, docfield, new) + frappe.db.set_value(parent, {docfield: old}, docfield, new, update_modified=False) # update cached link_fields as per new if doctype=='DocType' and field['parent'] == old: diff --git a/frappe/patches.txt b/frappe/patches.txt index 27ba1a145d..af7e4d6e3f 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -182,6 +182,7 @@ 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 @@ -190,3 +191,4 @@ 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/v13_0/set_first_day_of_the_week.py b/frappe/patches/v13_0/set_first_day_of_the_week.py new file mode 100644 index 0000000000..cfb694bbf1 --- /dev/null +++ b/frappe/patches/v13_0/set_first_day_of_the_week.py @@ -0,0 +1,7 @@ +import frappe + +def execute(): + frappe.reload_doctype("System Settings") + # setting first_day_of_the_week value as "Monday" to avoid breaking change + # because before the configuration was introduced, system used to consider "Monday" as start of the week + frappe.db.set_value("System Settings", "System Settings", "first_day_of_the_week", "Monday") \ No newline at end of file diff --git a/frappe/patches/v14_0/transform_todo_schema.py b/frappe/patches/v14_0/transform_todo_schema.py new file mode 100644 index 0000000000..73b06569a5 --- /dev/null +++ b/frappe/patches/v14_0/transform_todo_schema.py @@ -0,0 +1,12 @@ +import frappe +from frappe.query_builder.utils import DocType + + +def execute(): + # Email Template & Help Article have owner field that doesn't have any additional functionality + # Only ToDo has to be updated. + + ToDo = DocType("ToDo") + frappe.reload_doctype("ToDo", force=True) + + frappe.qb.update(ToDo).set(ToDo.allocated_to, ToDo.owner).run() diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 99b644a5c7..cac02c7a68 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -63,6 +63,7 @@ import "./frappe/utils/address_and_contact.js"; import "./frappe/utils/preview_email.js"; import "./frappe/utils/file_manager.js"; import "./frappe/utils/diffview"; +import "./frappe/utils/datatable.js"; import "./frappe/upload.js"; import "./frappe/ui/tree.js"; diff --git a/frappe/public/js/frappe/data_import/import_preview.js b/frappe/public/js/frappe/data_import/import_preview.js index 75bfb90bde..2264042539 100644 --- a/frappe/public/js/frappe/data_import/import_preview.js +++ b/frappe/public/js/frappe/data_import/import_preview.js @@ -142,6 +142,8 @@ frappe.data_import.ImportPreview = class ImportPreview { columns: this.columns, layout: this.columns.length < 10 ? 'fluid' : 'fixed', cellHeight: 35, + language: frappe.boot.lang, + translations: frappe.utils.datatable.get_translations(), serialNoColumn: false, checkboxColumn: false, noDataMessage: __('No Data'), diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 64767e1232..202cee645a 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -275,11 +275,7 @@ frappe.Application = class Application { this.set_globals(); this.sync_pages(); frappe.router.setup(); - moment.locale("en"); - moment.user_utc_offset = moment().utcOffset(); - if(frappe.boot.timezone_info) { - moment.tz.add(frappe.boot.timezone_info); - } + this.setup_moment(); if(frappe.boot.print_css) { frappe.dom.set_style(frappe.boot.print_css, "print-style"); } @@ -628,6 +624,19 @@ frappe.Application = class Application { } }); } + + setup_moment() { + moment.updateLocale('en', { + week: { + dow: frappe.datetime.get_first_day_of_the_week_index(), + } + }); + moment.locale("en"); + moment.user_utc_offset = moment().utcOffset(); + if (frappe.boot.timezone_info) { + moment.tz.add(frappe.boot.timezone_info); + } + } } frappe.get_module = function(m, default_module) { diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 7af0705e78..ce871c50cb 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -148,8 +148,9 @@ frappe.ui.form.Control = class BaseControl { return this.doc[this.df.fieldname]; } } - set_value(value) { - return this.validate_and_set_in_model(value); + + set_value(value, force_set_value=false) { + return this.validate_and_set_in_model(value, null, force_set_value); } parse_validate_and_set_in_model(value, e) { if(this.parse) { @@ -157,12 +158,11 @@ frappe.ui.form.Control = class BaseControl { } return this.validate_and_set_in_model(value, e); } - validate_and_set_in_model(value, e) { - var me = this; - let force_value_set = (this.doc && this.doc.__run_link_triggers); - let is_value_same = (this.get_model_value() === value); + validate_and_set_in_model(value, e, force_set_value=false) { + const me = this; + const is_value_same = (this.get_model_value() === value); - if (this.inside_change_event || (!force_value_set && is_value_same)) { + if (this.inside_change_event || (is_value_same && !force_set_value)) { return Promise.resolve(); } diff --git a/frappe/public/js/frappe/form/controls/date.js b/frappe/public/js/frappe/form/controls/date.js index 28e7f2a478..7ad1887d62 100644 --- a/frappe/public/js/frappe/form/controls/date.js +++ b/frappe/public/js/frappe/form/controls/date.js @@ -10,14 +10,16 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat this.set_t_for_today(); } set_formatted_input(value) { + if (value === "Today") { + value = this.get_now_date(); + } + super.set_formatted_input(value); if (this.timepicker_only) return; if (!this.datepicker) return; if (!value) { this.datepicker.clear(); return; - } else if (value === "Today") { - value = this.get_now_date(); } let should_refresh = this.last_value && this.last_value !== value; @@ -62,6 +64,7 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat dateFormat: date_format, startDate: this.get_start_date(), keyboardNav: false, + firstDay: frappe.datetime.get_first_day_of_the_week_index(), onSelect: () => { this.$input.trigger('change'); }, @@ -77,7 +80,7 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat } get_start_date() { - return new Date(this.get_now_date()); + return this.get_now_date(); } set_datepicker() { @@ -116,7 +119,7 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat this.datepicker.update('position', position); } get_now_date() { - return frappe.datetime.convert_to_system_tz(frappe.datetime.now_date(true)); + return frappe.datetime.convert_to_system_tz(frappe.datetime.now_date(true), false).toDate(); } set_t_for_today() { var me = this; diff --git a/frappe/public/js/frappe/form/controls/multiselect_list.js b/frappe/public/js/frappe/form/controls/multiselect_list.js index 8c79071762..5b25b75279 100644 --- a/frappe/public/js/frappe/form/controls/multiselect_list.js +++ b/frappe/public/js/frappe/form/controls/multiselect_list.js @@ -109,6 +109,7 @@ frappe.ui.form.ControlMultiSelectList = class ControlMultiSelectList extends fra let value = decodeURIComponent($selectable_item.data().value); if ($selectable_item.hasClass('selected')) { + this.values = this.values.slice(); this.values.push(value); } else { this.values = this.values.filter(val => val !== value); diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 9a75e510da..1459b38df6 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -215,7 +215,7 @@ frappe.ui.form.Form = class FrappeForm { if (this.layout.tabs.length) { this.layout.tabs.every(tab => { - if (tab.df.options === 'Dashboard') { + if (tab.df.show_dashboard) { tab.wrapper.prepend(dashboard_parent); dashboard_added = true; return false; @@ -983,7 +983,7 @@ frappe.ui.form.Form = class FrappeForm { $.each(this.fields_dict, function(fieldname, field) { if (field.df.fieldtype=="Link" && this.doc[fieldname]) { // triggers add fetch, sets value in model and runs triggers - field.set_value(this.doc[fieldname]); + field.set_value(this.doc[fieldname], true); } }); diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 96e502663d..a40f428969 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -196,7 +196,7 @@ export default class GridRow { // REDESIGN-TODO: Make translation contextual, this No is Number var txt = (this.doc ? this.doc.idx : __("No.")); this.row_index = $( - `
+ `
${this.row_check_html}
`) .appendTo(this.row) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 938531865d..22f8377a57 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1500,6 +1500,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { read_only: 1, }, ], + primary_action_label: __("Copy to clipboard"), + primary_action: () => { + frappe.utils.copy_to_clipboard(this.get_share_url()); + d.hide(); + }, }); d.show(); } diff --git a/frappe/public/js/frappe/utils/datatable.js b/frappe/public/js/frappe/utils/datatable.js new file mode 100644 index 0000000000..ec82d256f1 --- /dev/null +++ b/frappe/public/js/frappe/utils/datatable.js @@ -0,0 +1,22 @@ +frappe.provide("frappe.utils.datatable"); + +frappe.utils.datatable.get_translations = function () { + let translations = {}; + translations[frappe.boot.lang] = { + "Sort Ascending": __("Sort Ascending"), + "Sort Descending": __("Sort Descending"), + "Reset sorting": __("Reset sorting"), + "Remove column": __("Remove column"), + "No Data": __("No Data"), + "{count} cells copied": { + "1": __("{count} cell copied"), + "default": __("{count} cells copied") + }, + "{count} rows selected": { + "1": __("{count} row selected"), + "default": __("{count} rows selected") + } + }; + + return translations; +}; diff --git a/frappe/public/js/frappe/utils/datetime.js b/frappe/public/js/frappe/utils/datetime.js index 7bb6076b72..196bdf68a3 100644 --- a/frappe/public/js/frappe/utils/datetime.js +++ b/frappe/public/js/frappe/utils/datetime.js @@ -254,6 +254,11 @@ $.extend(frappe.datetime, { ], true).isValid(); }, + get_first_day_of_the_week_index() { + const first_day_of_the_week = frappe.sys_defaults.first_day_of_the_week || "Sunday"; + return moment.weekdays().indexOf(first_day_of_the_week); + } + }); // Proxy for dateutil and get_today diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 448b3f6fd2..7ba0a0228f 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -106,14 +106,17 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { return; } + let route_options = {}; + route_options = Object.assign(route_options, frappe.route_options); + if (this.report_name !== frappe.get_route()[1]) { // different report - this.load_report(); + this.load_report(route_options); } else if (frappe.has_route_options()) { // filters passed through routes // so refresh report again - this.refresh_report(); + this.refresh_report(route_options); } else { // same report // don't do anything to preserve state @@ -121,7 +124,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { } } - load_report() { + load_report(route_options) { this.page.clear_inner_toolbar(); this.route = frappe.get_route(); this.page_name = frappe.get_route_str(); @@ -137,7 +140,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { () => this.get_report_settings(), () => this.setup_progress_bar(), () => this.setup_page_head(), - () => this.refresh_report(), + () => this.refresh_report(route_options), () => this.add_chart_buttons_to_toolbar(true), () => this.add_card_button_to_toolbar(true), ]); @@ -343,13 +346,13 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { }); } - refresh_report() { + refresh_report(route_options) { this.toggle_message(true); this.toggle_report(false); return frappe.run_serially([ () => this.setup_filters(), - () => this.set_route_filters(), + () => this.set_route_filters(route_options), () => this.page.clear_custom_actions(), () => this.report_settings.onload && this.report_settings.onload(this), () => this.refresh() @@ -525,15 +528,17 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { }); } - set_route_filters() { - if(frappe.route_options) { - const fields = Object.keys(frappe.route_options); + set_route_filters(route_options) { + if (!route_options) route_options = frappe.route_options; + + if (route_options) { + const fields = Object.keys(route_options); const filters_to_set = this.filters.filter(f => fields.includes(f.df.fieldname)); const promises = filters_to_set.map(f => { return () => { - const value = frappe.route_options[f.df.fieldname]; + const value = route_options[f.df.fieldname]; f.set_value(value); }; }); @@ -844,6 +849,8 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { columns: columns, data: data, inlineFilters: true, + language: frappe.boot.lang, + translations: frappe.utils.datatable.get_translations(), treeView: this.tree_report, layout: 'fixed', cellHeight: 33, diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index c70c64be0e..6d8e281793 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -284,6 +284,8 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { columns: this.columns, data: this.get_data(values), getEditor: this.get_editing_object.bind(this), + language: frappe.boot.lang, + translations: frappe.utils.datatable.get_translations(), checkboxColumn: true, inlineFilters: true, cellHeight: 35, diff --git a/frappe/public/js/frappe/views/treeview.js b/frappe/public/js/frappe/views/treeview.js index cc0a233003..7179e4ab56 100644 --- a/frappe/public/js/frappe/views/treeview.js +++ b/frappe/public/js/frappe/views/treeview.js @@ -409,7 +409,9 @@ frappe.views.TreeView = class TreeView { }, ]; - if (frappe.user.has_role('System Manager')) { + if (frappe.user.has_role('System Manager') && + frappe.meta.has_field(me.doctype, "lft") && + frappe.meta.has_field(me.doctype, "rgt")) { this.menu_items.push( { label: __('Rebuild Tree'), diff --git a/frappe/public/js/frappe/web_form/web_form.js b/frappe/public/js/frappe/web_form/web_form.js index 964a8ad0bb..1f540958df 100644 --- a/frappe/public/js/frappe/web_form/web_form.js +++ b/frappe/public/js/frappe/web_form/web_form.js @@ -9,6 +9,7 @@ export default class WebForm extends frappe.ui.FieldGroup { frappe.web_form = this; frappe.web_form.events = {}; Object.assign(frappe.web_form.events, EventEmitterMixin); + this.current_section = 0; } prepare(web_form_doc, doc) { @@ -19,12 +20,16 @@ export default class WebForm extends frappe.ui.FieldGroup { make() { super.make(); + this.set_sections(); this.set_field_values(); + this.setup_listeners(); if (this.introduction_text) this.set_form_description(this.introduction_text); if (this.allow_print && !this.is_new) this.setup_print_button(); if (this.allow_delete && !this.is_new) this.setup_delete_button(); if (this.is_new) this.setup_cancel_button(); this.setup_primary_action(); + this.setup_previous_next_button(); + this.toggle_section(); $(".link-btn").remove(); // webform client script @@ -40,6 +45,88 @@ export default class WebForm extends frappe.ui.FieldGroup { }; } + setup_listeners() { + // Event listener for triggering Save/Next button for Multi Step Forms + // Do not use `on` event here since that can be used by user which will render this function useless + // setTimeout has 200ms delay so that all the base_control triggers for the fields have been run + let me = this; + + if (!me.is_multi_step_form) { + return; + } + + for (let field of $(".input-with-feedback")) { + $(field).change((e) => { + setTimeout(() => { + e.stopPropagation(); + me.toggle_buttons(); + }, 200); + }); + } + } + + set_sections() { + if (this.sections.length) return; + + this.sections = $(`.form-section`); + } + + setup_previous_next_button() { + let me = this; + + if (!me.is_multi_step_form) { + return; + } + + $('.web-form-footer').after(` + + `); + + $('.btn-previous').on('click', function () { + let is_validated = me.validate_section(); + + if (!is_validated) return; + + /** + The eslint utility cannot figure out if this is an infinite loop in backwards and + throws an error. Disabling for-direction just for this section. + for-direction doesnt throw an error if the values are hardcoded in the + reverse for-loop, but in this case its a dynamic loop. + https://eslint.org/docs/rules/for-direction + */ + /* eslint-disable for-direction */ + for (let idx = me.current_section; idx < me.sections.length; idx--) { + let is_empty = me.is_previous_section_empty(idx); + me.current_section = me.current_section > 0 ? me.current_section - 1 : me.current_section; + + if (!is_empty) { + break; + } + } + /* eslint-enable for-direction */ + me.toggle_section(); + }); + + $('.btn-next').on('click', function () { + let is_validated = me.validate_section(); + + if (!is_validated) return; + + for (let idx = me.current_section; idx < me.sections.length; idx++) { + let is_empty = me.is_next_section_empty(idx); + me.current_section = me.current_section < me.sections.length ? me.current_section + 1 : me.current_section; + + if (!is_empty) { + break; + } + } + me.toggle_section(); + }); + } + set_field_values() { if (this.doc.name) this.set_values(this.doc); else return; @@ -104,6 +191,113 @@ export default class WebForm extends frappe.ui.FieldGroup { ); } + validate_section() { + if (this.allow_incomplete) return true; + + let fields = $(`.form-section:eq(${this.current_section}) .form-control`); + let errors = []; + let invalid_values = []; + + for (let field of fields) { + let fieldname = $(field).attr("data-fieldname"); + if (!fieldname) continue; + + field = this.fields_dict[fieldname]; + + if (field.get_value) { + let value = field.get_value(); + if (field.df.reqd && is_null(typeof value === 'string' ? strip_html(value) : value)) errors.push(__(field.df.label)); + + if (field.df.reqd && field.df.fieldtype === 'Text Editor' && is_null(strip_html(cstr(value)))) errors.push(__(field.df.label)); + + if (field.df.invalid) invalid_values.push(__(field.df.label)); + } + } + + let message = ''; + if (invalid_values.length) { + message += __('Invalid values for fields:') + '

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

'; + } + + if (invalid_values.length || errors.length) { + frappe.msgprint({ + title: __('Error'), + message: message, + indicator: 'orange' + }); + } + + return !(errors.length || invalid_values.length); + } + + toggle_section() { + if (!this.is_multi_step_form) return; + + this.toggle_previous_button(); + this.hide_sections(); + this.show_section(); + this.toggle_buttons(); + } + + toggle_buttons() { + for (let idx = this.current_section; idx < this.sections.length; idx++) { + if (this.is_next_section_empty(idx)) { + this.show_save_and_hide_next_button(); + } else { + this.show_next_and_hide_save_button(); + break; + } + } + } + + is_next_section_empty(section) { + if (section + 1 > this.sections.length) return true; + + let _section = $(`.form-section:eq(${section + 1})`); + let visible_controls = _section.find(".frappe-control:not(.hide-control)"); + + return !visible_controls.length ? true : false; + } + + is_previous_section_empty(section) { + if (section - 1 > this.sections.length) return true; + + let _section = $(`.form-section:eq(${section - 1})`); + let visible_controls = _section.find(".frappe-control:not(.hide-control)"); + + return !visible_controls.length ? true : false; + } + + show_save_and_hide_next_button() { + $('.btn-next').hide(); + $('.web-form-footer').show(); + } + + show_next_and_hide_save_button() { + $('.btn-next').show(); + $('.web-form-footer').hide(); + } + + toggle_previous_button() { + this.current_section == 0 ? $('.btn-previous').hide() : $('.btn-previous').show(); + } + + show_section() { + $(`.form-section:eq(${this.current_section})`).show(); + } + + hide_sections() { + for (let idx=0; idx < this.sections.length; idx++) { + if (idx !== this.current_section) { + $(`.form-section:eq(${idx})`).hide(); + } + } + } + save() { let is_new = this.is_new; if (this.validate && !this.validate()) { diff --git a/frappe/public/scss/common/awesomeplete.scss b/frappe/public/scss/common/awesomeplete.scss index b9e8035d68..17f33d7e82 100644 --- a/frappe/public/scss/common/awesomeplete.scss +++ b/frappe/public/scss/common/awesomeplete.scss @@ -39,6 +39,7 @@ padding: var(--padding-sm); color: var(--text-color); border-radius: var(--border-radius); + white-space: unset; @extend .ellipsis; &:not(:last-child) { margin-bottom: var(--margin-xs); diff --git a/frappe/public/scss/desk/form.scss b/frappe/public/scss/desk/form.scss index 7e7d6170c9..f56d9da59a 100644 --- a/frappe/public/scss/desk/form.scss +++ b/frappe/public/scss/desk/form.scss @@ -54,7 +54,7 @@ .form-section.card-section, .form-dashboard-section { - border-bottom: 1px solid var(--gray-200); + border-bottom: 1px solid var(--border-color); padding: var(--padding-xs); } @@ -316,12 +316,12 @@ .form-tabs-list { padding-left: var(--padding-xs); - border-bottom: 1px solid var(--gray-200); + border-bottom: 1px solid var(--border-color); .form-tabs { .nav-item { .nav-link { - color: var(--gray-700); + color: var(--text-muted); padding: var(--padding-md) 0; margin: 0 var(--margin-md); diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 5f26842be2..1839f15ae8 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -335,7 +335,10 @@ def make_test_records_for_doctype(doctype, verbose=0, force=False): frappe.local.test_objects[doctype] += test_module._make_test_records(verbose) elif hasattr(test_module, "test_records"): - frappe.local.test_objects[doctype] += make_test_objects(doctype, test_module.test_records, verbose, force) + if doctype in frappe.local.test_objects: + frappe.local.test_objects[doctype] += make_test_objects(doctype, test_module.test_records, verbose, force) + else: + frappe.local.test_objects[doctype] = make_test_objects(doctype, test_module.test_records, verbose, force) else: test_records = frappe.get_test_records(doctype) diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index dec55b4714..cdef4354ed 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -12,6 +12,7 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.utils import random_string from frappe.utils.testutils import clear_custom_fields from frappe.query_builder import Field +from frappe.database import savepoint from .test_query_builder import run_only_if, db_type_is from frappe.query_builder.functions import Concat_ws @@ -267,6 +268,32 @@ class TestDB(unittest.TestCase): for d in created_docs: self.assertTrue(frappe.db.exists("ToDo", d)) + def test_savepoints_wrapper(self): + frappe.db.rollback() + + class SpecificExc(Exception): + pass + + created_docs = [] + failed_docs = [] + + for _ in range(5): + with savepoint(catch=SpecificExc): + doc_kept = frappe.get_doc(doctype="ToDo", description="nope").save() + created_docs.append(doc_kept.name) + + with savepoint(catch=SpecificExc): + doc_gone = frappe.get_doc(doctype="ToDo", description="nope").save() + failed_docs.append(doc_gone.name) + raise SpecificExc + + frappe.db.commit() + + for d in failed_docs: + self.assertFalse(frappe.db.exists("ToDo", d)) + for d in created_docs: + self.assertTrue(frappe.db.exists("ToDo", d)) + @run_only_if(db_type_is.MARIADB) class TestDDLCommandsMaria(unittest.TestCase): diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 48e97d5bb0..5cd6690209 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -1,6 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe, unittest +import frappe +import datetime +import unittest from frappe.model.db_query import DatabaseQuery from frappe.desk.reportview import get_filters_cond @@ -380,6 +382,22 @@ class TestReportview(unittest.TestCase): owners = DatabaseQuery("DocType").execute(filters={"name": "DocType"}, pluck="owner") self.assertEqual(owners, ["Administrator"]) + def test_prepare_select_args(self): + # frappe.get_all inserts modified field into order_by clause + # test to make sure this is inserted into select field when postgres + doctypes = frappe.get_all("DocType", + filters={"docstatus": 0, "document_type": ("!=", "")}, + group_by="document_type", + fields=["document_type", "sum(is_submittable) as is_submittable"], + limit=1, + as_list=True, + ) + if frappe.conf.db_type == "mariadb": + self.assertTrue(len(doctypes[0]) == 2) + else: + self.assertTrue(len(doctypes[0]) == 3) + self.assertTrue(isinstance(doctypes[0][2], datetime.datetime)) + def test_column_comparison(self): """Test DatabaseQuery.execute to test column comparison """ diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index 29cec8b230..34a1dd070c 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -252,3 +252,8 @@ class TestDocument(unittest.TestCase): 'currency': 100000 }) self.assertEquals(d.get_formatted('currency', currency='INR', format="#,###.##"), '₹ 100,000.00') + + def test_limit_for_get(self): + doc = frappe.get_doc("DocType", "DocType") + # assuming DocType has more that 3 Data fields + self.assertEquals(len(doc.get("fields", filters={"fieldtype": "Data"}, limit=3)), 3) \ No newline at end of file diff --git a/frappe/tests/test_frappe_client.py b/frappe/tests/test_frappe_client.py index 66e1160eea..e84163eb41 100644 --- a/frappe/tests/test_frappe_client.py +++ b/frappe/tests/test_frappe_client.py @@ -10,8 +10,9 @@ import requests import base64 class TestFrappeClient(unittest.TestCase): + PASSWORD = "admin" def test_insert_many(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Note", {"title": ("in", ('Sing','a','song','of','sixpence'))}) frappe.db.commit() @@ -30,7 +31,7 @@ class TestFrappeClient(unittest.TestCase): self.assertTrue(frappe.db.get_value('Note', {'title': 'sixpence'})) def test_create_doc(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Note", {"title": "test_create"}) frappe.db.commit() @@ -39,13 +40,13 @@ class TestFrappeClient(unittest.TestCase): self.assertTrue(frappe.db.get_value('Note', {'title': 'test_create'})) def test_list_docs(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) doc_list = server.get_list("Note") self.assertTrue(len(doc_list)) def test_get_doc(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Note", {"title": "get_this"}) frappe.db.commit() @@ -56,7 +57,7 @@ class TestFrappeClient(unittest.TestCase): self.assertTrue(doc) def test_get_value(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Note", {"title": "get_value"}) frappe.db.commit() @@ -74,14 +75,14 @@ class TestFrappeClient(unittest.TestCase): self.assertRaises(FrappeException, server.get_value, "Note", "(select (password) from(__Auth) order by name desc limit 1)", {"title": "get_value"}) def test_get_single(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) server.set_value('Website Settings', 'Website Settings', 'title_prefix', 'test-prefix') self.assertEqual(server.get_value('Website Settings', 'title_prefix', 'Website Settings').get('title_prefix'), 'test-prefix') self.assertEqual(server.get_value('Website Settings', 'title_prefix').get('title_prefix'), 'test-prefix') frappe.db.set_value('Website Settings', None, 'title_prefix', '') def test_update_doc(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Note", {"title": ("in", ("Sing", "sing"))}) frappe.db.commit() @@ -93,7 +94,7 @@ class TestFrappeClient(unittest.TestCase): self.assertTrue(doc["title"] == changed_title) def test_update_child_doc(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Contact", {"first_name": "George", "last_name": "Steevens"}) frappe.db.delete("Contact", {"first_name": "William", "last_name": "Shakespeare"}) frappe.db.delete("Communication", {"reference_doctype": "Event"}) @@ -130,7 +131,7 @@ class TestFrappeClient(unittest.TestCase): self.assertTrue(frappe.db.exists("Communication Link", {"link_name": "William Shakespeare"})) def test_delete_doc(self): - server = FrappeClient(get_url(), "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Note", {"title": "delete"}) frappe.db.commit() diff --git a/frappe/tests/test_permissions.py b/frappe/tests/test_permissions.py index b4e7db9956..fdff4d103e 100644 --- a/frappe/tests/test_permissions.py +++ b/frappe/tests/test_permissions.py @@ -12,6 +12,7 @@ from frappe.core.page.permission_manager.permission_manager import update, reset from frappe.test_runner import make_test_records_for_doctype from frappe.core.doctype.user_permission.user_permission import clear_user_permissions from frappe.desk.form.load import getdoc +from frappe.utils.data import now_datetime test_dependencies = ['Blogger', 'Blog Post', "User", "Contact", "Salutation"] @@ -197,6 +198,32 @@ class TestPermissions(unittest.TestCase): doc = frappe.get_doc("Blog Post", "-test-blog-post") self.assertTrue(doc.has_permission("read")) + def test_set_standard_fields_manually(self): + # check that creation and owner cannot be set manually + from datetime import timedelta + + fake_creation = now_datetime() + timedelta(days=-7) + fake_owner = frappe.db.get_value("User", {"name": ("!=", frappe.session.user)}) + + d = frappe.new_doc("ToDo") + d.description = "ToDo created via test_set_standard_fields_manually" + d.creation = fake_creation + d.owner = fake_owner + d.save() + self.assertNotEqual(d.creation, fake_creation) + self.assertNotEqual(d.owner, fake_owner) + + def test_dont_change_standard_constants(self): + # check that Document.creation cannot be changed + user = frappe.get_doc("User", frappe.session.user) + user.creation = now_datetime() + self.assertRaises(frappe.CannotChangeConstantError, user.save) + + # check that Document.owner cannot be changed + user.reload() + user.owner = frappe.db.get_value("User", {"name": ("!=", user.name)}) + self.assertRaises(frappe.CannotChangeConstantError, user.save) + def test_set_only_once(self): blog_post = frappe.get_meta("Blog Post") doc = frappe.get_doc("Blog Post", "-test-blog-post-1") diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 599a638ce2..5c1541e0de 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -15,6 +15,8 @@ import io from mimetypes import guess_type from datetime import datetime, timedelta, date +from unittest.mock import patch + class TestFilters(unittest.TestCase): def test_simple_dict(self): self.assertTrue(evaluate_filters({'doctype': 'User', 'status': 'Open'}, {'status': 'Open'})) @@ -306,3 +308,24 @@ class TestDiffUtils(unittest.TestCase): diff = get_version_diff(old_version, latest_version) self.assertIn('-2;', diff) self.assertIn('+42;', diff) + +class TestDateUtils(unittest.TestCase): + def test_first_day_of_week(self): + # Monday as start of the week + with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"): + self.assertEqual(frappe.utils.get_first_day_of_week("2020-12-25"), + frappe.utils.getdate("2020-12-21")) + self.assertEqual(frappe.utils.get_first_day_of_week("2020-12-20"), + frappe.utils.getdate("2020-12-14")) + + # Sunday as start of the week + self.assertEqual(frappe.utils.get_first_day_of_week("2020-12-25"), + frappe.utils.getdate("2020-12-20")) + self.assertEqual(frappe.utils.get_first_day_of_week("2020-12-21"), + frappe.utils.getdate("2020-12-20")) + + def test_last_day_of_week(self): + self.assertEqual(frappe.utils.get_last_day_of_week("2020-12-24"), + frappe.utils.getdate("2020-12-26")) + self.assertEqual(frappe.utils.get_last_day_of_week("2020-12-28"), + frappe.utils.getdate("2021-01-02")) \ No newline at end of file diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index 992d876243..e40a07c0ec 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -197,6 +197,7 @@ class TestWebsite(unittest.TestCase): frappe.cache().delete_key('app_hooks') def test_printview_page(self): + frappe.db.value_cache[('DocType', 'Language', 'name')] = (('Language',),) content = get_response_content('/Language/ru') self.assertIn('