diff --git a/cypress/integration/web_form.js b/cypress/integration/web_form.js index 8346c96313..bd1c7e147e 100644 --- a/cypress/integration/web_form.js +++ b/cypress/integration/web_form.js @@ -7,8 +7,8 @@ context('Web Form', () => { 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'); + cy.wait(5000); + cy.url().should('include', '/me'); }); it('Navigate and Submit a MultiStep WebForm', () => { @@ -16,14 +16,12 @@ context('Web Form', () => { 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'); + cy.wait(5000); + cy.url().should('include', '/me'); }); }); }); diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 63da4db093..e788c7ec4d 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -728,7 +728,7 @@ def move(dest_dir, site): @click.command('set-password') @click.argument('user') @click.argument('password', required=False) -@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) +@click.option('--logout-all-sessions', help='Log out from all sessions', is_flag=True, default=False) @pass_context def set_password(context, user, password=None, logout_all_sessions=False): "Set password for a user on a site" @@ -741,7 +741,7 @@ def set_password(context, user, password=None, logout_all_sessions=False): @click.command('set-admin-password') @click.argument('admin-password', required=False) -@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) +@click.option('--logout-all-sessions', help='Log out from all sessions', is_flag=True, default=False) @pass_context def set_admin_password(context, admin_password=None, logout_all_sessions=False): "Set Administrator password for a site" diff --git a/frappe/core/doctype/log_settings/test_log_settings.py b/frappe/core/doctype/log_settings/test_log_settings.py index 7c9c7b5067..f398577665 100644 --- a/frappe/core/doctype/log_settings/test_log_settings.py +++ b/frappe/core/doctype/log_settings/test_log_settings.py @@ -2,20 +2,17 @@ # License: MIT. See LICENSE from datetime import datetime -import unittest import frappe from frappe.utils import now_datetime, add_to_date from frappe.core.doctype.log_settings.log_settings import run_log_clean_up +from frappe.tests.utils import FrappeTestCase -class TestLogSettings(unittest.TestCase): +class TestLogSettings(FrappeTestCase): @classmethod def setUpClass(cls): - cls.savepoint = "TestLogSettings" - # SAVEPOINT can only be used in transaction blocks and we don't wan't to take chances - frappe.db.begin() - frappe.db.savepoint(cls.savepoint) + super().setUpClass() frappe.db.set_single_value( "Log Settings", @@ -26,10 +23,6 @@ class TestLogSettings(unittest.TestCase): }, ) - @classmethod - def tearDownClass(cls): - frappe.db.rollback(save_point=cls.savepoint) - def setUp(self) -> None: if self._testMethodName == "test_delete_logs": self.datetime = frappe._dict() diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 9cb40dffd4..bf82a3f684 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -11,7 +11,7 @@ from frappe.modules import make_boilerplate from frappe.core.doctype.page.page import delete_custom_role from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles from frappe.desk.reportview import append_totals_row -from frappe.utils.safe_exec import safe_exec +from frappe.utils.safe_exec import safe_exec, check_safe_sql_query class Report(Document): @@ -110,8 +110,7 @@ class Report(Document): if not self.query: frappe.throw(_("Must specify a Query to run"), title=_('Report Document Error')) - if not self.query.lower().startswith("select"): - frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error')) + check_safe_sql_query(self.query) result = [list(t) for t in frappe.db.sql(self.query, filters)] columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()] diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py index a077956d71..e58d038993 100644 --- a/frappe/core/doctype/report/test_report.py +++ b/frappe/core/doctype/report/test_report.py @@ -1,17 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import textwrap + import frappe, json, os -import unittest from frappe.desk.query_report import run, save_report, add_total_row from frappe.desk.reportview import delete_report, save_report as _save_report from frappe.custom.doctype.customize_form.customize_form import reset_customization from frappe.core.doctype.user_permission.test_user_permission import create_user +from frappe.tests.utils import FrappeTestCase test_records = frappe.get_test_records('Report') test_dependencies = ['User'] -class TestReport(unittest.TestCase): +class TestReport(FrappeTestCase): def test_report_builder(self): if frappe.db.exists('Report', 'User Activity Report'): frappe.delete_doc('Report', 'User Activity Report') @@ -335,3 +337,29 @@ result = [ self.assertEqual(result[-1][0], "Total") self.assertEqual(result[-1][1], 200) self.assertEqual(result[-1][2], 150.50) + + def test_cte_in_query_report(self): + cte_query = textwrap.dedent(""" + with enabled_users as ( + select name + from `tabUser` + where enabled = 1 + ) + select * from enabled_users; + """) + + report = frappe.get_doc({ + "doctype": "Report", + "ref_doctype": "User", + "report_name": "Enabled Users List", + "report_type": "Query Report", + "is_standard": "No", + "query": cte_query, + }).insert() + + if frappe.db.db_type == "mariadb": + col, rows = report.execute_query_report(filters={}) + self.assertEqual(col[0], "name") + self.assertGreaterEqual(len(rows), 1) + elif frappe.db.db_type == "postgres": + self.assertRaises(frappe.PermissionError, report.execute_query_report, filters={}) diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 9e9529cd5e..642a392a58 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -2,7 +2,7 @@ "actions": [], "allow_import": 1, "allow_rename": 1, - "creation": "2014-03-11 14:55:00", + "creation": "2022-01-10 17:29:51.672911", "description": "Represents a User in the system.", "doctype": "DocType", "engine": "InnoDB", @@ -48,6 +48,12 @@ "document_follow_notifications_section", "document_follow_notify", "document_follow_frequency", + "column_break_75", + "follow_created_documents", + "follow_commented_documents", + "follow_liked_documents", + "follow_assigned_documents", + "follow_shared_documents", "email_settings", "email_signature", "thread_notify", @@ -606,6 +612,45 @@ "fieldtype": "Link", "label": "Module Profile", "options": "Module Profile" + }, + { + "fieldname": "column_break_75", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:(doc.document_follow_notify== 1)", + "fieldname": "follow_created_documents", + "fieldtype": "Check", + "label": "Auto follow documents that you create" + }, + { + "default": "0", + "depends_on": "eval:(doc.document_follow_notify== 1)", + "fieldname": "follow_commented_documents", + "fieldtype": "Check", + "label": "Auto follow documents that you comment on" + }, + { + "default": "0", + "depends_on": "eval:(doc.document_follow_notify== 1)", + "fieldname": "follow_liked_documents", + "fieldtype": "Check", + "label": "Auto follow documents that you Like" + }, + { + "default": "0", + "depends_on": "eval:(doc.document_follow_notify== 1)", + "fieldname": "follow_shared_documents", + "fieldtype": "Check", + "label": "Auto follow documents that are shared with you" + }, + { + "default": "0", + "depends_on": "eval:(doc.document_follow_notify== 1)", + "fieldname": "follow_assigned_documents", + "fieldtype": "Check", + "label": "Auto follow documents that are assigned to you" } ], "icon": "fa fa-user", @@ -704,4 +749,4 @@ "states": [], "title_field": "full_name", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/core/web_form/edit_profile/edit_profile.json b/frappe/core/web_form/edit_profile/edit_profile.json index 7072584670..c04e705820 100644 --- a/frappe/core/web_form/edit_profile/edit_profile.json +++ b/frappe/core/web_form/edit_profile/edit_profile.json @@ -1,133 +1,159 @@ { - "accept_payment": 0, - "allow_comments": 0, - "allow_delete": 0, - "allow_edit": 1, - "allow_incomplete": 0, - "allow_multiple": 0, - "allow_print": 0, - "amount": 0.0, - "amount_based_on_field": 0, - "breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]", - "creation": "2016-09-19 05:16:59.242754", - "doc_type": "User", - "docstatus": 0, - "doctype": "Web Form", - "idx": 0, - "introduction_text": "", - "is_standard": 1, - "login_required": 1, - "max_attachment_size": 0, - "modified": "2019-01-28 12:45:17.158069", - "modified_by": "Administrator", - "module": "Core", - "name": "edit-profile", - "owner": "Administrator", - "published": 1, - "route": "update-profile", - "show_in_grid": 0, - "show_sidebar": 1, - "sidebar_items": [], - "success_message": "Profile updated successfully.", - "success_url": "/me", - "title": "Update Profile", + "accept_payment": 0, + "allow_comments": 0, + "allow_delete": 0, + "allow_edit": 1, + "allow_incomplete": 0, + "allow_multiple": 0, + "allow_print": 0, + "amount": 0.0, + "amount_based_on_field": 0, + "apply_document_permissions": 0, + "breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]", + "creation": "2016-09-19 05:16:59.242754", + "doc_type": "User", + "docstatus": 0, + "doctype": "Web Form", + "idx": 0, + "introduction_text": "", + "is_multi_step_form": 0, + "is_standard": 1, + "login_required": 1, + "max_attachment_size": 0, + "modified": "2022-03-22 15:00:43.456738", + "modified_by": "Administrator", + "module": "Core", + "name": "edit-profile", + "owner": "Administrator", + "published": 1, + "route": "update-profile", + "route_to_success_link": 0, + "show_attachments": 0, + "show_in_grid": 0, + "show_sidebar": 0, + "sidebar_items": [], + "success_message": "Profile updated successfully.", + "success_url": "/me", + "title": "Update Profile", "web_form_fields": [ { - "allow_read_on_all_link_options": 0, - "fieldname": "first_name", - "fieldtype": "Data", - "hidden": 0, - "label": "First Name", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 1, + "allow_read_on_all_link_options": 0, + "fieldname": "first_name", + "fieldtype": "Data", + "hidden": 0, + "label": "First Name", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 1, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldname": "middle_name", - "fieldtype": "Data", - "hidden": 0, - "label": "Middle Name (Optional)", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "middle_name", + "fieldtype": "Data", + "hidden": 0, + "label": "Middle Name (Optional)", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldname": "last_name", - "fieldtype": "Data", - "hidden": 0, - "label": "Last Name", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "last_name", + "fieldtype": "Data", + "hidden": 0, + "label": "Last Name", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "description": "", - "fieldname": "user_image", - "fieldtype": "Attach Image", - "hidden": 0, - "label": "User Image", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "", + "fieldtype": "Column Break", + "hidden": 0, + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldtype": "Section Break", - "hidden": 0, - "label": "More Information", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "description": "", + "fieldname": "user_image", + "fieldtype": "Attach Image", + "hidden": 0, + "label": "Profile Picture", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldname": "phone", - "fieldtype": "Data", - "hidden": 0, - "label": "Phone", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldtype": "Section Break", + "hidden": 0, + "label": "More Information", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldname": "mobile_no", - "fieldtype": "Data", - "hidden": 0, - "label": "Mobile Number", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "phone", + "fieldtype": "Data", + "hidden": 0, + "label": "Phone", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "description": "", - "fieldname": "language", - "fieldtype": "Link", - "hidden": 0, - "label": "Language", - "max_length": 0, - "max_value": 0, - "options": "Language", - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "mobile_no", + "fieldtype": "Data", + "hidden": 0, + "label": "Mobile Number", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "", + "fieldtype": "Column Break", + "hidden": 0, + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "description": "", + "fieldname": "language", + "fieldtype": "Link", + "hidden": 0, + "label": "Language", + "max_length": 0, + "max_value": 0, + "options": "Language", + "read_only": 0, + "reqd": 0, "show_in_filter": 0 } ] diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index 049d33c1ec..d79927a506 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -84,7 +84,8 @@ def add(args=None): shared_with_users.append(assign_to) # make this document followed by assigned user - follow_document(args['doctype'], args['name'], assign_to) + if frappe.get_cached_value("User", assign_to, "follow_assigned_documents"): + follow_document(args['doctype'], args['name'], assign_to) # notify notify_assignment(d.assigned_by, d.allocated_to, d.reference_type, d.reference_name, action='ASSIGN', diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py index 14970092d0..7dd2b64c21 100644 --- a/frappe/desk/form/document_follow.py +++ b/frappe/desk/form/document_follow.py @@ -6,7 +6,7 @@ import frappe.utils from frappe.utils import get_url_to_form from frappe.model import log_types from frappe import _ -from itertools import groupby +from frappe.query_builder import DocType @frappe.whitelist() def update_follow(doctype, doc_name, following): @@ -94,33 +94,50 @@ def send_document_follow_mails(frequency): call method to send mail ''' - users = frappe.get_list("Document Follow", - fields=["*"]) + user_list = get_user_list(frequency) - sorted_users = sorted(users, key=lambda k: k['user']) + for user in user_list: + message, valid_document_follows = get_message_for_user(frequency, user) + if message: + send_email_alert(user, valid_document_follows, message) + # send an email if we have already spent resources creating the message + # nosemgrep + frappe.db.commit() - grouped_by_user = {} - for k, v in groupby(sorted_users, key=lambda k: k['user']): - grouped_by_user[k] = list(v) +def get_user_list(frequency): + DocumentFollow = DocType('Document Follow') + User = DocType('User') + return (frappe.qb.from_(DocumentFollow).join(User) + .on(DocumentFollow.user == User.name) + .where(User.document_follow_notify == 1) + .where(User.document_follow_frequency == frequency) + .select(DocumentFollow.user) + .groupby(DocumentFollow.user)).run(pluck="user") - for user in grouped_by_user: - user_frequency = frappe.db.get_value("User", user, "document_follow_frequency") - message = [] - valid_document_follows = [] - if user_frequency == frequency: - for d in grouped_by_user[user]: - content = get_message(d.ref_docname, d.ref_doctype, frequency, user) - if content: - message = message + content - valid_document_follows.append({ - "reference_docname": d.ref_docname, - "reference_doctype": d.ref_doctype, - "reference_url": get_url_to_form(d.ref_doctype, d.ref_docname) - }) +def get_message_for_user(frequency, user): + message = [] + latest_document_follows = get_document_followed_by_user(user) + valid_document_follows = [] - if message and frappe.db.get_value("User", user, "document_follow_notify", ignore=True): - send_email_alert(user, valid_document_follows, message) + for document_follow in latest_document_follows: + content = get_message(document_follow.ref_docname, document_follow.ref_doctype, frequency, user) + if content: + message = message + content + valid_document_follows.append({ + "reference_docname": document_follow.ref_docname, + "reference_doctype": document_follow.ref_doctype, + "reference_url": get_url_to_form(document_follow.ref_doctype, document_follow.ref_docname) + }) + return message, valid_document_follows +def get_document_followed_by_user(user): + DocumentFollow = DocType('Document Follow') + # at max 20 documents are sent for each user + return (frappe.qb.from_(DocumentFollow) + .where(DocumentFollow.user == user) + .select(DocumentFollow.ref_doctype, DocumentFollow.ref_docname) + .orderby(DocumentFollow.modified) + .limit(20)).run(as_dict=True) def get_version(doctype, doc_name, frequency, user): timeline = [] diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py index 291767de10..f80b89d2b8 100644 --- a/frappe/desk/form/utils.py +++ b/frappe/desk/form/utils.py @@ -31,8 +31,8 @@ def add_comment(reference_doctype, reference_name, content, comment_email, comme reference_doc = frappe.get_doc(reference_doctype, reference_name) doc.content = extract_images_from_html(reference_doc, content, is_private=True) doc.insert(ignore_permissions=True) - - follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user) + if frappe.get_cached_value("User", frappe.session.user, "follow_commented_documents"): + follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user) return doc.as_dict() @frappe.whitelist() diff --git a/frappe/desk/like.py b/frappe/desk/like.py index 4480ed8a1e..5e5a789973 100644 --- a/frappe/desk/like.py +++ b/frappe/desk/like.py @@ -41,7 +41,8 @@ def _toggle_like(doctype, name, add, user=None): if user not in liked_by: liked_by.append(user) add_comment(doctype, name) - follow_document(doctype, name, user) + if frappe.get_cached_value("User", user, "follow_liked_documents"): + follow_document(doctype, name, user) else: if user in liked_by: liked_by.remove(user) diff --git a/frappe/email/doctype/document_follow/test_document_follow.py b/frappe/email/doctype/document_follow/test_document_follow.py index 050add65e9..0f6ff6c114 100644 --- a/frappe/email/doctype/document_follow/test_document_follow.py +++ b/frappe/email/doctype/document_follow/test_document_follow.py @@ -3,10 +3,18 @@ # License: MIT. See LICENSE import frappe import unittest +from dataclasses import dataclass import frappe.desk.form.document_follow as document_follow +from frappe.query_builder import DocType +from frappe.desk.form.utils import add_comment +from frappe.desk.form.document_follow import get_document_followed_by_user +from frappe.desk.like import toggle_like +from frappe.desk.form.assign_to import add +from frappe.share import add as share +from frappe.query_builder.functions import Cast_ class TestDocumentFollow(unittest.TestCase): - def test_document_follow(self): + def test_document_follow_version(self): user = get_user() event_doc = get_event() @@ -18,18 +26,173 @@ class TestDocumentFollow(unittest.TestCase): self.assertEqual(doc.user, user.name) document_follow.send_hourly_updates() + emails = get_emails(event_doc, '%This is a test description for sending mail%') + self.assertIsNotNone(emails) - email_queue_entry_name = frappe.get_all("Email Queue", limit=1)[0].name - email_queue_entry_doc = frappe.get_doc("Email Queue", email_queue_entry_name) - self.assertEqual((email_queue_entry_doc.recipients[0].recipient), user.name) + def test_document_follow_comment(self): + user = get_user() + event_doc = get_event() - self.assertIn(event_doc.doctype, email_queue_entry_doc.message) - self.assertIn(event_doc.name, email_queue_entry_doc.message) + add_comment(event_doc.doctype, event_doc.name, "This is a test comment", 'Administrator@example.com', 'Bosh') + + document_follow.unfollow_document("Event", event_doc.name, user.name) + doc = document_follow.follow_document("Event", event_doc.name, user.name) + self.assertEqual(doc.user, user.name) + + document_follow.send_hourly_updates() + emails = get_emails(event_doc, '%This is a test comment%') + self.assertIsNotNone(emails) + + + def test_follow_limit(self): + user = get_user() + for _ in range(25): + event_doc = get_event() + document_follow.unfollow_document("Event", event_doc.name, user.name) + doc = document_follow.follow_document("Event", event_doc.name, user.name) + self.assertEqual(doc.user, user.name) + self.assertEqual(len(get_document_followed_by_user(user.name)), 20) + + def test_follow_on_create(self): + user = get_user(DocumentFollowConditions(1)) + frappe.set_user(user.name) + event = get_event() + + event.description = "This is a test description for sending mail" + event.save(ignore_version=False) + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertTrue(documents_followed) + + def test_do_not_follow_on_create(self): + user = get_user() + frappe.set_user(user.name) + + event = get_event() + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertFalse(documents_followed) + + def test_do_not_follow_on_update(self): + user = get_user() + frappe.set_user(user.name) + event = get_event() + + event.description = "This is a test description for sending mail" + event.save(ignore_version=False) + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertFalse(documents_followed) + + def test_follow_on_comment(self): + user = get_user(DocumentFollowConditions(0, 1)) + frappe.set_user(user.name) + event = get_event() + + add_comment(event.doctype, event.name, "This is a test comment", 'Administrator@example.com', 'Bosh') + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertTrue(documents_followed) + + def test_do_not_follow_on_comment(self): + user = get_user() + frappe.set_user(user.name) + event = get_event() + + add_comment(event.doctype, event.name, "This is a test comment", 'Administrator@example.com', 'Bosh') + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertFalse(documents_followed) + + def test_follow_on_like(self): + user = get_user(DocumentFollowConditions(0, 0, 1)) + frappe.set_user(user.name) + event = get_event() + + toggle_like(event.doctype, event.name, add="Yes") + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertTrue(documents_followed) + + def test_do_not_follow_on_like(self): + user = get_user() + frappe.set_user(user.name) + event = get_event() + + toggle_like(event.doctype, event.name) + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertFalse(documents_followed) + + def test_follow_on_assign(self): + user = get_user(DocumentFollowConditions(0, 0, 0, 1)) + event = get_event() + + add({ + 'assign_to': [user.name], + 'doctype': event.doctype, + 'name': event.name + }) + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertTrue(documents_followed) + + def test_do_not_follow_on_assign(self): + user = get_user() + frappe.set_user(user.name) + event = get_event() + + add({ + 'assign_to': [user.name], + 'doctype': event.doctype, + 'name': event.name + }) + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertFalse(documents_followed) + + def test_follow_on_share(self): + user = get_user(DocumentFollowConditions(0, 0, 0, 0, 1)) + event = get_event() + + share( + user= user.name, + doctype= event.doctype, + name= event.name + ) + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertTrue(documents_followed) + + def test_do_not_follow_on_share(self): + user = get_user() + event = get_event() + + share( + user = user.name, + doctype = event.doctype, + name = event.name + ) + + documents_followed = get_events_followed_by_user(event.name, user.name) + self.assertFalse(documents_followed) def tearDown(self): frappe.db.rollback() + frappe.db.delete('Email Queue') + frappe.db.delete('Email Queue Recipient') + frappe.db.delete('Document Follow') + frappe.db.delete('Event') + +def get_events_followed_by_user(event_name, user_name): + DocumentFollow = DocType('Document Follow') + return (frappe.qb.from_(DocumentFollow) + .where(DocumentFollow.ref_doctype == 'Event') + .where(DocumentFollow.ref_docname == event_name) + .where(DocumentFollow.user == user_name) + .select(DocumentFollow.name)).run() def get_event(): doc = frappe.get_doc({ @@ -42,16 +205,40 @@ def get_event(): doc.insert() return doc -def get_user(): +def get_user(document_follow=None): + frappe.set_user("Administrator") if frappe.db.exists('User', 'test@docsub.com'): - doc = frappe.get_doc('User', 'test@docsub.com') - else: - doc = frappe.new_doc("User") - doc.email = "test@docsub.com" - doc.first_name = "Test" - doc.last_name = "User" - doc.send_welcome_email = 0 - doc.document_follow_notify = 1 - doc.document_follow_frequency = "Hourly" - doc.insert() - return doc \ No newline at end of file + doc = frappe.delete_doc('User', 'test@docsub.com') + doc = frappe.new_doc("User") + doc.email = "test@docsub.com" + doc.first_name = "Test" + doc.last_name = "User" + doc.send_welcome_email = 0 + doc.document_follow_notify = 1 + doc.document_follow_frequency = "Hourly" + doc.__dict__.update(document_follow.__dict__ if document_follow else {}) + doc.insert() + doc.add_roles('System Manager') + return doc + +def get_emails(event_doc, search_string): + EmailQueue = DocType('Email Queue') + EmailQueueRecipient = DocType('Email Queue Recipient') + + return (frappe.qb.from_(EmailQueue) + .join(EmailQueueRecipient) + .on(EmailQueueRecipient.parent == Cast_(EmailQueue.name, "varchar")) + .where(EmailQueueRecipient.recipient == 'test@docsub.com',) + .where(EmailQueue.message.like(f'%{event_doc.doctype}%')) + .where(EmailQueue.message.like(f'%{event_doc.name}%')) + .where(EmailQueue.message.like(search_string)) + .select(EmailQueue.message) + .limit(1)).run() + +@dataclass +class DocumentFollowConditions: + follow_created_documents: int = 0 + follow_commented_documents: int = 0 + follow_liked_documents: int = 0 + follow_assigned_documents: int = 0 + follow_shared_documents: int = 0 diff --git a/frappe/model/document.py b/frappe/model/document.py index 3c38ff3442..3848fa8029 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -276,7 +276,8 @@ class Document(BaseDocument): delattr(self, "__unsaved") if not (frappe.flags.in_migrate or frappe.local.flags.in_install or frappe.flags.in_setup_wizard): - follow_document(self.doctype, self.name, frappe.session.user) + if frappe.get_cached_value("User", frappe.session.user, "follow_created_documents"): + follow_document(self.doctype, self.name, frappe.session.user) return self def save(self, *args, **kwargs): @@ -1125,7 +1126,8 @@ class Document(BaseDocument): version.insert(ignore_permissions=True) if not frappe.flags.in_migrate: # follow since you made a change? - follow_document(self.doctype, self.name, frappe.session.user) + if frappe.get_cached_value("User", frappe.session.user, "follow_created_documents"): + follow_document(self.doctype, self.name, frappe.session.user) @staticmethod def hook(f): diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index 13b52d2020..4768faff48 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -272,7 +272,7 @@ def make_boilerplate(template, doc, opts=None): frappe.utils.cstr(source.read()).format( app_publisher=app_publisher, year=frappe.utils.nowdate()[:4], - classname=doc.name.replace(" ", ""), + classname=doc.name.replace(" ", "").replace("-", ""), base_class_import=base_class_import, base_class=base_class, doctype=doc.name, **opts, diff --git a/frappe/patches.txt b/frappe/patches.txt index e85c837286..bc2bc22637 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -146,7 +146,7 @@ frappe.patches.v13_0.update_duration_options frappe.patches.v13_0.replace_old_data_import # 2020-06-24 frappe.patches.v13_0.create_custom_dashboards_cards_and_charts frappe.patches.v13_0.rename_is_custom_field_in_dashboard_chart -frappe.patches.v13_0.add_standard_navbar_items # 2022-03-15 +frappe.patches.v13_0.add_standard_navbar_items # 2020-12-15 frappe.patches.v13_0.generate_theme_files_in_public_folder frappe.patches.v13_0.increase_password_length frappe.patches.v12_0.fix_email_id_formatting diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 7ec6677c7f..6191e35073 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1701,13 +1701,17 @@ frappe.ui.form.Form = class FrappeForm { } update_in_all_rows(table_fieldname, fieldname, value) { - // update the child value in all tables where it is missing - if(!value) return; - var cl = this.doc[table_fieldname] || []; - for(var i = 0; i < cl.length; i++){ - if(!cl[i][fieldname]) cl[i][fieldname] = value; - } - refresh_field("items"); + // Update the `value` of the field named `fieldname` in all rows of the + // child table named `table_fieldname`. + // Do not overwrite existing values. + if (value === undefined) return; + + frappe.model + .get_children(this.doc, table_fieldname) + .filter(child => !frappe.model.has_value(child.doctype, child.name, fieldname)) + .forEach(child => + frappe.model.set_value(child.doctype, child.name, fieldname, value) + ); } get_sum(table_fieldname, fieldname) { diff --git a/frappe/public/js/frappe/web_form/web_form.js b/frappe/public/js/frappe/web_form/web_form.js index a45fc941d3..11e0b782ae 100644 --- a/frappe/public/js/frappe/web_form/web_form.js +++ b/frappe/public/js/frappe/web_form/web_form.js @@ -25,7 +25,6 @@ export default class WebForm extends frappe.ui.FieldGroup { 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(); @@ -79,9 +78,9 @@ export default class WebForm extends frappe.ui.FieldGroup { } $('.web-form-footer').after(` -