@@ -0,0 +1,47 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
CheatFlag,
|
||||
Contest,
|
||||
ContestAnswer,
|
||||
ContestAttempt,
|
||||
ContestQuestion,
|
||||
LeaderboardSnapshot,
|
||||
Question,
|
||||
QuestionVersion,
|
||||
RatingHistory,
|
||||
RealtimeMatch,
|
||||
)
|
||||
|
||||
|
||||
class ContestQuestionInline(admin.TabularInline):
|
||||
model = ContestQuestion
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Contest)
|
||||
class ContestAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "kind", "track", "status", "starts_at", "ends_at")
|
||||
list_filter = ("kind", "track", "status")
|
||||
inlines = [ContestQuestionInline]
|
||||
|
||||
|
||||
@admin.register(ContestAttempt)
|
||||
class ContestAttemptAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "user", "contest", "status", "score", "duration_ms", "started_at")
|
||||
list_filter = ("status", "contest__kind", "contest__track")
|
||||
readonly_fields = ("started_at", "submitted_at")
|
||||
|
||||
|
||||
@admin.register(CheatFlag)
|
||||
class CheatFlagAdmin(admin.ModelAdmin):
|
||||
list_display = ("attempt", "reason", "status", "created_at")
|
||||
list_filter = ("status", "reason")
|
||||
|
||||
|
||||
admin.site.register(Question)
|
||||
admin.site.register(QuestionVersion)
|
||||
admin.site.register(ContestAnswer)
|
||||
admin.site.register(RealtimeMatch)
|
||||
admin.site.register(RatingHistory)
|
||||
admin.site.register(LeaderboardSnapshot)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ContestConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'contest'
|
||||
@@ -0,0 +1,54 @@
|
||||
from channels.db import database_sync_to_async
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
|
||||
from .models import RealtimeMatch
|
||||
|
||||
|
||||
class MatchConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.match_id = self.scope["url_route"]["kwargs"]["match_id"]
|
||||
self.group_name = f"match_{self.match_id}"
|
||||
user = self.scope["user"]
|
||||
if not user.is_authenticated or not await self._is_participant(user.id):
|
||||
await self.close(code=4403)
|
||||
return
|
||||
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||
await self.accept()
|
||||
await self.send_json({"type": "connected", "match_id": str(self.match_id)})
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
if hasattr(self, "group_name"):
|
||||
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||
|
||||
async def receive_json(self, content, **kwargs):
|
||||
event_type = content.get("type")
|
||||
if event_type == "ping":
|
||||
await self.send_json({"type": "pong"})
|
||||
return
|
||||
if event_type == "progress":
|
||||
answered_count = max(0, min(int(content.get("answered_count", 0)), 100))
|
||||
await self.channel_layer.group_send(
|
||||
self.group_name,
|
||||
{
|
||||
"type": "match.progress",
|
||||
"user_id": str(self.scope["user"].id),
|
||||
"answered_count": answered_count,
|
||||
},
|
||||
)
|
||||
|
||||
async def match_progress(self, event):
|
||||
await self.send_json(
|
||||
{
|
||||
"type": "progress",
|
||||
"user_id": event["user_id"],
|
||||
"answered_count": event["answered_count"],
|
||||
}
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def _is_participant(self, user_id):
|
||||
return RealtimeMatch.objects.filter(id=self.match_id).filter(
|
||||
player_one_id=user_id
|
||||
).exists() or RealtimeMatch.objects.filter(
|
||||
id=self.match_id, player_two_id=user_id
|
||||
).exists()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
|
||||
|
||||
|
||||
QUESTIONS = {
|
||||
Question.Track.BEGINNER: [
|
||||
("b-12-plus-19", "12 + 19", "31"),
|
||||
("b-8-times-7", "8 × 7", "56"),
|
||||
("b-90-minus-37", "90 - 37", "53"),
|
||||
("b-144-div-12", "144 ÷ 12", "12"),
|
||||
("b-25-times-4", "25 × 4", "100"),
|
||||
],
|
||||
Question.Track.STANDARD: [
|
||||
("s-17-times-23", "17 × 23", "391"),
|
||||
("s-625-div-25", "625 ÷ 25", "25"),
|
||||
("s-48-times-15", "48 × 15", "720"),
|
||||
("s-1000-minus-387", "1000 - 387", "613"),
|
||||
("s-35-squared", "35²", "1225"),
|
||||
],
|
||||
Question.Track.ADVANCED: [
|
||||
("a-mod-2pow10", "2¹⁰ 除以 7 的余数", "2"),
|
||||
("a-sum-1-50", "1 到 50 的整数和", "1275"),
|
||||
("a-15-choose-2", "C(15,2)", "105"),
|
||||
("a-sqrt-2025", "√2025", "45"),
|
||||
("a-3pow6", "3⁶", "729"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "创建首批分层题目、实时 1v1、每日赛和单人练习"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
versions = {}
|
||||
for track, questions in QUESTIONS.items():
|
||||
versions[track] = []
|
||||
for slug, prompt, answer in questions:
|
||||
question, _ = Question.objects.update_or_create(
|
||||
slug=slug,
|
||||
defaults={"track": track, "tags": ["口算"], "is_active": True},
|
||||
)
|
||||
version, _ = QuestionVersion.objects.update_or_create(
|
||||
question=question,
|
||||
version=1,
|
||||
defaults={
|
||||
"prompt": prompt,
|
||||
"answer": answer,
|
||||
"explanation": f"答案为 {answer}",
|
||||
},
|
||||
)
|
||||
versions[track].append(version)
|
||||
|
||||
contest_specs = []
|
||||
for track, label in (
|
||||
(Question.Track.BEGINNER, "入门"),
|
||||
(Question.Track.STANDARD, "标准"),
|
||||
(Question.Track.ADVANCED, "进阶"),
|
||||
):
|
||||
contest_specs.extend(
|
||||
[
|
||||
(
|
||||
f"realtime-{track}",
|
||||
f"{label}实时 1v1",
|
||||
Contest.Kind.REALTIME,
|
||||
track,
|
||||
60,
|
||||
),
|
||||
(
|
||||
f"daily-{track}",
|
||||
f"{label}今日挑战",
|
||||
Contest.Kind.DAILY,
|
||||
track,
|
||||
180,
|
||||
),
|
||||
(
|
||||
f"practice-{track}",
|
||||
f"{label}单人闯关",
|
||||
Contest.Kind.PRACTICE,
|
||||
track,
|
||||
300,
|
||||
),
|
||||
]
|
||||
)
|
||||
for slug, title, kind, track, duration in contest_specs:
|
||||
contest, _ = Contest.objects.update_or_create(
|
||||
slug=slug,
|
||||
defaults={
|
||||
"title": title,
|
||||
"kind": kind,
|
||||
"track": track,
|
||||
"duration_seconds": duration,
|
||||
"status": Contest.Status.PUBLISHED,
|
||||
},
|
||||
)
|
||||
ContestQuestion.objects.filter(contest=contest).delete()
|
||||
ContestQuestion.objects.bulk_create(
|
||||
[
|
||||
ContestQuestion(
|
||||
contest=contest,
|
||||
question_version=version,
|
||||
order=index,
|
||||
points=100,
|
||||
)
|
||||
for index, version in enumerate(versions[track], start=1)
|
||||
]
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"已创建 {sum(map(len, QUESTIONS.values()))} 道题和 {len(contest_specs)} 场比赛"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
# Generated by Django 4.2.23 on 2026-08-08 11:15
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Contest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('title', models.CharField(max_length=120)),
|
||||
('kind', models.CharField(choices=[('realtime', '实时 1v1'), ('daily', '今日挑战'), ('practice', '单人闯关'), ('weekly', '主题周赛')], max_length=16)),
|
||||
('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)),
|
||||
('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('closed', '已结束')], default='draft', max_length=16)),
|
||||
('duration_seconds', models.PositiveIntegerField(default=60)),
|
||||
('starts_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ends_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Question',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)),
|
||||
('tags', models.JSONField(blank=True, default=list)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RealtimeMatch',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('status', models.CharField(choices=[('waiting', '等待对手'), ('active', '进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='waiting', max_length=16)),
|
||||
('player_one_rating', models.PositiveIntegerField()),
|
||||
('player_two_rating', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('contest', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.contest')),
|
||||
('player_one', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='matches_as_player_one', to=settings.AUTH_USER_MODEL)),
|
||||
('player_two', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='matches_as_player_two', to=settings.AUTH_USER_MODEL)),
|
||||
('winner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='won_matches', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RatingHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('rating_before', models.PositiveIntegerField()),
|
||||
('rating_after', models.PositiveIntegerField()),
|
||||
('delta', models.SmallIntegerField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('match', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rating_changes', to='contest.realtimematch')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rating_history', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QuestionVersion',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.PositiveIntegerField()),
|
||||
('prompt', models.TextField()),
|
||||
('answer', models.CharField(max_length=200)),
|
||||
('explanation', models.TextField(blank=True)),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='contest.question')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeaderboardSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)),
|
||||
('period', models.CharField(max_length=40)),
|
||||
('rankings', models.JSONField(default=list)),
|
||||
('generated_at', models.DateTimeField(auto_now_add=True)),
|
||||
('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contest.contest')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContestQuestion',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.PositiveIntegerField()),
|
||||
('points', models.PositiveIntegerField(default=100)),
|
||||
('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_questions', to='contest.contest')),
|
||||
('question_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.questionversion')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContestAttempt',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('status', models.CharField(choices=[('active', '进行中'), ('submitted', '已提交'), ('expired', '已超时')], default='active', max_length=16)),
|
||||
('score', models.PositiveIntegerField(default=0)),
|
||||
('correct_count', models.PositiveIntegerField(default=0)),
|
||||
('answer_count', models.PositiveIntegerField(default=0)),
|
||||
('duration_ms', models.PositiveIntegerField(default=0)),
|
||||
('submission_key', models.CharField(blank=True, max_length=80, null=True)),
|
||||
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||
('submitted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('contest', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempts', to='contest.contest')),
|
||||
('match', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='attempts', to='contest.realtimematch')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_attempts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-started_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContestAnswer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('submitted_answer', models.CharField(max_length=200)),
|
||||
('is_correct', models.BooleanField()),
|
||||
('elapsed_ms', models.PositiveIntegerField()),
|
||||
('answered_at', models.DateTimeField(auto_now_add=True)),
|
||||
('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='contest.contestattempt')),
|
||||
('contest_question', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.contestquestion')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CheatFlag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('reason', models.CharField(max_length=120)),
|
||||
('evidence', models.JSONField(default=dict)),
|
||||
('status', models.CharField(choices=[('open', '待处理'), ('dismissed', '已忽略'), ('confirmed', '已确认')], default='open', max_length=16)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cheat_flags', to='contest.contestattempt')),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='questionversion',
|
||||
constraint=models.UniqueConstraint(fields=('question', 'version'), name='unique_question_version'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contestquestion',
|
||||
constraint=models.UniqueConstraint(fields=('contest', 'order'), name='unique_contest_question_order'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contestattempt',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'submission_key'), name='unique_user_contest_submission'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contestanswer',
|
||||
constraint=models.UniqueConstraint(fields=('attempt', 'contest_question'), name='unique_attempt_question_answer'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.23 on 2026-08-08 13:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contest', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='realtimematch',
|
||||
index=models.Index(fields=['contest', 'status', 'player_one_rating', 'created_at'], name='matchmaking_lookup_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,194 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Question(models.Model):
|
||||
class Track(models.TextChoices):
|
||||
BEGINNER = "beginner", "入门"
|
||||
STANDARD = "standard", "标准"
|
||||
ADVANCED = "advanced", "进阶"
|
||||
OPEN = "open", "Open"
|
||||
|
||||
slug = models.SlugField(unique=True)
|
||||
track = models.CharField(max_length=16, choices=Track.choices)
|
||||
tags = models.JSONField(default=list, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.slug
|
||||
|
||||
|
||||
class QuestionVersion(models.Model):
|
||||
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="versions")
|
||||
version = models.PositiveIntegerField()
|
||||
prompt = models.TextField()
|
||||
answer = models.CharField(max_length=200)
|
||||
explanation = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("question", "version"), name="unique_question_version")
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.question.slug} v{self.version}"
|
||||
|
||||
|
||||
class Contest(models.Model):
|
||||
class Kind(models.TextChoices):
|
||||
REALTIME = "realtime", "实时 1v1"
|
||||
DAILY = "daily", "今日挑战"
|
||||
PRACTICE = "practice", "单人闯关"
|
||||
WEEKLY = "weekly", "主题周赛"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "草稿"
|
||||
PUBLISHED = "published", "已发布"
|
||||
CLOSED = "closed", "已结束"
|
||||
|
||||
slug = models.SlugField(unique=True)
|
||||
title = models.CharField(max_length=120)
|
||||
kind = models.CharField(max_length=16, choices=Kind.choices)
|
||||
track = models.CharField(max_length=16, choices=Question.Track.choices)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
|
||||
duration_seconds = models.PositiveIntegerField(default=60)
|
||||
starts_at = models.DateTimeField(null=True, blank=True)
|
||||
ends_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class ContestQuestion(models.Model):
|
||||
contest = models.ForeignKey(Contest, on_delete=models.CASCADE, related_name="contest_questions")
|
||||
question_version = models.ForeignKey(QuestionVersion, on_delete=models.PROTECT)
|
||||
order = models.PositiveIntegerField()
|
||||
points = models.PositiveIntegerField(default=100)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("contest", "order"), name="unique_contest_question_order")
|
||||
]
|
||||
ordering = ["order"]
|
||||
|
||||
|
||||
class RealtimeMatch(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
WAITING = "waiting", "等待对手"
|
||||
ACTIVE = "active", "进行中"
|
||||
COMPLETED = "completed", "已完成"
|
||||
CANCELLED = "cancelled", "已取消"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
contest = models.ForeignKey(Contest, on_delete=models.PROTECT)
|
||||
player_one = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one"
|
||||
)
|
||||
player_two = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="matches_as_player_two",
|
||||
)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.WAITING)
|
||||
player_one_rating = models.PositiveIntegerField()
|
||||
player_two_rating = models.PositiveIntegerField(null=True, blank=True)
|
||||
winner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="won_matches",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=("contest", "status", "player_one_rating", "created_at"),
|
||||
name="matchmaking_lookup_idx",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class ContestAttempt(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "进行中"
|
||||
SUBMITTED = "submitted", "已提交"
|
||||
EXPIRED = "expired", "已超时"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
contest = models.ForeignKey(Contest, on_delete=models.PROTECT, related_name="attempts")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="contest_attempts")
|
||||
match = models.ForeignKey(
|
||||
RealtimeMatch, null=True, blank=True, on_delete=models.PROTECT, related_name="attempts"
|
||||
)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
|
||||
score = models.PositiveIntegerField(default=0)
|
||||
correct_count = models.PositiveIntegerField(default=0)
|
||||
answer_count = models.PositiveIntegerField(default=0)
|
||||
duration_ms = models.PositiveIntegerField(default=0)
|
||||
submission_key = models.CharField(max_length=80, null=True, blank=True)
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "submission_key"), name="unique_user_contest_submission"
|
||||
)
|
||||
]
|
||||
ordering = ["-started_at"]
|
||||
|
||||
|
||||
class ContestAnswer(models.Model):
|
||||
attempt = models.ForeignKey(ContestAttempt, on_delete=models.CASCADE, related_name="answers")
|
||||
contest_question = models.ForeignKey(ContestQuestion, on_delete=models.PROTECT)
|
||||
submitted_answer = models.CharField(max_length=200)
|
||||
is_correct = models.BooleanField()
|
||||
elapsed_ms = models.PositiveIntegerField()
|
||||
answered_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("attempt", "contest_question"), name="unique_attempt_question_answer"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class RatingHistory(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="rating_history")
|
||||
match = models.ForeignKey(RealtimeMatch, on_delete=models.PROTECT, related_name="rating_changes")
|
||||
rating_before = models.PositiveIntegerField()
|
||||
rating_after = models.PositiveIntegerField()
|
||||
delta = models.SmallIntegerField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class LeaderboardSnapshot(models.Model):
|
||||
contest = models.ForeignKey(Contest, on_delete=models.CASCADE)
|
||||
track = models.CharField(max_length=16, choices=Question.Track.choices)
|
||||
period = models.CharField(max_length=40)
|
||||
rankings = models.JSONField(default=list)
|
||||
generated_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class CheatFlag(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
OPEN = "open", "待处理"
|
||||
DISMISSED = "dismissed", "已忽略"
|
||||
CONFIRMED = "confirmed", "已确认"
|
||||
|
||||
attempt = models.ForeignKey(ContestAttempt, on_delete=models.CASCADE, related_name="cheat_flags")
|
||||
reason = models.CharField(max_length=120)
|
||||
evidence = models.JSONField(default=dict)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from .consumers import MatchConsumer
|
||||
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/v1/contest/matches/<uuid:match_id>/", MatchConsumer.as_asgi()),
|
||||
]
|
||||
@@ -0,0 +1,279 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from accounts.models import User
|
||||
from .models import (
|
||||
CheatFlag,
|
||||
Contest,
|
||||
ContestAnswer,
|
||||
ContestAttempt,
|
||||
RatingHistory,
|
||||
RealtimeMatch,
|
||||
)
|
||||
|
||||
|
||||
def normalize_answer(value):
|
||||
text = str(value).strip().lower().replace(" ", "")
|
||||
try:
|
||||
return str(Decimal(text).normalize())
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def attempt_payload(attempt, include_results=False):
|
||||
questions = []
|
||||
answers = {answer.contest_question_id: answer for answer in attempt.answers.all()}
|
||||
for item in attempt.contest.contest_questions.select_related("question_version").all():
|
||||
question = {
|
||||
"order": item.order,
|
||||
"prompt": item.question_version.prompt,
|
||||
"metadata": item.question_version.metadata,
|
||||
"points": item.points,
|
||||
}
|
||||
if include_results and item.id in answers:
|
||||
answer = answers[item.id]
|
||||
question.update(
|
||||
{
|
||||
"submitted_answer": answer.submitted_answer,
|
||||
"is_correct": answer.is_correct,
|
||||
"correct_answer": item.question_version.answer,
|
||||
"explanation": item.question_version.explanation,
|
||||
}
|
||||
)
|
||||
questions.append(question)
|
||||
return {
|
||||
"attempt_id": attempt.id,
|
||||
"contest": attempt.contest.title,
|
||||
"kind": attempt.contest.kind,
|
||||
"status": attempt.status,
|
||||
"duration_seconds": attempt.contest.duration_seconds,
|
||||
"server_started_at": attempt.started_at,
|
||||
"score": attempt.score,
|
||||
"correct_count": attempt.correct_count,
|
||||
"answer_count": attempt.answer_count,
|
||||
"duration_ms": attempt.duration_ms,
|
||||
"questions": questions,
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def start_attempt(user, contest):
|
||||
now = timezone.now()
|
||||
if contest.status != Contest.Status.PUBLISHED:
|
||||
raise ValidationError("比赛尚未发布")
|
||||
if contest.starts_at and contest.starts_at > now:
|
||||
raise ValidationError("比赛尚未开始")
|
||||
if contest.ends_at and contest.ends_at <= now:
|
||||
raise ValidationError("比赛已经结束")
|
||||
if contest.kind == Contest.Kind.DAILY:
|
||||
existing = ContestAttempt.objects.filter(user=user, contest=contest).first()
|
||||
if existing:
|
||||
return attempt_payload(
|
||||
existing,
|
||||
include_results=existing.status != ContestAttempt.Status.ACTIVE,
|
||||
)
|
||||
attempt = ContestAttempt.objects.create(contest=contest, user=user)
|
||||
return attempt_payload(attempt)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def submit_attempt(user, attempt_id, raw_answers, submission_key):
|
||||
attempt = (
|
||||
ContestAttempt.objects.select_for_update()
|
||||
.select_related("contest")
|
||||
.get(id=attempt_id, user=user)
|
||||
)
|
||||
if attempt.status != ContestAttempt.Status.ACTIVE:
|
||||
if submission_key and attempt.submission_key == submission_key:
|
||||
return attempt_payload(attempt, include_results=True)
|
||||
raise ValidationError("该答题记录已经结算")
|
||||
if not submission_key:
|
||||
raise ValidationError({"Idempotency-Key": "正式提交必须提供幂等键"})
|
||||
|
||||
now = timezone.now()
|
||||
duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
|
||||
limit_ms = attempt.contest.duration_seconds * 1000
|
||||
items = list(
|
||||
attempt.contest.contest_questions.select_related("question_version").all()
|
||||
)
|
||||
if not isinstance(raw_answers, list):
|
||||
raise ValidationError({"answers": "答案必须是数组"})
|
||||
by_order = {}
|
||||
try:
|
||||
for item in raw_answers:
|
||||
order = int(item["order"])
|
||||
if order <= 0 or order in by_order:
|
||||
raise ValueError
|
||||
by_order[order] = item.get("answer", "")
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValidationError({"answers": "答案题号无效或重复"}) from exc
|
||||
score = 0
|
||||
correct_count = 0
|
||||
for contest_question in items:
|
||||
submitted = str(by_order.get(contest_question.order, ""))[:200]
|
||||
correct = normalize_answer(submitted) == normalize_answer(
|
||||
contest_question.question_version.answer
|
||||
)
|
||||
if correct and duration_ms <= limit_ms:
|
||||
score += contest_question.points
|
||||
correct_count += 1
|
||||
ContestAnswer.objects.create(
|
||||
attempt=attempt,
|
||||
contest_question=contest_question,
|
||||
submitted_answer=submitted,
|
||||
is_correct=correct and duration_ms <= limit_ms,
|
||||
elapsed_ms=min(duration_ms, limit_ms + 60_000),
|
||||
)
|
||||
|
||||
attempt.status = (
|
||||
ContestAttempt.Status.SUBMITTED
|
||||
if duration_ms <= limit_ms
|
||||
else ContestAttempt.Status.EXPIRED
|
||||
)
|
||||
attempt.score = score
|
||||
attempt.correct_count = correct_count
|
||||
attempt.answer_count = len(raw_answers)
|
||||
attempt.duration_ms = duration_ms
|
||||
attempt.submission_key = submission_key
|
||||
attempt.submitted_at = now
|
||||
attempt.save()
|
||||
|
||||
if raw_answers and duration_ms / len(raw_answers) < 150:
|
||||
CheatFlag.objects.create(
|
||||
attempt=attempt,
|
||||
reason="extreme_answer_speed",
|
||||
evidence={
|
||||
"duration_ms": duration_ms,
|
||||
"answer_count": len(raw_answers),
|
||||
},
|
||||
)
|
||||
if attempt.match_id:
|
||||
finalize_match(attempt.match_id)
|
||||
return attempt_payload(attempt, include_results=True)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def find_match(user, contest):
|
||||
if contest.kind != Contest.Kind.REALTIME or contest.status != Contest.Status.PUBLISHED:
|
||||
raise ValidationError("实时比赛不可用")
|
||||
existing = (
|
||||
RealtimeMatch.objects.filter(
|
||||
Q(player_one=user) | Q(player_two=user),
|
||||
contest=contest,
|
||||
status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE],
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
waiting = (
|
||||
RealtimeMatch.objects.select_for_update(skip_locked=True)
|
||||
.filter(
|
||||
contest=contest,
|
||||
status=RealtimeMatch.Status.WAITING,
|
||||
player_one_rating__gte=max(0, user.rating - 300),
|
||||
player_one_rating__lte=user.rating + 300,
|
||||
)
|
||||
.exclude(player_one=user)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
if waiting is None:
|
||||
return RealtimeMatch.objects.create(
|
||||
contest=contest,
|
||||
player_one=user,
|
||||
player_one_rating=user.rating,
|
||||
)
|
||||
|
||||
waiting.player_two = user
|
||||
waiting.player_two_rating = user.rating
|
||||
waiting.status = RealtimeMatch.Status.ACTIVE
|
||||
waiting.started_at = timezone.now()
|
||||
waiting.save(
|
||||
update_fields=["player_two", "player_two_rating", "status", "started_at"]
|
||||
)
|
||||
ContestAttempt.objects.bulk_create(
|
||||
[
|
||||
ContestAttempt(contest=contest, user=waiting.player_one, match=waiting),
|
||||
ContestAttempt(contest=contest, user=user, match=waiting),
|
||||
]
|
||||
)
|
||||
return waiting
|
||||
|
||||
|
||||
def match_payload(match, user):
|
||||
attempt = match.attempts.filter(user=user).first()
|
||||
opponent = match.player_two if match.player_one_id == user.id else match.player_one
|
||||
return {
|
||||
"match_id": match.id,
|
||||
"status": match.status,
|
||||
"opponent": (
|
||||
{"nickname": opponent.nickname, "rating": opponent.rating}
|
||||
if opponent
|
||||
else None
|
||||
),
|
||||
"attempt": attempt_payload(attempt) if attempt else None,
|
||||
"websocket_path": f"/ws/v1/contest/matches/{match.id}/",
|
||||
}
|
||||
|
||||
|
||||
def _elo_delta(rating, opponent_rating, score, k=32):
|
||||
expected = 1 / (1 + 10 ** ((opponent_rating - rating) / 400))
|
||||
return round(k * (score - expected))
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def finalize_match(match_id):
|
||||
match = RealtimeMatch.objects.select_for_update().get(id=match_id)
|
||||
if match.status != RealtimeMatch.Status.ACTIVE:
|
||||
return match
|
||||
attempts = list(match.attempts.select_related("user").order_by("user_id"))
|
||||
if len(attempts) != 2 or any(
|
||||
attempt.status == ContestAttempt.Status.ACTIVE for attempt in attempts
|
||||
):
|
||||
return match
|
||||
|
||||
first = next(item for item in attempts if item.user_id == match.player_one_id)
|
||||
second = next(item for item in attempts if item.user_id == match.player_two_id)
|
||||
if first.score > second.score:
|
||||
first_result, second_result = 1.0, 0.0
|
||||
match.winner_id = first.user_id
|
||||
elif second.score > first.score:
|
||||
first_result, second_result = 0.0, 1.0
|
||||
match.winner_id = second.user_id
|
||||
else:
|
||||
first_result = second_result = 0.5
|
||||
|
||||
users = {
|
||||
user.id: user
|
||||
for user in User.objects.select_for_update().filter(
|
||||
id__in=[match.player_one_id, match.player_two_id]
|
||||
)
|
||||
}
|
||||
player_one = users[match.player_one_id]
|
||||
player_two = users[match.player_two_id]
|
||||
deltas = (
|
||||
_elo_delta(player_one.rating, player_two.rating, first_result),
|
||||
_elo_delta(player_two.rating, player_one.rating, second_result),
|
||||
)
|
||||
for user, delta in zip((player_one, player_two), deltas):
|
||||
before = user.rating
|
||||
user.rating = max(0, before + delta)
|
||||
user.save(update_fields=["rating"])
|
||||
RatingHistory.objects.create(
|
||||
user=user,
|
||||
match=match,
|
||||
rating_before=before,
|
||||
rating_after=user.rating,
|
||||
delta=delta,
|
||||
)
|
||||
match.status = RealtimeMatch.Status.COMPLETED
|
||||
match.completed_at = timezone.now()
|
||||
match.save(update_fields=["winner", "status", "completed_at"])
|
||||
return match
|
||||
@@ -0,0 +1,203 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from accounts.models import User
|
||||
from contest.models import (
|
||||
Contest,
|
||||
ContestAttempt,
|
||||
ContestQuestion,
|
||||
Question,
|
||||
QuestionVersion,
|
||||
RatingHistory,
|
||||
RealtimeMatch,
|
||||
)
|
||||
from contest.services import (
|
||||
finalize_match,
|
||||
find_match,
|
||||
normalize_answer,
|
||||
start_attempt,
|
||||
submit_attempt,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create_user(
|
||||
username="contest_user",
|
||||
password="StrongPass_2026",
|
||||
nickname="比赛用户",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daily_contest(db):
|
||||
question = Question.objects.create(
|
||||
slug="sum-question",
|
||||
track=Question.Track.STANDARD,
|
||||
)
|
||||
version = QuestionVersion.objects.create(
|
||||
question=question,
|
||||
version=1,
|
||||
prompt="17 + 25",
|
||||
answer="42",
|
||||
explanation="相加得 42",
|
||||
)
|
||||
contest = Contest.objects.create(
|
||||
slug="daily-test",
|
||||
title="测试今日赛",
|
||||
kind=Contest.Kind.DAILY,
|
||||
track=Question.Track.STANDARD,
|
||||
status=Contest.Status.PUBLISHED,
|
||||
duration_seconds=60,
|
||||
)
|
||||
ContestQuestion.objects.create(
|
||||
contest=contest,
|
||||
question_version=version,
|
||||
order=1,
|
||||
points=100,
|
||||
)
|
||||
return contest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
pytest.param(" 1.0 ", "1", id="小数标准化"),
|
||||
pytest.param("ABC ", "abc", id="文本去空格并转小写"),
|
||||
pytest.param("-0", "-0", id="保留十进制负零表示"),
|
||||
],
|
||||
)
|
||||
def test_normalize_answer_标准化输入(raw, expected):
|
||||
assert normalize_answer(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_start_attempt_每日赛重复进入复用同一记录(user, daily_contest):
|
||||
first = start_attempt(user, daily_contest)
|
||||
second = start_attempt(user, daily_contest)
|
||||
|
||||
assert first["attempt_id"] == second["attempt_id"]
|
||||
assert ContestAttempt.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_attempt_服务端判分且幂等重放(user, daily_contest):
|
||||
started = start_attempt(user, daily_contest)
|
||||
|
||||
result = submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": 1, "answer": "42.0"}],
|
||||
"submission-1",
|
||||
)
|
||||
replay = submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": 1, "answer": "0"}],
|
||||
"submission-1",
|
||||
)
|
||||
|
||||
assert result["status"] == ContestAttempt.Status.SUBMITTED
|
||||
assert result["score"] == 100
|
||||
assert result["correct_count"] == 1
|
||||
assert result["questions"][0]["correct_answer"] == "42"
|
||||
assert replay["score"] == 100
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_attempt_缺少幂等键时拒绝(user, daily_contest):
|
||||
started = start_attempt(user, daily_contest)
|
||||
|
||||
with pytest.raises(ValidationError, match="幂等键"):
|
||||
submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": 1, "answer": "42"}],
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_attempt_畸形题号返回校验错误且记录保持进行中(user, daily_contest):
|
||||
started = start_attempt(user, daily_contest)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": "first", "answer": "42"}],
|
||||
"malformed-order",
|
||||
)
|
||||
|
||||
attempt = ContestAttempt.objects.get(id=started["attempt_id"])
|
||||
assert attempt.status == ContestAttempt.Status.ACTIVE
|
||||
assert attempt.answers.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_attempt_超过服务端时限不计分(user, daily_contest):
|
||||
started = start_attempt(user, daily_contest)
|
||||
ContestAttempt.objects.filter(id=started["attempt_id"]).update(
|
||||
started_at=timezone.now() - timedelta(seconds=61)
|
||||
)
|
||||
|
||||
result = submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": 1, "answer": "42"}],
|
||||
"late-submit",
|
||||
)
|
||||
|
||||
assert result["status"] == ContestAttempt.Status.EXPIRED
|
||||
assert result["score"] == 0
|
||||
assert result["correct_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating():
|
||||
first = User.objects.create_user(
|
||||
username="player_one",
|
||||
password="StrongPass_2026",
|
||||
nickname="玩家一",
|
||||
)
|
||||
second = User.objects.create_user(
|
||||
username="player_two",
|
||||
password="StrongPass_2026",
|
||||
nickname="玩家二",
|
||||
)
|
||||
contest = Contest.objects.create(
|
||||
slug="realtime-test",
|
||||
title="测试实时赛",
|
||||
kind=Contest.Kind.REALTIME,
|
||||
track=Question.Track.STANDARD,
|
||||
status=Contest.Status.PUBLISHED,
|
||||
)
|
||||
|
||||
waiting = find_match(first, contest)
|
||||
active = find_match(second, contest)
|
||||
active.refresh_from_db()
|
||||
|
||||
assert waiting.id == active.id
|
||||
assert active.status == RealtimeMatch.Status.ACTIVE
|
||||
assert active.attempts.count() == 2
|
||||
|
||||
active.attempts.filter(user=first).update(
|
||||
status=ContestAttempt.Status.SUBMITTED,
|
||||
score=200,
|
||||
)
|
||||
active.attempts.filter(user=second).update(
|
||||
status=ContestAttempt.Status.SUBMITTED,
|
||||
score=100,
|
||||
)
|
||||
finalized = finalize_match(active.id)
|
||||
first.refresh_from_db()
|
||||
second.refresh_from_db()
|
||||
|
||||
assert finalized.status == RealtimeMatch.Status.COMPLETED
|
||||
assert finalized.winner == first
|
||||
assert first.rating == 1016
|
||||
assert second.rating == 984
|
||||
assert RatingHistory.objects.filter(match=active).count() == 2
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
AttemptStartView,
|
||||
AttemptSubmitView,
|
||||
ContestListView,
|
||||
LeaderboardView,
|
||||
MatchmakingView,
|
||||
MatchStateView,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", ContestListView.as_view(), name="contest-list"),
|
||||
path("<slug:slug>/start/", AttemptStartView.as_view(), name="attempt-start"),
|
||||
path("<slug:slug>/matchmaking/", MatchmakingView.as_view(), name="matchmaking"),
|
||||
path("<slug:slug>/leaderboard/", LeaderboardView.as_view(), name="leaderboard"),
|
||||
path("attempts/<uuid:attempt_id>/submit/", AttemptSubmitView.as_view(), name="attempt-submit"),
|
||||
path("matches/<uuid:match_id>/", MatchStateView.as_view(), name="match-state"),
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import Contest, ContestAttempt, RealtimeMatch
|
||||
from .services import (
|
||||
find_match,
|
||||
match_payload,
|
||||
start_attempt,
|
||||
submit_attempt,
|
||||
)
|
||||
|
||||
|
||||
class ContestListView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
contests = Contest.objects.filter(status=Contest.Status.PUBLISHED).order_by(
|
||||
"kind", "title"
|
||||
)
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"slug": contest.slug,
|
||||
"title": contest.title,
|
||||
"kind": contest.kind,
|
||||
"track": contest.track,
|
||||
"duration_seconds": contest.duration_seconds,
|
||||
"starts_at": contest.starts_at,
|
||||
"ends_at": contest.ends_at,
|
||||
}
|
||||
for contest in contests
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class AttemptStartView(APIView):
|
||||
def post(self, request, slug):
|
||||
contest = get_object_or_404(Contest, slug=slug)
|
||||
return Response(start_attempt(request.user, contest), status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class AttemptSubmitView(APIView):
|
||||
def post(self, request, attempt_id):
|
||||
get_object_or_404(ContestAttempt, id=attempt_id, user=request.user)
|
||||
payload = submit_attempt(
|
||||
user=request.user,
|
||||
attempt_id=attempt_id,
|
||||
raw_answers=request.data.get("answers", []),
|
||||
submission_key=request.headers.get("Idempotency-Key"),
|
||||
)
|
||||
return Response(payload)
|
||||
|
||||
|
||||
class MatchmakingView(APIView):
|
||||
def post(self, request, slug):
|
||||
contest = get_object_or_404(Contest, slug=slug)
|
||||
match = find_match(request.user, contest)
|
||||
return Response(match_payload(match, request.user), status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class MatchStateView(APIView):
|
||||
def get(self, request, match_id):
|
||||
match = get_object_or_404(
|
||||
RealtimeMatch.objects.select_related("player_one", "player_two"),
|
||||
id=match_id,
|
||||
)
|
||||
if request.user.id not in (match.player_one_id, match.player_two_id):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
return Response(match_payload(match, request.user))
|
||||
|
||||
|
||||
class LeaderboardView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request, slug):
|
||||
contest = get_object_or_404(Contest, slug=slug)
|
||||
attempts = (
|
||||
ContestAttempt.objects.filter(
|
||||
contest=contest,
|
||||
status=ContestAttempt.Status.SUBMITTED,
|
||||
cheat_flags__isnull=True,
|
||||
)
|
||||
.select_related("user")
|
||||
.order_by("-score", "duration_ms", "submitted_at")[:100]
|
||||
)
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"rank": index,
|
||||
"nickname": attempt.user.nickname,
|
||||
"track": attempt.user.track,
|
||||
"score": attempt.score,
|
||||
"correct_count": attempt.correct_count,
|
||||
"duration_ms": attempt.duration_ms,
|
||||
}
|
||||
for index, attempt in enumerate(attempts, start=1)
|
||||
]
|
||||
)
|
||||
Reference in New Issue
Block a user