From 115d3cead06fb2a5ed75c2713f4d31c9ede3531c Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 5 Mar 2026 14:31:03 +0530 Subject: [PATCH] feat(assignment_rule): add weighted distribution strategy (#37741) --- .../assignment_rule/assignment_rule.json | 32 +++++++-- .../assignment_rule/assignment_rule.py | 71 +++++++++++++------ .../assignment_rule/test_assignment_rule.py | 20 ++++++ .../assignment_rule_user.json | 16 ++++- .../assignment_rule_user.py | 1 + 5 files changed, 111 insertions(+), 29 deletions(-) diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.json b/frappe/automation/doctype/assignment_rule/assignment_rule.json index 66f8b8d291..1061e4e1ad 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.json +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.json @@ -26,7 +26,10 @@ "rule", "field", "users", - "last_user" + "weighted_users", + "column_break_mkgo", + "last_user", + "current_index" ], "fields": [ { @@ -96,15 +99,15 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Rule", - "options": "Round Robin\nLoad Balancing\nBased on Field", + "options": "Round Robin\nLoad Balancing\nBased on Field\nWeighted Distribution", "reqd": 1 }, { - "depends_on": "eval: doc.rule !== 'Based on Field'", + "depends_on": "eval:in_list(['Round Robin', \"Load Balancing\"], doc.rule)", "fieldname": "users", "fieldtype": "Table MultiSelect", "label": "Users", - "mandatory_depends_on": "eval: doc.rule !== 'Based on Field'", + "mandatory_depends_on": "eval:in_list(['Round Robin', \"Load Balancing\"], doc.rule)", "options": "Assignment Rule User" }, { @@ -150,12 +153,31 @@ "fieldtype": "Select", "label": "Field", "mandatory_depends_on": "eval: doc.rule == 'Based on Field'" + }, + { + "depends_on": "eval:doc.rule=='Weighted Distribution'", + "fieldname": "current_index", + "fieldtype": "Int", + "label": "Current Index", + "read_only": 1 + }, + { + "fieldname": "column_break_mkgo", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.rule=='Weighted Distribution'", + "fieldname": "weighted_users", + "fieldtype": "Table", + "label": "Users", + "mandatory_depends_on": "eval:doc.rule=='Weighted Distribution'", + "options": "Assignment Rule User" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-08-25 17:09:11.644603", + "modified": "2026-03-03 13:08:12.561504", "modified_by": "Administrator", "module": "Automation", "name": "Assignment Rule", diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index 21fb77dfdd..5473b8e720 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -20,14 +20,13 @@ class AssignmentRule(Document): if TYPE_CHECKING: from frappe.automation.doctype.assignment_rule_day.assignment_rule_day import AssignmentRuleDay - from frappe.automation.doctype.assignment_rule_user.assignment_rule_user import ( - AssignmentRuleUser, - ) + from frappe.automation.doctype.assignment_rule_user.assignment_rule_user import AssignmentRuleUser from frappe.types import DF assign_condition: DF.Code assignment_days: DF.Table[AssignmentRuleDay] close_condition: DF.Code | None + current_index: DF.Int description: DF.SmallText disabled: DF.Check document_type: DF.Link @@ -35,9 +34,10 @@ class AssignmentRule(Document): field: DF.Literal[None] last_user: DF.Link | None priority: DF.Int - rule: DF.Literal["Round Robin", "Load Balancing", "Based on Field"] + rule: DF.Literal["Round Robin", "Load Balancing", "Based on Field", "Weighted Distribution"] unassign_condition: DF.Code | None users: DF.TableMultiSelect[AssignmentRuleUser] + weighted_users: DF.Table[AssignmentRuleUser] # end: auto-generated types def validate(self): @@ -80,25 +80,25 @@ class AssignmentRule(Document): user = self.get_user(doc) - if user: - assign_to.add( - dict( - assign_to=[user], - doctype=doc.get("doctype"), - name=doc.get("name"), - description=frappe.render_template(self.description, doc), - assignment_rule=self.name, - notify=True, - date=doc.get(self.due_date_based_on) if self.due_date_based_on else None, - ), - ignore_permissions=True, - ) + if not user or not frappe.db.exists("User", user): + return False - # set for reference in round robin - self.db_set("last_user", user) - return True + assign_to.add( + dict( + assign_to=[user], + doctype=doc.get("doctype"), + name=doc.get("name"), + description=frappe.render_template(self.description, doc), + assignment_rule=self.name, + notify=True, + date=doc.get(self.due_date_based_on) if self.due_date_based_on else None, + ), + ignore_permissions=True, + ) - return False + # set for reference in round robin + self.db_set("last_user", user) + return True def clear_assignment(self, doc): """Clear assignments""" @@ -122,6 +122,8 @@ class AssignmentRule(Document): return self.get_user_load_balancing() elif self.rule == "Based on Field": return self.get_user_based_on_field(doc) + elif self.rule == "Weighted Distribution": + return self.get_weighted_user() def get_user_round_robin(self): """ @@ -167,6 +169,33 @@ class AssignmentRule(Document): if frappe.db.exists("User", val): return val + def get_weighted_user(self): + """ + Assign to the user based on weights assigned to users + Each rule maintains its own counter. + """ + users = [(d.user, d.weight or 1) for d in self.weighted_users if d.user] + if not users: + return None + + total_weight = sum(weight for _, weight in users) + if total_weight <= 0: + return None + + current_index = ( + frappe.db.get_value("Assignment Rule", self.name, "current_index", for_update=True) or 0 + ) + slot = current_index % total_weight + + cumulative_weight = 0 + for user, weight in users: + cumulative_weight += weight + if slot < cumulative_weight: + frappe.db.set_value( + "Assignment Rule", self.name, "current_index", current_index + 1, update_modified=False + ) + return user + def safe_eval(self, fieldname, doc): try: if self.get(fieldname): diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py index be8a7621fe..eea8c698ea 100644 --- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py @@ -113,6 +113,26 @@ class TestAutoAssign(IntegrationTestCase): len(frappe.get_all("ToDo", dict(allocated_to=user, reference_type=TEST_DOCTYPE))), 10 ) + def test_weighted_distribution(self): + self.assignment_rule.rule = "Weighted Distribution" + self.assignment_rule.weighted_users.clear() + self.assignment_rule.append("weighted_users", dict(user="test@example.com", weight=1)) + self.assignment_rule.append("weighted_users", dict(user="test1@example.com", weight=2)) + self.assignment_rule.save() + + for _ in range(5): + _make_test_record(public=1) + + # check if users are assigned based on weights (out of 5, + # test@example.com should have 2 assignments and test1@example.com should have 3 assignments ) + self.assertEqual( + len(frappe.get_all("ToDo", dict(allocated_to="test@example.com", reference_type=TEST_DOCTYPE))), 2 + ) + self.assertEqual( + len(frappe.get_all("ToDo", dict(allocated_to="test1@example.com", reference_type=TEST_DOCTYPE))), + 3, + ) + def test_assingment_on_guest_submissions(self): """Sometimes documents are inserted as guest, check if assignment rules run on them. Use case: Web Forms""" with self.set_user("Guest"): diff --git a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json index 79ef550de8..4ded75d2df 100644 --- a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json +++ b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json @@ -5,7 +5,8 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "user" + "user", + "weight" ], "fields": [ { @@ -15,20 +16,29 @@ "label": "User", "options": "User", "reqd": 1 + }, + { + "default": "1", + "fieldname": "weight", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Weight" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-23 16:01:27.847608", + "modified": "2026-03-03 12:30:01.394107", "modified_by": "Administrator", "module": "Automation", "name": "Assignment Rule User", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py index 54edd74747..be7b0b76f4 100644 --- a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py +++ b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py @@ -18,6 +18,7 @@ class AssignmentRuleUser(Document): parentfield: DF.Data parenttype: DF.Data user: DF.Link + weight: DF.Int # end: auto-generated types pass