Skip to content

Commit

Permalink
Replaced UserCache with ContestScore and UserScore
Browse files Browse the repository at this point in the history
  • Loading branch information
JasonLovesDoggo committed Mar 8, 2024
1 parent 099c70e commit 1fa8cf3
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 121 deletions.
4 changes: 2 additions & 2 deletions gameserver/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from django.contrib.auth import get_user_model
from django.contrib.flatpages.admin import FlatPageAdmin
from django.contrib.flatpages.models import FlatPage
from django.db.models import Q
from django.utils.translation import gettext_lazy as _

from . import models
Expand Down Expand Up @@ -252,7 +251,8 @@ class UserAdmin(admin.ModelAdmin):


admin.site.register(User, UserAdmin)
admin.site.register(models.UserCache)
admin.site.register(models.ContestScore)
admin.site.register(models.UserScore)
admin.site.register(models.Problem, ProblemAdmin)
admin.site.register(models.Submission)
admin.site.register(models.ProblemType)
Expand Down
184 changes: 126 additions & 58 deletions gameserver/models/cache.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,138 @@
from typing import Optional

from django.db import models
from django.db.models import Count, Sum
from django.db import models, transaction
from django.db.models.functions import Coalesce
from django.db.models import Count, F, Sum
from .profile import User


class UserCache(models.Model):
user = models.ForeignKey(
"User",
on_delete=models.CASCADE,
related_name="caches",
)

participation = models.ForeignKey(
"ContestParticipation",
on_delete=models.SET_NULL,
related_name="current_caches",
null=True,
blank=True,
)
from typing import TYPE_CHECKING

points = models.IntegerField("Points")

flags = models.IntegerField("Flags")
if TYPE_CHECKING:
from .contest import ContestParticipation, Contest


class UserScore(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True, unique=True)
points = models.PositiveIntegerField(help_text="The amount of points.", default=0)
flag_count = models.PositiveIntegerField(help_text="The amount of flags the user/team has.", default=0)

def __str__(self) -> str:
return f"{self.user_id}'s score"

@classmethod
def update_or_create(cls, change_in_score: int, user: User, update_flags: bool = True):
assert change_in_score > 0
queryset = cls.objects.filter(user=user)

if not queryset.exists(): # no user found matching that
cls.objects.create(user=user, flag_count=int(update_flags), points=change_in_score)
return cls.update_or_create(change_in_score=change_in_score, user=user, update_flags=update_flags)

if update_flags:
queryset.update(points=F('points') + change_in_score)
else:
queryset.update(points=F('points') + change_in_score, flag_count=F('flag_count') + 1)

@classmethod
def invalidate(cls, user: User):
try:
cls.objects.get(user=user).delete()
except cls.DoesNotExist:
pass # user was not found.


@classmethod
def get(cls, user: User) -> None | "UserScore":
obj = cls.objects.filter(user=user)
if obj is None:
return None
return obj.first()

@classmethod
def initalize_data(cls):
cls.objects.all().delete() # clear inital objs
users = User.objects.all()
scores_to_create = []
for user in users:
queryset = user._get_unique_correct_submissions()
queryset = queryset.aggregate(
points=Coalesce(Sum("problem__points"), 0),
flags=Count("problem"),
)
scores_to_create.append(
UserScore(
user=user,
flag_count=queryset['flags'],
points=queryset['points']
)
)
# Use bulk_create to create all objects in a single query
cls.objects.bulk_create(scores_to_create)



class Meta:
unique_together = ('user', 'participation')


class ContestScore(models.Model):
participation=models.ForeignKey("ContestParticipation", on_delete=models.CASCADE, db_index=True, unique=True)
points = models.PositiveIntegerField(help_text="The amount of points.", default=0)
flag_count = models.PositiveIntegerField(help_text="The amount of flags the user/team has.", default=0)

def __str__(self) -> str:
return f'{self.user} {"global" if self.participation is None else self.participation}'
return f'Score for {self.participation} on {self.participation.contest.name}'

@classmethod
def get(cls, user: "User", participation: Optional["ContestParticipation"]) -> "UserCache":
assert user is not None
q = cls.objects.filter(user=user, participation=participation)
if not q:
obj = cls.fill_cache(user, participation)
obj.save()
return obj
else:
return q.first()
# TODO: cleanup duplicates

@classmethod
def invalidate(cls, user: "User", participation: Optional["ContestParticipation"]) -> None:
assert user is not None
q = cls.objects.filter(user=user, participation=participation)
obj = cls.fill_cache(user, participation)
if not q:
obj.save()
else:
q.update(points=obj.points, flags=obj.flags)
def update_or_create(cls, change_in_score: int, participant: "ContestParticipation", update_flags: bool = True):
assert change_in_score > 0
queryset = cls.objects.filter(participation=participant)

if not queryset.exists(): # no user/team found matching that
cls.objects.create(participation=participant, flag_count=int(update_flags), points=change_in_score)
return cls.update_or_create(change_in_score=change_in_score, participant=participant, update_flags=update_flags)

with transaction.atomic():
queryset.select_for_update() # prevent race conditions with other team members

if update_flags:
queryset.update(points=F('points') + change_in_score)
else:
queryset.update(points=F('points') + change_in_score, flag_count=F('flag_count') + 1)

@classmethod
def fill_cache(cls, user: "User", participation: Optional["ContestParticipation"]) -> "UserCache":
assert user is not None
if participation is None:
queryset = user._get_unique_correct_submissions().filter(problem__is_public=True)
else:
queryset = participation._get_unique_correct_submissions()
queryset = queryset.aggregate(
points=Coalesce(Sum("problem__points"), 0),
flags=Count("problem"),
)
return UserCache(
user=user,
participation=participation,
**{key: queryset[key] for key in ("points", "flags")}
)
def invalidate(cls, participant: ContestParticipation):
try:
cls.objects.get(participant=participant).delete()
except cls.DoesNotExist:
pass # participant was not found.


