Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

leaderboard time tweaks #1926

Merged
merged 13 commits into from
Jan 17, 2025
Merged
3 changes: 3 additions & 0 deletions projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,10 +288,12 @@ def __str__(self):

def save(self, *args, **kwargs):
creating = not self.pk
# Check if the primary leaderboard is associated with this project
if self.primary_leaderboard and self.primary_leaderboard.project != self:
raise ValueError(
"Primary leaderboard must be associated with this project."
)

super().save(*args, **kwargs)
if (
creating
Expand All @@ -300,6 +302,7 @@ def save(self, *args, **kwargs):
in (
self.ProjectTypes.TOURNAMENT,
self.ProjectTypes.QUESTION_SERIES,
self.ProjectTypes.COMMUNITY,
)
):
# create default leaderboard when creating a new tournament/question series
Expand Down
48 changes: 41 additions & 7 deletions scoring/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,22 @@ def get_queryset(self, request):

@admin.register(Leaderboard)
class LeaderboardAdmin(admin.ModelAdmin):
change_list_template = "admin/scoring/leaderboard_action_descriptions.html"
search_fields = ["name", "project", "score_type"]
list_display = ["__str__", "id", "project", "score_type"]
list_display = ["__str__", "id", "project", "score_type", "finalized"]
autocomplete_fields = ["project"]
list_filter = [
AutocompleteFilterFactory("Project", "project"),
"score_type",
"finalized",
]
inlines = [LeaderboardEntryInline]
actions = ["make_primary_leaderboard", "update_leaderboards"]
actions = [
"make_primary_leaderboard",
"update_leaderboards",
"force_update_leaderboards",
"force_finalize_and_asign_medals_leaderboards",
]

def make_primary_leaderboard(self, request, queryset):
for leaderboard in queryset:
Expand All @@ -91,16 +99,42 @@ def make_primary_leaderboard(self, request, queryset):
messages.SUCCESS,
)

make_primary_leaderboard.short_description = (
"Make selected leaderboards their project's primary_leaderboard"
)
make_primary_leaderboard.short_description = "Make Primary Leaderboard"

def update_leaderboards(self, request, queryset):
leaderboard: Leaderboard
for leaderboard in queryset:
update_project_leaderboard(leaderboard.project, leaderboard)
update_project_leaderboard(
leaderboard.project,
leaderboard,
)

update_leaderboards.short_description = "Update selected Leaderboards"
update_leaderboards.short_description = "Update Leaderboards"

def force_update_leaderboards(self, request, queryset):
leaderboard: Leaderboard
for leaderboard in queryset:
update_project_leaderboard(
leaderboard.project,
leaderboard,
force_update=True,
)

force_update_leaderboards.short_description = "Force Update Leaderboards"

def force_finalize_and_asign_medals_leaderboards(self, request, queryset):
leaderboard: Leaderboard
for leaderboard in queryset:
update_project_leaderboard(
leaderboard.project,
leaderboard,
force_update=True,
force_finalize=True,
)

force_finalize_and_asign_medals_leaderboards.short_description = (
"Force Update, Finalize, and Assign Medals/Prizes"
)


@admin.register(LeaderboardEntry)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Generated by Django 5.1.4 on 2025-01-16 19:35

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("scoring", "0006_alter_leaderboardentry_excluded_and_more"),
]

