From e9d6d387ce187f659ee977d12fb4e5f31ab08b34 Mon Sep 17 00:00:00 2001 From: Michael McDonald Date: Thu, 4 Oct 2018 02:28:03 -0700 Subject: [PATCH 1/3] Implemented stable marriage algorithm for assignments --- huxley/core/admin/assignment.py | 190 +++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) diff --git a/huxley/core/admin/assignment.py b/huxley/core/admin/assignment.py index cb9ca3cba..8b8374da6 100644 --- a/huxley/core/admin/assignment.py +++ b/huxley/core/admin/assignment.py @@ -9,7 +9,7 @@ from django.http import HttpResponse, HttpResponseRedirect from django.utils import html -from huxley.core.models import Assignment, Committee, Country, School +from huxley.core.models import Assignment, Committee, Country, Registration, School class AssignmentAdmin(admin.ModelAdmin): @@ -89,6 +89,194 @@ def generate_assignments(reader): return HttpResponseRedirect(reverse('admin:core_assignment_changelist')) + def stable_marriage(self, suitor_preferences, suitor_max_proposals, ranking_of_suitors, accepter_max_proposals, suitors_per_accept): + """ + Note: This is one of the most complicated functions in Huxley. + It has many subtleties; be careful when making changes. + + This finds a stable marriage where: + (1) each suitor contains multiple individuals + (2) each accepter accepts proposals from multiple suitors + (3) each accepter takes a certain number of individuals per acceptance + (4) all accepters have the same preference list + (5) suitor preference lists do not contain every accepter + (6) suitors stop proposing when they have proposed to their entire preference list + (7) not all individuals may be matched at the end + (8) not all accepters may be full at the end + + suitor_preferences: Mapping of suitor to their preference-ordered list of accepters. + suitor_max_proposals: Mapping from a suitor to the number of individuals it contains. + ranking_of_suitors: Maps suitors to their rank. Assumes each accepter has the same preference order for suitors. + accepter_max_proposals: Mapping of accepters to total number of proposals htey can accept. + suitors_per_accept: Mapping from accepter to the number of individuals taken per acceptance. + """ + suitor_n_accepted = {s:0 for s in suitor_preferences} + accepted_proposals = {a:[] for a in accepter_max_proposals} + + unstable = True + while unstable: + unstable = False + for s in suitor_preferences: + n_prefs = len(suitor_preferences[s]) + for n in range(n_prefs): + if not suitor_max_proposals[s]: break + next_proposal = suitor_preferences[s].pop(0) + if suitors_per_accept[next_proposal] > suitor_max_proposals[s]: + suitor_preferences[s].append(next_proposal) + continue + accepted_proposals[next_proposal].append(s) + suitor_max_proposals[s] -= suitors_per_accept[next_proposal] + + for a in accepted_proposals: + max_proposals = accepter_max_proposals[a] + if len(accepted_proposals[a]) > max_proposals: + unstable = True + accepted_proposals[a].sort(key=lambda s: ranking_of_suitors[s]) + for s in accepted_proposals[a][max_proposals:]: + suitor_max_proposals[s] += suitors_per_accept[next_proposal] + accepted_proposals[a] = accepted_proposals[a][:max_proposals] + + for a in accepter_max_proposals: + accepter_max_proposals[a] -= len(accepted_proposals[a]) + + return accepted_proposals, suitor_max_proposals, accepter_max_proposals + + def assign(self, request): + '''Return a CSV file containing automated country assignments.''' + registrations = Registration.objects.filter(is_waitlisted__exact=False).order_by('registered_at') + committees = Committee.objects.all() + assignments = Assignment.objects.all() + + final_assigments = {c: [] for c in committees} + assigned = {c: [] for c in committees} + delegation_sizes = {c: c.delegation_size for c in committees} + + # Start by assuming each registration and committee has all space available + reg_unassigned = {r: r.num_beginner_delegates + + r.num_intermediate_delegates + + r.num_advanced_delegates + for r in registrations} + + committee_unassigned = {c: c.countries.all().count() for c in committees} + + # Set aside existing assignments + for a in assignments: + if a.registration is None: continue + + # Determine which countries are already assigned for each committee + assigned[a.committee].append(a) + + # Reduce how much space is available per each registration and committee + reg_unassigned[a.registration] -= a.committee.delegation_size + committee_unassigned[a.committee] -= 1 + + # Add existing assigments directly to the collection of final assignments + final_assigments[a.committee].append((a.registration, a.country, a.rejected)) + + + # Registrations are ranked by their registration time + reg_ranking = {r: r.registered_at for r in registrations} + + # Registrations do not order committee preferences. For the sake of the algorithm, + # choose an arbitrary order for theirs preference lists. + reg_committee_rankings = {r: [] for r in registrations} + for r in registrations: + for c in r.committee_preferences.all(): + reg_committee_rankings[r].append(c) + + + # Find a stable marriage, determine how much space is left per each registration and committee + accepted, reg_unassigned, committee_unassigned = self.stable_marriage(reg_committee_rankings, + reg_unassigned, + reg_ranking, + committee_unassigned, + delegation_sizes) + + # Fill remaining space per each registration; try to place in non-specialized committees first + for r in reg_unassigned: + for c in committee_unassigned: + if not reg_unassigned[r]: break + + if not c.special and c.delegation_size <= reg_unassigned[r] and \ + committee_unassigned[c] and r not in accepted[c]: + accepted[c].append(r) + committee_unassigned[c] -= 1 + reg_unassigned[r] -= c.delegation_size + + + for r in reg_unassigned: + for c in committee_unassigned: + if not reg_unassigned[r]: break + + if c.delegation_size <= reg_unassigned[r] and \ + committee_unassigned[c] and r not in accepted[c]: + accepted[c].append(r) + committee_unassigned[c] -= 1 + reg_unassigned[r] -= c.delegation_size + + + # Within each committee, determine each registration's country assignment + for c in accepted: + exclude_countries = set(map(lambda a: a.country.id, assigned[c])) + + # This is a 1-to-1 pairing, so we do not need to worry about multiple proposals/acceptances + country_unassigned = {country:1 for country in c.countries.all() if country.id not in exclude_countries} + country_per_reg = {r:1 for r in accepted[c]} + + # Consturct each registration's preference list + reg_country_rankings = {r:[] for r in accepted[c]} + for r in accepted[c]: + for pref in r.country_preferences.all(): + if pref.country.id in exclude_countries: continue + reg_country_rankings[r].append(pref) + reg_country_rankings[r].sort(key=lambda p: p.rank) + + country_pairing, country_per_reg, country_unassigned = self.stable_marriage(reg_country_rankings, + country_per_reg, + reg_ranking, + country_unassigned, + country_unassigned) + + # Handle the remaining pairings. By construction, + # can assume number of unpaired countries equals + # number of unpaired registrations + for r in country_per_reg: + if country_per_reg[r]: + for country in country_unassigned: + if country_unassigned[country]: + country_pairing[country] = r + country_unassigned[country] -= 1 + break + + + # No further work needs to be done for these assignments + final_assigments[c].extend([(country_pairing[country][0], country, False) for country in country_pairing]) + + + # Format and write results to CSV + to_write = [] + for committee in final_assigments: + registration, country, rejected = final_assigments[committee] + + # External likes the number of lines in the CSV to equal the number of delegates + for n in range(committee.delegation_size): + to_write.append((registration.school.name, committee.name, country.name, rejected)) + + assignments = HttpResponse(content_type='text/csv') + assignments['Content-Disposition'] = 'attachment; filename="assignments.csv"' + writer = csv.writer(assignments) + writer.writerow([ + 'School', + 'Committee', + 'Country', + 'Rejected' + ]) + + for line in to_write: + writer.writerow(line) + + return assignments + def get_urls(self): return super(AssignmentAdmin, self).get_urls() + [ url( From f0403b98de35ac519fef4fe6ecbcc51633f58c7c Mon Sep 17 00:00:00 2001 From: Michael McDonald Date: Wed, 24 Oct 2018 22:01:15 -0700 Subject: [PATCH 2/3] Updates to auto assign with improved unit tests --- huxley/core/admin/assignment.py | 54 +++++--- huxley/core/models.py | 12 +- huxley/core/tests/admin/test_assignment.py | 128 +++++++++++++++++- .../admin/core/assignment/change_list.html | 5 + huxley/utils/test/models.py | 11 +- 5 files changed, 181 insertions(+), 29 deletions(-) diff --git a/huxley/core/admin/assignment.py b/huxley/core/admin/assignment.py index 8b8374da6..4c31f4c8f 100644 --- a/huxley/core/admin/assignment.py +++ b/huxley/core/admin/assignment.py @@ -9,7 +9,7 @@ from django.http import HttpResponse, HttpResponseRedirect from django.utils import html -from huxley.core.models import Assignment, Committee, Country, Registration, School +from huxley.core.models import Assignment, Committee, Country, CountryPreference, Registration, School class AssignmentAdmin(admin.ModelAdmin): @@ -91,9 +91,6 @@ def generate_assignments(reader): def stable_marriage(self, suitor_preferences, suitor_max_proposals, ranking_of_suitors, accepter_max_proposals, suitors_per_accept): """ - Note: This is one of the most complicated functions in Huxley. - It has many subtleties; be careful when making changes. - This finds a stable marriage where: (1) each suitor contains multiple individuals (2) each accepter accepts proposals from multiple suitors @@ -119,7 +116,7 @@ def stable_marriage(self, suitor_preferences, suitor_max_proposals, ranking_of_s for s in suitor_preferences: n_prefs = len(suitor_preferences[s]) for n in range(n_prefs): - if not suitor_max_proposals[s]: break + if suitor_max_proposals[s] <= 0: break next_proposal = suitor_preferences[s].pop(0) if suitors_per_accept[next_proposal] > suitor_max_proposals[s]: suitor_preferences[s].append(next_proposal) @@ -133,7 +130,7 @@ def stable_marriage(self, suitor_preferences, suitor_max_proposals, ranking_of_s unstable = True accepted_proposals[a].sort(key=lambda s: ranking_of_suitors[s]) for s in accepted_proposals[a][max_proposals:]: - suitor_max_proposals[s] += suitors_per_accept[next_proposal] + suitor_max_proposals[s] += suitors_per_accept[a] accepted_proposals[a] = accepted_proposals[a][:max_proposals] for a in accepter_max_proposals: @@ -173,7 +170,6 @@ def assign(self, request): # Add existing assigments directly to the collection of final assignments final_assigments[a.committee].append((a.registration, a.country, a.rejected)) - # Registrations are ranked by their registration time reg_ranking = {r: r.registered_at for r in registrations} @@ -214,22 +210,26 @@ def assign(self, request): committee_unassigned[c] -= 1 reg_unassigned[r] -= c.delegation_size - # Within each committee, determine each registration's country assignment for c in accepted: - exclude_countries = set(map(lambda a: a.country.id, assigned[c])) + if not len(accepted[c]): continue + exclude_countries = set(map(lambda a: a.country, assigned[c])) # This is a 1-to-1 pairing, so we do not need to worry about multiple proposals/acceptances - country_unassigned = {country:1 for country in c.countries.all() if country.id not in exclude_countries} + countries = c.countries.all() + country_unassigned = {country:1 for country in countries if country.id not in exclude_countries} + for country in exclude_countries: + country_unassigned[country] = 0 country_per_reg = {r:1 for r in accepted[c]} # Consturct each registration's preference list reg_country_rankings = {r:[] for r in accepted[c]} for r in accepted[c]: - for pref in r.country_preferences.all(): - if pref.country.id in exclude_countries: continue + for pref in CountryPreference.objects.filter(registration__id=r.id): + if pref.country.id in exclude_countries or pref.country.id not in countries: continue reg_country_rankings[r].append(pref) reg_country_rankings[r].sort(key=lambda p: p.rank) + reg_country_rankings[r] = map(lambda p: p.country, reg_country_rankings[r]) country_pairing, country_per_reg, country_unassigned = self.stable_marriage(reg_country_rankings, country_per_reg, @@ -241,26 +241,31 @@ def assign(self, request): # can assume number of unpaired countries equals # number of unpaired registrations for r in country_per_reg: - if country_per_reg[r]: + if country_per_reg[r] > 0: for country in country_unassigned: if country_unassigned[country]: - country_pairing[country] = r - country_unassigned[country] -= 1 + country_pairing[country] = [r] + country_unassigned[country] = 0 + country_per_reg[r] = 0 break - # No further work needs to be done for these assignments - final_assigments[c].extend([(country_pairing[country][0], country, False) for country in country_pairing]) - + for country in country_pairing: + if not len(country_pairing[country]): continue + final_assigments[c].append((country_pairing[country][0], country, False)) # Format and write results to CSV to_write = [] for committee in final_assigments: - registration, country, rejected = final_assigments[committee] + if not len(final_assigments[committee]): continue + for assignment in final_assigments[committee]: + registration, country, rejected = assignment + + # External likes the number of lines in the CSV to equal the number of delegates + for n in range(committee.delegation_size): + to_write.append((registration.school.name, committee.name, country.name, rejected)) - # External likes the number of lines in the CSV to equal the number of delegates - for n in range(committee.delegation_size): - to_write.append((registration.school.name, committee.name, country.name, rejected)) + to_write.sort(key=lambda row: row[0]+row[1]+row[2]) assignments = HttpResponse(content_type='text/csv') assignments['Content-Disposition'] = 'attachment; filename="assignments.csv"' @@ -284,6 +289,11 @@ def get_urls(self): self.admin_site.admin_view(self.list), name='core_assignment_list' ), + url( + r'assign', + self.admin_site.admin_view(self.assign), + name='core_assignment_assign' + ), url( r'load', self.admin_site.admin_view(self.load), diff --git a/huxley/core/models.py b/huxley/core/models.py index d27ccc651..164240f7a 100644 --- a/huxley/core/models.py +++ b/huxley/core/models.py @@ -474,10 +474,10 @@ def update_assignments(cls, new_assignments): def add(committee, country, registration, paper, rejected): additions.append( - cls(committee_id=committee.id, - country_id=country.id, - registration_id=registration.id, - paper_id=paper.id, + cls(committee=committee, + country=country, + registration=registration, + paper=paper, rejected=rejected, )) def remove(assignment_data): @@ -493,9 +493,11 @@ def remove(assignment_data): if type(country) is not Country: country = Country(name=country + ' - DOES NOT EXIST') is_invalid = True - if type(school) is not School: + if type(school) is not School and school != '': school = School(name=school + ' - DOES NOT EXIST') is_invalid = True + elif school == '': + registration = None else: try: registration = Registration.objects.get( diff --git a/huxley/core/tests/admin/test_assignment.py b/huxley/core/tests/admin/test_assignment.py index 8f34f0749..162546612 100644 --- a/huxley/core/tests/admin/test_assignment.py +++ b/huxley/core/tests/admin/test_assignment.py @@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse from django.test import TestCase -from huxley.core.models import Assignment, Committee, Country, School +from huxley.core.models import Assignment, Committee, Country, Registration, School from huxley.utils.test import models, TestFiles @@ -44,3 +44,129 @@ def test_import(self): registration=registration, committee=Committee.objects.get(name='USS'), country=Country.objects.get(name='Barbara Boxer')).exists()) + + def test_auto_assign(self): + '''Test that the automated assignments obeys the proper rules for stable marriage.''' + models.new_superuser(username='testuser', password='test') + self.client.login(username='testuser', password='test') + countries = [models.new_country(name='test_country_'+str(i)) for i in range(3)] + normal_committees = [models.new_committee(name='normal_'+str(i), delegation_size=2) for i in range(20)] + special_committees = [models.new_committee(name='special_'+str(i), delegation_size=1) for i in range(5)] + assignments = [] + + def add_free_assigment(assigments, committee, country): + assignments.append(models.new_assignment(committee=committee, country=country)) + assignments[-1].registration = None + assignments[-1].save() + + for committee in normal_committees+special_committees: + add_free_assigment(assignments, committee, countries[0]) + + Registration.objects.all().delete() + schools = [models.new_school(name='test_school_'+str(i)) for i in range(20)] + registrations = [models.new_registration(school=schools[i], num_advanced_delegates=2) for i in range(3)] + + # Case 1: No conflicts. Every school gets their choice. + committee_prefs = {} + country_prefs = {} + for i in range(len(registrations)): + r = registrations[i] + r.committee_preferences = Committee.objects.filter(name__in=['normal_'+str(i)]) + r.save() + models.new_country_preference( + registration=r, + country=countries[0], + rank=1) + + country_prefs[r] = countries[0] + committee_prefs[r] = normal_committees[i] + + response_1 = self.client.get(reverse('admin:core_assignment_assign')) + response_1_array = response_1.content.split("\r\n") + header = ['School', 'Committee', 'Country', 'Rejected'] + fields_csv = ",".join(map(str, header)) + "\r\n" + for r in registrations: + for _ in range(committee_prefs[r].delegation_size): + fields = [r.school.name, committee_prefs[r].name, country_prefs[r].name, False] + fields_csv += ','.join(map(str, fields)) + "\r\n" + self.assertEquals(fields_csv, response_1.content) + + # Case 2: Conflicts; preference given in registration order. + registrations_2 = [models.new_registration(school=schools[3+i], num_advanced_delegates=2) for i in range(3)] + for i in range(len(registrations_2)): + r = registrations_2[i] + r.committee_preferences = Committee.objects.filter(name__in=['normal_'+str(j) for j in range(10)]).order_by('name') + r.save() + + country_prefs[r] = countries[0] + committee_prefs[r] = normal_committees[len(registrations_2)+i] + + response_2 = self.client.get(reverse('admin:core_assignment_assign')) + response_2_array = response_2.content.split("\r\n") + fields_csv_2 = ",".join(map(str, header)) + "\r\n" + for r in registrations: + for _ in range(committee_prefs[r].delegation_size): + fields = [r.school.name, committee_prefs[r].name, country_prefs[r].name, False] + fields_csv_2 += ','.join(map(str, fields)) + "\r\n" + + for r in registrations_2: + for _ in range(committee_prefs[r].delegation_size): + fields = [r.school.name, committee_prefs[r].name, country_prefs[r].name, False] + fields_csv_2 += ','.join(map(str, fields)) + "\r\n" + + self.assertEquals(fields_csv_2, response_2.content) + + for item in response_1_array: + self.assertTrue(item in response_2_array) + + # Case 3: Make sure odd number delegates are assigned to special committees and assignments are not overwritten. + registrations_3 = [models.new_registration(school=schools[6+i], num_advanced_delegates=3) for i in range(3)] + assignments[0].registration = registrations_3[0] + assignments[0].save() + assignments[1].registration = registrations[-1] + assignments[1].save() + for i in range(len(registrations_3)): + r = registrations_3[i] + r.committee_preferences = Committee.objects.filter(name__in=['normal_'+str(j) for j in range(6)]).order_by('name') + r.save() + + country_prefs[r] = countries[0] + committee_prefs[r] = normal_committees[i] + + response_3 = self.client.get(reverse('admin:core_assignment_assign')) + response_3_array = response_3.content.split("\r\n") + + self.assertTrue(response_3.content.count('special')==len(registrations_3)) + self.assertTrue('{0},{1},{2},{3}'.format(registrations_3[0].school.name, + assignments[0].committee.name, + assignments[0].country.name, + False) + in response_3.content) + self.assertTrue('{0},{1},{2},{3}'.format(registrations[-1].school.name, + assignments[1].committee.name, + assignments[1].country.name, + False) + in response_3.content) + + for committee in normal_committees+special_committees: + for country in countries[1:]: + add_free_assigment(assignments, committee, country) + + registrations_4 = [models.new_registration(school=schools[9+i], num_intermediate_delegates=4, num_advanced_delegates=3) for i in range(3)] + for i in range(len(registrations_4)): + r = registrations_4[i] + r.committee_preferences = Committee.objects.filter(name__in=['normal_'+str(j) for j in range(6)]).order_by('name') + r.save() + + country_prefs[r] = countries[0] + committee_prefs[r] = normal_committees[i] + + # Case 4: Make sure that every registration gets the right number of assignments, that committees accept multiple schools + response_4 = self.client.get(reverse('admin:core_assignment_assign')) + response_4_array = response_4.content.split("\r\n") + all_registrations = Registration.objects.all() + total_delegates = sum([r.num_beginner_delegates+r.num_intermediate_delegates+r.num_advanced_delegates for r in all_registrations]) + + # CSV has two extra rows; header and empty final line + self.assertTrue(len(response_4_array)==total_delegates+2) + self.assertTrue(response_4.content.count('{0}'.format(normal_committees[0].name))==6) diff --git a/huxley/templates/admin/core/assignment/change_list.html b/huxley/templates/admin/core/assignment/change_list.html index 38ec8313f..448ae8ca8 100644 --- a/huxley/templates/admin/core/assignment/change_list.html +++ b/huxley/templates/admin/core/assignment/change_list.html @@ -17,6 +17,11 @@ Download Assignment List +
  • + + Automatically Generate Assignments + +
  • {% endblock %} {% endif %} diff --git a/huxley/utils/test/models.py b/huxley/utils/test/models.py index c6b46755e..0bd29990a 100644 --- a/huxley/utils/test/models.py +++ b/huxley/utils/test/models.py @@ -9,7 +9,7 @@ from huxley.accounts.models import User from huxley.core.constants import ContactGender, ContactType, ProgramTypes -from huxley.core.models import School, Committee, CommitteeFeedback, Country, Delegate, Assignment, Registration, Conference, PositionPaper, Rubric, SecretariatMember +from huxley.core.models import School, Committee, CommitteeFeedback, Country, CountryPreference, Delegate, Assignment, Registration, Conference, PositionPaper, Rubric, SecretariatMember if not settings.TESTING: raise PermissionDenied @@ -148,6 +148,15 @@ def new_country(**kwargs): return c +def new_country_preference(**kwargs): + c = CountryPreference( + registration=kwargs.pop('registration', None) or new_registration(), + country=kwargs.pop('country', None) or new_country(), + rank=kwargs.pop('rank', 1)) + c.save() + return c + + def new_delegate(**kwargs): a = kwargs.pop('assignment', None) or new_assignment() s = kwargs.pop('school', None) or a.registration.school From 4b7050eef600fdd120b629c3d47ab619e12ab824 Mon Sep 17 00:00:00 2001 From: Michael McDonald Date: Sat, 27 Oct 2018 15:00:43 -0700 Subject: [PATCH 3/3] Ran autoformatter --- huxley/core/admin/assignment.py | 165 ++++++++++----------- huxley/core/tests/admin/test_assignment.py | 95 +++++++----- 2 files changed, 139 insertions(+), 121 deletions(-) diff --git a/huxley/core/admin/assignment.py b/huxley/core/admin/assignment.py index 4c31f4c8f..04ae36e46 100644 --- a/huxley/core/admin/assignment.py +++ b/huxley/core/admin/assignment.py @@ -14,32 +14,22 @@ class AssignmentAdmin(admin.ModelAdmin): - search_fields = ( - 'country__name', - 'registration__school__name', - 'committee__name', - 'committee__full_name' - ) + search_fields = ('country__name', 'registration__school__name', + 'committee__name', 'committee__full_name') def list(self, request): '''Return a CSV file containing the current country assignments.''' assignments = HttpResponse(content_type='text/csv') - assignments['Content-Disposition'] = 'attachment; filename="assignments.csv"' + assignments[ + 'Content-Disposition'] = 'attachment; filename="assignments.csv"' writer = csv.writer(assignments) - writer.writerow([ - 'School', - 'Committee', - 'Country', - 'Rejected' - ]) + writer.writerow(['School', 'Committee', 'Country', 'Rejected']) - for assignment in Assignment.objects.all().order_by('registration__school__name', - 'committee__name'): + for assignment in Assignment.objects.all().order_by( + 'registration__school__name', 'committee__name'): writer.writerow([ - assignment.registration.school, - assignment.committee, - assignment.country, - assignment.rejected + assignment.registration.school, assignment.committee, + assignment.country, assignment.rejected ]) return assignments @@ -64,32 +54,40 @@ def generate_assignments(reader): schools = {} for row in reader: - if (row[0]=='School' and row[1]=='Committee' and row[2]=='Country'): - continue # skip the first row if it is a header + if (row[0] == 'School' and row[1] == 'Committee' and + row[2] == 'Country'): + continue # skip the first row if it is a header while len(row) < 3: - row.append("") # extend the row to have the minimum proper num of columns + row.append( + "") # extend the row to have the minimum proper num of columns if len(row) < 4: - rejected = False # allow for the rejected field to be null + rejected = False # allow for the rejected field to be null else: - rejected = (row[3].lower() == 'true') # use the provided value if admin provides it + rejected = ( + row[3].lower() == 'true' + ) # use the provided value if admin provides it committee = get_model(Committee, row[1], committees) country = get_model(Country, row[2], countries) school = get_model(School, row[0], schools) yield (committee, country, school, rejected) - - failed_rows = Assignment.update_assignments(generate_assignments(reader)) + failed_rows = Assignment.update_assignments( + generate_assignments(reader)) if failed_rows: # Format the message with HTML to put each failed assignment on a new line - messages.error(request, - html.format_html('Assignment upload aborted. These assignments failed:
    ' + '
    '.join(failed_rows))) + messages.error(request, html.format_html( + 'Assignment upload aborted. These assignments failed:
    ' + + '
    '.join(failed_rows))) - return HttpResponseRedirect(reverse('admin:core_assignment_changelist')) + return HttpResponseRedirect( + reverse('admin:core_assignment_changelist')) - def stable_marriage(self, suitor_preferences, suitor_max_proposals, ranking_of_suitors, accepter_max_proposals, suitors_per_accept): + def stable_marriage(self, suitor_preferences, suitor_max_proposals, + ranking_of_suitors, accepter_max_proposals, + suitors_per_accept): """ This finds a stable marriage where: (1) each suitor contains multiple individuals @@ -107,8 +105,8 @@ def stable_marriage(self, suitor_preferences, suitor_max_proposals, ranking_of_s accepter_max_proposals: Mapping of accepters to total number of proposals htey can accept. suitors_per_accept: Mapping from accepter to the number of individuals taken per acceptance. """ - suitor_n_accepted = {s:0 for s in suitor_preferences} - accepted_proposals = {a:[] for a in accepter_max_proposals} + suitor_n_accepted = {s: 0 for s in suitor_preferences} + accepted_proposals = {a: [] for a in accepter_max_proposals} unstable = True while unstable: @@ -118,20 +116,24 @@ def stable_marriage(self, suitor_preferences, suitor_max_proposals, ranking_of_s for n in range(n_prefs): if suitor_max_proposals[s] <= 0: break next_proposal = suitor_preferences[s].pop(0) - if suitors_per_accept[next_proposal] > suitor_max_proposals[s]: + if suitors_per_accept[ + next_proposal] > suitor_max_proposals[s]: suitor_preferences[s].append(next_proposal) continue accepted_proposals[next_proposal].append(s) - suitor_max_proposals[s] -= suitors_per_accept[next_proposal] + suitor_max_proposals[s] -= suitors_per_accept[ + next_proposal] for a in accepted_proposals: max_proposals = accepter_max_proposals[a] if len(accepted_proposals[a]) > max_proposals: unstable = True - accepted_proposals[a].sort(key=lambda s: ranking_of_suitors[s]) + accepted_proposals[a].sort( + key=lambda s: ranking_of_suitors[s]) for s in accepted_proposals[a][max_proposals:]: suitor_max_proposals[s] += suitors_per_accept[a] - accepted_proposals[a] = accepted_proposals[a][:max_proposals] + accepted_proposals[a] = accepted_proposals[ + a][:max_proposals] for a in accepter_max_proposals: accepter_max_proposals[a] -= len(accepted_proposals[a]) @@ -140,7 +142,8 @@ def stable_marriage(self, suitor_preferences, suitor_max_proposals, ranking_of_s def assign(self, request): '''Return a CSV file containing automated country assignments.''' - registrations = Registration.objects.filter(is_waitlisted__exact=False).order_by('registered_at') + registrations = Registration.objects.filter( + is_waitlisted__exact=False).order_by('registered_at') committees = Committee.objects.all() assignments = Assignment.objects.all() @@ -149,12 +152,14 @@ def assign(self, request): delegation_sizes = {c: c.delegation_size for c in committees} # Start by assuming each registration and committee has all space available - reg_unassigned = {r: r.num_beginner_delegates + - r.num_intermediate_delegates + - r.num_advanced_delegates - for r in registrations} + reg_unassigned = { + r: r.num_beginner_delegates + r.num_intermediate_delegates + + r.num_advanced_delegates + for r in registrations + } - committee_unassigned = {c: c.countries.all().count() for c in committees} + committee_unassigned = {c: c.countries.all().count() + for c in committees} # Set aside existing assignments for a in assignments: @@ -168,7 +173,8 @@ def assign(self, request): committee_unassigned[a.committee] -= 1 # Add existing assigments directly to the collection of final assignments - final_assigments[a.committee].append((a.registration, a.country, a.rejected)) + final_assigments[a.committee].append( + (a.registration, a.country, a.rejected)) # Registrations are ranked by their registration time reg_ranking = {r: r.registered_at for r in registrations} @@ -180,13 +186,10 @@ def assign(self, request): for c in r.committee_preferences.all(): reg_committee_rankings[r].append(c) - # Find a stable marriage, determine how much space is left per each registration and committee - accepted, reg_unassigned, committee_unassigned = self.stable_marriage(reg_committee_rankings, - reg_unassigned, - reg_ranking, - committee_unassigned, - delegation_sizes) + accepted, reg_unassigned, committee_unassigned = self.stable_marriage( + reg_committee_rankings, reg_unassigned, reg_ranking, + committee_unassigned, delegation_sizes) # Fill remaining space per each registration; try to place in non-specialized committees first for r in reg_unassigned: @@ -199,7 +202,6 @@ def assign(self, request): committee_unassigned[c] -= 1 reg_unassigned[r] -= c.delegation_size - for r in reg_unassigned: for c in committee_unassigned: if not reg_unassigned[r]: break @@ -217,25 +219,28 @@ def assign(self, request): # This is a 1-to-1 pairing, so we do not need to worry about multiple proposals/acceptances countries = c.countries.all() - country_unassigned = {country:1 for country in countries if country.id not in exclude_countries} + country_unassigned = {country: 1 + for country in countries + if country.id not in exclude_countries} for country in exclude_countries: country_unassigned[country] = 0 - country_per_reg = {r:1 for r in accepted[c]} + country_per_reg = {r: 1 for r in accepted[c]} # Consturct each registration's preference list - reg_country_rankings = {r:[] for r in accepted[c]} + reg_country_rankings = {r: [] for r in accepted[c]} for r in accepted[c]: - for pref in CountryPreference.objects.filter(registration__id=r.id): - if pref.country.id in exclude_countries or pref.country.id not in countries: continue + for pref in CountryPreference.objects.filter( + registration__id=r.id): + if pref.country.id in exclude_countries or pref.country.id not in countries: + continue reg_country_rankings[r].append(pref) reg_country_rankings[r].sort(key=lambda p: p.rank) - reg_country_rankings[r] = map(lambda p: p.country, reg_country_rankings[r]) + reg_country_rankings[r] = map(lambda p: p.country, + reg_country_rankings[r]) - country_pairing, country_per_reg, country_unassigned = self.stable_marriage(reg_country_rankings, - country_per_reg, - reg_ranking, - country_unassigned, - country_unassigned) + country_pairing, country_per_reg, country_unassigned = self.stable_marriage( + reg_country_rankings, country_per_reg, reg_ranking, + country_unassigned, country_unassigned) # Handle the remaining pairings. By construction, # can assume number of unpaired countries equals @@ -248,11 +253,12 @@ def assign(self, request): country_unassigned[country] = 0 country_per_reg[r] = 0 break - + # No further work needs to be done for these assignments for country in country_pairing: if not len(country_pairing[country]): continue - final_assigments[c].append((country_pairing[country][0], country, False)) + final_assigments[c].append( + (country_pairing[country][0], country, False)) # Format and write results to CSV to_write = [] @@ -263,19 +269,16 @@ def assign(self, request): # External likes the number of lines in the CSV to equal the number of delegates for n in range(committee.delegation_size): - to_write.append((registration.school.name, committee.name, country.name, rejected)) + to_write.append((registration.school.name, committee.name, + country.name, rejected)) - to_write.sort(key=lambda row: row[0]+row[1]+row[2]) + to_write.sort(key=lambda row: row[0] + row[1] + row[2]) assignments = HttpResponse(content_type='text/csv') - assignments['Content-Disposition'] = 'attachment; filename="assignments.csv"' + assignments[ + 'Content-Disposition'] = 'attachment; filename="assignments.csv"' writer = csv.writer(assignments) - writer.writerow([ - 'School', - 'Committee', - 'Country', - 'Rejected' - ]) + writer.writerow(['School', 'Committee', 'Country', 'Rejected']) for line in to_write: writer.writerow(line) @@ -284,19 +287,13 @@ def assign(self, request): def get_urls(self): return super(AssignmentAdmin, self).get_urls() + [ - url( - r'list', + url(r'list', self.admin_site.admin_view(self.list), - name='core_assignment_list' - ), - url( - r'assign', + name='core_assignment_list'), + url(r'assign', self.admin_site.admin_view(self.assign), - name='core_assignment_assign' - ), - url( - r'load', + name='core_assignment_assign'), + url(r'load', self.admin_site.admin_view(self.load), - name='core_assignment_load', - ), + name='core_assignment_load', ), ] diff --git a/huxley/core/tests/admin/test_assignment.py b/huxley/core/tests/admin/test_assignment.py index 162546612..8c181258d 100644 --- a/huxley/core/tests/admin/test_assignment.py +++ b/huxley/core/tests/admin/test_assignment.py @@ -49,34 +49,40 @@ def test_auto_assign(self): '''Test that the automated assignments obeys the proper rules for stable marriage.''' models.new_superuser(username='testuser', password='test') self.client.login(username='testuser', password='test') - countries = [models.new_country(name='test_country_'+str(i)) for i in range(3)] - normal_committees = [models.new_committee(name='normal_'+str(i), delegation_size=2) for i in range(20)] - special_committees = [models.new_committee(name='special_'+str(i), delegation_size=1) for i in range(5)] + countries = [models.new_country(name='test_country_' + str(i)) + for i in range(3)] + normal_committees = [models.new_committee( + name='normal_' + str(i), delegation_size=2) for i in range(20)] + special_committees = [models.new_committee( + name='special_' + str(i), delegation_size=1) for i in range(5)] assignments = [] def add_free_assigment(assigments, committee, country): - assignments.append(models.new_assignment(committee=committee, country=country)) + assignments.append( + models.new_assignment( + committee=committee, country=country)) assignments[-1].registration = None assignments[-1].save() - for committee in normal_committees+special_committees: + for committee in normal_committees + special_committees: add_free_assigment(assignments, committee, countries[0]) Registration.objects.all().delete() - schools = [models.new_school(name='test_school_'+str(i)) for i in range(20)] - registrations = [models.new_registration(school=schools[i], num_advanced_delegates=2) for i in range(3)] + schools = [models.new_school(name='test_school_' + str(i)) + for i in range(20)] + registrations = [models.new_registration( + school=schools[i], num_advanced_delegates=2) for i in range(3)] # Case 1: No conflicts. Every school gets their choice. committee_prefs = {} country_prefs = {} for i in range(len(registrations)): r = registrations[i] - r.committee_preferences = Committee.objects.filter(name__in=['normal_'+str(i)]) + r.committee_preferences = Committee.objects.filter( + name__in=['normal_' + str(i)]) r.save() models.new_country_preference( - registration=r, - country=countries[0], - rank=1) + registration=r, country=countries[0], rank=1) country_prefs[r] = countries[0] committee_prefs[r] = normal_committees[i] @@ -87,31 +93,37 @@ def add_free_assigment(assigments, committee, country): fields_csv = ",".join(map(str, header)) + "\r\n" for r in registrations: for _ in range(committee_prefs[r].delegation_size): - fields = [r.school.name, committee_prefs[r].name, country_prefs[r].name, False] + fields = [r.school.name, committee_prefs[r].name, + country_prefs[r].name, False] fields_csv += ','.join(map(str, fields)) + "\r\n" self.assertEquals(fields_csv, response_1.content) # Case 2: Conflicts; preference given in registration order. - registrations_2 = [models.new_registration(school=schools[3+i], num_advanced_delegates=2) for i in range(3)] + registrations_2 = [models.new_registration( + school=schools[3 + i], num_advanced_delegates=2) for i in range(3)] for i in range(len(registrations_2)): r = registrations_2[i] - r.committee_preferences = Committee.objects.filter(name__in=['normal_'+str(j) for j in range(10)]).order_by('name') + r.committee_preferences = Committee.objects.filter( + name__in=['normal_' + str(j) + for j in range(10)]).order_by('name') r.save() country_prefs[r] = countries[0] - committee_prefs[r] = normal_committees[len(registrations_2)+i] + committee_prefs[r] = normal_committees[len(registrations_2) + i] response_2 = self.client.get(reverse('admin:core_assignment_assign')) response_2_array = response_2.content.split("\r\n") fields_csv_2 = ",".join(map(str, header)) + "\r\n" for r in registrations: for _ in range(committee_prefs[r].delegation_size): - fields = [r.school.name, committee_prefs[r].name, country_prefs[r].name, False] + fields = [r.school.name, committee_prefs[r].name, + country_prefs[r].name, False] fields_csv_2 += ','.join(map(str, fields)) + "\r\n" for r in registrations_2: for _ in range(committee_prefs[r].delegation_size): - fields = [r.school.name, committee_prefs[r].name, country_prefs[r].name, False] + fields = [r.school.name, committee_prefs[r].name, + country_prefs[r].name, False] fields_csv_2 += ','.join(map(str, fields)) + "\r\n" self.assertEquals(fields_csv_2, response_2.content) @@ -120,14 +132,17 @@ def add_free_assigment(assigments, committee, country): self.assertTrue(item in response_2_array) # Case 3: Make sure odd number delegates are assigned to special committees and assignments are not overwritten. - registrations_3 = [models.new_registration(school=schools[6+i], num_advanced_delegates=3) for i in range(3)] + registrations_3 = [models.new_registration( + school=schools[6 + i], num_advanced_delegates=3) for i in range(3)] assignments[0].registration = registrations_3[0] assignments[0].save() assignments[1].registration = registrations[-1] assignments[1].save() for i in range(len(registrations_3)): r = registrations_3[i] - r.committee_preferences = Committee.objects.filter(name__in=['normal_'+str(j) for j in range(6)]).order_by('name') + r.committee_preferences = Committee.objects.filter( + name__in=['normal_' + str(j) + for j in range(6)]).order_by('name') r.save() country_prefs[r] = countries[0] @@ -136,26 +151,28 @@ def add_free_assigment(assigments, committee, country): response_3 = self.client.get(reverse('admin:core_assignment_assign')) response_3_array = response_3.content.split("\r\n") - self.assertTrue(response_3.content.count('special')==len(registrations_3)) - self.assertTrue('{0},{1},{2},{3}'.format(registrations_3[0].school.name, - assignments[0].committee.name, - assignments[0].country.name, - False) - in response_3.content) - self.assertTrue('{0},{1},{2},{3}'.format(registrations[-1].school.name, - assignments[1].committee.name, - assignments[1].country.name, - False) - in response_3.content) - - for committee in normal_committees+special_committees: + self.assertTrue( + response_3.content.count('special') == len(registrations_3)) + self.assertTrue('{0},{1},{2},{3}'.format( + registrations_3[0].school.name, assignments[0].committee.name, + assignments[0].country.name, False) in response_3.content) + self.assertTrue('{0},{1},{2},{3}'.format( + registrations[-1].school.name, assignments[1].committee.name, + assignments[1].country.name, False) in response_3.content) + + for committee in normal_committees + special_committees: for country in countries[1:]: add_free_assigment(assignments, committee, country) - registrations_4 = [models.new_registration(school=schools[9+i], num_intermediate_delegates=4, num_advanced_delegates=3) for i in range(3)] + registrations_4 = [models.new_registration( + school=schools[9 + i], + num_intermediate_delegates=4, + num_advanced_delegates=3) for i in range(3)] for i in range(len(registrations_4)): r = registrations_4[i] - r.committee_preferences = Committee.objects.filter(name__in=['normal_'+str(j) for j in range(6)]).order_by('name') + r.committee_preferences = Committee.objects.filter( + name__in=['normal_' + str(j) + for j in range(6)]).order_by('name') r.save() country_prefs[r] = countries[0] @@ -165,8 +182,12 @@ def add_free_assigment(assigments, committee, country): response_4 = self.client.get(reverse('admin:core_assignment_assign')) response_4_array = response_4.content.split("\r\n") all_registrations = Registration.objects.all() - total_delegates = sum([r.num_beginner_delegates+r.num_intermediate_delegates+r.num_advanced_delegates for r in all_registrations]) + total_delegates = sum( + [r.num_beginner_delegates + r.num_intermediate_delegates + + r.num_advanced_delegates for r in all_registrations]) # CSV has two extra rows; header and empty final line - self.assertTrue(len(response_4_array)==total_delegates+2) - self.assertTrue(response_4.content.count('{0}'.format(normal_committees[0].name))==6) + self.assertTrue(len(response_4_array) == total_delegates + 2) + self.assertTrue( + response_4.content.count('{0}'.format(normal_committees[0].name)) + == 6)