@classmethod
def get(cls, participant: ContestParticipation) -> None | "ContestScore":
obj = cls.objects.filter(participant=participant)
if obj is None:
return None
return obj.first()

@classmethod
def initalize_data(cls, contest: "Contest"):
cls.objects.all().delete() # clear inital objs
participants = contest.participations.all()
scores_to_create = []
for participant in participants:
queryset = participant._get_unique_correct_submissions()
queryset = queryset.aggregate(
points=Coalesce(Sum("problem__points"), 0),
flags=Count("problem"),
)
scores_to_create.append(
ContestScore(
participation=participant,
flag_count=queryset['flags'],
points=queryset['points']
)
)
# Use bulk_create to create all objects in a single query
cls.objects.bulk_create(scores_to_create)



64 changes: 19 additions & 45 deletions gameserver/models/contest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import uuid
from datetime import timedelta

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
Expand All @@ -13,7 +11,11 @@
from django.urls import reverse
from django.utils import timezone

from django.db import models
from django.db.models import F
from . import abstract
from functools import cached_property
from .cache import ContestScore

# Create your models here.

Expand Down Expand Up @@ -56,27 +58,27 @@ def __str__(self):
def get_absolute_url(self):
return reverse("contest_detail", args=[self.slug])

@property
@cached_property
def is_private(self):
return not self.is_public

@property
@cached_property
def teams_allowed(self):
return self.max_team_size is None or self.max_team_size > 1

@property
@cached_property
def is_started(self):
return self.start_time <= timezone.now()

@property
@cached_property
def is_finished(self):
return self.end_time < timezone.now()

@property
@cached_property
def is_ongoing(self):
return self.is_started and not self.is_finished

@property
@cached_property
def duration(self):
return self.end_time - self.start_time

Expand All @@ -86,7 +88,7 @@ def has_problem(self, problem):
else:
return self.problems.filter(problem__pk=problem.pk).exists()

@property
@cached_property
def __meta_key(self):
return f"contest_ranks_{self.pk}"

Expand Down Expand Up @@ -251,7 +253,7 @@ def __str__(self):
def get_absolute_url(self):
return reverse("contest_participation_detail", args=[self.pk])

@property
@cached_property
def participant(self):
if self.team is None:
return self.participants.first()
Expand All @@ -275,7 +277,7 @@ def points(self):
def flags(self):
return self._get_unique_correct_submissions().count()

@property
@cached_property
def last_solve(self):
submissions = (
self.submissions.filter(submission__is_correct=True)
Expand All @@ -291,15 +293,15 @@ def last_solve(self):
else:
return None

@property
@cached_property
def last_solve_time(self):
last_solve = self.last_solve
if last_solve is not None:
return last_solve.submission.date_created
else:
return self.contest.start_time

@property
@cached_property
def time_taken(self) -> str:
"""Returns the total amount of time the user has spent on the contest"""
solve_time = self.last_solve_time
Expand Down Expand Up @@ -330,7 +332,7 @@ def has_firstblooded(self, problem):

class ContestProblem(models.Model):
contest = models.ForeignKey(
Contest, on_delete=models.CASCADE, related_name="problems", related_query_name="problem"
Contest, on_delete=models.CASCADE, related_name="problems", related_query_name="problem", db_index=True
)
problem = models.ForeignKey(
"Problem", on_delete=models.CASCADE, related_name="contests", related_query_name="contest"
Expand Down Expand Up @@ -397,11 +399,11 @@ class ContestSubmission(models.Model):
def __str__(self):
return f"{self.participation.participant}'s submission for {self.problem.problem.name} in {self.problem.contest.name}"

@property
@cached_property
def is_correct(self):
return self.submission.is_correct

@property
@cached_property
def is_firstblood(self):
prev_correct_submissions = ContestSubmission.objects.filter(
problem=self.problem, submission__is_correct=True, pk__lte=self.pk
Expand All @@ -410,33 +412,5 @@ def is_firstblood(self):
return prev_correct_submissions.count() == 1 and prev_correct_submissions.first() == self

def save(self, *args, **kwargs):
for key in cache.get(f"contest_ranks_{self.participation.contest.pk}", default=[]):
cache.delete(key)
ContestScore.invalidate(self.participation)
super().save(*args, **kwargs)


from django.db import models, transaction
from django.db.models import F
from typing import Optional
from django.contrib.auth.models import User

class ContestScore(models.Model):
participation=models.ForeignKey(ContestParticipation, on_delete=CASCADE, db_index=True)
points = models.PositiveIntegerField(help_text="The amount of points.", default=0)
flag_count = models.PositiveIntegerField(help_text="The amount of flags the user/team has.", default=0)

@classmethod
def update_or_create(cls, change_in_score: int, participant: ContestParticipation, update_flags: bool = True):
queryset = cls.objects.filter(participation=participant)

if not queryset.exists(): # no user/team found matching that
cls.objects.create(participation=participant, flag_count=int(update_flags), points=change_in_score)
return cls.update_or_create(contest=contest, change_in_score=change_in_score, user=user, team=team, update_flags=update_flags)

with transaction.atomic():
queryset.select_for_update()

if update_flags:
queryset.update(points=F('points') + change_in_score)
else:
queryset.update(points=F('points') + change_in_score, flag_count=F('flag_count') + 1)
Loading

0 comments on commit 1fa8cf3

Please sign in to comment.