operations = [
migrations.AddField(
model_name="leaderboard",
name="finalized",
field=models.BooleanField(
default=False,
help_text="If true, this Leaderboard's entries cannot be updated except by a manual action in the admin panel. Automatically set to True the first time this leaderboard is updated after the finalize_time.",
),
),
migrations.AddField(
model_name="leaderboard",
name="prize_pool",
field=models.DecimalField(
blank=True,
decimal_places=2,
default=None,
help_text="Optional. If not set, the Project's prize_pool will be used instead.\n </br>- If the Project has a prize pool, but this leaderboard has none, set this to 0.\n ",
max_digits=15,
null=True,
),
),
migrations.AlterField(
model_name="leaderboard",
name="end_time",
field=models.DateTimeField(
blank=True,
help_text="Optional (required for global leaderboards).\n </br>- Global Leaderboards: filters for questions that have a scheduled_close_time before this (plus a grace period). Automatically set, do not change.\n </br>- Non-Global Leaderboards: has no effect on question filtering.\n </br>- Filtering MedalExclusionRecords: MedalExclusionRecords that have a start_time less than this (and no end_time or an end_time later that this Leaderboard's start_time) will be triggered. If not set, this Leaderboard's finalize_time will be used instead - it is recommended not to use this field unless required.\n ",
null=True,
),
),
migrations.AlterField(
model_name="leaderboard",
name="finalize_time",
field=models.DateTimeField(
blank=True,
help_text="Optional. If not set, the Project's close_date will be used instead.\n </br>- For all Leaderboards: used to filter out questions that have a resolution_set_time after this (as they were resolved after this Leaderboard was finalized).\n </br>- Filtering MedalExclusionRecords: If set and end_time is not set, MedalExclusionRecords that have a start_time less than this (and no end_time or an end_time later that this Leaderboard's start_time) will be triggered.\n ",
null=True,
),
),
migrations.AlterField(
model_name="leaderboard",
name="score_type",
field=models.CharField(
choices=[
("peer_tournament", "Peer Tournament"),
("spot_peer_tournament", "Spot Peer Tournament"),
("relative_legacy_tournament", "Relative Legacy Tournament"),
("baseline_global", "Baseline Global"),
("peer_global", "Peer Global"),
("peer_global_legacy", "Peer Global Legacy"),
("comment_insight", "Comment Insight"),
("question_writing", "Question Writing"),
("manual", "Manual"),
],
help_text="\n <table>\n <tr><td>peer_tournament</td><td> Sum of peer scores. Most likely what you want.</td></tr>\n <tr><td>spot_peer_tournament</td><td> Sum of spot peer scores.</td></tr>\n <tr><td>relative_legacy</td><td> Old site scoring.</td></tr>\n <tr><td>baseline_global</td><td> Sum of baseline scores.</td></tr>\n <tr><td>peer_global</td><td> Coverage-weighted average of peer scores.</td></tr>\n <tr><td>peer_global_legacy</td><td> Average of peer scores.</td></tr>\n <tr><td>comment_insight</td><td> H-index of upvotes for comments on questions.</td></tr>\n <tr><td>question_writing</td><td> H-index of number of forecasters / 10 on questions.</td></tr>\n <tr><td>manual</td><td> Does not automatically update. Manually set all entries.</td></tr>\n </table>\n ",
max_length=200,
),
),
migrations.AlterField(
model_name="leaderboard",
name="start_time",
field=models.DateTimeField(
blank=True,
help_text="Optional (required for global leaderboards). If not set, the Project's open_date will be used instead.\n </br>- Global Leaderboards: filters for questions that have an open time after this. Automatically set, do not change.\n </br>- Non-Global Leaderboards: has no effect on question filtering.\n </br>- Filtering MedalExclusionRecords: MedalExclusionRecords that have no end_time or an end_time greater than this (and a start_time before this Leaderboard's end_time or finalize_time) will be triggered.\n ",
null=True,
),
),
]
185 changes: 118 additions & 67 deletions scoring/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,12 @@ class Leaderboard(TimeStampedModel):
)

class ScoreTypes(models.TextChoices):
RELATIVE_LEGACY_TOURNAMENT = "relative_legacy_tournament"
PEER_GLOBAL = "peer_global"
PEER_GLOBAL_LEGACY = "peer_global_legacy"
PEER_TOURNAMENT = "peer_tournament"
SPOT_PEER_TOURNAMENT = "spot_peer_tournament"
RELATIVE_LEGACY_TOURNAMENT = "relative_legacy_tournament"
BASELINE_GLOBAL = "baseline_global"
PEER_GLOBAL = "peer_global"
PEER_GLOBAL_LEGACY = "peer_global_legacy"
COMMENT_INSIGHT = "comment_insight"
QUESTION_WRITING = "question_writing"
MANUAL = "manual"
Expand Down Expand Up @@ -162,10 +162,63 @@ def get_base_score(cls, score_type: str) -> Score.ScoreTypes:
"Question Writing leaderboards do not have base scores"
)

