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
Next Next commit
add finalize flag
help text on leaderboard time fields
filtering adjustments to update leaderbaord
finalize operation
  • Loading branch information
lsabor committed Jan 8, 2025
commit d82b3c29f245a125d06eff084dbfe19d0980dbf3
31 changes: 31 additions & 0 deletions projects/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from django.db import models
from django.db.models import (
Count,
@@ -17,6 +19,8 @@
from users.models import User
from utils.models import validate_alpha_slug, TimeStampedModel, TranslatedModel

logger = logging.getLogger(__name__)


class ProjectsQuerySet(models.QuerySet):
def filter_topic(self):
@@ -288,10 +292,34 @@ 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."
)
# If updating this project's end_date (and this isn't site_main),
# update the finalize_time of all leaderboards
if not creating:
previous = Project.objects.get(pk=self.pk)
if (
self.close_date
and self.close_date != previous.close_date
and self.type
in (
self.ProjectTypes.TOURNAMENT,
self.ProjectTypes.QUESTION_SERIES,
)
):
from scoring.models import Leaderboard

# warn since this might not be an expected side effect
logger.warning(
f"Updating finalize_time for leaderboards of project {self.pk}"
)
Leaderboard.objects.filter(project=self).update(
finalize_time=self.close_date
)

super().save(*args, **kwargs)
if (
creating
@@ -300,6 +328,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
@@ -308,6 +337,8 @@ def save(self, *args, **kwargs):
leaderboard = Leaderboard.objects.create(
project=self,
score_type=Leaderboard.ScoreTypes.PEER_TOURNAMENT,
start_time=self.start_date,
finalize_time=self.close_date,
)
Project.objects.filter(pk=self.pk).update(primary_leaderboard=leaderboard)

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 5.1.4 on 2025-01-08 20:28

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("scoring", "0005_alter_score_aggregation_method_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.AlterField(
model_name="leaderboard",
name="end_time",
field=models.DateTimeField(
blank=True,
help_text="This field is optional. It is used for:\n <br></br>- Filtering MedalExclusionRecords: If 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. If not set, this Leaderboard's finalize_time will be used instead.\n <br></br>- Global Leaderboards: Required for global leaderboards, filters for questions that have a scheduled_close_time before this (plus a grace period). Automatically set, do not change.\n <br></br>- Non-Global Leaderboards: has no effect on question filtering. Only used in filtering MedalExclusionRecords. Since finalize_time does the same thing in this case, it is recommended not to use this field.\n ",
null=True,
),
),
migrations.AlterField(
model_name="leaderboard",
name="finalize_time",
field=models.DateTimeField(
blank=True,
help_text="This field is optional, though should generally be set (exception could include a non-concluding Leaderboard like for a Tag or Topic Leaderboard). It is used for:\n <br></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 <br></br>- For all Leaderboards: If set, used to filter out questions that have a resolution_set_time after this (as they were resolved after this Leaderboard was finalized).\n <br></br>- Non-Global Leaderboards: This field is automatically set to the Project's close_date. If the Project's close_date is updated, this field will be updated as well. If you want this leaderboard to have a manually set finalize_time, updating this field manually will be required after each update to the Project's close_date.\n ",
null=True,
),
),
migrations.AlterField(
model_name="leaderboard",
name="start_time",
field=models.DateTimeField(
blank=True,
help_text="This field is optional. It is used for:\n <br></br>- Filtering MedalExclusionRecords: If set, 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 <br></br>- Global Leaderboards: Required for global leaderboards, filters for questions that have an open time after this. Automatically set, do not change.\n <br></br>- Non-Global Leaderboards: has no effect on question filtering. Only used in filtering MedalExclusionRecords.\n ",
null=True,
),
),
]
150 changes: 88 additions & 62 deletions scoring/models.py
Original file line number Diff line number Diff line change
@@ -161,9 +161,37 @@ def get_base_score(cls, score_type: str) -> Score.ScoreTypes:
)

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)
start_time = models.DateTimeField(
null=True,
blank=True,
help_text="""This field is optional. It is used for:
<br></br>- Filtering MedalExclusionRecords: If set, 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.
<br></br>- Global Leaderboards: Required for global leaderboards, filters for questions that have an open time after this. Automatically set, do not change.
<br></br>- Non-Global Leaderboards: has no effect on question filtering. Only used in filtering MedalExclusionRecords.
""",
)
end_time = models.DateTimeField(
null=True,
blank=True,
help_text="""This field is optional. It is used for:
<br></br>- Filtering MedalExclusionRecords: If 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. If not set, this Leaderboard's finalize_time will be used instead.
<br></br>- Global Leaderboards: Required for global leaderboards, filters for questions that have a scheduled_close_time before this (plus a grace period). Automatically set, do not change.
<br></br>- Non-Global Leaderboards: has no effect on question filtering. Only used in filtering MedalExclusionRecords. Since finalize_time does the same thing in this case, it is recommended not to use this field.
""",
)
finalize_time = models.DateTimeField(
null=True,
blank=True,
help_text="""This field is optional, though should generally be set (exception could include a non-concluding Leaderboard like for a Tag or Topic Leaderboard). It is used for:
<br></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.
<br></br>- For all Leaderboards: If set, used to filter out questions that have a resolution_set_time after this (as they were resolved after this Leaderboard was finalized).
<br></br>- Non-Global Leaderboards: This field is automatically set to the Project's close_date. If the Project's close_date is updated, this field will be updated as well. If you want this leaderboard to have a manually set finalize_time, updating this field manually will be required after each update to the Project's close_date.
""",
)
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.",
)

