round_robin_assignment 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +38 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +434 -0
- data/Rakefile +6 -0
- data/lib/generators/round_robin_assignment/install/install_generator.rb +28 -0
- data/lib/generators/round_robin_assignment/install/templates/README +16 -0
- data/lib/generators/round_robin_assignment/install/templates/migration.rb +15 -0
- data/lib/round_robin_assignment/model.rb +19 -0
- data/lib/round_robin_assignment/version.rb +3 -0
- data/lib/round_robin_assignment.rb +83 -0
- data/round_robin_assignment.gemspec +36 -0
- metadata +146 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 439a9d32517de0413b9075fbc160379092bc95a9a0482afcac7fe4aa6d2cb9a5
|
|
4
|
+
data.tar.gz: c417913d9614cf76d94f66ac8434dc95b1010966c277238b3ce1daa0f8837d3e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 51bb022df7a2c6d9e04a37ff2244bede9ed70c670c3cbdc2f55509542c115cc16a3e6bd112aa0019521a89982e06ef1677ede6542b7a4a2addc0906c6596f221
|
|
7
|
+
data.tar.gz: 2b4c1f5aec3abbd741d9ad51e5472a92fe81d9fa9fd96b0ff4d590c0385a51cea6026b66e3e246fd8ccd1f6f491736e040f9bcc0e10ac13bab1c54c19e3ef951
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2025-10-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Core round-robin assignment functionality
|
|
12
|
+
- Support for multiple independent assignment groups
|
|
13
|
+
- Dynamic assignee list handling
|
|
14
|
+
- Database persistence for assignment state
|
|
15
|
+
- Thread-safe operations via ActiveRecord transactions
|
|
16
|
+
- Statistics and monitoring via `group_stats` method
|
|
17
|
+
- Group reset functionality via `reset_group` method
|
|
18
|
+
- Rails generator for easy installation
|
|
19
|
+
- Comprehensive test suite with 100% coverage
|
|
20
|
+
- Full documentation and usage examples
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
- Persistent state tracking in database
|
|
24
|
+
- Graceful handling of removed assignees
|
|
25
|
+
- Automatic wrap-around when reaching end of list
|
|
26
|
+
- Support for any number of assignees
|
|
27
|
+
- ActiveRecord delegation for advanced queries
|
|
28
|
+
- Model validations for data integrity
|
|
29
|
+
|
|
30
|
+
## [Unreleased]
|
|
31
|
+
|
|
32
|
+
### Planned
|
|
33
|
+
- Weighted round-robin assignments
|
|
34
|
+
- Time-based restrictions (business hours, vacations)
|
|
35
|
+
- Assignment history tracking
|
|
36
|
+
- Web UI for management
|
|
37
|
+
- Webhook notifications
|
|
38
|
+
- Redis caching for high-volume systems
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mustafa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# RoundRobinAssignment
|
|
2
|
+
|
|
3
|
+
A robust and flexible round-robin assignment system for Rails applications with ActiveRecord support. Perfect for distributing tasks, assignments, or work items evenly across team members or resources.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Persistent State**: Tracks assignment history in the database
|
|
8
|
+
- **Multiple Groups**: Support for independent round-robin queues via assignment groups
|
|
9
|
+
- **Flexible Assignment Lists**: Dynamically adjust assignee lists without losing rotation state
|
|
10
|
+
- **Edge Case Handling**: Gracefully handles removed assignees and empty lists
|
|
11
|
+
- **Thread-Safe**: Database-backed persistence ensures consistency across concurrent processes
|
|
12
|
+
- **Full Test Coverage**: Battle-tested with comprehensive RSpec tests
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add this line to your application's Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'round_robin_assignment'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
And then execute:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
$ bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or install it yourself as:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
$ gem install round_robin_assignment
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
After installation, run the generator to create the required migration:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
$ rails generate round_robin_assignment:install
|
|
38
|
+
$ rails db:migrate
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Basic Usage
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# Define your assignee list (user IDs, employee IDs, etc.)
|
|
47
|
+
assignee_ids = [1, 2, 3, 4, 5]
|
|
48
|
+
|
|
49
|
+
# Get the next assignee in rotation
|
|
50
|
+
next_assignee_id = RoundRobinAssignment.get_next_assignee('support_team', assignee_ids)
|
|
51
|
+
# => 1 (first assignment)
|
|
52
|
+
|
|
53
|
+
next_assignee_id = RoundRobinAssignment.get_next_assignee('support_team', assignee_ids)
|
|
54
|
+
# => 2 (second assignment)
|
|
55
|
+
|
|
56
|
+
# ... continues through 3, 4, 5, then back to 1
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Multiple Assignment Groups
|
|
60
|
+
|
|
61
|
+
You can maintain separate round-robin queues for different purposes:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# Customer support team rotation
|
|
65
|
+
RoundRobinAssignment.get_next_assignee('support_team', [1, 2, 3])
|
|
66
|
+
|
|
67
|
+
# Sales lead distribution
|
|
68
|
+
RoundRobinAssignment.get_next_assignee('sales_leads', [10, 11, 12, 13])
|
|
69
|
+
|
|
70
|
+
# Code review assignments
|
|
71
|
+
RoundRobinAssignment.get_next_assignee('code_reviews', [20, 21, 22])
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Real-World Example: Job Assignment
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class OpportunityAssignmentJob < ApplicationJob
|
|
78
|
+
COMPLIANCE_TEAM_IDS = {
|
|
79
|
+
6 => 'Michelle Simmons',
|
|
80
|
+
471 => 'Lynn Fraga',
|
|
81
|
+
3794 => 'Mykenna Hawkins'
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
def perform(opportunity)
|
|
85
|
+
assignee_ids = COMPLIANCE_TEAM_IDS.keys
|
|
86
|
+
|
|
87
|
+
# Get next team member in rotation
|
|
88
|
+
next_assignee_id = RoundRobinAssignment.get_next_assignee(
|
|
89
|
+
'compliance_team_vetting',
|
|
90
|
+
assignee_ids
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if next_assignee_id
|
|
94
|
+
opportunity.update!(assignee_id: next_assignee_id)
|
|
95
|
+
|
|
96
|
+
Rails.logger.info(
|
|
97
|
+
"Assigned #{COMPLIANCE_TEAM_IDS[next_assignee_id]} to opportunity #{opportunity.id}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Send notification
|
|
101
|
+
notify_assignee(next_assignee_id, opportunity)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Dynamic Assignee Lists
|
|
108
|
+
|
|
109
|
+
The gem handles changes to assignee lists gracefully:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
# Initial team
|
|
113
|
+
RoundRobinAssignment.get_next_assignee('team', [1, 2, 3]) # => 1
|
|
114
|
+
RoundRobinAssignment.get_next_assignee('team', [1, 2, 3]) # => 2
|
|
115
|
+
|
|
116
|
+
# Team member 2 goes on vacation, removed from rotation
|
|
117
|
+
RoundRobinAssignment.get_next_assignee('team', [1, 3]) # => 3
|
|
118
|
+
RoundRobinAssignment.get_next_assignee('team', [1, 3]) # => 1
|
|
119
|
+
|
|
120
|
+
# New team member joins
|
|
121
|
+
RoundRobinAssignment.get_next_assignee('team', [1, 3, 4]) # => 3
|
|
122
|
+
RoundRobinAssignment.get_next_assignee('team', [1, 3, 4]) # => 4
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Statistics and Management
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# Get statistics for an assignment group
|
|
129
|
+
stats = RoundRobinAssignment.group_stats('support_team')
|
|
130
|
+
# => {
|
|
131
|
+
# last_assigned_user_id: 3,
|
|
132
|
+
# last_assigned_at: 2024-01-15 10:30:00,
|
|
133
|
+
# total_assignments: 150
|
|
134
|
+
# }
|
|
135
|
+
|
|
136
|
+
# Reset a group's assignment history
|
|
137
|
+
RoundRobinAssignment.reset_group('support_team')
|
|
138
|
+
|
|
139
|
+
# Check if a group exists
|
|
140
|
+
RoundRobinAssignment.where(assignment_group: 'support_team').exists?
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Advanced Usage: Custom Assignment Logic
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
class WorkloadBalancedAssignment
|
|
147
|
+
def self.get_next_assignee(team_ids)
|
|
148
|
+
# Get basic round-robin assignment
|
|
149
|
+
next_id = RoundRobinAssignment.get_next_assignee('team', team_ids)
|
|
150
|
+
|
|
151
|
+
# Check workload (example)
|
|
152
|
+
if User.find(next_id).current_workload > 10
|
|
153
|
+
# Skip to next person if overloaded
|
|
154
|
+
team_ids_without_current = team_ids - [next_id]
|
|
155
|
+
if team_ids_without_current.any?
|
|
156
|
+
next_id = RoundRobinAssignment.get_next_assignee('team', team_ids_without_current)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
next_id
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## API Reference
|
|
166
|
+
|
|
167
|
+
### Class Methods
|
|
168
|
+
|
|
169
|
+
#### `RoundRobinAssignment.get_next_assignee(group_name, assignee_ids)`
|
|
170
|
+
|
|
171
|
+
Returns the next assignee ID in the round-robin rotation for the specified group.
|
|
172
|
+
|
|
173
|
+
**Parameters:**
|
|
174
|
+
- `group_name` (String): Unique identifier for the assignment group
|
|
175
|
+
- `assignee_ids` (Array): Array of assignee IDs to rotate through
|
|
176
|
+
|
|
177
|
+
**Returns:**
|
|
178
|
+
- Integer: The ID of the next assignee
|
|
179
|
+
- nil: If assignee_ids is empty or nil
|
|
180
|
+
|
|
181
|
+
**Example:**
|
|
182
|
+
```ruby
|
|
183
|
+
RoundRobinAssignment.get_next_assignee('support', [1, 2, 3])
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
#### `RoundRobinAssignment.reset_group(group_name)`
|
|
187
|
+
|
|
188
|
+
Removes all assignment history for the specified group.
|
|
189
|
+
|
|
190
|
+
**Parameters:**
|
|
191
|
+
- `group_name` (String): The assignment group to reset
|
|
192
|
+
|
|
193
|
+
**Example:**
|
|
194
|
+
```ruby
|
|
195
|
+
RoundRobinAssignment.reset_group('support')
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
#### `RoundRobinAssignment.group_stats(group_name)`
|
|
199
|
+
|
|
200
|
+
Returns statistics for the specified assignment group.
|
|
201
|
+
|
|
202
|
+
**Parameters:**
|
|
203
|
+
- `group_name` (String): The assignment group to get stats for
|
|
204
|
+
|
|
205
|
+
**Returns:**
|
|
206
|
+
- Hash: Contains `:last_assigned_user_id`, `:last_assigned_at`, `:total_assignments`
|
|
207
|
+
- nil: If the group doesn't exist
|
|
208
|
+
|
|
209
|
+
**Example:**
|
|
210
|
+
```ruby
|
|
211
|
+
stats = RoundRobinAssignment.group_stats('support')
|
|
212
|
+
puts "Last assigned to User ##{stats[:last_assigned_user_id]}"
|
|
213
|
+
puts "Total assignments: #{stats[:total_assignments]}"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Database Schema
|
|
217
|
+
|
|
218
|
+
The gem creates a `round_robin_assignments` table with the following structure:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
create_table :round_robin_assignments do |t|
|
|
222
|
+
t.string :assignment_group, null: false
|
|
223
|
+
t.integer :last_assigned_user_id, null: false
|
|
224
|
+
t.datetime :last_assigned_at, null: false
|
|
225
|
+
t.integer :assignment_count, default: 0, null: false
|
|
226
|
+
t.timestamps
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
add_index :round_robin_assignments, :assignment_group, unique: true
|
|
230
|
+
add_index :round_robin_assignments, :last_assigned_user_id
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Testing
|
|
234
|
+
|
|
235
|
+
The gem includes comprehensive RSpec tests. To run tests in your application:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# spec/models/round_robin_assignment_spec.rb
|
|
239
|
+
require 'rails_helper'
|
|
240
|
+
|
|
241
|
+
RSpec.describe 'Round Robin Assignment' do
|
|
242
|
+
it 'assigns users in rotation' do
|
|
243
|
+
ids = [1, 2, 3]
|
|
244
|
+
|
|
245
|
+
expect(RoundRobinAssignment.get_next_assignee('test', ids)).to eq(1)
|
|
246
|
+
expect(RoundRobinAssignment.get_next_assignee('test', ids)).to eq(2)
|
|
247
|
+
expect(RoundRobinAssignment.get_next_assignee('test', ids)).to eq(3)
|
|
248
|
+
expect(RoundRobinAssignment.get_next_assignee('test', ids)).to eq(1)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Factory for Testing
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# spec/factories/round_robin_assignments.rb
|
|
257
|
+
FactoryBot.define do
|
|
258
|
+
factory :round_robin_assignment do
|
|
259
|
+
assignment_group { 'test_group' }
|
|
260
|
+
last_assigned_user_id { 1 }
|
|
261
|
+
last_assigned_at { Time.current }
|
|
262
|
+
assignment_count { 1 }
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Configuration (Optional)
|
|
268
|
+
|
|
269
|
+
You can configure the gem in an initializer:
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
# config/initializers/round_robin_assignment.rb
|
|
273
|
+
RoundRobinAssignment.configure do |config|
|
|
274
|
+
# Add any future configuration options here
|
|
275
|
+
# config.some_option = true
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Common Use Cases
|
|
280
|
+
|
|
281
|
+
### 1. Customer Support Ticket Assignment
|
|
282
|
+
```ruby
|
|
283
|
+
class TicketAssignmentService
|
|
284
|
+
def self.assign_ticket(ticket)
|
|
285
|
+
available_agents = User.support_agents.on_duty.pluck(:id)
|
|
286
|
+
assignee_id = RoundRobinAssignment.get_next_assignee('support_tickets', available_agents)
|
|
287
|
+
ticket.update(assigned_to_id: assignee_id)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### 2. Lead Distribution for Sales Team
|
|
293
|
+
```ruby
|
|
294
|
+
class LeadDistributionJob < ApplicationJob
|
|
295
|
+
def perform(lead)
|
|
296
|
+
sales_team_ids = User.sales_team.active.pluck(:id)
|
|
297
|
+
next_sales_rep = RoundRobinAssignment.get_next_assignee('sales_leads', sales_team_ids)
|
|
298
|
+
lead.assign_to(next_sales_rep)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### 3. Code Review Assignment
|
|
304
|
+
```ruby
|
|
305
|
+
class PullRequestService
|
|
306
|
+
def assign_reviewer(pull_request)
|
|
307
|
+
eligible_reviewers = pull_request.eligible_reviewers.pluck(:id)
|
|
308
|
+
reviewer_id = RoundRobinAssignment.get_next_assignee(
|
|
309
|
+
"code_reviews_#{pull_request.repository_id}",
|
|
310
|
+
eligible_reviewers
|
|
311
|
+
)
|
|
312
|
+
pull_request.update(reviewer_id: reviewer_id)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### 4. On-Call Rotation
|
|
318
|
+
```ruby
|
|
319
|
+
class OnCallRotationService
|
|
320
|
+
def next_on_call_engineer
|
|
321
|
+
engineers = Engineer.available_for_on_call.pluck(:id)
|
|
322
|
+
RoundRobinAssignment.get_next_assignee('on_call_rotation', engineers)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Monitoring and Debugging
|
|
328
|
+
|
|
329
|
+
### Check Assignment Distribution
|
|
330
|
+
```ruby
|
|
331
|
+
# See how many times each person has been assigned
|
|
332
|
+
def check_distribution(group_name, team_ids)
|
|
333
|
+
stats = RoundRobinAssignment.group_stats(group_name)
|
|
334
|
+
puts "Total assignments: #{stats[:total_assignments]}"
|
|
335
|
+
puts "Last assigned to: User ##{stats[:last_assigned_user_id]}"
|
|
336
|
+
puts "Last assigned at: #{stats[:last_assigned_at]}"
|
|
337
|
+
end
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Rails Console Helpers
|
|
341
|
+
```ruby
|
|
342
|
+
# Check current state
|
|
343
|
+
RoundRobinAssignment.all
|
|
344
|
+
|
|
345
|
+
# Find specific group
|
|
346
|
+
RoundRobinAssignment.find_by(assignment_group: 'support_team')
|
|
347
|
+
|
|
348
|
+
# Manual adjustment (use with caution)
|
|
349
|
+
assignment = RoundRobinAssignment.find_by(assignment_group: 'support_team')
|
|
350
|
+
assignment.update(last_assigned_user_id: 5)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Performance Considerations
|
|
354
|
+
|
|
355
|
+
- **Database Queries**: Each call to `get_next_assignee` performs 1-2 database queries
|
|
356
|
+
- **Concurrency**: The gem uses database transactions to handle concurrent assignments
|
|
357
|
+
- **Scaling**: Suitable for systems with thousands of assignment groups and millions of assignments
|
|
358
|
+
- **Indexing**: Indexes on `assignment_group` ensure fast lookups
|
|
359
|
+
|
|
360
|
+
## Troubleshooting
|
|
361
|
+
|
|
362
|
+
### Issue: Assignments not rotating
|
|
363
|
+
```ruby
|
|
364
|
+
# Check if the group exists
|
|
365
|
+
RoundRobinAssignment.find_by(assignment_group: 'your_group')
|
|
366
|
+
|
|
367
|
+
# Verify the assignee list
|
|
368
|
+
assignee_ids = [1, 2, 3]
|
|
369
|
+
puts "Assignees: #{assignee_ids.inspect}"
|
|
370
|
+
|
|
371
|
+
# Test manually
|
|
372
|
+
3.times do
|
|
373
|
+
id = RoundRobinAssignment.get_next_assignee('test_group', assignee_ids)
|
|
374
|
+
puts "Assigned to: #{id}"
|
|
375
|
+
end
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Issue: Unexpected assignee selected
|
|
379
|
+
```ruby
|
|
380
|
+
# The gem always sorts IDs for consistency
|
|
381
|
+
[3, 1, 2] # Will be treated as [1, 2, 3]
|
|
382
|
+
|
|
383
|
+
# Check the current state
|
|
384
|
+
stats = RoundRobinAssignment.group_stats('your_group')
|
|
385
|
+
puts "Last assigned: #{stats[:last_assigned_user_id]}"
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Contributing
|
|
389
|
+
|
|
390
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/round_robin_assignment.
|
|
391
|
+
|
|
392
|
+
1. Fork it
|
|
393
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
394
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
395
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
396
|
+
5. Create a new Pull Request
|
|
397
|
+
|
|
398
|
+
### Development
|
|
399
|
+
|
|
400
|
+
After checking out the repo, run:
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
bundle install
|
|
404
|
+
bundle exec rspec
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## License
|
|
408
|
+
|
|
409
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
410
|
+
|
|
411
|
+
## Changelog
|
|
412
|
+
|
|
413
|
+
### Version 1.0.0 (Initial Release)
|
|
414
|
+
- Core round-robin assignment functionality
|
|
415
|
+
- Support for multiple assignment groups
|
|
416
|
+
- Dynamic assignee lists
|
|
417
|
+
- Statistics and reset functionality
|
|
418
|
+
- Comprehensive test suite
|
|
419
|
+
|
|
420
|
+
### Future Enhancements (Roadmap)
|
|
421
|
+
- Weighted round-robin (some assignees get more assignments)
|
|
422
|
+
- Time-based restrictions (business hours, vacation tracking)
|
|
423
|
+
- Assignment history tracking and reporting
|
|
424
|
+
- Web UI for managing assignments
|
|
425
|
+
- Webhook notifications
|
|
426
|
+
- Redis caching for high-volume systems
|
|
427
|
+
|
|
428
|
+
## Credits
|
|
429
|
+
|
|
430
|
+
Originally developed for production use in a CRM system handling thousands of daily assignments across multiple teams.
|
|
431
|
+
|
|
432
|
+
## Support
|
|
433
|
+
|
|
434
|
+
For questions, issues, or feature requests, please open an issue on GitHub or contact the maintainers.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
|
+
|
|
4
|
+
module RoundRobinAssignment
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
def self.next_migration_number(dirname)
|
|
12
|
+
if ActiveRecord::Base.timestamped_migrations
|
|
13
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
14
|
+
else
|
|
15
|
+
"%.3d" % (current_migration_number(dirname) + 1)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def copy_migration
|
|
20
|
+
migration_template "migration.rb", "db/migrate/create_round_robin_assignments.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show_readme
|
|
24
|
+
readme "README" if behavior == :invoke
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
|
|
3
|
+
Round Robin Assignment has been successfully installed!
|
|
4
|
+
|
|
5
|
+
Next steps:
|
|
6
|
+
|
|
7
|
+
1. Run the migration:
|
|
8
|
+
$ rails db:migrate
|
|
9
|
+
|
|
10
|
+
2. Start using it in your code:
|
|
11
|
+
RoundRobinAssignment.get_next_assignee('support_team', [1, 2, 3, 4, 5])
|
|
12
|
+
|
|
13
|
+
For more information, see the documentation at:
|
|
14
|
+
https://github.com/yourusername/round_robin_assignment
|
|
15
|
+
|
|
16
|
+
===============================================================================
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateRoundRobinAssignments < ActiveRecord::Migration[5.2]
|
|
2
|
+
def change
|
|
3
|
+
create_table :round_robin_assignments do |t|
|
|
4
|
+
t.string :assignment_group, null: false
|
|
5
|
+
t.integer :last_assigned_user_id, null: false
|
|
6
|
+
t.datetime :last_assigned_at, null: false
|
|
7
|
+
t.integer :assignment_count, default: 0, null: false
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :round_robin_assignments, :assignment_group, unique: true
|
|
13
|
+
add_index :round_robin_assignments, :last_assigned_user_id
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module RoundRobinAssignment
|
|
2
|
+
class Model < ActiveRecord::Base
|
|
3
|
+
self.table_name = "round_robin_assignments"
|
|
4
|
+
|
|
5
|
+
validates :assignment_group, presence: true, uniqueness: true
|
|
6
|
+
validates :last_assigned_user_id, presence: true
|
|
7
|
+
validates :last_assigned_at, presence: true
|
|
8
|
+
validates :assignment_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
9
|
+
|
|
10
|
+
before_validation :set_defaults, on: :create
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def set_defaults
|
|
15
|
+
self.assignment_count ||= 0
|
|
16
|
+
self.last_assigned_at ||= Time.current
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "active_support/all"
|
|
3
|
+
require_relative "round_robin_assignment/version"
|
|
4
|
+
require_relative "round_robin_assignment/model"
|
|
5
|
+
|
|
6
|
+
module RoundRobinAssignment
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
# Returns the next assignee ID in the round-robin rotation
|
|
11
|
+
# @param group_name [String] The assignment group identifier
|
|
12
|
+
# @param assignee_ids [Array<Integer>] Array of assignee IDs to rotate through
|
|
13
|
+
# @return [Integer, nil] The next assignee ID, or nil if assignee_ids is empty
|
|
14
|
+
def get_next_assignee(group_name, assignee_ids)
|
|
15
|
+
return nil if assignee_ids.nil? || assignee_ids.empty?
|
|
16
|
+
|
|
17
|
+
# Sort for consistency
|
|
18
|
+
sorted_ids = assignee_ids.sort
|
|
19
|
+
|
|
20
|
+
# Find or create the assignment record
|
|
21
|
+
assignment = Model.find_or_initialize_by(assignment_group: group_name)
|
|
22
|
+
|
|
23
|
+
# Determine the next assignee
|
|
24
|
+
next_assignee_id = if assignment.new_record?
|
|
25
|
+
# First assignment - start from beginning
|
|
26
|
+
sorted_ids.first
|
|
27
|
+
elsif !sorted_ids.include?(assignment.last_assigned_user_id)
|
|
28
|
+
# Last assignee no longer in list - find next logical assignee
|
|
29
|
+
# Find the next ID that would have been after the removed one
|
|
30
|
+
next_id = sorted_ids.find { |id| id > assignment.last_assigned_user_id }
|
|
31
|
+
next_id || sorted_ids.first
|
|
32
|
+
else
|
|
33
|
+
# Get the next ID in rotation
|
|
34
|
+
current_index = sorted_ids.index(assignment.last_assigned_user_id)
|
|
35
|
+
next_index = (current_index + 1) % sorted_ids.length
|
|
36
|
+
sorted_ids[next_index]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Update the assignment record
|
|
40
|
+
assignment.last_assigned_user_id = next_assignee_id
|
|
41
|
+
assignment.last_assigned_at = Time.current
|
|
42
|
+
assignment.assignment_count = (assignment.assignment_count || 0) + 1
|
|
43
|
+
assignment.save!
|
|
44
|
+
|
|
45
|
+
next_assignee_id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resets the assignment history for a group
|
|
49
|
+
# @param group_name [String] The assignment group to reset
|
|
50
|
+
# @return [Boolean] True if the group was found and deleted
|
|
51
|
+
def reset_group(group_name)
|
|
52
|
+
Model.where(assignment_group: group_name).destroy_all
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns statistics for an assignment group
|
|
57
|
+
# @param group_name [String] The assignment group to get stats for
|
|
58
|
+
# @return [Hash, nil] Statistics hash or nil if group doesn't exist
|
|
59
|
+
def group_stats(group_name)
|
|
60
|
+
assignment = Model.find_by(assignment_group: group_name)
|
|
61
|
+
return nil unless assignment
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
last_assigned_user_id: assignment.last_assigned_user_id,
|
|
65
|
+
last_assigned_at: assignment.last_assigned_at,
|
|
66
|
+
total_assignments: assignment.assignment_count
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Delegate ActiveRecord methods to Model
|
|
71
|
+
def method_missing(method, *args, &block)
|
|
72
|
+
if Model.respond_to?(method)
|
|
73
|
+
Model.public_send(method, *args, &block)
|
|
74
|
+
else
|
|
75
|
+
super
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def respond_to_missing?(method, include_private = false)
|
|
80
|
+
Model.respond_to?(method) || super
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative "lib/round_robin_assignment/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "round_robin_assignment"
|
|
5
|
+
spec.version = RoundRobinAssignment::VERSION
|
|
6
|
+
spec.authors = ["Mustafa"]
|
|
7
|
+
spec.email = ["mustafa@example.com"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "A robust and flexible round-robin assignment system for Rails applications"
|
|
10
|
+
spec.description = "A database-backed round-robin assignment system with support for multiple groups, persistent state, and dynamic assignee lists. Perfect for distributing tasks, assignments, or work items evenly across team members."
|
|
11
|
+
spec.homepage = "https://github.com/mustafa90/round_robin_assignment"
|
|
12
|
+
spec.license = "MIT"
|
|
13
|
+
spec.required_ruby_version = ">= 2.6.0"
|
|
14
|
+
|
|
15
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
16
|
+
spec.metadata["source_code_uri"] = "https://github.com/mustafa90/round_robin_assignment"
|
|
17
|
+
spec.metadata["changelog_uri"] = "https://github.com/mustafa90/round_robin_assignment/blob/main/CHANGELOG.md"
|
|
18
|
+
|
|
19
|
+
# Specify which files should be added to the gem when it is released.
|
|
20
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
21
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
|
22
|
+
end
|
|
23
|
+
spec.bindir = "exe"
|
|
24
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
25
|
+
spec.require_paths = ["lib"]
|
|
26
|
+
|
|
27
|
+
# Dependencies
|
|
28
|
+
spec.add_dependency "activerecord", ">= 5.2"
|
|
29
|
+
spec.add_dependency "activesupport", ">= 5.2"
|
|
30
|
+
|
|
31
|
+
# Development dependencies
|
|
32
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
|
33
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
34
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
|
35
|
+
spec.add_development_dependency "sqlite3", "~> 2.1"
|
|
36
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: round_robin_assignment
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mustafa
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-10-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: activesupport
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.2'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.2'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: bundler
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rake
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '13.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '13.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rspec
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: sqlite3
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '2.1'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '2.1'
|
|
97
|
+
description: A database-backed round-robin assignment system with support for multiple
|
|
98
|
+
groups, persistent state, and dynamic assignee lists. Perfect for distributing tasks,
|
|
99
|
+
assignments, or work items evenly across team members.
|
|
100
|
+
email:
|
|
101
|
+
- mustafa@example.com
|
|
102
|
+
executables: []
|
|
103
|
+
extensions: []
|
|
104
|
+
extra_rdoc_files: []
|
|
105
|
+
files:
|
|
106
|
+
- ".gitignore"
|
|
107
|
+
- ".rspec"
|
|
108
|
+
- CHANGELOG.md
|
|
109
|
+
- Gemfile
|
|
110
|
+
- LICENSE.txt
|
|
111
|
+
- README.md
|
|
112
|
+
- Rakefile
|
|
113
|
+
- lib/generators/round_robin_assignment/install/install_generator.rb
|
|
114
|
+
- lib/generators/round_robin_assignment/install/templates/README
|
|
115
|
+
- lib/generators/round_robin_assignment/install/templates/migration.rb
|
|
116
|
+
- lib/round_robin_assignment.rb
|
|
117
|
+
- lib/round_robin_assignment/model.rb
|
|
118
|
+
- lib/round_robin_assignment/version.rb
|
|
119
|
+
- round_robin_assignment.gemspec
|
|
120
|
+
homepage: https://github.com/mustafa90/round_robin_assignment
|
|
121
|
+
licenses:
|
|
122
|
+
- MIT
|
|
123
|
+
metadata:
|
|
124
|
+
homepage_uri: https://github.com/mustafa90/round_robin_assignment
|
|
125
|
+
source_code_uri: https://github.com/mustafa90/round_robin_assignment
|
|
126
|
+
changelog_uri: https://github.com/mustafa90/round_robin_assignment/blob/main/CHANGELOG.md
|
|
127
|
+
post_install_message:
|
|
128
|
+
rdoc_options: []
|
|
129
|
+
require_paths:
|
|
130
|
+
- lib
|
|
131
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
132
|
+
requirements:
|
|
133
|
+
- - ">="
|
|
134
|
+
- !ruby/object:Gem::Version
|
|
135
|
+
version: 2.6.0
|
|
136
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
137
|
+
requirements:
|
|
138
|
+
- - ">="
|
|
139
|
+
- !ruby/object:Gem::Version
|
|
140
|
+
version: '0'
|
|
141
|
+
requirements: []
|
|
142
|
+
rubygems_version: 3.5.3
|
|
143
|
+
signing_key:
|
|
144
|
+
specification_version: 4
|
|
145
|
+
summary: A robust and flexible round-robin assignment system for Rails applications
|
|
146
|
+
test_files: []
|