score_type = models.CharField(max_length=200, choices=ScoreTypes.choices)
start_time = models.DateTimeField(null=True, blank=True)
end_time = models.DateTimeField(null=True, blank=True)
finalize_time = models.DateTimeField(null=True, blank=True)
score_type = models.CharField(
max_length=200,
choices=ScoreTypes.choices,
help_text="""
<table>
<tr><td>peer_tournament</td><td> Sum of peer scores. Most likely what you want.</td></tr>
<tr><td>spot_peer_tournament</td><td> Sum of spot peer scores.</td></tr>
<tr><td>relative_legacy</td><td> Old site scoring.</td></tr>
<tr><td>baseline_global</td><td> Sum of baseline scores.</td></tr>
<tr><td>peer_global</td><td> Coverage-weighted average of peer scores.</td></tr>
<tr><td>peer_global_legacy</td><td> Average of peer scores.</td></tr>
<tr><td>comment_insight</td><td> H-index of upvotes for comments on questions.</td></tr>
<tr><td>question_writing</td><td> H-index of number of forecasters / 10 on questions.</td></tr>
<tr><td>manual</td><td> Does not automatically update. Manually set all entries.</td></tr>
</table>
""",
)
start_time = models.DateTimeField(
null=True,
blank=True,
help_text="""Optional (required for global leaderboards). If not set, the Project's open_date will be used instead.
</br>- Global Leaderboards: filters for questions that have an open time after this. Automatically set, do not change.
</br>- Non-Global Leaderboards: has no effect on question filtering.
</br>- Filtering MedalExclusionRecords: MedalExclusionRecords that have no end_time or an end_time greater than this (and a start_time before this Leaderboard's end_time or finalize_time) will be triggered.
""",
)
end_time = models.DateTimeField(
null=True,
blank=True,
help_text="""Optional (required for global leaderboards).
</br>- Global Leaderboards: filters for questions that have a scheduled_close_time before this (plus a grace period). Automatically set, do not change.
</br>- Non-Global Leaderboards: has no effect on question filtering.
</br>- Filtering MedalExclusionRecords: MedalExclusionRecords that have a start_time less than this (and no end_time or an end_time later that this Leaderboard's start_time) will be triggered. If not set, this Leaderboard's finalize_time will be used instead - it is recommended not to use this field unless required.
""",
)
finalize_time = models.DateTimeField(
null=True,
blank=True,
help_text="""Optional. If not set, the Project's close_date will be used instead.
</br>- For all Leaderboards: used to filter out questions that have a resolution_set_time after this (as they were resolved after this Leaderboard was finalized).
</br>- Filtering MedalExclusionRecords: If set and end_time is not set, MedalExclusionRecords that have a start_time less than this (and no end_time or an end_time later that this Leaderboard's start_time) will be triggered.
""",
)
finalized = models.BooleanField(
lsabor marked this conversation as resolved.
Show resolved Hide resolved
default=False,
help_text="If true, this Leaderboard's entries cannot be updated except by a manual action in the admin panel. Automatically set to True the first time this leaderboard is updated after the finalize_time.",
)
prize_pool = models.DecimalField(
default=None,
decimal_places=2,
max_digits=15,
null=True,
blank=True,
help_text="""Optional. If not set, the Project's prize_pool will be used instead.
</br>- If the Project has a prize pool, but this leaderboard has none, set this to 0.
""",
)

def __str__(self):
if self.name:
Expand All @@ -179,74 +232,72 @@ def get_questions(self) -> QuerySet[Question]:
related_posts__post__curation_status=Post.CurationStatus.APPROVED
)

if self.project and self.project.type == Project.ProjectTypes.SITE_MAIN:
# global leaderboard
if self.start_time is None or self.end_time is None:
raise ValueError("Global leaderboards must have start and end times")

