diff --git a/frappe/automation/doctype/auto_assign/__init__.py b/frappe/automation/doctype/assignment_rule/__init__.py similarity index 100% rename from frappe/automation/doctype/auto_assign/__init__.py rename to frappe/automation/doctype/assignment_rule/__init__.py diff --git a/frappe/automation/doctype/auto_assign/auto_assign.js b/frappe/automation/doctype/assignment_rule/assignment_rule.js similarity index 92% rename from frappe/automation/doctype/auto_assign/auto_assign.js rename to frappe/automation/doctype/assignment_rule/assignment_rule.js index 84cb7b75b1..3e86f6cefa 100644 --- a/frappe/automation/doctype/auto_assign/auto_assign.js +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.js @@ -1,7 +1,7 @@ // Copyright (c) 2019, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Auto Assign', { +frappe.ui.form.on('Assignment Rule', { refresh: function(frm) { // refresh description frm.events.rule(frm); diff --git a/frappe/automation/doctype/auto_assign/auto_assign.json b/frappe/automation/doctype/assignment_rule/assignment_rule.json similarity index 98% rename from frappe/automation/doctype/auto_assign/auto_assign.json rename to frappe/automation/doctype/assignment_rule/assignment_rule.json index 2e209e3651..014c2ba2c7 100644 --- a/frappe/automation/doctype/auto_assign/auto_assign.json +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.json @@ -6,7 +6,7 @@ "allow_rename": 1, "autoname": "Prompt", "beta": 0, - "creation": "2019-02-27 11:44:44.232703", + "creation": "2019-02-28 17:12:18.815830", "custom": 0, "description": "Automatically Assign Documents to Users", "docstatus": 0, @@ -391,7 +391,7 @@ "label": "Users", "length": 0, "no_copy": 0, - "options": "Auto Assign User", + "options": "Assignment Rule User", "permlevel": 0, "precision": "", "print_hide": 0, @@ -449,10 +449,10 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2019-02-28 16:45:56.645592", + "modified": "2019-02-28 17:12:44.413781", "modified_by": "Administrator", "module": "Automation", - "name": "Auto Assign", + "name": "Assignment Rule", "name_case": "", "owner": "Administrator", "permissions": [ diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py new file mode 100644 index 0000000000..4d49c17ff8 --- /dev/null +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import frappe +from frappe.model.document import Document +from frappe.desk.form import assign_to + +class AssignmentRule(Document): + def on_update(self): # pylint: disable=no-self-use + frappe.cache().delete_value('assignment_rule') + + def after_rename(self): # pylint: disable=no-self-use + frappe.cache().delete_value('assignment_rule') + + def apply(self, doc): + if self.safe_eval('assign_condition', doc): + self.do_assignment(doc) + return True + + # try clearing + if self.unassign_condition: + return self.clear_assignment(doc) + + return False + + def do_assignment(self, doc): + user = self.get_user() + + assign_to.add(dict( + assign_to = user, + doctype = doc.get('doctype'), + name = doc.get('name'), + description = frappe.render_template(self.description, doc) + )) + + # set for reference in round robin + self.db_set('last_user', user) + + def clear_assignment(self, doc): + '''Clear assignments''' + if self.safe_eval('unassign_condition', doc): + return assign_to.clear(doc.get('doctype'), doc.get('name')) + + def get_user(self): + ''' + Get the next user for assignment + ''' + if self.rule == 'Round Robin': + return self.get_user_round_robin() + elif self.rule == 'Load Balancing': + return self.get_user_load_balancing() + + def get_user_round_robin(self): + ''' + Get next user based on round robin + ''' + + # first time, or last in list, pick the first + if not self.last_user or self.last_user == self.users[-1].user: + return self.users[0].user + + # find out the next user in the list + for i, d in enumerate(self.users): + if self.last_user == d.user: + return self.users[i+1].user + + def get_user_load_balancing(self): + '''Assign to the user with least number of open assignments''' + counts = [] + for d in self.users: + counts.append(dict( + user = d.user, + count = frappe.db.count('ToDo', dict( + reference_type = self.document_type, + owner = d.user, + status = "Open")) + )) + + # sort by dict value + sorted_counts = sorted(counts, key = lambda k: k['count']) + + # pick the first user + return sorted_counts[0].get('user') + + def safe_eval(self, fieldname, doc): + try: + return frappe.safe_eval(self.get(fieldname), None, doc) + except Exception: + # when assignment fails, don't block the document as it may be + # a part of the email pulling + frappe.msgprint(frappe._('Auto assignment failed'), indicator = 'orange') + +def apply(doc, method): + if frappe.flags.in_patch or frappe.flags.in_install: + return + + assignment_rules = frappe.cache().get_value('assignment_rule', get_assignment_rules) + if doc.doctype in assignment_rules: + # multiple auto assigns + for d in frappe.db.get_all('Assignment Rule', dict(document_type=doc.doctype, disabled = 0), order_by = 'priority desc'): + if frappe.get_doc('Assignment Rule', d.name).apply(doc.as_dict()): + break + +def get_assignment_rules(): + return [d.document_type for d in frappe.db.get_all('Assignment Rule', fields=['document_type'], filters=dict(disabled = 0))] \ No newline at end of file diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py new file mode 100644 index 0000000000..654e372b5c --- /dev/null +++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from frappe.utils import random_string + +class TestAutoAssign(unittest.TestCase): + def setUp(self): + self.assignment_rule = get_assignment_rule() + clear_assignments() + + def test_round_robin(self): + note = make_note(dict(public=1)) + + # check if auto assigned to first user + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), 'test@example.com') + + note = make_note(dict(public=1)) + + # check if auto assigned to second user + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), 'test1@example.com') + + clear_assignments() + + note = make_note(dict(public=1)) + + # check if auto assigned to third user, even if + # previous assignments where closed + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), 'test2@example.com') + + # check loop back to first user + note = make_note(dict(public=1)) + + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), 'test@example.com') + + def test_load_balancing(self): + self.assignment_rule.rule = 'Load Balancing' + self.assignment_rule.save() + + for _ in range(30): + note = make_note(dict(public=1)) + + # check if each user has 10 assignments (?) + for user in ('test@example.com', 'test1@example.com', 'test2@example.com'): + self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10) + + # clear 5 assignments for first user + # can't do a limit in "delete" since postgres does not support it + for d in frappe.get_all('ToDo', dict(reference_type = 'Note', owner = 'test@example.com'), limit=5): + frappe.db.sql("delete from tabToDo where name = %s", d.name) + + # add 5 more assignments + for i in range(5): + make_note(dict(public=1)) + + # check if each user still has 10 assignments + for user in ('test@example.com', 'test1@example.com', 'test2@example.com'): + self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10) + + + def test_assign_condition(self): + # check condition + note = make_note(dict(public=0)) + + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), None) + + def test_clear_assignment(self): + note = make_note(dict(public=1)) + + # check if auto assigned to first user + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), 'test@example.com') + + # test auto unassign + note.public = 0 + note.save() + + # check if cleared + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), None) + + def check_multiple_rules(self): + note = make_note(dict(public=1, notify_on_login=1)) + + # check if auto assigned to test3 (2nd rule is applied, as it has higher priority) + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), 'test@example.com') + +def clear_assignments(): + frappe.db.sql("delete from tabToDo where reference_type = 'Note'") + +def get_assignment_rule(): + frappe.delete_doc_if_exists('Assignment Rule', 'For Note 1') + + assignment_rule = frappe.get_doc(dict( + name = 'For Note 1', + doctype = 'Assignment Rule', + priority = 0, + document_type = 'Note', + assign_condition = 'public == 1', + unassign_condition = 'public == 0', + rule = 'Round Robin', + users = [ + dict(user = 'test@example.com'), + dict(user = 'test1@example.com'), + dict(user = 'test2@example.com'), + ] + )).insert() + + frappe.delete_doc_if_exists('Assignment Rule', 'For Note 2') + + # 2nd rule + frappe.get_doc(dict( + name = 'For Note 2', + doctype = 'Assignment Rule', + priority = 1, + document_type = 'Note', + assign_condition = 'notify_on_login == 1', + unassign_condition = 'notify_on_login == 0', + rule = 'Round Robin', + users = [ + dict(user = 'test3@example.com') + ] + )).insert() + + + return assignment_rule + +def make_note(values=None): + note = frappe.get_doc(dict( + doctype = 'Note', + title = random_string(10), + content = random_string(20) + )) + + if values: + note.update(values) + + note.insert() + + return note \ No newline at end of file diff --git a/frappe/automation/doctype/auto_assign_user/__init__.py b/frappe/automation/doctype/assignment_rule_user/__init__.py similarity index 100% rename from frappe/automation/doctype/auto_assign_user/__init__.py rename to frappe/automation/doctype/assignment_rule_user/__init__.py diff --git a/frappe/automation/doctype/auto_assign_user/auto_assign_user.json b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json similarity index 100% rename from frappe/automation/doctype/auto_assign_user/auto_assign_user.json rename to frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json diff --git a/frappe/automation/doctype/auto_assign_user/auto_assign_user.py b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py similarity index 86% rename from frappe/automation/doctype/auto_assign_user/auto_assign_user.py rename to frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py index 2de3bf87d8..ee8081c6d8 100644 --- a/frappe/automation/doctype/auto_assign_user/auto_assign_user.py +++ b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe from frappe.model.document import Document -class AutoAssignUser(Document): +class AssignmentRuleUser(Document): pass diff --git a/frappe/automation/doctype/auto_assign/auto_assign.py b/frappe/automation/doctype/auto_assign/auto_assign.py index 99f12a4cb9..7419d5b723 100644 --- a/frappe/automation/doctype/auto_assign/auto_assign.py +++ b/frappe/automation/doctype/auto_assign/auto_assign.py @@ -10,10 +10,10 @@ from frappe.desk.form import assign_to class AutoAssign(Document): def on_update(self): # pylint: disable=no-self-use - frappe.cache().delete_value('auto_assign') + frappe.cache().delete_value('assignment_rule') def after_rename(self): # pylint: disable=no-self-use - frappe.cache().delete_value('auto_assign') + frappe.cache().delete_value('assignment_rule') def apply(self, doc): if self.safe_eval('assign_condition', doc): @@ -97,12 +97,12 @@ def apply(doc, method): if frappe.flags.in_patch or frappe.flags.in_install: return - auto_assigns = frappe.cache().get_value('auto_assign', get_auto_assigns) - if doc.doctype in auto_assigns: + assignment_rules = frappe.cache().get_value('assignment_rule', get_assignment_rules) + if doc.doctype in assignment_rules: # multiple auto assigns - for d in frappe.db.get_all('Auto Assign', dict(document_type=doc.doctype, disabled = 0), order_by = 'priority desc'): - if frappe.get_doc('Auto Assign', d.name).apply(doc.as_dict()): + for d in frappe.db.get_all('Assignment Rule', dict(document_type=doc.doctype, disabled = 0), order_by = 'priority desc'): + if frappe.get_doc('Assignment Rule', d.name).apply(doc.as_dict()): break -def get_auto_assigns(): - return [d.document_type for d in frappe.db.get_all('Auto Assign', fields=['document_type'], filters=dict(disabled = 0))] \ No newline at end of file +def get_assignment_rules(): + return [d.document_type for d in frappe.db.get_all('Assignment Rule', fields=['document_type'], filters=dict(disabled = 0))] \ No newline at end of file diff --git a/frappe/automation/doctype/auto_assign/test_auto_assign.py b/frappe/automation/doctype/auto_assign/test_auto_assign.py index 83c599c3db..654e372b5c 100644 --- a/frappe/automation/doctype/auto_assign/test_auto_assign.py +++ b/frappe/automation/doctype/auto_assign/test_auto_assign.py @@ -9,7 +9,7 @@ from frappe.utils import random_string class TestAutoAssign(unittest.TestCase): def setUp(self): - self.auto_assign = get_auto_assign() + self.assignment_rule = get_assignment_rule() clear_assignments() def test_round_robin(self): @@ -53,8 +53,8 @@ class TestAutoAssign(unittest.TestCase): ), 'owner'), 'test@example.com') def test_load_balancing(self): - self.auto_assign.rule = 'Load Balancing' - self.auto_assign.save() + self.assignment_rule.rule = 'Load Balancing' + self.assignment_rule.save() for _ in range(30): note = make_note(dict(public=1)) @@ -121,12 +121,12 @@ class TestAutoAssign(unittest.TestCase): def clear_assignments(): frappe.db.sql("delete from tabToDo where reference_type = 'Note'") -def get_auto_assign(): - frappe.delete_doc_if_exists('Auto Assign', 'For Note 1') +def get_assignment_rule(): + frappe.delete_doc_if_exists('Assignment Rule', 'For Note 1') - auto_assign = frappe.get_doc(dict( + assignment_rule = frappe.get_doc(dict( name = 'For Note 1', - doctype = 'Auto Assign', + doctype = 'Assignment Rule', priority = 0, document_type = 'Note', assign_condition = 'public == 1', @@ -139,12 +139,12 @@ def get_auto_assign(): ] )).insert() - frappe.delete_doc_if_exists('Auto Assign', 'For Note 2') + frappe.delete_doc_if_exists('Assignment Rule', 'For Note 2') # 2nd rule frappe.get_doc(dict( name = 'For Note 2', - doctype = 'Auto Assign', + doctype = 'Assignment Rule', priority = 1, document_type = 'Note', assign_condition = 'notify_on_login == 1', @@ -156,7 +156,7 @@ def get_auto_assign(): )).insert() - return auto_assign + return assignment_rule def make_note(values=None): note = frappe.get_doc(dict( diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index d6b4affe61..a913f5fba9 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -40,7 +40,7 @@ def clear_global_cache(): frappe.cache().delete_value(["app_hooks", "installed_apps", "app_modules", "module_app", "notification_config", 'system_settings', 'scheduler_events', 'time_zone', 'webhooks', 'active_domains', - 'active_modules', 'auto_assign']) + 'active_modules', 'assignment_rule']) frappe.setup_module_map() def clear_defaults_cache(user=None): diff --git a/frappe/config/settings.py b/frappe/config/settings.py index 75c8972feb..9577879fc0 100644 --- a/frappe/config/settings.py +++ b/frappe/config/settings.py @@ -169,20 +169,13 @@ def get_data(): "name": "Workflow Action", "description": _("Actions for workflow (e.g. Approve, Cancel).") }, + { + "type": "doctype", + "name": "Assignment Rule", + "description": _("Set up rules for user assignments.") + } ] }, - { - "label": _("Applications"), - "items":[ - { - "type": "page", - "name": "applications", - "label": _("Application Installer"), - "description": _("Install Applications."), - "icon": "fa fa-download" - }, - ] - } ] add_setup_section(data, "frappe", "website", _("Website"), "fa fa-globe") return data diff --git a/frappe/hooks.py b/frappe/hooks.py index 9ae1753d4c..e8d3424fed 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -119,7 +119,7 @@ doc_events = { "frappe.desk.notifications.clear_doctype_notifications", "frappe.core.doctype.activity_log.feed.update_feed", "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions", - "frappe.automation.doctype.auto_assign.auto_assign.apply" + "frappe.automation.doctype.assignment_rule.assignment_rule.apply" ], "after_rename": "frappe.desk.notifications.clear_doctype_notifications", "on_cancel": [