def __str__(self):
if self.name:
@@ -177,72 +205,70 @@ 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

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(
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,
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,
)

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:
return 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

19 changes: 17 additions & 2 deletions scoring/utils.py
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
from dataclasses import dataclass
from decimal import Decimal
from io import StringIO
import logging

import numpy as np
from django.db import transaction
@@ -29,6 +30,8 @@
from utils.the_math.formulas import string_location_to_bucket_index
from utils.the_math.measures import decimal_h_index

logger = logging.getLogger(__name__)


def score_question(
question: Question,
@@ -84,6 +87,7 @@ def generate_scoring_leaderboard_entries(

if leaderboard.finalize_time:
qs_filters["question__scheduled_close_time__lte"] = leaderboard.finalize_time
qs_filters["question__resolution_set_time__lte"] = leaderboard.finalize_time

archived_scores = ArchivedScore.objects.filter(**qs_filters).prefetch_related(
"question"
@@ -398,6 +402,7 @@ def assign_prizes(
def update_project_leaderboard(
project: Project | None = None,
leaderboard: Leaderboard | None = None,
force_update: bool = False,
) -> list[LeaderboardEntry]:
if project is None and leaderboard is None:
raise ValueError("Either project or leaderboard must be provided")
@@ -410,6 +415,12 @@ def update_project_leaderboard(
if leaderboard.score_type == Leaderboard.ScoreTypes.MANUAL:
return list(leaderboard.entries.all().order_by("rank"))

if not force_update and leaderboard.finalized:
logger.warning(
"Leaderboard %s is already finalized, not updating", leaderboard.name
)
return list(leaderboard.entries.all().order_by("rank"))

# new entries
new_entries = generate_project_leaderboard(project, leaderboard)

@@ -440,6 +451,8 @@ def update_project_leaderboard(
# add prize if applicable
if project.prize_pool:
new_entries = assign_prizes(new_entries, project.prize_pool)
# set finalize
Leaderboard.objects.filter(pk=leaderboard.pk).update(finalized=True)

# save entries
previous_entries_map = {
@@ -644,10 +657,12 @@ def get_contributions(

if leaderboard.finalize_time:
calculated_scores = calculated_scores.filter(
question__scheduled_close_time__lte=leaderboard.finalize_time
question__scheduled_close_time__lte=leaderboard.finalize_time,
question__resolution_set_time__lte=leaderboard.finalize_time,
).prefetch_related("question")
archived_scores = archived_scores.filter(
question__scheduled_close_time__lte=leaderboard.finalize_time
question__scheduled_close_time__lte=leaderboard.finalize_time,
question__resolution_set_time__lte=leaderboard.finalize_time,
).prefetch_related("question")
scores = list(archived_scores)

Loading