questions = questions.filter_public().filter(
related_posts__post__in=Post.objects.filter_for_main_feed()
)

if self.score_type == self.ScoreTypes.COMMENT_INSIGHT:
# post must be published
return questions.filter(
related_posts__post__published_at__lt=self.end_time
)
elif self.score_type == self.ScoreTypes.QUESTION_WRITING:
# post must be published, and can't be resolved before the start_time
# of the leaderboard
return questions.filter(
Q(scheduled_close_time__gte=self.start_time)
& (
Q(actual_close_time__isnull=True)
| Q(actual_close_time__gte=self.start_time)
),
related_posts__post__published_at__lt=self.end_time,
if not (self.project and self.project.type == Project.ProjectTypes.SITE_MAIN):
# normal Project leaderboard
if self.project:
questions = questions.filter(
Q(related_posts__post__projects=self.project)
| Q(related_posts__post__default_project=self.project),
)
return questions.distinct("id")

close_grace_period = timedelta(days=3)
resolve_grace_period = timedelta(days=100)
# global leaderboard
if self.start_time is None or self.end_time is None:
raise ValueError("Global leaderboards must have start and end times")

questions = questions.filter_public().filter(
related_posts__post__in=Post.objects.filter_for_main_feed()
)

questions = questions.filter(
Q(actual_resolve_time__isnull=True)
| Q(actual_resolve_time__lte=self.end_time + resolve_grace_period),
open_time__gte=self.start_time,
open_time__lt=self.end_time,
scheduled_close_time__lte=self.end_time + close_grace_period,
if self.score_type == self.ScoreTypes.COMMENT_INSIGHT:
# post must be published
return questions.filter(related_posts__post__published_at__lt=self.end_time)
elif self.score_type == self.ScoreTypes.QUESTION_WRITING:
# post must be published, and can't be resolved before the start_time
# of the leaderboard
return questions.filter(
Q(scheduled_close_time__gte=self.start_time)
& (
Q(actual_close_time__isnull=True)
| Q(actual_close_time__gte=self.start_time)
),
related_posts__post__published_at__lt=self.end_time,
)

gl_dates = global_leaderboard_dates()
checked_intervals: list[tuple[datetime, datetime]] = []
for start, end in gl_dates[::-1]: # must be in reverse order, biggest first
if (
(self.start_time, self.end_time) == (start, end)
or start < self.start_time
or self.end_time < end
):
continue
to_add = True
for checked_start, checked_end in checked_intervals:
if checked_start < start and end < checked_end:
to_add = False
break
if to_add:
checked_intervals.append((start, end))
questions = questions.filter(
Q(open_time__lt=start)
| Q(scheduled_close_time__gt=end + close_grace_period)
| Q(actual_resolve_time__gt=end + resolve_grace_period)
)
close_grace_period = timedelta(days=3)
resolve_grace_period = timedelta(days=100)

return questions
questions = questions.filter(
Q(actual_resolve_time__isnull=True)
| Q(actual_resolve_time__lte=self.end_time + resolve_grace_period),
open_time__gte=self.start_time,
open_time__lt=self.end_time,
scheduled_close_time__lte=self.end_time + close_grace_period,
)

if self.project:
questions = questions.filter(
Q(related_posts__post__projects=self.project)
| Q(related_posts__post__default_project=self.project)
)
gl_dates = global_leaderboard_dates()
checked_intervals: list[tuple[datetime, datetime]] = []
for start, end in gl_dates[::-1]: # must be in reverse order, biggest first
if (
(self.start_time, self.end_time) == (start, end)
or start < self.start_time
or self.end_time < end
):
continue
to_add = True
for checked_start, checked_end in checked_intervals:
if checked_start < start and end < checked_end:
to_add = False
break
if to_add:
checked_intervals.append((start, end))
questions = questions.filter(
Q(open_time__lt=start)
| Q(scheduled_close_time__gt=end + close_grace_period)
| Q(actual_resolve_time__gt=end + resolve_grace_period)
)

return questions.distinct("id")
return questions


def name_and_slug_for_global_leaderboard_dates(
Expand Down
Loading
Loading