diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index bafee2c..e91ffa1 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -1,4 +1,4 @@ -# GitHub recommends pinning actions to a commit SHA. +# Github recommends pinning actions to a commit SHA. # To get a newer version, you will need to update the SHA. # You can also reference a tag or branch, but the action may change without warning. @@ -7,6 +7,11 @@ name: Create and publish a Docker image on: push: +concurrency: + group: ${{ github.workflow }}-${{ github.GITHUB_REF }} + cancel-in-progress: true + + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} diff --git a/Dockerfile b/Dockerfile index 1b9e69d..c60db86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,8 @@ RUN set -eux; cd /app/public/scss; mkdir out; for f in *.scss; \ mv out/* .; \ chmod a+r /app/public/scss/*.css +STOPSIGNAL SIGTERM +# Gunicorn listens to SIGTERM RUN apt-get purge -y sassc && \ rm -rf /var/lib/apt/lists/* && \ rm -rf /var/cache/* @@ -41,6 +43,6 @@ EXPOSE 28730 CMD /app/.venv/bin/gunicorn \ --bind :28730 \ --error-logfile - \ - --timeout 120 \ + --timeout 20 \ --config /app/container/gunicorn.py \ mCTF.wsgi:application diff --git a/gameserver/admin.py b/gameserver/admin.py index d7866b2..fe9f0f2 100644 --- a/gameserver/admin.py +++ b/gameserver/admin.py @@ -1,11 +1,14 @@ -from adminsortable2.admin import SortableInlineAdminMixin, SortableAdminBase +from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from django.contrib import admin from django.contrib.auth import get_user_model from django.contrib.flatpages.admin import FlatPageAdmin from django.contrib.flatpages.models import FlatPage +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from . import models +from .models import Submission, ContestSubmission +from .utils.actions import * User = get_user_model() @@ -212,6 +215,7 @@ class ContestAdmin(SortableAdminBase, admin.ModelAdmin): "summary", "start_time", "end_time", + "first_blood_webhook", "tags", "max_team_size", "is_public", @@ -225,6 +229,7 @@ class ContestAdmin(SortableAdminBase, admin.ModelAdmin): "start_time", "end_time", ] + actions = [recalculate_score] def has_view_permission(self, request, obj=None): if request.user.has_perm("gameserver.view_contest"): @@ -277,11 +282,92 @@ class UserAdmin(admin.ModelAdmin): "username", "full_name", ] + actions = [recalculate_user_scores, recalculate_all_user_scores] + + +class UserScoreAdmin(admin.ModelAdmin): + fields = ( + "user", + "points", + "flag_count", + "last_correct_submission", + "last_correct_submission_obj", + ) + list_display = [ + "user", + "points", + "last_correct_submission", + ] + ordering = ["-points"] + search_fields = [ + "user__username", + "user__full_name", + ] + readonly_fields = [ + "user", + "points", + "flag_count", + "last_correct_submission", + "last_correct_submission_obj", + ] + + def last_correct_submission_obj(self, obj): + try: + obj = Submission.objects.filter( + user=obj.user, is_correct=True, problem__is_public=True + ).latest("date_created") + except Submission.DoesNotExist: + return "No correct submissions" + return format_html("{url}", url=obj.get_absolute_admin_url()) + + last_correct_submission_obj.allow_tags = True + + last_correct_submission_obj.short_description = "Last correct submission URL" + + +class ContestScoreAdmin(admin.ModelAdmin): + fields = ( + "participation", + "points", + "flag_count", + "last_correct_submission", + "last_correct_submission_obj", + ) + list_display = [ + "participation", + "points", + "last_correct_submission", + ] + list_filter = ["participation__contest"] + ordering = ["-points"] + search_fields = [ + "participation__team__name", + "participation__team__members__username", + ] + readonly_fields = [ + "participation", + "points", + "flag_count", + "last_correct_submission", + "last_correct_submission_obj", + ] + + def last_correct_submission_obj(self, obj): + try: + obj = ContestSubmission.objects.filter( + participation=obj.participation, submission__is_correct=True + ).latest("submission__date_created") + except ContestSubmission.DoesNotExist: + return "No correct submissions" + return format_html("{url}", url=obj.get_absolute_admin_url()) + + last_correct_submission_obj.allow_tags = True + last_correct_submission_obj.short_description = "Last correct submission URL" admin.site.register(User, UserAdmin) -admin.site.register(models.ContestScore) -admin.site.register(models.UserScore) +admin.site.register(models.ContestScore, ContestScoreAdmin) +admin.site.register(models.UserScore, UserScoreAdmin) admin.site.register(models.Problem, ProblemAdmin) admin.site.register(models.Submission, SubmissionAdmin) admin.site.register(models.ProblemType) @@ -295,6 +381,7 @@ class UserAdmin(admin.ModelAdmin): admin.site.register(models.Contest, ContestAdmin) admin.site.register(models.ContestTag) admin.site.register(models.ContestParticipation) +admin.site.register(models.ContestSubmission) admin.site.site_header = "mCTF administration" admin.site.site_title = "mCTF admin" diff --git a/gameserver/api/routes.py b/gameserver/api/routes.py index 5d8fa8e..41dc1a6 100644 --- a/gameserver/api/routes.py +++ b/gameserver/api/routes.py @@ -1,12 +1,14 @@ -from django.db.models import F, OuterRef, Subquery, Case, When, Q +import datetime +from typing import Any, List + +from django.db.models import F, OuterRef, Max, Subquery, Case, When, Value, BooleanField, TextField from django.shortcuts import get_object_or_404 +from ninja import NinjaAPI, Schema from gameserver.models.cache import ContestScore -from gameserver.models.contest import ContestProblem, ContestSubmission, Contest -from ninja import NinjaAPI, Schema -from typing import List, Any +from gameserver.models.profile import User +from gameserver.models.contest import Contest, ContestProblem, ContestSubmission, ContestParticipation -import datetime def unicode_safe(string): return string.encode("unicode_escape").decode() @@ -32,11 +34,12 @@ class CTFSchema(Schema): lastAccept: Any = None @staticmethod - def resolve_lastAccept(obj) -> int: + def resolve_lastAccept(obj: dict) -> int: """Turns a datetime object into a timestamp.""" - if obj["lastAccept"] is None: + print(obj, ' - DEBUG PRINT') + if obj['lastAccept'] is None: return 0 - return int(obj["lastAccept"].timestamp()) + return int(obj['lastAccept'].timestamp()) @staticmethod def resolve_team(obj): @@ -60,21 +63,28 @@ def ctftime_standings(request, contest_name: str): .order_by("-submission__date_created") .values("submission__date_created") ) + standings = ( ContestScore.ranks(contest=contest_id) .annotate( pos=F("rank"), score=F("points"), - team=F("participation__team__name"), - # team=Coalesce(F("participation__team__name"), F("participation__participants__username")), - # Using Coalesce and indexing - # team=Case( - # When(F("participation__team__isnull")==True, then=Q(("participation__participants")[0]["username"])), - # default=F("team_name"), - # output_field=TextField(), - # ), - lastAccept=Subquery(last_sub_time), + is_solo=Case( + When(participation__team_id=None, then=Value(False)), + default=Value(True), + output_field=BooleanField(), + ), + team=Case( + When(participation__team_id=None, then=Subquery( # If the team is None, use the username of the participant ( solo player ) + User.objects.filter(contest_participations=OuterRef("participation_id")).values( + "username")[:1] + ),), + default=F("participation__team__name"), + output_field=TextField(), + ), + lastAccept=Max("participation__submission__submission__date_created"), ) + .filter(score__gt=0) .values("pos", "score", "team", "lastAccept") ) task_names = ( @@ -85,9 +95,9 @@ def ctftime_standings(request, contest_name: str): return {"standings": standings, "tasks": task_names} + @api.get("/contests", response=List[ContestOutSchema]) def contests(request): - return ( - Contest.objects.filter(is_public=True) - .values("name", "slug", "start_time", "end_time", "max_team_size", "description", "summary") + return Contest.objects.filter(is_public=True).values( + "name", "slug", "start_time", "end_time", "max_team_size", "description", "summary" ) diff --git a/gameserver/migrations/0033_contestscore_userscore_alter_user_timezone_and_more.py b/gameserver/migrations/0033_contestscore_userscore_alter_user_timezone_and_more.py index 62fafa2..ace97cc 100644 --- a/gameserver/migrations/0033_contestscore_userscore_alter_user_timezone_and_more.py +++ b/gameserver/migrations/0033_contestscore_userscore_alter_user_timezone_and_more.py @@ -1,8 +1,9 @@ # Generated by Django 4.0.1 on 2024-03-11 01:49 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion + import gameserver.models.profile diff --git a/gameserver/migrations/0034_alter_contestscore_options_alter_userscore_options_and_more.py b/gameserver/migrations/0034_alter_contestscore_options_alter_userscore_options_and_more.py index c4d53da..933e226 100644 --- a/gameserver/migrations/0034_alter_contestscore_options_alter_userscore_options_and_more.py +++ b/gameserver/migrations/0034_alter_contestscore_options_alter_userscore_options_and_more.py @@ -1,6 +1,7 @@ # Generated by Django 5.0.4 on 2024-04-04 13:44 from django.db import migrations, models + import gameserver diff --git a/gameserver/migrations/0035_contest_first_blood_webhook_and_more.py b/gameserver/migrations/0035_contest_first_blood_webhook_and_more.py new file mode 100644 index 0000000..0a8e34f --- /dev/null +++ b/gameserver/migrations/0035_contest_first_blood_webhook_and_more.py @@ -0,0 +1,679 @@ +# Generated by Django 5.0.4 on 2024-04-06 18:37 + +import datetime +import django.db.models.deletion +import gameserver.models.profile +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("gameserver", "0034_alter_contestscore_options_alter_userscore_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="contest", + name="first_blood_webhook", + field=models.URLField( + blank=True, + help_text="URL to send a POST request to when a user gets the first blood on a problem", + ), + ), + migrations.AddField( + model_name="contestscore", + name="last_correct_submission", + field=models.DateTimeField( + blank=True, + default=datetime.datetime(1971, 1, 1, 0, 0), + editable=False, + help_text="The date of the last correct submission.", + ), + ), + migrations.AddField( + model_name="userscore", + name="last_correct_submission", + field=models.DateTimeField( + blank=True, + default=datetime.datetime(1971, 1, 1, 0, 0), + editable=False, + help_text="The date of the last correct submission.", + ), + ), + migrations.AlterField( + model_name="contest", + name="organizations", + field=models.ManyToManyField( + blank=True, + help_text="Only users of these organizations can access the contest", + related_name="contests", + to="gameserver.organization", + ), + ), + migrations.AlterField( + model_name="contestscore", + name="participation", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="score_cache", + to="gameserver.contestparticipation", + ), + ), + migrations.AlterField( + model_name="user", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("Factory", "Factory"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default=gameserver.models.profile.get_default_user_timezone, + max_length=50, + ), + ), + migrations.AlterField( + model_name="userscore", + name="user", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="score_cache", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/gameserver/models/cache.py b/gameserver/models/cache.py index 24b026b..833e02a 100644 --- a/gameserver/models/cache.py +++ b/gameserver/models/cache.py @@ -1,5 +1,6 @@ -from typing import TYPE_CHECKING, Optional, Self, Protocol, Callable -from django.http import HttpRequest +from datetime import datetime +from typing import TYPE_CHECKING, Callable, Optional, Protocol, Self + from django.apps import apps from django.db import models, transaction from django.db.models import ( @@ -7,23 +8,26 @@ Case, Count, F, - OuterRef, - Subquery, + QuerySet, Sum, Value, When, - QuerySet, Window, ) -from django.db.models.functions import Coalesce, Rank, RowNumber +from django.db.models.functions import Coalesce, Rank +from django.http import HttpRequest +from django.utils import timezone if TYPE_CHECKING: - from .profile import User from .contest import Contest, ContestParticipation, ContestSubmission + from .profile import User + +EPOCH_TIME = datetime(1971, 1, 1, 0, 0, 0) class ResetableCache(Protocol): - def can_reset(cls, request: HttpRequest) -> None: ... + def can_reset(cls, request: HttpRequest) -> None: + ... class CacheMeta(models.Model): @@ -48,14 +52,23 @@ def _can_reset(cls, request: HttpRequest): class UserScore(CacheMeta): - user = models.OneToOneField("User", on_delete=models.CASCADE, db_index=True) + user = models.OneToOneField( + "User", related_name="score_cache", on_delete=models.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 ) + last_correct_submission = models.DateTimeField( + help_text="The date of the last correct submission.", + # auto_now=True, auto now is not used as it does not allow you to override the value + editable=False, + blank=True, + default=EPOCH_TIME, # only used for migration (overwritten by reset_score) + ) @classmethod - def can_reset(cls, request: HttpRequest): + def should_reset(cls, request: HttpRequest): return cls._can_reset(request) and request.user.has_perm( "gameserver.can_reset_cache_user_score" ) @@ -71,9 +84,9 @@ def ranks(cls): qs = cls.objects.annotate( rank=Window( expression=Rank(), - order_by=F("points").desc(), + order_by=("-points", "-last_correct_submission"), ) - ).order_by("rank", "flag_count") + ).order_by("rank") return qs @classmethod @@ -82,71 +95,96 @@ def update_or_create(cls, change_in_score: int, user: "User", update_flags: bool 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) + cls.objects.create( + user=user, + flag_count=int(update_flags), + points=change_in_score, + last_correct_submission=timezone.now(), + ) 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) + queryset.update( + points=F("points") + change_in_score, + flag_count=F("flag_count") + 1, + last_correct_submission=timezone.now(), + ) else: - queryset.update(points=F("points") + change_in_score, flag_count=F("flag_count") + 1) + queryset.update( + points=F("points") + change_in_score, last_correct_submission=timezone.now() + ) @classmethod - def invalidate(cls, user: "User"): - try: - cls.objects.get(user=user).delete() - except cls.DoesNotExist: - pass # user was not found. + def get_rank(cls, user: "User") -> int: + """ + Get the rank of a user - @classmethod - def get(cls, user: "User") -> Self | None: - obj = cls.objects.filter(user=user) - if obj is None: - return None - return obj.first() + There are a couple issues with implementing this function as + cls.ranks().get(user=user).rank + - The biggest is django is lazy and the user's rank will always be 1 + The only way I see to implement this would be to use raw SQL (see cls.ranks().query) + """ + raise NotImplementedError @classmethod - def reset_data(cls): + def reset_data(cls, users: Optional[QuerySet["User"]] = None): from django.contrib.auth import get_user_model + from gameserver.models import Submission + + if users is None: + users = get_user_model().objects.all() + cls.objects.all().delete() # clear past objs + else: + cls.objects.filter(user__in=users).delete() # clear past objs - cls.objects.all().delete() # clear inital objs - UserModel = get_user_model() - users = UserModel.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"), ) + try: + last_correct_submission = ( + Submission.objects.filter(user=user, problem__is_public=True, is_correct=True) + .latest("date_created") + .date_created + ) + except Submission.DoesNotExist: # user have never submitted to a public problem + last_correct_submission = EPOCH_TIME + scores_to_create.append( - UserScore(user=user, flag_count=queryset["flags"], points=queryset["points"]) + UserScore( + user=user, + flag_count=queryset["flags"], + points=queryset["points"], + last_correct_submission=last_correct_submission, + ) ) # Use bulk_create to create all objects in a single query cls.objects.bulk_create(scores_to_create) -def get_contest_submission() -> Callable[[], "ContestSubmission"]: - model: "ContestSubmission" = None - - def inner(): - nonlocal model - if model is None: - model = apps.get_model("gameserver", "ContestSubmission", require_ready=True) - return model - - return inner - - class ContestScore(CacheMeta): participation = models.OneToOneField( - "ContestParticipation", on_delete=models.CASCADE, db_index=True + "ContestParticipation", related_name="score_cache", on_delete=models.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 ) + + last_correct_submission = models.DateTimeField( + help_text="The date of the last correct submission.", + # auto_now=True, auto now is not used as it does not allow you to override the value + editable=False, + blank=True, + default=EPOCH_TIME, # only used for migration (overwritten by reset_score) + ) + # contest_id = models.IntegerField( # help_text="The id for which contest this applies to. Do not change this value manually.", # blank=True, @@ -158,7 +196,7 @@ class ContestScore(CacheMeta): # super().save(*args, **kwargs) @classmethod - def can_reset(cls, request: HttpRequest): + def should_reset(cls, request: HttpRequest): return cls._can_reset(request) and request.user.has_perm( "gameserver.can_reset_cache_user_score" ) @@ -187,12 +225,6 @@ def ranks( # perm_edit_all_contests = Permission.objects.get( # codename="edit_all_contests", content_type=contest_content_type # ) - max_submission_time_subquery = ( - get_contest_submission()() - .objects.filter(participation=OuterRef("participation")) - .order_by("-submission__date_created") - .values("submission__date_created")[:1] - ) if participation: if isinstance(participation, QuerySet): query = cls.objects.filter(participation__in=participation) @@ -216,16 +248,11 @@ def ranks( default=Value(True), output_field=BooleanField(), ), - sub_rank=Window( - expression=Rank(), - order_by=F("points").desc(), - ), rank=Window( - expression=RowNumber(), - order_by=F("points").desc(), + expression=Rank(), + order_by=("-points", "-last_correct_submission"), ), - max_submission_time=Subquery(max_submission_time_subquery), - ).order_by("rank", "flag_count", "-max_submission_time") + ).order_by("rank") return data def __str__(self) -> str: @@ -239,45 +266,38 @@ def __str__(self) -> str: def update_or_create( cls, change_in_score: int, participant: "ContestParticipation", update_flags: bool = True ): - assert change_in_score > 0 + assert change_in_score > 0, "change_in_score must be greater than 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 + participation=participant, + flag_count=int(update_flags), + points=change_in_score, + last_correct_submission=timezone.now(), ) 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) + queryset.update( + points=F("points") + change_in_score, + flag_count=F("flag_count") + 1, + last_correct_submission=timezone.now(), + ) else: queryset.update( - points=F("points") + change_in_score, flag_count=F("flag_count") + 1 + points=F("points") + change_in_score, last_correct_submission=timezone.now() ) - @classmethod - 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") -> Self | None: - obj = cls.objects.filter(participant=participant) - if obj is None: - return None - return obj.first() - @classmethod def reset_data(cls, contest: Optional["Contest"] = None, all: bool = False): assert contest is not None or all, "Either contest or all must be set to True" ContestModel = apps.get_model("gameserver", "Contest") + ContestSubmissionModel: ContestSubmission = apps.get_model( + "gameserver", "ContestSubmission" + ) if all: contests = ContestModel.objects.all() for contest in contests: @@ -293,11 +313,28 @@ def reset_data(cls, contest: Optional["Contest"] = None, all: bool = False): points=Coalesce(Sum("problem__points"), 0), flags=Count("problem"), ) + try: + last_correct_submission = ( + ContestSubmissionModel.objects.filter( + problem__contest=contest, + participation=participant, + problem__problem__is_public=True, + submission__is_correct=True, + ) + .prefetch_related("submission") + .latest("submission__date_created") + .submission.date_created + ) + except ( + ContestSubmissionModel.DoesNotExist + ): # user have never submitted to a public problem + last_correct_submission = EPOCH_TIME scores_to_create.append( ContestScore( participation=participant, flag_count=queryset["flags"], points=queryset["points"], + last_correct_submission=last_correct_submission, ) ) # Use bulk_create to create all objects in a single query diff --git a/gameserver/models/comment.py b/gameserver/models/comment.py index 19b36d0..6efb542 100644 --- a/gameserver/models/comment.py +++ b/gameserver/models/comment.py @@ -1,9 +1,7 @@ -from typing import Iterable from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from django.utils import timezone from .profile import User diff --git a/gameserver/models/contest.py b/gameserver/models/contest.py index e6a6166..90d82ef 100644 --- a/gameserver/models/contest.py +++ b/gameserver/models/contest.py @@ -1,25 +1,21 @@ from datetime import timedelta from django.apps import apps -from django.contrib.auth.models import Permission -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.cache.utils import make_template_fragment_key from django.core.validators import MinValueValidator from django.db import models -from django.db.models import Count, F, Max, Min, OuterRef, Q, Subquery, Sum -from django.db.models.expressions import Window -from django.db.models.functions import Coalesce, Rank +from django.db.models import Min, Q from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property +from django.utils.html import format_html + from gameserver.models.cache import ContestScore from ..templatetags.common_tags import strfdelta from . import abstract -# Create your models here. - class ContestTag(abstract.Category): pass @@ -49,12 +45,16 @@ def __init__(self, *args, **kwargs): tags = models.ManyToManyField(ContestTag, blank=True) - is_public = models.BooleanField(default=True) + is_public = models.BooleanField(default=True, db_index=True) max_team_size = models.PositiveSmallIntegerField( null=True, blank=True, validators=[MinValueValidator(1)] ) date_created = models.DateTimeField(auto_now_add=True) + first_blood_webhook = models.URLField( + blank=True, + help_text="URL to send a POST request to when a user gets the first blood on a problem", + ) class Meta: permissions = ( @@ -98,70 +98,9 @@ def has_problem(self, problem): else: return self.problems.filter(problem__pk=problem.pk).exists() - @cached_property - def __meta_key(self): - return f"contest_ranks_{self.pk}" - def ranks(self): return self.ContestScore.ranks(self) - def _ranks(self, queryset=None): - if queryset is None: - contest_content_type = ContentType.objects.get_for_model(Contest) - perm_edit_all_contests = Permission.objects.get( - codename="edit_all_contests", content_type=contest_content_type - ) - - queryset = self.participations.exclude( - Q(participants__is_superuser=True) - | Q(participants__groups__permissions=perm_edit_all_contests) - | Q(participants__user_permissions=perm_edit_all_contests) - | Q(participants__in=self.organizers.all()) - | Q(participants__in=self.curators.all()) - ) - - submissions_with_points = ( - ContestSubmission.objects.filter( - participation=OuterRef("pk"), submission__is_correct=True - ) - .order_by() - .values("submission__problem") - .distinct() - .annotate(sub_pk=Min("pk")) - .values("sub_pk") - ) - return ( - queryset.annotate( - points=Coalesce( - Sum( - "submission__problem__points", - filter=Q(submission__in=Subquery(submissions_with_points)), - ), - 0, - ), - flags=Coalesce( - Count( - "submission__pk", filter=Q(submission__in=Subquery(submissions_with_points)) - ), - 0, - ), - most_recent_solve_time=Coalesce( - Max( - "submission__submission__date_created", - filter=Q(submission__in=Subquery(submissions_with_points)), - ), - self.start_time, - ), - ) - .annotate( - rank=Window( - expression=Rank(), - order_by=[F("points").desc(), F("most_recent_solve_time").asc()], - ) - ) - .order_by("rank", "flags") - ) - def is_visible_by(self, user): if self.is_public: return True @@ -413,6 +352,12 @@ class ContestSubmission(models.Model): def __str__(self): return f"{self.participation.participant}'s submission for {self.problem.problem.name} in {self.problem.contest.name}" + def get_absolute_admin_url(self): + url = reverse( + "admin:%s_%s_change" % (self._meta.app_label, self._meta.model_name), args=[self.id] + ) + return format_html('{url}', url=url) + @cached_property def is_correct(self): return self.submission.is_correct @@ -425,14 +370,22 @@ def is_firstblood(self): return prev_correct_submissions.count() == 1 and prev_correct_submissions.first() == self + @property + async def ais_firstblooded(self): + prev_correct_submissions = ContestSubmission.objects.filter( + problem=self.problem, submission__is_correct=True, pk__lte=self.pk + ) + + return ( + await prev_correct_submissions.acount() == 1 + and await prev_correct_submissions.afirst() == self + ) + def save(self, *args, **kwargs): - for key in cache.get(f"contest_ranks_{self.participation.contest.pk}", default=[]): - cache.delete(key) cache.delete( make_template_fragment_key("participant_data", [self.participation]) ) # see participation.html cache.delete( make_template_fragment_key("user_participation", [self.participation]) ) # see scoreboard.html - ContestScore.invalidate(self.participation) super().save(*args, **kwargs) diff --git a/gameserver/models/problem.py b/gameserver/models/problem.py index 64fc2e5..88be513 100644 --- a/gameserver/models/problem.py +++ b/gameserver/models/problem.py @@ -47,7 +47,7 @@ class Problem(models.Model): challenge_spec = models.JSONField(null=True, blank=True) log_submission_content = models.BooleanField(default=False) - is_public = models.BooleanField(default=False) + is_public = models.BooleanField(default=False, db_index=True) date_created = models.DateTimeField(auto_now_add=True) diff --git a/gameserver/models/profile.py b/gameserver/models/profile.py index 9b380ab..e62cba7 100644 --- a/gameserver/models/profile.py +++ b/gameserver/models/profile.py @@ -62,28 +62,17 @@ def _get_unique_correct_submissions(self, queryset=None): return queryset.values("problem", "problem__points").distinct() def points(self, queryset=None): - cache = UserScore.get(user=self) - + cache = UserScore.objects.filter(user=self).first() if cache is None: return 0 return cache.points def flags(self, queryset=None): - cache = UserScore.get(user=self) + cache = UserScore.objects.filter(user=self).first() if cache is None: return 0 return cache.flag_count - def rank(self, queryset=None): - return ( - self.cached_ranks(f"user_{self.pk}", queryset) - .filter( - points__gt=self.points, - ) - .count() - + 1 - ) - @classmethod def ranks(cls, queryset=None): if queryset is None: diff --git a/gameserver/models/submission.py b/gameserver/models/submission.py index 5b2ecb5..577447d 100644 --- a/gameserver/models/submission.py +++ b/gameserver/models/submission.py @@ -1,6 +1,8 @@ from django.db import models from django.db.models import Q +from django.urls import reverse from django.utils.functional import cached_property +from django.utils.html import format_html from .cache import UserScore @@ -13,17 +15,25 @@ class Submission(models.Model): blank=True, related_name="submissions", related_query_name="submission", + db_index=True, ) problem = models.ForeignKey( "Problem", on_delete=models.CASCADE, related_name="submissions", + db_index=True, related_query_name="submission", ) is_correct = models.BooleanField(default=False, db_index=True) date_created = models.DateTimeField(auto_now_add=True) content = models.CharField(max_length=256, null=True, default=None) + def get_absolute_admin_url(self): + url = reverse( + "admin:%s_%s_change" % (self._meta.app_label, self._meta.model_name), args=[self.id] + ) + return format_html('{url}', url=url) + def __str__(self): return f"{self.user.username}'s submission for {self.problem.name}" diff --git a/gameserver/signals.py b/gameserver/signals.py new file mode 100644 index 0000000..9c66293 --- /dev/null +++ b/gameserver/signals.py @@ -0,0 +1,48 @@ +import logging + +import aiohttp +from asgiref.sync import sync_to_async +from django.contrib.sites.models import Site +from django.db.models.signals import post_save +from django.dispatch import receiver + +from gameserver.models import ContestSubmission + +logger = logging.getLogger(__name__) + + +def is_discord(webhook: str): + return webhook.startswith("https://discord.com/api") + + +def construct_discord_payload(submission: ContestSubmission) -> dict: + BASE_URL = "https://" + Site.objects.get_current().domain + + return { + "username": f"{submission.participation.contest.name} First Blood Notifier", + "avatar_url": BASE_URL + "/static/favicon.png", + "content": f"First blood on [{submission.problem.problem}]({BASE_URL + submission.problem.get_absolute_url()}) by [{submission.participation.participant}]({BASE_URL + submission.participation.participant.get_absolute_url()})!", + } + + +@receiver(post_save, sender=ContestSubmission, dispatch_uid="notify_contest_firstblood") +async def my_handler(sender, instance, created, raw, using, update_fields, **kwargs): + if not created: # only for new submissions + return + if not await instance.ais_firstblooded: + return + + if webhook := instance.participation.contest.first_blood_webhook: + payload: dict = await sync_to_async(construct_discord_payload)(submission=instance) + if not is_discord(webhook): + payload = payload["content"] # only send the content to non-discord webhooks + async with aiohttp.ClientSession() as session: + async with session.post( + webhook, + json=payload, + ) as resp: + if resp.status != 200: + text = await resp.text() + logger.error( + f"Failed to send webhook: {text} - {resp.status} - {webhook} - {payload}" + ) diff --git a/gameserver/templatetags/bleach_allowlist.py b/gameserver/templatetags/bleach_allowlist.py deleted file mode 100644 index bdecab9..0000000 --- a/gameserver/templatetags/bleach_allowlist.py +++ /dev/null @@ -1,1149 +0,0 @@ -# Based on https://github.com/DMOJ/online-judge/blob/master/judge/jinja2/markdown/bleach_whitelist.py , which is, in turn, based on https://github.com/yourcelf/bleach-whitelist/blob/master/bleach_whitelist/bleach_whitelist.py - -standard_styles = [ - # Taken from https://developer.mozilla.org/en-US/docs/Web/CSS/Reference - # This includes pseudo-classes, pseudo-elements, @-rules, units, and - # selectors in addition to properties, but it doesn't matter for our - # purposes -- we don't need to filter styles.. - ":active", - "::after (:after)", - "align-content", - "align-items", - "align-self", - "all", - "", - "animation", - "animation-delay", - "animation-direction", - "animation-duration", - "animation-fill-mode", - "animation-iteration-count", - "animation-name", - "animation-play-state", - "animation-timing-function", - "@annotation", - "annotation()", - "attr()", - "::backdrop", - "backface-visibility", - "background", - "background-attachment", - "background-blend-mode", - "background-clip", - "background-color", - "background-image", - "background-origin", - "background-position", - "background-repeat", - "background-size", - "", - "::before (:before)", - "", - "blur()", - "border", - "border-bottom", - "border-bottom-color", - "border-bottom-left-radius", - "border-bottom-right-radius", - "border-bottom-style", - "border-bottom-width", - "border-collapse", - "border-color", - "border-image", - "border-image-outset", - "border-image-repeat", - "border-image-slice", - "border-image-source", - "border-image-width", - "border-left", - "border-left-color", - "border-left-style", - "border-left-width", - "border-radius", - "border-right", - "border-right-color", - "border-right-style", - "border-right-width", - "border-spacing", - "border-style", - "border-top", - "border-top-color", - "border-top-left-radius", - "border-top-right-radius", - "border-top-style", - "border-top-width", - "border-width", - "bottom", - "box-decoration-break", - "box-shadow", - "box-sizing", - "break-after", - "break-before", - "break-inside", - "brightness()", - "calc()", - "caption-side", - "ch", - "@character-variant", - "character-variant()", - "@charset", - ":checked", - "circle()", - "clear", - "clip", - "clip-path", - "cm", - "color", - "", - "columns", - "column-count", - "column-fill", - "column-gap", - "column-rule", - "column-rule-color", - "column-rule-style", - "column-rule-width", - "column-span", - "column-width", - "content", - "contrast()", - "", - "counter-increment", - "counter-reset", - "@counter-style", - "cubic-bezier()", - "cursor", - "", - ":default", - "deg", - ":dir()", - "direction", - ":disabled", - "display", - "@document", - "dpcm", - "dpi", - "dppx", - "drop-shadow()", - "element()", - "ellipse()", - "em", - ":empty", - "empty-cells", - ":enabled", - "ex", - "filter", - ":first", - ":first-child", - "::first-letter", - "::first-line", - ":first-of-type", - "flex", - "flex-basis", - "flex-direction", - "flex-flow", - "flex-grow", - "flex-shrink", - "flex-wrap", - "float", - ":focus", - "font", - "@font-face", - "font-family", - "font-feature-settings", - "@font-feature-values", - "font-kerning", - "font-language-override", - "font-size", - "font-size-adjust", - "font-stretch", - "font-style", - "font-synthesis", - "font-variant", - "font-variant-alternates", - "font-variant-caps", - "font-variant-east-asian", - "font-variant-ligatures", - "font-variant-numeric", - "font-variant-position", - "font-weight", - "", - ":fullscreen", - "grad", - "", - "grayscale()", - "grid", - "grid-area", - "grid-auto-columns", - "grid-auto-flow", - "grid-auto-position", - "grid-auto-rows", - "grid-column", - "grid-column-start", - "grid-column-end", - "grid-row", - "grid-row-start", - "grid-row-end", - "grid-template", - "grid-template-areas", - "grid-template-rows", - "grid-template-columns", - "height", - ":hover", - "hsl()", - "hsla()", - "hue-rotate()", - "hyphens", - "hz", - "", - "image()", - "image-rendering", - "image-resolution", - "image-orientation", - "ime-mode", - "@import", - "in", - ":indeterminate", - "inherit", - "initial", - ":in-range", - "inset()", - "", - ":invalid", - "invert()", - "isolation", - "justify-content", - "@keyframes", - "khz", - ":lang()", - ":last-child", - ":last-of-type", - "left", - ":left", - "", - "letter-spacing", - "linear-gadient()", - "line-break", - "line-height", - ":link", - "list-style", - "list-style-image", - "list-style-position", - "list-style-type", - "margin", - "margin-bottom", - "margin-left", - "margin-right", - "margin-top", - "marks", - "mask", - "mask-type", - "matrix()", - "matrix3d()", - "max-height", - "max-width", - "@media", - "min-height", - "minmax()", - "min-width", - "mix-blend-mode", - "mm", - "ms", - "@namespace", - ":not()", - ":nth-child()", - ":nth-last-child()", - ":nth-last-of-type()", - ":nth-of-type()", - "", - "object-fit", - "object-position", - ":only-child", - ":only-of-type", - "opacity", - "opacity()", - ":optional", - "order", - "@ornaments", - "ornaments()", - "orphans", - "outline", - "outline-color", - "outline-offset", - "outline-style", - "outline-width", - ":out-of-range", - "overflow", - "overflow-wrap", - "overflow-x", - "overflow-y", - "padding", - "padding-bottom", - "padding-left", - "padding-right", - "padding-top", - "@page", - "page-break-after", - "page-break-before", - "page-break-inside", - "pc", - "", - "perspective", - "perspective()", - "perspective-origin", - "pointer-events", - "polygon()", - "position", - "", - "pt", - "px", - "quotes", - "rad", - "radial-gradient()", - "", - ":read-only", - ":read-write", - "rect()", - "rem", - "repeat()", - "::repeat-index", - "::repeat-item", - "repeating-linear-gradient()", - "repeating-radial-gradient()", - ":required", - "resize", - "", - "rgb()", - "rgba()", - "right", - ":right", - ":root", - "rotate()", - "rotatex()", - "rotatey()", - "rotatez()", - "rotate3d()", - "ruby-align", - "ruby-merge", - "ruby-position", - "s", - "saturate()", - "scale()", - "scalex()", - "scaley()", - "scalez()", - "scale3d()", - ":scope", - "scroll-behavior", - "::selection", - "sepia()", - "", - "shape-image-threshold", - "shape-margin", - "shape-outside", - "skew()", - "skewx()", - "skewy()", - "steps()", - "", - "@styleset", - "styleset()", - "@stylistic", - "stylistic()", - "@supports", - "@swash", - "swash()", - "symbol()", - "table-layout", - "tab-size", - ":target", - "text-align", - "text-align-last", - "text-combine-upright", - "text-decoration", - "text-decoration-color", - "text-decoration-line", - "text-decoration-style", - "text-indent", - "text-orientation", - "text-overflow", - "text-rendering", - "text-shadow", - "text-transform", - "text-underline-position", - "