diff --git a/huxley/core/admin/assignment.py b/huxley/core/admin/assignment.py
index cb9ca3cba..04ae36e46 100644
--- a/huxley/core/admin/assignment.py
+++ b/huxley/core/admin/assignment.py
@@ -9,37 +9,27 @@
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, CountryPreference, Registration, School
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,41 +54,246 @@ 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'))
+
+ 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
+ (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 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)
+ 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[a]
+ 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)
- return HttpResponseRedirect(reverse('admin:core_assignment_changelist'))
+ # 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:
+ 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
+ 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 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, 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] > 0:
+ for country in country_unassigned:
+ if country_unassigned[country]:
+ country_pairing[country] = [r]
+ 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))
+
+ # Format and write results to CSV
+ to_write = []
+ for committee in final_assigments:
+ 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))
+
+ 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"'
+ 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(
- r'list',
+ url(r'list',
self.admin_site.admin_view(self.list),
- name='core_assignment_list'
- ),
- url(
- r'load',
+ 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),
- name='core_assignment_load',
- ),
+ name='core_assignment_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..8c181258d 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,150 @@ 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
+