Add "Olympic scoring" method.
authorVik Fearing <vik@chouppes.com>
Thu, 10 Oct 2024 11:54:24 +0000 (13:54 +0200)
committerVik Fearing <vik@chouppes.com>
Thu, 10 Oct 2024 11:54:24 +0000 (13:54 +0200)
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
docs/confreg/configuring.md
postgresqleu/confreg/backendforms.py
postgresqleu/confreg/migrations/0116_conference_scoring_method.py [new file with mode: 0644]
postgresqleu/confreg/models.py
postgresqleu/confreg/views.py
template/confreg/sessionvotes.html

index b2194a12d8bf49910dc297044888cf2b49c3247c..e7bdb472cf9fffb5d025cabf79b772ce7b695acd 100644 (file)
@@ -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 &mdash; This is the standard average of all the scores.
+- Olympic average &mdash; 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
index 3a266848de3880ab66ae1b4f2697cdfb1c82f59e..dae15d1ab886c152d0e956d1d90f39857d9a4e38 100644 (file)
@@ -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 &mdash; This is the standard average of all the scores.
+- Olympic average &mdash; 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
index f3b4f6a65c194c5c73c9d88a3a875c6b57c59cdd..70cfdc1442fa0f49a43b629ea200505305efc6cc 100644 (file)
@@ -87,7 +87,7 @@ class BackendConferenceForm(BackendForm):
                   '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 @@ class BackendConferenceForm(BackendForm):
         {'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 (file)
index 0000000..d2959ab
--- /dev/null
@@ -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'),
+        ),
+    ]
index 860449afb649f833d6bb3c392fd7ff4ae6939ed3..b24f6d6b9b89734a62c0dde4ee017b0bac930eb1 100644 (file)
@@ -56,6 +56,11 @@ TWITTER_POST_CHOICES = (
     (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.")
index 3ba7eb80034f2b223f95954dffdd8a29acccc2a3..417295541c7a080156b66abc9a45434c9ea94614 100644 (file)
@@ -29,7 +29,7 @@ from .models import AttendeeMail, ConferenceAdditionalOption
 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 @@ LEFT JOIN LATERAL (
     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 @@ ORDER BY {}s.title,s.id""".format(nonvotedquery, order), {
         'urlfilter': urltrackfilter + urlstatusfilter,
         'helplink': 'callforpapers',
         'options': options,
+        'scoring_method': SCORING_METHOD_CHOICES[conference.scoring_method][1],
     })
 
 
index 2a2ddccdfd2de50fd39d72745868e3664ef6ff1f..e7211acd2b2cc62510491ca1d3c967a0f98c12f9 100644 (file)
@@ -245,6 +245,9 @@ ul.comments span.username {
 {%if not hasvoters%}
 <div class="alert alert-warning">There are no talkvoters configured on this conference! List of talks will be empty!</div>
 {%endif%}
+
+<p><b>Scoring method:</b> {{ scoring_method }}</p>
+
 <table id="votetable" class="table table-bordered table-condensed" style="display:none">
  <tr>
   <th style="width: 1%">Seq</th>