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/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 4fe315c372..758b3cde2b 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -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 50a6f8b17e..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): - 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): + + if 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"))) + plural = "s" if len(repeated_days) > 1 else "" - def on_update(self): - clear_assignment_rule_cache(self) - - def after_rename(self, old, new, merge): - clear_assignment_rule_cache(self) - - 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) @@ -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 84b8dbd3c8..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 = [ @@ -129,7 +139,7 @@ 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.allocated_to, 'test@example.com') @@ -151,7 +161,7 @@ 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.allocated_to, 'test@example.com') 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/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/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/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/database/database.py b/frappe/database/database.py index a157343be6..65242e0419 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -164,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) @@ -178,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: @@ -267,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 @@ -282,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() @@ -701,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) @@ -713,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): @@ -835,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) 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/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/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.py b/frappe/desk/doctype/todo/todo.py index eabb28a6f3..e689faafbe 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -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=["allocated_to"], 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) diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index 7ea87b8d15..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=['allocated_to', '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): 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/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)) + all_ids = list(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, - }) + 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/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 eeef552a8a..26a4658c36 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -768,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) @@ -1008,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 e519ab257b..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,10 +469,12 @@ class Document(BaseDocument): self._original_modified = self.modified self.modified = now() self.modified_by = frappe.session.user - if not self.creation: - self.creation = self.modified + + # 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.owner = self.flags.owner or self.modified_by + self.creation = self.modified + self.owner = self.modified_by for d in self.get_all_children(): d.modified = self.modified @@ -501,7 +504,6 @@ class Document(BaseDocument): self._sanitize_content() self._save_passwords() self.validate_workflow() - self.validate_owner() children = self.get_all_children() for d in children: @@ -544,11 +546,6 @@ class Document(BaseDocument): if not self._action == 'save': set_workflow_state_on_action(self, workflow, self._action) - def validate_owner(self): - """Validate if the owner of the Document has changed""" - if not self.is_new() and self.has_value_changed('owner'): - frappe.throw(_('Document owner cannot be changed')) - def validate_set_only_once(self): """Validate that fields are not changed if not in insert""" set_only_once_fields = self.meta.get_set_only_once_fields() @@ -568,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 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 53991235c9..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) diff --git a/frappe/patches.txt b/frappe/patches.txt index 39d60d9496..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 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/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index c9ace2e47e..e056a34be2 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/form.js b/frappe/public/js/frappe/form/form.js index 57e3f576a1..1459b38df6 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -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/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 920a252b56..7ba0a0228f 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -105,7 +105,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { this.toggle_nothing_to_show(true); return; } - + let route_options = {}; route_options = Object.assign(route_options, frappe.route_options); @@ -849,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/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_assign.py b/frappe/tests/test_assign.py index 971f9ce071..05bf7e2fb3 100644 --- a/frappe/tests/test_assign.py +++ b/frappe/tests/test_assign.py @@ -13,7 +13,7 @@ class TestAssign(unittest.TestCase): added = assign(todo, "test@example.com") - self.assertTrue("test@example.com" in [d.allocated_to for d in added]) + self.assertTrue("test@example.com" in [d.owner for d in added]) removed = frappe.desk.form.assign_to.remove(todo.doctype, todo.name, "test@example.com") 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 46638f5bf2..34a1dd070c 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -253,21 +253,7 @@ class TestDocument(unittest.TestCase): }) self.assertEquals(d.get_formatted('currency', currency='INR', format="#,###.##"), '₹ 100,000.00') - def test_owner_changed(self): - frappe.delete_doc_if_exists("User", "hello@example.com") - frappe.set_user("Administrator") - - d = frappe.get_doc({ - "doctype": "User", - "email": "hello@example.com", - "first_name": "John" - }) - d.insert() - self.assertEqual(frappe.db.get_value("User", d.owner), d.owner) - - d.set("owner", "johndoe@gmail.com") - self.assertRaises(frappe.ValidationError, d.save) - - d.reload() - d.save() - self.assertEqual(frappe.db.get_value("User", d.owner), d.owner) + 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('