From a2d79c1e418bc73685759888297055fc6f50e6f1 Mon Sep 17 00:00:00 2001 From: Vik Fearing Date: Thu, 10 Oct 2024 13:54:24 +0200 Subject: [PATCH] Add "Olympic scoring" method. If there are more than two votes, then one copy each of the highest and lowest scores is removed before averaging. --- docs/confreg/callforpapers.md | 13 +++++++ docs/confreg/configuring.md | 8 ++++ postgresqleu/confreg/backendforms.py | 4 +- .../0116_conference_scoring_method.py | 18 +++++++++ postgresqleu/confreg/models.py | 6 +++ postgresqleu/confreg/views.py | 37 +++++++++++++++---- template/confreg/sessionvotes.html | 3 ++ 7 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 postgresqleu/confreg/migrations/0116_conference_scoring_method.py diff --git a/docs/confreg/callforpapers.md b/docs/confreg/callforpapers.md index b2194a12d..e7bdb472c 100644 --- a/docs/confreg/callforpapers.md +++ b/docs/confreg/callforpapers.md @@ -48,6 +48,19 @@ Click the status to bring up a dialog allowing the change of status. Sessions can be sorted by session name (default), speakers or average score by clicking the appropriate headlines. +#### 2.1 Scoring method + +There are two methods for calculating the average score of a +proposal: + +- Average — This is the standard average of all the scores. +- Olympic average — This method removes one instance each of the + maximum and minimum of the scores before averaging. This helps + prevent both favoritism and also sabotage. + +The method used by the conference is set by the administrator and +shown at the top of the voting page. + ### 3. Deciding and notifying speakers Once the voting is done, the decisions can be made and the speakers be diff --git a/docs/confreg/configuring.md b/docs/confreg/configuring.md index 3a266848d..dae15d1ab 100644 --- a/docs/confreg/configuring.md +++ b/docs/confreg/configuring.md @@ -60,6 +60,14 @@ You can also decide if you want talkvoters to be able to see how others voted and the overall average vote. Usually this would be off until everyone has finished voting. +There are two methods for calculating the average score of a +proposal: + +- Average — This is the standard average of all the scores. +- Olympic average — This method removes one instance each of the + maximum and minimum of the scores before averaging. This helps + prevent both favoritism and also sabotage. + ### Roles There are four types of roles that can be configured at the level of diff --git a/postgresqleu/confreg/backendforms.py b/postgresqleu/confreg/backendforms.py index f3b4f6a65..70cfdc144 100644 --- a/postgresqleu/confreg/backendforms.py +++ b/postgresqleu/confreg/backendforms.py @@ -87,7 +87,7 @@ class Meta: 'schedulewidth', 'pixelsperminute', 'notifyregs', 'notifysessionstatus', 'notifyvolunteerstatus', 'testers', 'talkvoters', 'staff', 'volunteers', 'checkinprocessors', 'asktshirt', 'askfood', 'asknick', 'asktwitter', 'askbadgescan', 'askshareemail', 'askphotoconsent', - 'callforpapersmaxsubmissions', 'skill_levels', 'showvotes', 'callforpaperstags', 'callforpapersrecording', 'sendwelcomemail', + 'callforpapersmaxsubmissions', 'skill_levels', 'showvotes', 'scoring_method', 'callforpaperstags', 'callforpapersrecording', 'sendwelcomemail', 'tickets', 'confirmpolicy', 'queuepartitioning', 'invoice_autocancel_hours', 'attendees_before_waitlist', 'transfer_cost', 'initial_common_countries', 'jinjaenabled', 'dynafields', 'scannerfields', 'videoproviders', ] @@ -106,7 +106,7 @@ def fix_fields(self): {'id': 'twitter', 'legend': 'Twitter settings', 'fields': ['twitter_timewindow_start', 'twitter_timewindow_end', 'twitter_postpolicy', ]}, {'id': 'fields', 'legend': 'Registration fields', 'fields': ['asktshirt', 'askfood', 'asknick', 'asktwitter', 'askbadgescan', 'askshareemail', 'askphotoconsent', 'dynafields', 'scannerfields', ]}, {'id': 'steps', 'legend': 'Steps', 'fields': ['registrationopen', 'registrationtimerange', 'allowedit', 'callforpapersopen', 'callforpaperstimerange', 'callforsponsorsopen', 'callforsponsorstimerange', 'scheduleactive', 'tbdinschedule', 'sessionsactive', 'cardsactive', 'checkinactive', 'conferencefeedbackopen', 'feedbackopen']}, - {'id': 'callforpapers', 'legend': 'Call for papers', 'fields': ['callforpapersmaxsubmissions', 'skill_levels', 'callforpaperstags', 'callforpapersrecording', 'showvotes']}, + {'id': 'callforpapers', 'legend': 'Call for papers', 'fields': ['callforpapersmaxsubmissions', 'skill_levels', 'callforpaperstags', 'callforpapersrecording', 'showvotes', 'scoring_method']}, {'id': 'roles', 'legend': 'Roles', 'fields': ['testers', 'talkvoters', 'staff', 'volunteers', 'checkinprocessors', ]}, {'id': 'display', 'legend': 'Display', 'fields': ['jinjaenabled', 'videoproviders', ]}, {'id': 'legacy', 'legend': 'Legacy', 'fields': ['schedulewidth', 'pixelsperminute']}, diff --git a/postgresqleu/confreg/migrations/0116_conference_scoring_method.py b/postgresqleu/confreg/migrations/0116_conference_scoring_method.py new file mode 100644 index 000000000..d2959ab16 --- /dev/null +++ b/postgresqleu/confreg/migrations/0116_conference_scoring_method.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-09-11 14:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('confreg', '0115_speaker_photo_hashvals'), + ] + + operations = [ + migrations.AddField( + model_name='conference', + name='scoring_method', + field=models.IntegerField(choices=[(0, 'Average'), (1, 'Olympic Average')], default=0, verbose_name='Scoring method'), + ), + ] diff --git a/postgresqleu/confreg/models.py b/postgresqleu/confreg/models.py index 860449afb..b24f6d6b9 100644 --- a/postgresqleu/confreg/models.py +++ b/postgresqleu/confreg/models.py @@ -56,6 +56,11 @@ (4, "Volunteers and admins can post without approval"), ) +SCORING_METHOD_CHOICES = ( + (0, "Average"), + (1, "Olympic Average"), +) + # NOTE! The contents of these arrays must also be matched with the # database table confreg_status_strings. This one is managed by # manually creating a separate migration in case the contents change. @@ -209,6 +214,7 @@ class Conference(models.Model): callforpaperstags = models.BooleanField(blank=False, null=False, default=False, verbose_name='Use tags') callforpapersrecording = models.BooleanField(blank=False, null=False, default=False, verbose_name='Ask for recording consent') showvotes = models.BooleanField(blank=False, null=False, default=False, verbose_name="Show votes", help_text="Show other people's votes on the talkvote page") + scoring_method = models.IntegerField(blank=False, null=False, default=0, choices=SCORING_METHOD_CHOICES, verbose_name="Scoring method") sendwelcomemail = models.BooleanField(blank=False, null=False, default=False, verbose_name="Send welcome email", help_text="Send an email to attendees once their registration is completed.") tickets = models.BooleanField(blank=False, null=False, default=False, verbose_name="Use tickets", help_text="Generate and send tickets to all attendees once their registration is completed.") diff --git a/postgresqleu/confreg/views.py b/postgresqleu/confreg/views.py index 3ba7eb800..417295541 100644 --- a/postgresqleu/confreg/views.py +++ b/postgresqleu/confreg/views.py @@ -29,7 +29,7 @@ from .models import PendingAdditionalOrder from .models import RegistrationWaitlistEntry, RegistrationWaitlistHistory from .models import RegistrationTransferPending -from .models import STATUS_CHOICES +from .models import STATUS_CHOICES, SCORING_METHOD_CHOICES from .models import ConferenceNews, ConferenceTweetQueue from .models import SavedReportDefinition from .models import ConferenceMessaging @@ -2877,12 +2877,34 @@ def talkvote(request, confname): WHERE cs.conferencesession_id=s.id ) speakers ON true LEFT JOIN LATERAL ( - SELECT avg(vote) FILTER (WHERE vote > 0)::numeric(3,2) AS avg, - jsonb_object_agg(username, vote) AS votes, - jsonb_object_agg(username, comment) FILTER (WHERE comment IS NOT NULL AND comment != '') AS comments - FROM confreg_conferencesessionvote - INNER JOIN auth_user ON auth_user.id=voter_id - WHERE session_id=s.id + WITH + aggs (votes, comments, avg, sum, count, min, max) AS ( + SELECT + jsonb_object_agg(username, vote), + jsonb_object_agg(username, comment) FILTER (WHERE comment > ''), + AVG(vote) FILTER (WHERE vote > 0), + SUM(vote) FILTER (WHERE vote > 0), + COUNT(*) FILTER (WHERE vote > 0), + MIN(vote) FILTER (WHERE vote > 0), + MAX(vote) FILTER (WHERE vote > 0) + FROM confreg_conferencesessionvote + INNER JOIN auth_user ON auth_user.id=voter_id + WHERE session_id=s.id + ) + SELECT + CASE (SELECT scoring_method FROM confreg_conference WHERE id = %(confid)s) + WHEN 0 /* Average */ + THEN avg + + WHEN 1 /* Olympic average */ + THEN CASE WHEN count > 2 + THEN (sum - max - min) / (count - 2) + ELSE avg + END + END::numeric(3,2) AS avg, + votes, + comments + FROM aggs ) votes ON true WHERE s.conference_id=%(confid)s AND (COALESCE(s.track_id,0)=ANY(%(tracks)s)) AND @@ -2925,6 +2947,7 @@ def talkvote(request, confname): 'urlfilter': urltrackfilter + urlstatusfilter, 'helplink': 'callforpapers', 'options': options, + 'scoring_method': SCORING_METHOD_CHOICES[conference.scoring_method][1], }) diff --git a/template/confreg/sessionvotes.html b/template/confreg/sessionvotes.html index 2a2ddccdf..e7211acd2 100644 --- a/template/confreg/sessionvotes.html +++ b/template/confreg/sessionvotes.html @@ -245,6 +245,9 @@

Vote for sessions - {{conference}}

{%if not hasvoters%}
There are no talkvoters configured on this conference! List of talks will be empty!
{%endif%} + +

Scoring method: {{ scoring_method }}

+