Compare commits

..

10 Commits

Author SHA1 Message Date
a1b51ebb9e update 2025-07-14 21:41:27 +08:00
a9a6b87fef test for asgi 2025-07-14 21:33:03 +08:00
2d3588c755 revert 2025-06-15 20:26:43 +08:00
a2bfc28ac7 test 2025-06-15 20:21:37 +08:00
6aac767641 test 2025-06-15 20:18:24 +08:00
73af9d96b2 test 2025-06-15 20:15:49 +08:00
8a2fa11afc test 2025-06-15 20:12:48 +08:00
3f1c7250bd test 2025-06-15 20:06:50 +08:00
bd0a7f30f8 test 2025-06-15 19:35:11 +08:00
8a043d2ffa test 2025-06-15 19:26:45 +08:00
229 changed files with 3689 additions and 15805 deletions

View File

@@ -1,9 +1,4 @@
venv
.venv
.idea
.git
.DS_Store
__pycache__
*.pyc
.ruff_cache
.pytest_cache

10
.flake8 Normal file
View File

@@ -0,0 +1,10 @@
[flake8]
exclude =
xss_filter.py,
*/migrations/,
*settings.py
*/apps.py
venv/
max-line-length = 180
inline-quotes = "
no-accept-encodings = True

12
.github/issue_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
在提交issue之前请
- 认真阅读文档 http://docs.onlinejudge.me/#/
- 搜索和查看历史issues
- 安全类问题请不要在 GitHub 上公布,请发送邮件到 `admin@qduoj.com`,根据漏洞危害程度发送红包感谢。
然后提交issue请写清楚下列事项
 - 进行什么操作的时候遇到了什么问题,最好能有复现步骤
 - 错误提示是什么如果看不到错误提示请去data文件夹查看相应log文件。大段的错误提示请包在代码块标记里面。
- 你尝试修复问题的操作
- 页面问题请写清浏览器版本,尽量有截图

View File

@@ -1,31 +0,0 @@
name: Deploy
on:
push:
branches:
- yuetsh
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: debian
remote_port: 22
script: /root/OJDeploy/backend.sh
- name: school
remote_port: 8822
script: /root/OJ/backend.sh
steps:
- name: Deploy to ${{ matrix.name }}
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
port: ${{ matrix.remote_port }}
username: root
key: ${{ secrets.KEY }}
script: sh ${{ matrix.script }}

54
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Release build
on:
push:
tags:
- v**
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
packages: write
steps:
- name: Docker metadata
id: metadata
uses: docker/metadata-action@v5
with:
images: |
registry.cn-hongkong.aliyuncs.com/oj-image/backend
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: registry.cn-hongkong.aliyuncs.com
username: ${{ secrets.ALIYUN_ACR_USERNAME }}
password: ${{ secrets.ALIYUN_ACR_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.metadata.outputs.tags }}
annotations: ${{ steps.metadata.outputs.annotations }}

144
CLAUDE.md
View File

@@ -1,144 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**OnlineJudge** is the backend for an Online Judge platform. Built with Django 6 + Django REST Framework, PostgreSQL, Redis, Django Channels (WebSocket), and Dramatiq (async task queue). Python 3.12+, managed with `uv`.
## Commands
```bash
# Development
python dev.py # Start dev server: Django on :8000 + Daphne WebSocket on :8001
python manage.py runserver # HTTP only (no WebSocket support)
python manage.py migrate # Apply database migrations
python manage.py makemigrations # Create new migrations
# Dependencies
uv sync # Install dependencies from uv.lock
uv add <package> # Add a dependency
# Testing
python manage.py test # Run all tests
python manage.py test account # Run tests for a single app
python manage.py test account.tests.TestClassName # Run a single test class
# Linting
ruff check . # Lint (E, F, I rules, 180-char line length)
ruff format . # Format (double quotes)
## Testing Policy
Do not write tests
# Initial setup
python manage.py inituser --username admin --password <pw> --action create_super_admin
python manage.py inituser --username admin --password <pw> --action reset
```
## Architecture
### App Modules
Each Django app follows the same structure:
```
<app>/
├── models.py # Django models
├── serializers.py # DRF serializers
├── views/
│ ├── oj.py # User-facing API views
│ └── admin.py # Admin API views
└── urls/
├── oj.py # User-facing URL patterns
└── admin.py # Admin URL patterns
```
Apps: `account`, `problem`, `submission`, `contest`, `ai`, `flowchart`, `problemset`, `class_pk`, `announcement`, `tutorial`, `message`, `comment`, `conf`, `options`, `judge`
`utils/` is itself a Django app (listed in `INSTALLED_APPS`) — not just a helpers package. It provides `RichTextField` (XSS-sanitized `TextField`), `APIError`, the base `APIView`, caching, WebSocket helpers, and the `inituser` management command. Import shared utilities from `utils.*`.
### URL Routing
All routes are registered in `oj/urls.py`:
- `api/` — user-facing endpoints
- `api/admin/` — admin-only endpoints
WebSocket routing is in `oj/routing.py`.
### Settings Structure
- `oj/settings.py` — base configuration (imports dev or production settings based on `OJ_ENV`)
- `oj/dev_settings.py` — development overrides (imported when `OJ_ENV != "production"`)
- `oj/production_settings.py` — production overrides
### Base APIView & View Patterns
`utils/api/api.py` provides the custom base classes and decorators used by **all** views:
- **`APIView`** — base class for all views (not DRF's `APIView`). Key methods:
- `self.success(data)` — returns `{"error": null, "data": data}`
- `self.error(msg)` — returns `{"error": "error", "data": msg}`
- `self.paginate_data(request, query_set, serializer)` — offset/limit pagination
- `self.invalid_serializer(serializer)` — standard validation error response
- **`CSRFExemptAPIView`** — same as `APIView` but CSRF-exempt
- **`@validate_serializer(SerializerClass)`** — decorator for view methods that validates `request.data` against a serializer before the method runs. On success, `request.data` is replaced with validated data.
Typical view method pattern:
```python
@validate_serializer(CreateProblemSerializer)
@super_admin_required
def post(self, request):
# request.data is already validated
return self.success(...)
```
### Authentication & Permissions
`account/decorators.py` provides decorators used on view methods:
- `@login_required` / `@admin_role_required` / `@super_admin_required`
- `@problem_permission_required`
- `@check_contest_permission(check_type)` — validates contest access, sets `self.contest`
- `ensure_created_by(obj, user)` — helper that raises `APIError` if user doesn't own the object
### Judge System
- `judge/dispatcher.py` — dispatches submissions to the judge sandbox (JudgeServer)
- `judge/tasks.py` — Dramatiq async tasks for judging
- `judge/languages.py` — language configurations (compile/run commands, limits)
Judge status codes are defined in `submission/models.py` (`JudgeStatus` class, codes -2 to 8) and must match the frontend's `utils/constants.ts`.
### Site Configuration (SysOptions)
`options/options.py` provides `SysOptions` — a metaclass-based system for site-wide configuration stored in the database with thread-local caching. Access settings like `SysOptions.smtp_config`, `SysOptions.languages`, etc.
### WebSocket (Channels)
`submission/consumers.py` — WebSocket consumer for real-time submission status updates. Uses `channels-redis` as the channel layer backend. Push updates via `utils/websocket.py:push_submission_update()`.
### Caching
Redis-backed via `django-redis`. Cache keys use MD5 hashing for consistency. See `utils/cache.py`.
### AI Integration
`utils/openai.py` — OpenAI client wrapper configured to work with OpenAI-compatible APIs (e.g., DeepSeek). Used by `ai/` app for submission analysis.
### Data Directory
Test cases and submission outputs are stored in a separate data directory (configured in settings, not in the repo). The `data/` directory in the repo contains configuration templates and `secret.key`.
## Key Domain Concepts
| Concept | Details |
|---|---|
| Problem types | ACM (binary accept/reject) vs OI (partial scoring) |
| Judge statuses | COMPILE_ERROR(-2), WRONG_ANSWER(-1), ACCEPTED(0), CPU_TLE(1), REAL_TLE(2), MLE(3), RE(4), SE(5), PENDING(6), JUDGING(7), PARTIALLY_ACCEPTED(8) |
| User roles | Regular / Admin / Super Admin |
| Contest types | Public vs Password Protected |
| Supported languages | C, C++, Python3, Java, JavaScript, Golang |
## Related Repository
The frontend is at `../ojnext` — a Vue 3 + Rsbuild project. See its CLAUDE.md for frontend details.

View File

@@ -1,44 +1,29 @@
FROM python:3.13-slim
FROM python:3.12.2-alpine
ARG TARGETARCH
ARG TARGETVARIANT
ENV OJ_ENV=production
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
ENV OJ_ENV production
WORKDIR /app
COPY ./deploy/requirements.txt /app/deploy/
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-cache-$TARGETARCH$TARGETVARIANT-final \
--mount=type=cache,target=/root/.cache/pip,id=pip-cache-$TARGETARCH$TARGETVARIANT-final \
# psycopg2: libpg-dev
# pillow: libjpeg-turbo-dev zlib-dev freetype-dev
RUN --mount=type=cache,target=/etc/apk/cache,id=apk-cahce-$TARGETARCH$TARGETVARIANT-final \
--mount=type=cache,target=/root/.cache/pip,id=pip-cahce-$TARGETARCH$TARGETVARIANT-final \
<<EOS
set -ex
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
if [ -f /etc/apt/sources.list.d/debian.sources ]; then
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g; s|security.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list.d/debian.sources
fi
if [ -f /etc/apt/sources.list ]; then
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g; s|security.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list
fi
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
clang-format \
curl \
libjpeg62-turbo \
libpq5 \
nginx \
openssl \
passwd \
supervisor \
unzip \
zlib1g
pip config set global.index-url https://mirrors.ustc.edu.cn/pypi/web/simple
apk add gcc libc-dev python3-dev libpq libpq-dev libjpeg-turbo libjpeg-turbo-dev zlib zlib-dev freetype freetype-dev supervisor openssl nginx curl unzip
pip install -r /app/deploy/requirements.txt
rm -rf /var/lib/apt/lists/*
apk del gcc libc-dev python3-dev libpq-dev libjpeg-turbo-dev zlib-dev freetype-dev
EOS
COPY --chmod=755 ./ /app/
COPY ./ /app/
RUN mkdir -p /app/dist/
RUN chmod -R u=rwX,go=rX ./ && chmod +x ./deploy/entrypoint.sh
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python3 /app/deploy/health_check.py
HEALTHCHECK --interval=5s CMD [ "/usr/local/bin/python3", "/app/deploy/health_check.py" ]
EXPOSE 8000
ENTRYPOINT [ "/app/deploy/entrypoint.sh" ]

View File

@@ -1,85 +1,60 @@
import functools
import hashlib
import inspect
import time
from contest.models import Contest, ContestStatus, ContestType
from problem.models import Problem
from utils.api import APIError, JSONResponse
from contest.models import Contest, ContestType, ContestStatus, ContestRuleType
from utils.api import JSONResponse, APIError
from utils.constants import CONTEST_PASSWORD_SESSION_KEY
from .models import ProblemPermission
class BasePermissionDecorator(object):
def __init__(self, func):
self.func = func
functools.update_wrapper(self, func)
def __get__(self, obj, obj_type):
if inspect.iscoroutinefunction(self.func):
return functools.partial(self._async_call, obj)
return functools.partial(self.__call__, obj)
def error(self, data, err="permission-denied"):
return JSONResponse.response({"error": err, "data": data})
def _permission_error(self, request):
if not request.user.is_authenticated:
return self.error("请先登录", err="login-required")
return self.error("权限不足", err="permission-denied")
def error(self, data):
return JSONResponse.response({"error": "permission-denied", "data": data})
def __call__(self, *args, **kwargs):
request = args[1]
self.request = args[1]
if self.check_permission(request):
if request.user.is_disabled:
return self.error("账号已禁用")
if self.check_permission():
if self.request.user.is_disabled:
return self.error("Your account is disabled")
return self.func(*args, **kwargs)
else:
return self._permission_error(request)
return self.error("Please login first")
async def _async_call(self, *args, **kwargs):
request = args[1]
if self.check_permission(request):
if request.user.is_disabled:
return self.error("账号已禁用")
return await self.func(*args, **kwargs)
return self._permission_error(request)
def check_permission(self, request):
def check_permission(self):
raise NotImplementedError()
class login_required(BasePermissionDecorator):
def check_permission(self, request):
return request.user.is_authenticated
def check_permission(self):
return self.request.user.is_authenticated
class super_admin_required(BasePermissionDecorator):
def check_permission(self, request):
user = request.user
def check_permission(self):
user = self.request.user
return user.is_authenticated and user.is_super_admin()
class teacher_admin_required(BasePermissionDecorator):
def check_permission(self, request):
user = request.user
return user.is_authenticated and user.is_teacher_or_above()
class admin_role_required(BasePermissionDecorator):
def check_permission(self, request):
user = request.user
def check_permission(self):
user = self.request.user
return user.is_authenticated and user.is_admin_role()
class problem_permission_required(admin_role_required):
def check_permission(self, request):
if not super().check_permission(request):
def check_permission(self):
if not super(problem_permission_required, self).check_permission():
return False
if request.user.problem_permission == ProblemPermission.NONE:
if self.request.user.problem_permission == ProblemPermission.NONE:
return False
return True
@@ -116,42 +91,48 @@ def check_contest_permission(check_type="details"):
若通过验证在view中可通过self.contest获得该contest
"""
def _get_contest_id(request):
return request.data.get("contest_id") or request.GET.get("contest_id")
def _check_access(self, request, user):
if not user.is_authenticated:
return self.error("请先登录", err="login-required")
if user.is_contest_admin(self.contest):
return None
if self.contest.contest_type == ContestType.PASSWORD_PROTECTED_CONTEST:
if not check_contest_password(request.session.get(CONTEST_PASSWORD_SESSION_KEY, {}).get(self.contest.id), self.contest.password):
return self.error("Wrong password or password expired")
if self.contest.status == ContestStatus.CONTEST_NOT_START and check_type != "details":
return self.error("Contest has not started yet.")
return None
def decorator(func):
@functools.wraps(func)
async def _wrapper(*args, **kwargs):
def _check_permission(*args, **kwargs):
self = args[0]
request = args[1]
contest_id = _get_contest_id(request)
user = request.user
if request.data.get("contest_id"):
contest_id = request.data["contest_id"]
else:
contest_id = request.GET.get("contest_id")
if not contest_id:
return self.error("Parameter error, contest_id is required")
try:
self.contest = await Contest.objects.select_related("created_by").aget(id=contest_id, visible=True)
# use self.contest to avoid query contest again in view.
self.contest = Contest.objects.select_related("created_by").get(id=contest_id, visible=True)
except Contest.DoesNotExist:
return self.error("Contest %s doesn't exist" % contest_id)
error = _check_access(self, request, request.user)
if error:
return error
return await func(*args, **kwargs)
return _wrapper
# Anonymous
if not user.is_authenticated:
return self.error("Please login first.")
# creator or owner
if user.is_contest_admin(self.contest):
return func(*args, **kwargs)
if self.contest.contest_type == ContestType.PASSWORD_PROTECTED_CONTEST:
# password error
if not check_contest_password(request.session.get(CONTEST_PASSWORD_SESSION_KEY, {}).get(self.contest.id), self.contest.password):
return self.error("Wrong password or password expired")
# regular user get contest problems, ranks etc. before contest started
if self.contest.status == ContestStatus.CONTEST_NOT_START and check_type != "details":
return self.error("Contest has not started yet.")
# check does user have permission to get ranks, submissions in OI Contest
if self.contest.status == ContestStatus.CONTEST_UNDERWAY and self.contest.rule_type == ContestRuleType.OI:
if not self.contest.real_time_rank and (check_type == "ranks" or check_type == "submissions"):
return self.error(f"No permission to get {check_type}")
return func(*args, **kwargs)
return _check_permission
return decorator

View File

@@ -1,70 +0,0 @@
from django.core.management.base import BaseCommand
from account.models import UserProfile
from problem.models import Problem
from submission.models import JudgeStatus
ACCEPTED_STATUSES = {JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED}
class Command(BaseCommand):
help = "从用户 Profile 中移除已被删除的题目记录,并同步修正 accepted_number"
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true", help="只检查,不写入数据库")
def handle(self, *args, **options):
dry_run = options["dry_run"]
# 所有现存非比赛题目的 PK 集合
existing_ids = set(
Problem.objects.filter(contest__isnull=True).values_list("id", flat=True)
)
self.stdout.write(f"现存题库题目数: {len(existing_ids)}")
profiles = UserProfile.objects.select_related("user").exclude(
acm_problems_status={}
)
total = profiles.count()
self.stdout.write(f"检查用户数: {total}{'dry-run 模式)' if dry_run else ''}")
fixed_count = 0
for profile in profiles:
problems = profile.acm_problems_status.get("problems", {})
if not problems:
continue
stale_keys = [k for k in problems if int(k) not in existing_ids]
if not stale_keys:
continue
removed_accepted = sum(
1
for k in stale_keys
if problems[k].get("status") in ACCEPTED_STATUSES
)
stale_display = [problems[k].get("_id", k) for k in stale_keys]
self.stdout.write(
f" 用户 {profile.user.username}"
f" | 删除 {len(stale_keys)} 题: {', '.join(stale_display)}"
f"{f' | 其中已AC {removed_accepted}' if removed_accepted else ''}"
)
if dry_run:
continue
for k in stale_keys:
del profile.acm_problems_status["problems"][k]
if removed_accepted:
# 防止 accepted_number 变为负数
profile.accepted_number = max(0, profile.accepted_number - removed_accepted)
profile.save(update_fields=["acm_problems_status", "accepted_number"])
fixed_count += 1
if dry_run:
self.stdout.write(self.style.WARNING("dry-run 完成,未写入任何数据"))
else:
self.stdout.write(self.style.SUCCESS(f"完成,共修复 {fixed_count} 个用户 Profile"))

View File

@@ -1,10 +1,10 @@
from django.conf import settings
from django.db import connection
from django.utils.deprecation import MiddlewareMixin
from django.utils.timezone import now
from django.utils.deprecation import MiddlewareMixin
from account.models import User
from utils.api import JSONResponse
from account.models import User
class APITokenAuthMiddleware(MiddlewareMixin):
@@ -37,10 +37,8 @@ class AdminRoleRequiredMiddleware(MiddlewareMixin):
def process_request(self, request):
path = request.path_info
if path.startswith("/admin/") or path.startswith("/api/admin/"):
if not request.user.is_authenticated:
return JSONResponse.response({"error": "login-required", "data": "请先登录"})
if not request.user.is_admin_role():
return JSONResponse.response({"error": "permission-denied", "data": "权限不足"})
if not (request.user.is_authenticated and request.user.is_admin_role()):
return JSONResponse.response({"error": "login-required", "data": "Please login in first"})
class LogSqlMiddleware(MiddlewareMixin):

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-19 06:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='class_name',
field=models.TextField(null=True),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-19 06:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0002_userprofile_class_name'),
]
operations = [
migrations.RemoveField(
model_name='userprofile',
name='class_name',
),
migrations.AddField(
model_name='user',
name='class_name',
field=models.TextField(null=True),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-09 08:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("account", "0003_remove_userprofile_class_name_user_class_name"),
]
operations = [
migrations.AlterField(
model_name="user",
name="admin_type",
field=models.TextField(choices=[("Regular User", "Regular User"), ("Admin", "Admin"), ("Super Admin", "Super Admin")], default="Regular User"),
),
migrations.AlterField(
model_name="user",
name="problem_permission",
field=models.TextField(choices=[("None", "None"), ("Own", "Own"), ("All", "All")], default="None"),
),
]

View File

@@ -1,58 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-09 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0004_alter_user_admin_type_alter_user_problem_permission'),
]
operations = [
migrations.AlterField(
model_name='user',
name='is_disabled',
field=models.BooleanField(db_default=False, default=False),
),
migrations.AlterField(
model_name='user',
name='open_api',
field=models.BooleanField(db_default=False, default=False),
),
migrations.AlterField(
model_name='user',
name='session_keys',
field=models.JSONField(db_default=models.Value([], output_field=models.JSONField()), default=list),
),
migrations.AlterField(
model_name='user',
name='two_factor_auth',
field=models.BooleanField(db_default=False, default=False),
),
migrations.AlterField(
model_name='userprofile',
name='accepted_number',
field=models.IntegerField(db_default=0, default=0),
),
migrations.AlterField(
model_name='userprofile',
name='acm_problems_status',
field=models.JSONField(db_default=models.Value({}, output_field=models.JSONField()), default=dict),
),
migrations.AlterField(
model_name='userprofile',
name='oi_problems_status',
field=models.JSONField(db_default=models.Value({}, output_field=models.JSONField()), default=dict),
),
migrations.AlterField(
model_name='userprofile',
name='submission_number',
field=models.IntegerField(db_default=0, default=0),
),
migrations.AlterField(
model_name='userprofile',
name='total_score',
field=models.BigIntegerField(db_default=0, default=0),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0.4 on 2026-06-03 00:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0005_alter_user_is_disabled_alter_user_open_api_and_more'),
]
operations = [
migrations.AlterField(
model_name='user',
name='admin_type',
field=models.TextField(choices=[('Regular User', 'Regular User'), ('Student Admin', 'Student Admin'), ('Teacher Admin', 'Teacher Admin'), ('Super Admin', 'Super Admin')], default='Regular User'),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 6.0.4 on 2026-06-03 00:08
from django.db import migrations
def rename_admin_to_student_admin(apps, schema_editor):
User = apps.get_model("account", "User")
User.objects.filter(admin_type="Admin").update(admin_type="Student Admin")
def rename_student_admin_to_admin(apps, schema_editor):
User = apps.get_model("account", "User")
User.objects.filter(admin_type="Student Admin").update(admin_type="Admin")
class Migration(migrations.Migration):
dependencies = [
("account", "0006_alter_user_admin_type"),
]
operations = [
migrations.RunPython(
rename_admin_to_student_admin,
rename_student_admin_to_admin,
),
]

View File

@@ -1,21 +1,19 @@
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.conf import settings
from django.db import models
from utils.models import JSONField
class AdminType(models.TextChoices):
REGULAR_USER = "Regular User", "Regular User"
STUDENT_ADMIN = "Student Admin", "Student Admin"
TEACHER_ADMIN = "Teacher Admin", "Teacher Admin"
SUPER_ADMIN = "Super Admin", "Super Admin"
class AdminType(object):
REGULAR_USER = "Regular User"
ADMIN = "Admin"
SUPER_ADMIN = "Super Admin"
class ProblemPermission(models.TextChoices):
NONE = "None", "None"
OWN = "Own", "Own"
ALL = "All", "All"
class ProblemPermission(object):
NONE = "None"
OWN = "Own"
ALL = "All"
class UserManager(models.Manager):
@@ -24,63 +22,50 @@ class UserManager(models.Manager):
def get_by_natural_key(self, username):
return self.get(**{f"{self.model.USERNAME_FIELD}__iexact": username})
async def aget_by_natural_key(self, username):
return await self.aget(**{f"{self.model.USERNAME_FIELD}__iexact": username})
class User(AbstractBaseUser):
username = models.TextField(unique=True)
class_name = models.TextField(null=True)
email = models.TextField(null=True)
create_time = models.DateTimeField(auto_now_add=True, null=True)
# One of UserType
admin_type = models.TextField(default=AdminType.REGULAR_USER, choices=AdminType.choices)
problem_permission = models.TextField(default=ProblemPermission.NONE, choices=ProblemPermission.choices)
admin_type = models.TextField(default=AdminType.REGULAR_USER)
problem_permission = models.TextField(default=ProblemPermission.NONE)
reset_password_token = models.TextField(null=True)
reset_password_token_expire_time = models.DateTimeField(null=True)
# SSO auth token
auth_token = models.TextField(null=True)
two_factor_auth = models.BooleanField(default=False, db_default=False)
two_factor_auth = models.BooleanField(default=False)
tfa_token = models.TextField(null=True)
session_keys = JSONField(default=list, db_default=models.Value([], output_field=models.JSONField()))
session_keys = JSONField(default=list)
# open api key
open_api = models.BooleanField(default=False, db_default=False)
open_api = models.BooleanField(default=False)
open_api_appkey = models.TextField(null=True)
is_disabled = models.BooleanField(default=False, db_default=False)
raw_password = models.CharField(max_length=20, null=True, blank=True, verbose_name="明文密码")
is_disabled = models.BooleanField(default=False)
raw_password = models.CharField(
max_length=20, null=True, blank=True, verbose_name="明文密码"
)
USERNAME_FIELD = "username"
REQUIRED_FIELDS = []
objects = UserManager()
def is_regular_user(self):
return self.admin_type == AdminType.REGULAR_USER
def is_student_admin(self):
return self.admin_type == AdminType.STUDENT_ADMIN
def is_teacher_admin(self):
return self.admin_type == AdminType.TEACHER_ADMIN
def is_admin(self):
return self.admin_type == AdminType.ADMIN
def is_super_admin(self):
return self.admin_type == AdminType.SUPER_ADMIN
def is_admin_role(self):
return self.admin_type in [
AdminType.STUDENT_ADMIN,
AdminType.TEACHER_ADMIN,
AdminType.SUPER_ADMIN,
]
def is_teacher_or_above(self):
return self.admin_type in [AdminType.TEACHER_ADMIN, AdminType.SUPER_ADMIN]
return self.admin_type in [AdminType.ADMIN, AdminType.SUPER_ADMIN]
def can_mgmt_all_problem(self):
return self.problem_permission == ProblemPermission.ALL
def is_contest_admin(self, contest):
return self.is_authenticated and (contest.created_by == self or self.admin_type == AdminType.SUPER_ADMIN)
return self.is_authenticated and (
contest.created_by == self or self.admin_type == AdminType.SUPER_ADMIN
)
def set_password(self, raw_password):
super().set_password(raw_password)
@@ -107,9 +92,9 @@ class UserProfile(models.Model):
# }
# }
# }
acm_problems_status = JSONField(default=dict, db_default=models.Value({}, output_field=models.JSONField()))
acm_problems_status = JSONField(default=dict)
# like acm_problems_status, merely add "score" field
oi_problems_status = JSONField(default=dict, db_default=models.Value({}, output_field=models.JSONField()))
oi_problems_status = JSONField(default=dict)
real_name = models.TextField(null=True)
avatar = models.TextField(default=f"{settings.AVATAR_URI_PREFIX}/default.png")
@@ -120,24 +105,24 @@ class UserProfile(models.Model):
major = models.TextField(null=True)
language = models.TextField(null=True)
# for ACM
accepted_number = models.IntegerField(default=0, db_default=0)
accepted_number = models.IntegerField(default=0)
# for OI
total_score = models.BigIntegerField(default=0, db_default=0)
submission_number = models.IntegerField(default=0, db_default=0)
total_score = models.BigIntegerField(default=0)
submission_number = models.IntegerField(default=0)
def add_accepted_problem_number(self):
self.accepted_number = models.F("accepted_number") + 1
self.save(update_fields=["accepted_number"])
self.save()
def add_submission_number(self):
self.submission_number = models.F("submission_number") + 1
self.save(update_fields=["submission_number"])
self.save()
# 计算总分时, 应先减掉上次该题所得分数, 然后再加上本次所得分数
def add_score(self, this_time_score, last_time_score=None):
last_time_score = last_time_score or 0
self.total_score = models.F("total_score") - last_time_score + this_time_score
self.save(update_fields=["total_score"])
self.save()
class Meta:
db_table = "user_profile"

View File

@@ -1,6 +1,6 @@
from django import forms
from utils.api import UsernameSerializer, serializers
from utils.api import serializers, UsernameSerializer
from .models import AdminType, ProblemPermission, User, UserProfile
@@ -44,7 +44,9 @@ class GenerateUserSerializer(serializers.Serializer):
class ImportUserSerializer(serializers.Serializer):
users = serializers.ListField(child=serializers.ListField(child=serializers.CharField(max_length=64)))
users = serializers.ListField(
child=serializers.ListField(child=serializers.CharField(max_length=64))
)
class UserAdminSerializer(serializers.ModelSerializer):
@@ -65,7 +67,6 @@ class UserAdminSerializer(serializers.ModelSerializer):
"open_api",
"is_disabled",
"raw_password",
"class_name",
]
def get_real_name(self, obj):
@@ -92,7 +93,6 @@ class UserSerializer(serializers.ModelSerializer):
"two_factor_auth",
"open_api",
"is_disabled",
"class_name",
]
@@ -116,14 +116,19 @@ class EditUserSerializer(serializers.Serializer):
id = serializers.IntegerField()
username = serializers.CharField(max_length=32)
real_name = serializers.CharField(max_length=32, allow_blank=True, allow_null=True)
password = serializers.CharField(min_length=6, allow_blank=True, required=False, default=None)
password = serializers.CharField(
min_length=6, allow_blank=True, required=False, default=None
)
email = serializers.EmailField(max_length=64)
admin_type = serializers.ChoiceField(choices=AdminType.choices)
problem_permission = serializers.ChoiceField(choices=ProblemPermission.choices)
admin_type = serializers.ChoiceField(
choices=(AdminType.REGULAR_USER, AdminType.ADMIN, AdminType.SUPER_ADMIN)
)
problem_permission = serializers.ChoiceField(
choices=(ProblemPermission.NONE, ProblemPermission.OWN, ProblemPermission.ALL)
)
open_api = serializers.BooleanField()
two_factor_auth = serializers.BooleanField()
is_disabled = serializers.BooleanField()
class_name = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class EditUserProfileSerializer(serializers.Serializer):

View File

@@ -1,9 +1,8 @@
import logging
import dramatiq
from options.options import SysOptions
from utils.shortcuts import DRAMATIQ_WORKER_ARGS, send_email
from utils.shortcuts import send_email, DRAMATIQ_WORKER_ARGS
logger = logging.getLogger(__name__)

646
account/tests.py Normal file
View File

@@ -0,0 +1,646 @@
import time
from unittest import mock
from datetime import timedelta
from copy import deepcopy
from django.contrib import auth
from django.utils.timezone import now
from otpauth import OtpAuth
from utils.api.tests import APIClient, APITestCase
from utils.shortcuts import rand_str
from options.options import SysOptions
from .models import AdminType, ProblemPermission, User
from utils.constants import ContestRuleType
class PermissionDecoratorTest(APITestCase):
def setUp(self):
self.regular_user = User.objects.create(username="regular_user")
self.admin = User.objects.create(username="admin")
self.super_admin = User.objects.create(username="super_admin")
self.request = mock.MagicMock()
self.request.user.is_authenticated = mock.MagicMock()
def test_login_required(self):
self.request.user.is_authenticated.return_value = False
def test_admin_required(self):
pass
def test_super_admin_required(self):
pass
class DuplicateUserCheckAPITest(APITestCase):
def setUp(self):
user = self.create_user("test", "test123", login=False)
user.email = "test@test.com"
user.save()
self.url = self.reverse("check_username_or_email")
def test_duplicate_username(self):
resp = self.client.post(self.url, data={"username": "test"})
data = resp.data["data"]
self.assertEqual(data["username"], True)
resp = self.client.post(self.url, data={"username": "Test"})
self.assertEqual(resp.data["data"]["username"], True)
def test_ok_username(self):
resp = self.client.post(self.url, data={"username": "test1"})
data = resp.data["data"]
self.assertFalse(data["username"])
def test_duplicate_email(self):
resp = self.client.post(self.url, data={"email": "test@test.com"})
self.assertEqual(resp.data["data"]["email"], True)
resp = self.client.post(self.url, data={"email": "Test@Test.com"})
self.assertTrue(resp.data["data"]["email"])
def test_ok_email(self):
resp = self.client.post(self.url, data={"email": "aa@test.com"})
self.assertFalse(resp.data["data"]["email"])
class TFARequiredCheckAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("tfa_required_check")
self.create_user("test", "test123", login=False)
def test_not_required_tfa(self):
resp = self.client.post(self.url, data={"username": "test"})
self.assertSuccess(resp)
self.assertEqual(resp.data["data"]["result"], False)
def test_required_tfa(self):
user = User.objects.first()
user.two_factor_auth = True
user.save()
resp = self.client.post(self.url, data={"username": "test"})
self.assertEqual(resp.data["data"]["result"], True)
class UserLoginAPITest(APITestCase):
def setUp(self):
self.username = self.password = "test"
self.user = self.create_user(username=self.username, password=self.password, login=False)
self.login_url = self.reverse("user_login_api")
def _set_tfa(self):
self.user.two_factor_auth = True
tfa_token = rand_str(32)
self.user.tfa_token = tfa_token
self.user.save()
return tfa_token
def test_login_with_correct_info(self):
response = self.client.post(self.login_url,
data={"username": self.username, "password": self.password})
self.assertDictEqual(response.data, {"error": None, "data": "Succeeded"})
user = auth.get_user(self.client)
self.assertTrue(user.is_authenticated)
def test_login_with_correct_info_upper_username(self):
resp = self.client.post(self.login_url, data={"username": self.username.upper(), "password": self.password})
self.assertDictEqual(resp.data, {"error": None, "data": "Succeeded"})
user = auth.get_user(self.client)
self.assertTrue(user.is_authenticated)
def test_login_with_wrong_info(self):
response = self.client.post(self.login_url,
data={"username": self.username, "password": "invalid_password"})
self.assertDictEqual(response.data, {"error": "error", "data": "Invalid username or password"})
user = auth.get_user(self.client)
self.assertFalse(user.is_authenticated)
def test_tfa_login(self):
token = self._set_tfa()
code = OtpAuth(token).totp()
if len(str(code)) < 6:
code = (6 - len(str(code))) * "0" + str(code)
response = self.client.post(self.login_url,
data={"username": self.username,
"password": self.password,
"tfa_code": code})
self.assertDictEqual(response.data, {"error": None, "data": "Succeeded"})
user = auth.get_user(self.client)
self.assertTrue(user.is_authenticated)
def test_tfa_login_wrong_code(self):
self._set_tfa()
response = self.client.post(self.login_url,
data={"username": self.username,
"password": self.password,
"tfa_code": "qqqqqq"})
self.assertDictEqual(response.data, {"error": "error", "data": "Invalid two factor verification code"})
user = auth.get_user(self.client)
self.assertFalse(user.is_authenticated)
def test_tfa_login_without_code(self):
self._set_tfa()
response = self.client.post(self.login_url,
data={"username": self.username,
"password": self.password})
self.assertDictEqual(response.data, {"error": "error", "data": "tfa_required"})
user = auth.get_user(self.client)
self.assertFalse(user.is_authenticated)
def test_user_disabled(self):
self.user.is_disabled = True
self.user.save()
resp = self.client.post(self.login_url, data={"username": self.username,
"password": self.password})
self.assertDictEqual(resp.data, {"error": "error", "data": "Your account has been disabled"})
class CaptchaTest(APITestCase):
def _set_captcha(self, session):
captcha = rand_str(4)
session["_django_captcha_key"] = captcha
session["_django_captcha_expires_time"] = int(time.time()) + 30
session.save()
return captcha
class UserRegisterAPITest(CaptchaTest):
def setUp(self):
self.client = APIClient()
self.register_url = self.reverse("user_register_api")
self.captcha = rand_str(4)
self.data = {"username": "test_user", "password": "testuserpassword",
"real_name": "real_name", "email": "test@qduoj.com",
"captcha": self._set_captcha(self.client.session)}
def test_website_config_limit(self):
SysOptions.allow_register = False
resp = self.client.post(self.register_url, data=self.data)
self.assertDictEqual(resp.data, {"error": "error", "data": "Register function has been disabled by admin"})
def test_invalid_captcha(self):
self.data["captcha"] = "****"
response = self.client.post(self.register_url, data=self.data)
self.assertDictEqual(response.data, {"error": "error", "data": "Invalid captcha"})
self.data.pop("captcha")
response = self.client.post(self.register_url, data=self.data)
self.assertTrue(response.data["error"] is not None)
def test_register_with_correct_info(self):
response = self.client.post(self.register_url, data=self.data)
self.assertDictEqual(response.data, {"error": None, "data": "Succeeded"})
def test_username_already_exists(self):
self.test_register_with_correct_info()
self.data["captcha"] = self._set_captcha(self.client.session)
self.data["email"] = "test1@qduoj.com"
response = self.client.post(self.register_url, data=self.data)
self.assertDictEqual(response.data, {"error": "error", "data": "Username already exists"})
def test_email_already_exists(self):
self.test_register_with_correct_info()
self.data["captcha"] = self._set_captcha(self.client.session)
self.data["username"] = "test_user1"
response = self.client.post(self.register_url, data=self.data)
self.assertDictEqual(response.data, {"error": "error", "data": "Email already exists"})
class SessionManagementAPITest(APITestCase):
def setUp(self):
self.create_user("test", "test123")
self.url = self.reverse("session_management_api")
# launch a request to provide session data
login_url = self.reverse("user_login_api")
self.client.post(login_url, data={"username": "test", "password": "test123"})
def test_get_sessions(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
data = resp.data["data"]
self.assertEqual(len(data), 1)
# def test_delete_session_key(self):
# resp = self.client.delete(self.url + "?session_key=" + self.session_key)
# self.assertSuccess(resp)
def test_delete_session_with_invalid_key(self):
resp = self.client.delete(self.url + "?session_key=aaaaaaaaaa")
self.assertDictEqual(resp.data, {"error": "error", "data": "Invalid session_key"})
class UserProfileAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("user_profile_api")
def test_get_profile_without_login(self):
resp = self.client.get(self.url)
self.assertDictEqual(resp.data, {"error": None, "data": None})
def test_get_profile(self):
self.create_user("test", "test123")
resp = self.client.get(self.url)
self.assertSuccess(resp)
def test_update_profile(self):
self.create_user("test", "test123")
update_data = {"real_name": "zemal", "submission_number": 233, "language": "en-US"}
resp = self.client.put(self.url, data=update_data)
self.assertSuccess(resp)
data = resp.data["data"]
self.assertEqual(data["real_name"], "zemal")
self.assertEqual(data["submission_number"], 0)
self.assertEqual(data["language"], "en-US")
class TwoFactorAuthAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("two_factor_auth_api")
self.create_user("test", "test123")
def _get_tfa_code(self):
user = User.objects.first()
code = OtpAuth(user.tfa_token).totp()
if len(str(code)) < 6:
code = (6 - len(str(code))) * "0" + str(code)
return code
def test_get_image(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
def test_open_tfa_with_invalid_code(self):
self.test_get_image()
resp = self.client.post(self.url, data={"code": "000000"})
self.assertDictEqual(resp.data, {"error": "error", "data": "Invalid code"})
def test_open_tfa_with_correct_code(self):
self.test_get_image()
code = self._get_tfa_code()
resp = self.client.post(self.url, data={"code": code})
self.assertSuccess(resp)
user = User.objects.first()
self.assertEqual(user.two_factor_auth, True)
def test_close_tfa_with_invalid_code(self):
self.test_open_tfa_with_correct_code()
resp = self.client.post(self.url, data={"code": "000000"})
self.assertDictEqual(resp.data, {"error": "error", "data": "Invalid code"})
def test_close_tfa_with_correct_code(self):
self.test_open_tfa_with_correct_code()
code = self._get_tfa_code()
resp = self.client.put(self.url, data={"code": code})
self.assertSuccess(resp)
user = User.objects.first()
self.assertEqual(user.two_factor_auth, False)
@mock.patch("account.views.oj.send_email_async.send")
class ApplyResetPasswordAPITest(CaptchaTest):
def setUp(self):
self.create_user("test", "test123", login=False)
user = User.objects.first()
user.email = "test@oj.com"
user.save()
self.url = self.reverse("apply_reset_password_api")
self.data = {"email": "test@oj.com", "captcha": self._set_captcha(self.client.session)}
def _refresh_captcha(self):
self.data["captcha"] = self._set_captcha(self.client.session)
def test_apply_reset_password(self, send_email_send):
resp = self.client.post(self.url, data=self.data)
self.assertSuccess(resp)
send_email_send.assert_called()
def test_apply_reset_password_twice_in_20_mins(self, send_email_send):
self.test_apply_reset_password()
send_email_send.reset_mock()
self._refresh_captcha()
resp = self.client.post(self.url, data=self.data)
self.assertDictEqual(resp.data, {"error": "error", "data": "You can only reset password once per 20 minutes"})
send_email_send.assert_not_called()
def test_apply_reset_password_again_after_20_mins(self, send_email_send):
self.test_apply_reset_password()
user = User.objects.first()
user.reset_password_token_expire_time = now() - timedelta(minutes=21)
user.save()
self._refresh_captcha()
self.test_apply_reset_password()
class ResetPasswordAPITest(CaptchaTest):
def setUp(self):
self.create_user("test", "test123", login=False)
self.url = self.reverse("reset_password_api")
user = User.objects.first()
user.reset_password_token = "online_judge?"
user.reset_password_token_expire_time = now() + timedelta(minutes=20)
user.save()
self.data = {"token": user.reset_password_token,
"captcha": self._set_captcha(self.client.session),
"password": "test456"}
def test_reset_password_with_correct_token(self):
resp = self.client.post(self.url, data=self.data)
self.assertSuccess(resp)
self.assertTrue(self.client.login(username="test", password="test456"))
def test_reset_password_with_invalid_token(self):
self.data["token"] = "aaaaaaaaaaa"
resp = self.client.post(self.url, data=self.data)
self.assertDictEqual(resp.data, {"error": "error", "data": "Token does not exist"})
def test_reset_password_with_expired_token(self):
user = User.objects.first()
user.reset_password_token_expire_time = now() - timedelta(seconds=30)
user.save()
resp = self.client.post(self.url, data=self.data)
self.assertDictEqual(resp.data, {"error": "error", "data": "Token has expired"})
class UserChangeEmailAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("user_change_email_api")
self.user = self.create_user("test", "test123")
self.new_mail = "test@oj.com"
self.data = {"password": "test123", "new_email": self.new_mail}
def test_change_email_success(self):
resp = self.client.post(self.url, data=self.data)
self.assertSuccess(resp)
def test_wrong_password(self):
self.data["password"] = "aaaa"
resp = self.client.post(self.url, data=self.data)
self.assertDictEqual(resp.data, {"error": "error", "data": "Wrong password"})
def test_duplicate_email(self):
u = self.create_user("aa", "bb", login=False)
u.email = self.new_mail
u.save()
resp = self.client.post(self.url, data=self.data)
self.assertDictEqual(resp.data, {"error": "error", "data": "The email is owned by other account"})
class UserChangePasswordAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("user_change_password_api")
# Create user at first
self.username = "test_user"
self.old_password = "testuserpassword"
self.new_password = "new_password"
self.user = self.create_user(username=self.username, password=self.old_password, login=False)
self.data = {"old_password": self.old_password, "new_password": self.new_password}
def _get_tfa_code(self):
user = User.objects.first()
code = OtpAuth(user.tfa_token).totp()
if len(str(code)) < 6:
code = (6 - len(str(code))) * "0" + str(code)
return code
def test_login_required(self):
response = self.client.post(self.url, data=self.data)
self.assertEqual(response.data, {"error": "permission-denied", "data": "Please login first"})
def test_valid_ola_password(self):
self.assertTrue(self.client.login(username=self.username, password=self.old_password))
response = self.client.post(self.url, data=self.data)
self.assertEqual(response.data, {"error": None, "data": "Succeeded"})
self.assertTrue(self.client.login(username=self.username, password=self.new_password))
def test_invalid_old_password(self):
self.assertTrue(self.client.login(username=self.username, password=self.old_password))
self.data["old_password"] = "invalid"
response = self.client.post(self.url, data=self.data)
self.assertEqual(response.data, {"error": "error", "data": "Invalid old password"})
def test_tfa_code_required(self):
self.user.two_factor_auth = True
self.user.tfa_token = "tfa_token"
self.user.save()
self.assertTrue(self.client.login(username=self.username, password=self.old_password))
self.data["tfa_code"] = rand_str(6)
resp = self.client.post(self.url, data=self.data)
self.assertEqual(resp.data, {"error": "error", "data": "Invalid two factor verification code"})
self.data["tfa_code"] = self._get_tfa_code()
resp = self.client.post(self.url, data=self.data)
self.assertSuccess(resp)
class UserRankAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("user_rank_api")
self.create_user("test1", "test123", login=False)
self.create_user("test2", "test123", login=False)
test1 = User.objects.get(username="test1")
profile1 = test1.userprofile
profile1.submission_number = 10
profile1.accepted_number = 10
profile1.total_score = 240
profile1.save()
test2 = User.objects.get(username="test2")
profile2 = test2.userprofile
profile2.submission_number = 15
profile2.accepted_number = 10
profile2.total_score = 700
profile2.save()
def test_get_acm_rank(self):
resp = self.client.get(self.url, data={"rule": ContestRuleType.ACM})
self.assertSuccess(resp)
data = resp.data["data"]["results"]
self.assertEqual(data[0]["user"]["username"], "test1")
self.assertEqual(data[1]["user"]["username"], "test2")
def test_get_oi_rank(self):
resp = self.client.get(self.url, data={"rule": ContestRuleType.OI})
self.assertSuccess(resp)
data = resp.data["data"]["results"]
self.assertEqual(data[0]["user"]["username"], "test2")
self.assertEqual(data[1]["user"]["username"], "test1")
def test_admin_role_filted(self):
self.create_admin("admin", "admin123")
admin = User.objects.get(username="admin")
profile1 = admin.userprofile
profile1.submission_number = 20
profile1.accepted_number = 5
profile1.total_score = 300
profile1.save()
resp = self.client.get(self.url, data={"rule": ContestRuleType.ACM})
self.assertSuccess(resp)
self.assertEqual(len(resp.data["data"]), 2)
resp = self.client.get(self.url, data={"rule": ContestRuleType.OI})
self.assertSuccess(resp)
self.assertEqual(len(resp.data["data"]), 2)
class ProfileProblemDisplayIDRefreshAPITest(APITestCase):
def setUp(self):
pass
class AdminUserTest(APITestCase):
def setUp(self):
self.user = self.create_super_admin(login=True)
self.username = self.password = "test"
self.regular_user = self.create_user(username=self.username, password=self.password, login=False)
self.url = self.reverse("user_admin_api")
self.data = {"id": self.regular_user.id, "username": self.username, "real_name": "test_name",
"email": "test@qq.com", "admin_type": AdminType.REGULAR_USER,
"problem_permission": ProblemPermission.OWN, "open_api": True,
"two_factor_auth": False, "is_disabled": False}
def test_user_list(self):
response = self.client.get(self.url)
self.assertSuccess(response)
def test_edit_user_successfully(self):
response = self.client.put(self.url, data=self.data)
self.assertSuccess(response)
resp_data = response.data["data"]
self.assertEqual(resp_data["username"], self.username)
self.assertEqual(resp_data["email"], "test@qq.com")
self.assertEqual(resp_data["open_api"], True)
self.assertEqual(resp_data["two_factor_auth"], False)
self.assertEqual(resp_data["is_disabled"], False)
self.assertEqual(resp_data["problem_permission"], ProblemPermission.NONE)
self.assertTrue(self.regular_user.check_password("test"))
def test_edit_user_password(self):
data = self.data
new_password = "testpassword"
data["password"] = new_password
response = self.client.put(self.url, data=data)
self.assertSuccess(response)
user = User.objects.get(id=self.regular_user.id)
self.assertFalse(user.check_password(self.password))
self.assertTrue(user.check_password(new_password))
def test_edit_user_tfa(self):
data = self.data
self.assertIsNone(self.regular_user.tfa_token)
data["two_factor_auth"] = True
response = self.client.put(self.url, data=data)
self.assertSuccess(response)
resp_data = response.data["data"]
# if `tfa_token` is None, a new value will be generated
self.assertTrue(resp_data["two_factor_auth"])
token = User.objects.get(id=self.regular_user.id).tfa_token
self.assertIsNotNone(token)
response = self.client.put(self.url, data=data)
self.assertSuccess(response)
resp_data = response.data["data"]
# if `tfa_token` is not None, the value is not changed
self.assertTrue(resp_data["two_factor_auth"])
self.assertEqual(User.objects.get(id=self.regular_user.id).tfa_token, token)
def test_edit_user_openapi(self):
data = self.data
self.assertIsNone(self.regular_user.open_api_appkey)
data["open_api"] = True
response = self.client.put(self.url, data=data)
self.assertSuccess(response)
resp_data = response.data["data"]
# if `open_api_appkey` is None, a new value will be generated
self.assertTrue(resp_data["open_api"])
key = User.objects.get(id=self.regular_user.id).open_api_appkey
self.assertIsNotNone(key)
response = self.client.put(self.url, data=data)
self.assertSuccess(response)
resp_data = response.data["data"]
# if `openapi_app_key` is not None, the value is not changed
self.assertTrue(resp_data["open_api"])
self.assertEqual(User.objects.get(id=self.regular_user.id).open_api_appkey, key)
def test_import_users(self):
data = {"users": [["user1", "pass1", "eami1@e.com", "user1"],
["user2", "pass3", "eamil3@e.com", "user2"]]
}
resp = self.client.post(self.url, data)
self.assertSuccess(resp)
# successfully created 2 users
self.assertEqual(User.objects.all().count(), 4)
def test_import_duplicate_user(self):
data = {"users": [["user1", "pass1", "eami1@e.com", "user1"],
["user1", "pass1", "eami1@e.com", "user1"]]
}
resp = self.client.post(self.url, data)
self.assertFailed(resp, "DETAIL: Key (username)=(user1) already exists.")
# no user is created
self.assertEqual(User.objects.all().count(), 2)
def test_delete_users(self):
self.test_import_users()
user_ids = User.objects.filter(username__in=["user1", "user2"]).values_list("id", flat=True)
user_ids = ",".join([str(id) for id in user_ids])
resp = self.client.delete(self.url + "?id=" + user_ids)
self.assertSuccess(resp)
self.assertEqual(User.objects.all().count(), 2)
class GenerateUserAPITest(APITestCase):
def setUp(self):
self.create_super_admin()
self.url = self.reverse("generate_user_api")
self.data = {
"number_from": 100, "number_to": 105,
"prefix": "pre", "suffix": "suf",
"default_email": "test@test.com",
"password_length": 8
}
def test_error_case(self):
data = deepcopy(self.data)
data["prefix"] = "t" * 16
data["suffix"] = "s" * 14
resp = self.client.post(self.url, data=data)
self.assertEqual(resp.data["data"], "Username should not more than 32 characters")
data2 = deepcopy(self.data)
data2["number_from"] = 106
resp = self.client.post(self.url, data=data2)
self.assertEqual(resp.data["data"], "Start number must be lower than end number")
@mock.patch("account.views.admin.xlsxwriter.Workbook")
def test_generate_user_success(self, mock_workbook):
resp = self.client.post(self.url, data=self.data)
self.assertSuccess(resp)
mock_workbook.assert_called()
class OpenAPIAppkeyAPITest(APITestCase):
def setUp(self):
self.user = self.create_super_admin()
self.url = self.reverse("open_api_appkey_api")
def test_reset_appkey(self):
resp = self.client.post(self.url, data={})
self.assertFailed(resp)
self.user.open_api = True
self.user.save()
resp = self.client.post(self.url, data={})
self.assertSuccess(resp)
self.assertEqual(resp.data["data"]["appkey"], User.objects.get(username=self.user.username).open_api_appkey)

View File

@@ -1,9 +1,8 @@
from django.urls import path
from ..views.admin import GenerateUserAPI, ResetUserPasswordAPI, UserAdminAPI
from ..views.admin import UserAdminAPI, GenerateUserAPI
urlpatterns = [
path("user", UserAdminAPI.as_view()),
path("generate_user", GenerateUserAPI.as_view()), # DEPRECATED: 前端未调用
path("reset_password", ResetUserPasswordAPI.as_view()),
path("generate_user", GenerateUserAPI.as_view()),
]

View File

@@ -1,56 +1,54 @@
from django.urls import path
from utils.captcha.views import CaptchaAPIView
from ..views.oj import (
SSOAPI,
ApplyResetPasswordAPI,
AvatarUploadAPI,
CheckTFARequiredAPI,
Metrics,
OpenAPIAppkeyAPI,
ProfileProblemDisplayIDRefreshAPI,
ResetPasswordAPI,
SessionManagementAPI,
TwoFactorAuthAPI,
UserActivityRankAPI,
UserChangeEmailAPI,
UserChangePasswordAPI,
Metrics,
UserRegisterAPI,
UserChangeEmailAPI,
UserLoginAPI,
UserLogoutAPI,
UsernameOrEmailCheck,
UserProblemRankAPI,
AvatarUploadAPI,
TwoFactorAuthAPI,
UserProfileAPI,
UserRankAPI,
UserRegisterAPI,
UserActivityRankAPI,
CheckTFARequiredAPI,
SessionManagementAPI,
ProfileProblemDisplayIDRefreshAPI,
OpenAPIAppkeyAPI,
SSOAPI,
)
from utils.captcha.views import CaptchaAPIView
urlpatterns = [
path("login", UserLoginAPI.as_view()),
path("logout", UserLogoutAPI.as_view()),
path("register", UserRegisterAPI.as_view()),
path("change_password", UserChangePasswordAPI.as_view()), # DEPRECATED: 前端未调用
path("change_email", UserChangeEmailAPI.as_view()), # DEPRECATED: 前端未调用
path("apply_reset_password", ApplyResetPasswordAPI.as_view()), # DEPRECATED: 前端未调用
path("reset_password", ResetPasswordAPI.as_view()), # DEPRECATED: 前端未调用
path("change_password", UserChangePasswordAPI.as_view()),
path("change_email", UserChangeEmailAPI.as_view()),
path("apply_reset_password", ApplyResetPasswordAPI.as_view()),
path("reset_password", ResetPasswordAPI.as_view()),
path("captcha", CaptchaAPIView.as_view()),
path("check_username_or_email", UsernameOrEmailCheck.as_view()), # DEPRECATED: 前端未调用
path("check_username_or_email", UsernameOrEmailCheck.as_view()),
path("profile", UserProfileAPI.as_view(), name="user_profile_api"),
path("profile/fresh_display_id", ProfileProblemDisplayIDRefreshAPI.as_view()),
path("metrics", Metrics.as_view()),
path("upload_avatar", AvatarUploadAPI.as_view()),
path("tfa_required", CheckTFARequiredAPI.as_view()), # DEPRECATED: 前端未调用
path("tfa_required", CheckTFARequiredAPI.as_view()),
path(
"two_factor_auth", # DEPRECATED: 前端未调用
"two_factor_auth",
TwoFactorAuthAPI.as_view(),
),
path("user_rank", UserRankAPI.as_view()),
path("user_activity_rank", UserActivityRankAPI.as_view()),
path("user_problem_rank", UserProblemRankAPI.as_view()),
path("sessions", SessionManagementAPI.as_view()), # DEPRECATED: 前端未调用
path("sessions", SessionManagementAPI.as_view()),
path(
"open_api_appkey", # DEPRECATED: 前端未调用
"open_api_appkey",
OpenAPIAppkeyAPI.as_view(),
),
path("sso", SSOAPI.as_view()), # DEPRECATED: 前端未调用
path("sso", SSOAPI.as_view()),
]

View File

@@ -1,12 +1,11 @@
import os
import re
import xlsxwriter
from django.contrib.auth.hashers import make_password
from django.db import IntegrityError, transaction
from django.db.models import F, Q
from django.db import transaction, IntegrityError
from django.db.models import Q
from django.http import HttpResponse
from django.utils.crypto import get_random_string
from django.contrib.auth.hashers import make_password
from submission.models import Submission
from utils.api import APIView, validate_serializer
@@ -16,23 +15,10 @@ from ..decorators import super_admin_required
from ..models import AdminType, ProblemPermission, User, UserProfile
from ..serializers import (
EditUserSerializer,
GenerateUserSerializer,
ImportUserSerializer,
UserAdminSerializer,
GenerateUserSerializer,
)
# ks251XXX 或者 ks2510XX 返回 251 或者 2510
# 其他返回 None
def get_class_name(username):
if username.startswith("ks"):
result = re.search(r"ks\d+", username)
if result:
return result.group(0)[2:]
else:
return None
else:
return None
from ..serializers import ImportUserSerializer
class UserAdminAPI(APIView):
@@ -54,7 +40,6 @@ class UserAdminAPI(APIView):
password=make_password(user_data[1]),
email=user_data[2],
raw_password=user_data[1],
class_name=get_class_name(user_data[0]),
)
)
@@ -100,15 +85,12 @@ class UserAdminAPI(APIView):
pre_username = user.username
user.username = data["username"].lower()
user.class_name = get_class_name(data["username"])
user.email = data["email"].lower()
user.admin_type = data["admin_type"]
user.is_disabled = data["is_disabled"]
if data["admin_type"] == AdminType.STUDENT_ADMIN:
user.problem_permission = data["problem_permission"] or ProblemPermission.OWN
elif data["admin_type"] == AdminType.TEACHER_ADMIN:
user.problem_permission = data["problem_permission"] or ProblemPermission.OWN
if data["admin_type"] == AdminType.ADMIN:
user.problem_permission = data["problem_permission"]
elif data["admin_type"] == AdminType.SUPER_ADMIN:
user.problem_permission = ProblemPermission.ALL
else:
@@ -156,21 +138,12 @@ class UserAdminAPI(APIView):
return self.error("User does not exist")
return self.success(UserAdminSerializer(user).data)
# 获取排序参数
order_by = request.GET.get("order_by", "")
# 根据排序参数设置排序规则
if order_by == "-last_login":
# 最近登录,将 None 值放在最后
user = User.objects.all().order_by(F("last_login").desc(nulls_last=True))
else:
# 默认按创建时间倒序
user = User.objects.all().order_by("-create_time")
user = User.objects.all().order_by("-create_time")
type = request.GET.get("type", "")
is_admin = request.GET.get("admin", "0")
if type:
user = user.filter(admin_type=type)
if is_admin == "1":
user = user.exclude(admin_type=AdminType.REGULAR_USER)
keyword = request.GET.get("keyword", None)
if keyword:
@@ -193,7 +166,6 @@ class UserAdminAPI(APIView):
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class GenerateUserAPI(APIView):
@super_admin_required
def get(self, request):
@@ -267,27 +239,3 @@ class GenerateUserAPI(APIView):
# duplicate key value violates unique constraint "user_username_key"
# DETAIL: Key (username)=(root11) already exists.
return self.error(str(e).split("\n")[1])
class ResetUserPasswordAPI(APIView):
@super_admin_required
def post(self, request):
"""
重置用户密码为随机6位数字(不包括0)
"""
data = request.data
user_id = data["id"]
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return self.error("User does not exist")
# 生成6位随机数字密码(不包括0)
new_password = get_random_string(6, allowed_chars="123456789")
# 设置新密码
user.set_password(new_password)
user.save()
return self.success(new_password)

View File

@@ -1,67 +1,54 @@
import asyncio
import os
from datetime import timedelta
from importlib import import_module
import qrcode
from django.conf import settings
from django.contrib import auth
from django.db.models import Count, Q
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.timezone import now
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
from django.db.models import Count, Q
from django.utils import timezone
import qrcode
from otpauth import TOTP
from options.options import SysOptions
from problem.models import Problem
from submission.models import JudgeStatus, Submission
from utils.api import APIView, AsyncAPIView, CSRFExemptAPIView, validate_serializer
from utils.async_helpers import async_cache_get, async_cache_set
from submission.models import Submission, JudgeStatus
from utils.constants import ContestRuleType
from options.options import SysOptions
from utils.api import APIView, validate_serializer, CSRFExemptAPIView
from utils.captcha import Captcha
from utils.constants import CacheKey
from utils.shortcuts import datetime2str, img2base64, rand_str
from utils.shortcuts import rand_str, img2base64, datetime2str
from ..decorators import login_required
from ..models import AdminType, User, UserProfile
from ..models import User, UserProfile, AdminType
from ..serializers import (
ApplyResetPasswordSerializer,
EditUserProfileSerializer,
ImageUploadForm,
RankInfoSerializer,
ResetPasswordSerializer,
SSOSerializer,
TwoFactorAuthCodeSerializer,
UserChangeEmailSerializer,
UserChangePasswordSerializer,
UserLoginSerializer,
UsernameOrEmailCheckSerializer,
UserProfileSerializer,
UserRegisterSerializer,
UsernameOrEmailCheckSerializer,
RankInfoSerializer,
UserChangeEmailSerializer,
SSOSerializer,
)
from ..serializers import (
TwoFactorAuthCodeSerializer,
UserProfileSerializer,
EditUserProfileSerializer,
ImageUploadForm,
)
from ..tasks import send_email_async
def _totp(token):
return TOTP(token.encode("utf-8"))
def _totp_uri(token, label, issuer):
return _totp(token).to_uri(label, issuer)
def _valid_totp(token, code):
try:
code = int(code)
except (TypeError, ValueError):
return False
return _totp(token).verify(code)
class UserProfileAPI(AsyncAPIView):
class UserProfileAPI(APIView):
@method_decorator(ensure_csrf_cookie)
async def get(self, request, **kwargs):
def get(self, request, **kwargs):
"""
判断是否登录, 若登录返回用户信息
"""
user = request.user
if not user.is_authenticated:
return self.success()
@@ -69,51 +56,56 @@ class UserProfileAPI(AsyncAPIView):
username = request.GET.get("username")
try:
if username:
user = await User.objects.aget(username=username, is_disabled=False)
user = User.objects.get(username=username, is_disabled=False)
else:
user = request.user
# api返回的是自己的信息可以返real_name
show_real_name = True
except User.DoesNotExist:
return self.error("User does not exist")
profile = await UserProfile.objects.select_related("user").aget(user=user)
return self.success(UserProfileSerializer(profile, show_real_name=show_real_name).data)
return self.success(
UserProfileSerializer(user.userprofile, show_real_name=show_real_name).data
)
@login_required
@validate_serializer(EditUserProfileSerializer)
async def put(self, request):
@login_required
def put(self, request):
data = request.data
user_profile = await UserProfile.objects.select_related("user").aget(user=request.user)
user_profile = request.user.userprofile
for k, v in data.items():
setattr(user_profile, k, v)
await user_profile.asave()
return self.success(UserProfileSerializer(user_profile, show_real_name=True).data)
class Metrics(AsyncAPIView):
async def get(self, request):
userid = request.GET.get("userid")
qs = Submission.objects.filter(user_id=userid, contest_id__isnull=True)
count, latest, first = await asyncio.gather(
qs.acount(),
qs.order_by("-create_time").afirst(),
qs.order_by("create_time").afirst(),
)
if count == 0 or not latest or not first:
return self.error("暂无提交")
user_profile.save()
return self.success(
{
"now": datetime2str(timezone.now()),
"latest": datetime2str(latest.create_time),
"first": datetime2str(first.create_time),
}
UserProfileSerializer(user_profile, show_real_name=True).data
)
class AvatarUploadAPI(AsyncAPIView):
class Metrics(APIView):
def get(self, request):
userid = request.GET.get("userid")
submissions = Submission.objects.filter(user_id=userid, contest_id__isnull=True)
if submissions.count() == 0:
return self.error("暂无提交")
else:
latest_submission = submissions.first()
last_submission = submissions.last()
if last_submission and latest_submission:
return self.success(
{
"now": datetime2str(timezone.now()),
"latest": datetime2str(latest_submission.create_time),
"first": datetime2str(last_submission.create_time),
}
)
else:
return self.error("暂无提交")
class AvatarUploadAPI(APIView):
request_parsers = ()
@login_required
async def post(self, request):
def post(self, request):
form = ImageUploadForm(request.POST, request.FILES)
if form.is_valid():
avatar = form.cleaned_data["image"]
@@ -129,14 +121,13 @@ class AvatarUploadAPI(AsyncAPIView):
with open(os.path.join(settings.AVATAR_UPLOAD_DIR, name), "wb") as img:
for chunk in avatar:
img.write(chunk)
user_profile = await UserProfile.objects.aget(user=request.user)
user_profile = request.user.userprofile
user_profile.avatar = f"{settings.AVATAR_URI_PREFIX}/{name}"
await user_profile.asave()
user_profile.save()
return self.success("Succeeded")
# DEPRECATED: 前端未调用 (2026-05-26)
class TwoFactorAuthAPI(APIView):
@login_required
def get(self, request):
@@ -151,7 +142,11 @@ class TwoFactorAuthAPI(APIView):
user.save()
label = f"{SysOptions.website_name_shortcut}:{user.username}"
image = qrcode.make(_totp_uri(token, label, SysOptions.website_name.replace(" ", "")))
image = qrcode.make(
TOTP(token).to_uri(
"totp", label, SysOptions.website_name.replace(" ", "")
)
)
return self.success(img2base64(image))
@login_required
@@ -162,7 +157,7 @@ class TwoFactorAuthAPI(APIView):
"""
code = request.data["code"]
user = request.user
if _valid_totp(user.tfa_token, code):
if TOTP(user.tfa_token).verify(code):
user.two_factor_auth = True
user.save()
return self.success("Succeeded")
@@ -176,7 +171,7 @@ class TwoFactorAuthAPI(APIView):
user = request.user
if not user.two_factor_auth:
return self.error("2FA is already turned off")
if _valid_totp(user.tfa_token, code):
if TOTP(user.tfa_token).verify(code):
user.two_factor_auth = False
user.save()
return self.success("Succeeded")
@@ -184,7 +179,6 @@ class TwoFactorAuthAPI(APIView):
return self.error("Invalid code")
# DEPRECATED: 前端未调用 (2026-05-26)
class CheckTFARequiredAPI(APIView):
@validate_serializer(UsernameOrEmailCheckSerializer)
def post(self, request):
@@ -202,27 +196,28 @@ class CheckTFARequiredAPI(APIView):
return self.success({"result": result})
class UserLoginAPI(AsyncAPIView):
class UserLoginAPI(APIView):
@validate_serializer(UserLoginSerializer)
async def post(self, request):
def post(self, request):
"""
User login api
"""
data = request.data
user = await auth.aauthenticate(username=data["username"], password=data["password"])
user = auth.authenticate(username=data["username"], password=data["password"])
# None is returned if username or password is wrong
if user:
if user.is_disabled:
return self.error("Your account has been disabled")
if not user.two_factor_auth:
prev_login = user.last_login
await auth.alogin(request, user)
request.session["prev_login"] = datetime2str(prev_login) if prev_login else ""
auth.login(request, user)
return self.success("Succeeded")
# `tfa_code` not in post data
if user.two_factor_auth and "tfa_code" not in data:
return self.error("tfa_required")
if _valid_totp(user.tfa_token, data["tfa_code"]):
prev_login = user.last_login
await auth.alogin(request, user)
request.session["prev_login"] = datetime2str(prev_login) if prev_login else ""
if TOTP(user.tfa_token).verify(data["tfa_code"]):
auth.login(request, user)
return self.success("Succeeded")
else:
return self.error("Invalid two factor verification code")
@@ -230,13 +225,12 @@ class UserLoginAPI(AsyncAPIView):
return self.error("Invalid username or password")
class UserLogoutAPI(AsyncAPIView):
async def get(self, request):
await auth.alogout(request)
class UserLogoutAPI(APIView):
def get(self, request):
auth.logout(request)
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class UsernameOrEmailCheck(APIView):
@validate_serializer(UsernameOrEmailCheckSerializer)
def post(self, request):
@@ -247,16 +241,21 @@ class UsernameOrEmailCheck(APIView):
# True means already exist.
result = {"username": False, "email": False}
if data.get("username"):
result["username"] = User.objects.filter(username=data["username"].lower()).exists()
result["username"] = User.objects.filter(
username=data["username"].lower()
).exists()
if data.get("email"):
result["email"] = User.objects.filter(email=data["email"].lower()).exists()
return self.success(result)
class UserRegisterAPI(AsyncAPIView):
class UserRegisterAPI(APIView):
@validate_serializer(UserRegisterSerializer)
async def post(self, request):
if not await SysOptions.aget("allow_register"):
def post(self, request):
"""
User register api
"""
if not SysOptions.allow_register:
return self.error("Register function has been disabled by admin")
data = request.data
@@ -265,29 +264,30 @@ class UserRegisterAPI(AsyncAPIView):
captcha = Captcha(request)
if not captcha.check(data["captcha"]):
return self.error("Invalid captcha")
if await User.objects.filter(username=data["username"]).aexists():
if User.objects.filter(username=data["username"]).exists():
return self.error("Username already exists")
if await User.objects.filter(email=data["email"]).aexists():
if User.objects.filter(email=data["email"]).exists():
return self.error("Email already exists")
user = await User.objects.acreate(username=data["username"], email=data["email"])
user = User.objects.create(username=data["username"], email=data["email"])
user.set_password(data["password"])
await user.asave()
await UserProfile.objects.acreate(user=user)
user.save()
UserProfile.objects.create(user=user)
return self.success("Succeeded")
# DEPRECATED: 前端未调用 (2026-05-26)
class UserChangeEmailAPI(APIView):
@validate_serializer(UserChangeEmailSerializer)
@login_required
def post(self, request):
data = request.data
user = auth.authenticate(username=request.user.username, password=data["password"])
user = auth.authenticate(
username=request.user.username, password=data["password"]
)
if user:
if user.two_factor_auth:
if "tfa_code" not in data:
return self.error("tfa_required")
if not _valid_totp(user.tfa_token, data["tfa_code"]):
if not TOTP(user.tfa_token).verify(data["tfa_code"]):
return self.error("Invalid two factor verification code")
data["new_email"] = data["new_email"].lower()
if User.objects.filter(email=data["new_email"]).exists():
@@ -299,7 +299,6 @@ class UserChangeEmailAPI(APIView):
return self.error("Wrong password")
# DEPRECATED: 前端未调用 (2026-05-26)
class UserChangePasswordAPI(APIView):
@validate_serializer(UserChangePasswordSerializer)
@login_required
@@ -314,7 +313,7 @@ class UserChangePasswordAPI(APIView):
if user.two_factor_auth:
if "tfa_code" not in data:
return self.error("tfa_required")
if not _valid_totp(user.tfa_token, data["tfa_code"]):
if not TOTP(user.tfa_token).verify(data["tfa_code"]):
return self.error("Invalid two factor verification code")
user.set_password(data["new_password"])
user.save()
@@ -323,7 +322,6 @@ class UserChangePasswordAPI(APIView):
return self.error("Invalid old password")
# DEPRECATED: 前端未调用 (2026-05-26)
class ApplyResetPasswordAPI(APIView):
@validate_serializer(ApplyResetPasswordSerializer)
def post(self, request):
@@ -337,7 +335,12 @@ class ApplyResetPasswordAPI(APIView):
user = User.objects.get(email__iexact=data["email"])
except User.DoesNotExist:
return self.error("User does not exist")
if user.reset_password_token_expire_time and 0 < int((user.reset_password_token_expire_time - now()).total_seconds()) < 20 * 60:
if (
user.reset_password_token_expire_time
and 0
< int((user.reset_password_token_expire_time - now()).total_seconds())
< 20 * 60
):
return self.error("You can only reset password once per 20 minutes")
user.reset_password_token = rand_str()
user.reset_password_token_expire_time = now() + timedelta(minutes=20)
@@ -358,7 +361,6 @@ class ApplyResetPasswordAPI(APIView):
return self.success("Succeeded")
# DEPRECATED: 前端未调用 (2026-05-26)
class ResetPasswordAPI(APIView):
@validate_serializer(ResetPasswordSerializer)
def post(self, request):
@@ -379,7 +381,6 @@ class ResetPasswordAPI(APIView):
return self.success("Succeeded")
# DEPRECATED: 前端未调用 (2026-05-26)
class SessionManagementAPI(APIView):
@login_required
def get(self, request):
@@ -423,115 +424,78 @@ class SessionManagementAPI(APIView):
return self.error("Invalid session_key")
class UserRankAPI(AsyncAPIView):
async def get(self, request):
class UserRankAPI(APIView):
def get(self, request):
rule_type = request.GET.get("rule")
username = request.GET.get("username", "")
try:
n = int(request.GET.get("n", "0"))
except ValueError:
n = 0
if rule_type not in ContestRuleType.choices():
rule_type = ContestRuleType.ACM
profiles = UserProfile.objects.filter(
user__admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN],
user__admin_type=AdminType.REGULAR_USER,
user__is_disabled=False,
user__username__icontains=username,
).select_related("user").filter(accepted_number__gte=0).order_by("-accepted_number", "submission_number")
).select_related("user")
if rule_type == ContestRuleType.ACM:
profiles = profiles.filter(accepted_number__gte=0).order_by(
"-accepted_number", "submission_number"
)
else:
profiles = profiles.filter(total_score__gt=0).order_by("-total_score")
if n > 0:
profiles = profiles[:n]
return self.success(await self.async_paginate_data(request, profiles, RankInfoSerializer))
return self.success(self.paginate_data(request, profiles, RankInfoSerializer))
class UserActivityRankAPI(AsyncAPIView):
async def get(self, request):
class UserActivityRankAPI(APIView):
def get(self, request):
start = request.GET.get("start")
if not start:
return self.error("start time is required")
cache_key = f"{CacheKey.user_activity_rank}:{start}"
cached = await async_cache_get(cache_key)
if cached is not None:
return self.success(cached)
hidden_names = User.objects.filter(Q(admin_type=AdminType.SUPER_ADMIN) | Q(is_disabled=True)).values_list("username", flat=True)
hidden_names = User.objects.filter(
Q(admin_type=AdminType.SUPER_ADMIN)
| Q(admin_type=AdminType.ADMIN)
| Q(is_disabled=True)
).values_list("username", flat=True)
submissions = Submission.objects.filter(
contest_id__isnull=True,
create_time__gte=start,
result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
).exclude(username__in=hidden_names)
data = [
row
async for row in submissions.values("username")
.annotate(count=Count("problem_id", distinct=True))
.order_by("-count")[:10]
]
await async_cache_set(cache_key, data, 600)
return self.success(data)
class UserProblemRankAPI(AsyncAPIView):
async def get(self, request):
problem_id = request.GET.get("problem_id")
user = request.user
if not user.is_authenticated:
return self.error("User is not authenticated")
problem = await Problem.objects.aget(_id__iexact=problem_id, contest_id__isnull=True, visible=True)
submissions = Submission.objects.filter(problem=problem, result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])
all_ac_count = await submissions.values("user_id").distinct().acount()
class_name = user.class_name or ""
class_ac_count = 0
if class_name:
users = User.objects.filter(class_name=user.class_name, is_disabled=False).values_list("id", flat=True)
user_ids = [user_id async for user_id in users]
submissions = submissions.filter(user_id__in=user_ids)
class_ac_count = await submissions.values("user_id").distinct().acount()
my_submissions = submissions.filter(user_id=user.id)
if not await my_submissions.aexists():
return self.success(
{
"class_name": class_name,
"rank": -1,
"class_ac_count": class_ac_count,
"all_ac_count": all_ac_count,
}
)
my_first_submission = await my_submissions.order_by("create_time").afirst()
rank = await submissions.filter(create_time__lte=my_first_submission.create_time).acount()
return self.success(
{
"class_name": class_name,
"rank": rank,
"class_ac_count": class_ac_count,
"all_ac_count": all_ac_count,
}
contest_id__isnull=True, create_time__gte=start, result=JudgeStatus.ACCEPTED
)
counts = (
submissions.values("username")
.annotate(count=Count("problem_id", distinct=True))
.order_by("-count")[: 10 + len(hidden_names)]
)
data = []
for count in counts:
if count["username"] not in hidden_names:
data.append(count)
return self.success(data[:10])
class ProfileProblemDisplayIDRefreshAPI(AsyncAPIView):
class ProfileProblemDisplayIDRefreshAPI(APIView):
@login_required
async def get(self, request):
profile = await UserProfile.objects.aget(user=request.user)
def get(self, request):
profile = request.user.userprofile
acm_problems = profile.acm_problems_status.get("problems", {})
oi_problems = profile.oi_problems_status.get("problems", {})
ids = list(acm_problems.keys()) + list(oi_problems.keys())
if not ids:
return self.success()
display_ids = [did async for did in Problem.objects.filter(id__in=ids, visible=True).values_list("_id", flat=True)]
display_ids = Problem.objects.filter(id__in=ids, visible=True).values_list(
"_id", flat=True
)
id_map = dict(zip(ids, display_ids))
for k, v in acm_problems.items():
v["_id"] = id_map[k]
for k, v in oi_problems.items():
v["_id"] = id_map[k]
await profile.asave(update_fields=["acm_problems_status", "oi_problems_status"])
profile.save(update_fields=["acm_problems_status", "oi_problems_status"])
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class OpenAPIAppkeyAPI(APIView):
@login_required
def post(self, request):
@@ -544,7 +508,6 @@ class OpenAPIAppkeyAPI(APIView):
return self.success({"appkey": api_appkey})
# DEPRECATED: 前端未调用 (2026-05-26)
class SSOAPI(CSRFExemptAPIView):
@login_required
def get(self, request):

View File

View File

@@ -1,6 +0,0 @@
from django.apps import AppConfig
class AiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ai'

View File

@@ -1,34 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-24 12:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AIAnalysis',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.TextField(default='deepseek')),
('data', models.JSONField()),
('system_prompt', models.TextField()),
('user_prompt', models.TextField()),
('analysis', models.TextField()),
('create_time', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'ai_analysis',
'ordering': ['-create_time'],
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-24 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='aianalysis',
name='model',
field=models.TextField(default='deepseek-chat'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2026-04-27 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0002_aianalysis_model'),
]
operations = [
migrations.AlterField(
model_name='aianalysis',
name='model',
field=models.TextField(default='deepseek-v4-flash'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0.4 on 2026-06-04 14:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0003_alter_aianalysis_model'),
]
operations = [
migrations.AddField(
model_name='aianalysis',
name='is_pinned',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,19 +0,0 @@
from django.db import models
from account.models import User
class AIAnalysis(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
provider = models.TextField(default="deepseek")
model = models.TextField(default="deepseek-v4-flash")
data = models.JSONField()
system_prompt = models.TextField()
user_prompt = models.TextField()
analysis = models.TextField()
is_pinned = models.BooleanField(default=False)
create_time = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "ai_analysis"
ordering = ["-create_time"]

View File

@@ -1,27 +0,0 @@
from utils.api import serializers
from .models import AIAnalysis
class AIAnalysisListSerializer(serializers.ModelSerializer):
username = serializers.CharField(source="user.username")
analysis_excerpt = serializers.SerializerMethodField()
class Meta:
model = AIAnalysis
fields = ["id", "create_time", "username", "analysis_excerpt", "is_pinned"]
def get_analysis_excerpt(self, obj):
if not obj.analysis:
return ""
text = " ".join(obj.analysis.split())
return text[:120] if len(text) <= 120 else text[:120] + ""
class AIAnalysisDetailSerializer(serializers.ModelSerializer):
username = serializers.CharField(source="user.username")
class_name = serializers.CharField(source="user.class_name")
class Meta:
model = AIAnalysis
fields = ["id", "create_time", "username", "class_name", "analysis"]

View File

View File

@@ -1,7 +0,0 @@
from django.urls import path
from ..views.admin import AIAnalysisAdminAPI
urlpatterns = [
path("ai/reports", AIAnalysisAdminAPI.as_view()),
]

View File

@@ -1,25 +0,0 @@
from django.urls import path
from ..views.oj import (
AIAnalysisAPI,
AIDetailDataAPI,
AIDurationDataAPI,
AIHeatmapDataAPI,
AIHintAPI,
AILoginSummaryAPI,
AIPinnedReportAPI,
ClassPKAnalysisAPI,
SingleClassAnalysisAPI,
)
urlpatterns = [
path("ai/detail", AIDetailDataAPI.as_view()),
path("ai/duration", AIDurationDataAPI.as_view()),
path("ai/analysis", AIAnalysisAPI.as_view()),
path("ai/hint", AIHintAPI.as_view()),
path("ai/heatmap", AIHeatmapDataAPI.as_view()),
path("ai/login_summary", AILoginSummaryAPI.as_view()),
path("ai/pinned", AIPinnedReportAPI.as_view()),
path("ai/class_pk", ClassPKAnalysisAPI.as_view()),
path("ai/class_single", SingleClassAnalysisAPI.as_view()),
]

View File

View File

@@ -1,45 +0,0 @@
from account.decorators import teacher_admin_required
from utils.api import APIView
from ..models import AIAnalysis
from ..serializers import AIAnalysisDetailSerializer, AIAnalysisListSerializer
class AIAnalysisAdminAPI(APIView):
@teacher_admin_required
def get(self, request):
report_id = request.GET.get("id")
if report_id:
try:
report = AIAnalysis.objects.select_related("user").get(id=report_id)
except AIAnalysis.DoesNotExist:
return self.error("AIAnalysis not found")
return self.success(AIAnalysisDetailSerializer(report).data)
qs = AIAnalysis.objects.select_related("user").order_by("-create_time")
username = request.GET.get("username")
if username:
qs = qs.filter(user__username__icontains=username)
if request.GET.get("pinned_only") == "true":
pinned = qs.filter(is_pinned=True)
return self.success(AIAnalysisListSerializer(pinned, many=True).data)
return self.success(self.paginate_data(request, qs, AIAnalysisListSerializer))
@teacher_admin_required
def post(self, request):
report_id = request.data.get("id")
try:
report = AIAnalysis.objects.select_related("user").get(id=report_id)
except AIAnalysis.DoesNotExist:
return self.error("AIAnalysis not found")
if report.is_pinned:
report.is_pinned = False
else:
AIAnalysis.objects.filter(user=report.user, is_pinned=True).update(is_pinned=False)
report.is_pinned = True
report.save(update_fields=["is_pinned"])
return self.success({"is_pinned": report.is_pinned})

View File

@@ -1,973 +0,0 @@
import hashlib
import json
from collections import defaultdict
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from django.core.cache import cache
from django.db.models import Count, Min
from django.db.models.functions import TruncDate
from django.http import StreamingHttpResponse
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from account.decorators import login_required, teacher_admin_required
from account.models import User
from ai.models import AIAnalysis
from ai.serializers import AIAnalysisDetailSerializer
from flowchart.models import FlowchartSubmission, FlowchartSubmissionStatus
from problem.models import Problem
from submission.models import JudgeStatus, Submission
from utils.api import APIView
from utils.openai import get_ai_client, get_async_ai_client
from utils.shortcuts import datetime2str
CACHE_TIMEOUT = 300
DIFFICULTY_MAP = {"Low": "简单", "Mid": "中等", "High": "困难"}
DEFAULT_CLASS_SIZE = 45
# 评级阈值配置:(百分位上限, 评级)
GRADE_THRESHOLDS = [
(10, "S"), # 前10%: S级 - 卓越
(35, "A"), # 前35%: A级 - 优秀
(75, "B"), # 前75%: B级 - 良好
(100, "C"), # 其余: C级 - 及格
]
# 小规模参与惩罚配置:(最小人数, 等级降级映射)
SMALL_SCALE_PENALTY = {
"threshold": 10,
"downgrade": {"S": "A", "A": "B"},
}
# 等级权重映射(用于加权平均计算)
GRADE_WEIGHTS = {"S": 4, "A": 3, "B": 2, "C": 1}
# 平均等级阈值:(最小权重, 等级)
AVERAGE_GRADE_THRESHOLDS = [(3.5, "S"), (2.5, "A"), (1.5, "B")]
def get_cache_key(prefix, *args):
return hashlib.md5(f"{prefix}:{'_'.join(map(str, args))}".encode()).hexdigest()
def get_difficulty(difficulty):
return DIFFICULTY_MAP.get(difficulty, "中等")
def get_grade(rank, submission_count, reference_count=None):
"""
计算题目完成评级
评级标准:
- S级前10%卓越水平10%的人)
- A级前35%优秀水平25%的人)
- B级前75%良好水平40%的人)
- C级75%之后及格水平25%的人)
特殊规则:
- 小规模惩罚用 reference_count全时段人数判断避免同期窗口窄导致惩罚误触发
- reference_count 未传时退化为 submission_count
"""
if not rank or rank <= 0 or submission_count <= 0:
return "C"
percentile = (rank - 1) / submission_count * 100
base_grade = "C"
for threshold, grade in GRADE_THRESHOLDS:
if percentile < threshold:
base_grade = grade
break
penalty_count = reference_count if reference_count is not None else submission_count
if penalty_count < SMALL_SCALE_PENALTY["threshold"]:
base_grade = SMALL_SCALE_PENALTY["downgrade"].get(base_grade, base_grade)
return base_grade
def calculate_average_grade(grades):
"""根据等级列表计算加权平均等级"""
scores = [GRADE_WEIGHTS[g] for g in grades if g in GRADE_WEIGHTS]
if not scores:
return ""
avg = sum(scores) / len(scores)
for threshold, grade in AVERAGE_GRADE_THRESHOLDS:
if avg >= threshold:
return grade
return "C"
def find_user_rank(ranking_list, user_id):
"""在排名列表中找到用户的排名1-based未找到返回 None"""
return next(
(idx + 1 for idx, rec in enumerate(ranking_list) if rec["user_id"] == user_id),
None,
)
def get_class_user_ids(user):
if not user.class_name:
return []
cache_key = get_cache_key("class_users", user.class_name)
user_ids = cache.get(cache_key)
if user_ids is None:
user_ids = list(
User.objects.filter(class_name=user.class_name).values_list("id", flat=True)
)
cache.set(cache_key, user_ids, CACHE_TIMEOUT)
return user_ids
def get_user_first_ac_submissions(
user_id, start, end, class_user_ids=None, use_class_scope=False, include_all_time=True
):
# 用户自己的 AC 记录按时间范围过滤
user_first_ac = list(
Submission.objects.filter(
result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
user_id=user_id,
create_time__gte=start,
create_time__lte=end,
)
.values("problem_id")
.annotate(first_ac_time=Min("create_time"))
)
if not user_first_ac:
return [], {}, []
problem_ids = [item["problem_id"] for item in user_first_ac]
if not include_all_time:
return user_first_ac, {}, problem_ids
# 排名基于全局数据(不限时间),后注册的学生与所有人公平竞争
rank_qs = Submission.objects.filter(
result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
problem_id__in=problem_ids,
)
if use_class_scope and class_user_ids:
rank_qs = rank_qs.filter(user_id__in=class_user_ids)
ranked_first_ac = list(
rank_qs.values("user_id", "problem_id").annotate(first_ac_time=Min("create_time"))
)
by_problem = defaultdict(list)
for item in ranked_first_ac:
by_problem[item["problem_id"]].append(item)
for submissions in by_problem.values():
submissions.sort(key=lambda x: (x["first_ac_time"], x["user_id"]))
return user_first_ac, by_problem, problem_ids
async def stream_ai_response(client, system_prompt, user_prompt, on_complete=None):
"""SSE 流式响应异步生成器on_complete(full_text) 在流结束时调用。
必须是异步生成器:在 ASGI 下,同步生成器会被 Django 的
StreamingHttpResponse 通过 sync_to_async(list) 一次性消费完才发送,
导致整段内容生成完毕后才返回,失去流式效果。
"""
try:
stream = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
stream=True,
extra_body={"thinking": {"type": "disabled"}},
)
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
yield "event: end\n\n"
return
yield "event: start\n\n"
chunks = []
try:
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
if choice.finish_reason:
if on_complete:
await on_complete("".join(chunks).strip())
yield f"data: {json.dumps({'type': 'done'})}\n\n"
break
content = choice.delta.content
if content:
chunks.append(content)
yield f"data: {json.dumps({'type': 'delta', 'content': content})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
finally:
yield "event: end\n\n"
def make_sse_response(generator):
"""创建 SSE StreamingHttpResponse"""
response = StreamingHttpResponse(
streaming_content=generator,
content_type="text/event-stream",
)
response["Cache-Control"] = "no-cache"
# 关闭反向代理(如 nginx对流式响应的缓冲
response["X-Accel-Buffering"] = "no"
return response
class AIDetailDataAPI(APIView):
@login_required
def get(self, request):
start = request.GET.get("start")
end = request.GET.get("end")
username = request.GET.get("username")
if not start or not end:
return self.error("参数 start 和 end 不能为空")
if not parse_datetime(start):
return self.error("start 格式无效,请使用 ISO 8601 格式")
if not parse_datetime(end):
return self.error("end 格式无效,请使用 ISO 8601 格式")
user = request.user
if username and request.user.is_teacher_or_above():
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return self.error("User not found")
cache_key = get_cache_key(
"ai_detail", user.id, user.class_name or "", start, end
)
cached_result = cache.get(cache_key)
if cached_result:
return self.success(cached_result)
class_user_ids = get_class_user_ids(user)
use_class_scope = bool(user.class_name) and len(class_user_ids) > 1
user_first_ac, by_problem, problem_ids = get_user_first_ac_submissions(
user.id, start, end, class_user_ids, use_class_scope
)
# 同期排名:只统计时间窗口内解题的人
by_problem_period = defaultdict(list)
if problem_ids:
period_qs = Submission.objects.filter(
result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
problem_id__in=problem_ids,
create_time__gte=start,
create_time__lte=end,
)
if use_class_scope and class_user_ids:
period_qs = period_qs.filter(user_id__in=class_user_ids)
for item in period_qs.values("user_id", "problem_id").annotate(
first_ac_time=Min("create_time")
):
by_problem_period[item["problem_id"]].append(item)
for lst in by_problem_period.values():
lst.sort(key=lambda x: (x["first_ac_time"], x["user_id"]))
result = {
"user": user.username,
"class_name": user.class_name,
"start": start,
"end": end,
"solved": [],
"flowcharts": [],
"grade": "",
"tags": {},
"difficulty": {},
"contest_count": 0,
}
if user_first_ac:
problems = {
p.id: p
for p in Problem.objects.filter(id__in=problem_ids)
.select_related("contest")
.prefetch_related("tags")
}
solved, contest_ids = self._build_solved_records(
user_first_ac, by_problem, by_problem_period, problems, user.id
)
# 查找 flowchart submissions
flowcharts_query = FlowchartSubmission.objects.filter(
user_id=user,
status=FlowchartSubmissionStatus.COMPLETED,
)
# 添加时间范围过滤
if start:
flowcharts_query = flowcharts_query.filter(create_time__gte=start)
if end:
flowcharts_query = flowcharts_query.filter(create_time__lte=end)
flowcharts = flowcharts_query.select_related("problem").only(
"id",
"create_time",
"ai_score",
"ai_grade",
"problem___id",
"problem__title",
)
# 按problem分组
problem_groups = defaultdict(list)
for flowchart in flowcharts:
problem_id = flowchart.problem._id
problem_groups[problem_id].append(flowchart)
flowcharts_data = []
for problem_id, submissions in problem_groups.items():
if not submissions:
continue
# 获取第一个提交的基本信息
first_submission = submissions[0]
# 计算统计数据
scores = [s.ai_score for s in submissions if s.ai_score is not None]
times = [s.create_time for s in submissions]
# 找到最高分和对应的等级
best_score = max(scores) if scores else 0
best_submission = next(
(s for s in submissions if s.ai_score == best_score), submissions[0]
)
best_grade = best_submission.ai_grade or ""
# 计算平均分
avg_score = sum(scores) / len(scores) if scores else 0
# 最新提交时间
latest_time = max(times) if times else first_submission.create_time
merged_item = {
"problem__id": problem_id,
"problem_title": first_submission.problem.title,
"submission_count": len(submissions),
"best_score": best_score,
"best_grade": best_grade,
"latest_submission_time": latest_time.isoformat() if latest_time else None,
"avg_score": round(avg_score, 0),
}
flowcharts_data.append(merged_item)
# 按最新提交时间排序
flowcharts_data.sort(
key=lambda x: x["latest_submission_time"] or "", reverse=True
)
result.update(
{
"solved": solved,
"flowcharts": flowcharts_data,
"grade": calculate_average_grade([s["grade"] for s in solved]),
"tags": self._calculate_top_tags(problems.values()),
"difficulty": self._calculate_difficulty_distribution(
problems.values()
),
"contest_count": len(set(contest_ids)),
}
)
cache.set(cache_key, result, CACHE_TIMEOUT)
return self.success(result)
def _build_solved_records(self, user_first_ac, by_problem, by_problem_period, problems, user_id):
solved, contest_ids = [], []
for item in user_first_ac:
pid = item["problem_id"]
problem = problems.get(pid)
if not problem:
continue
ranking_list = by_problem.get(pid, [])
rank = find_user_rank(ranking_list, user_id)
period_ranking_list = by_problem_period.get(pid, [])
period_rank = find_user_rank(period_ranking_list, user_id)
if problem.contest_id:
contest_ids.append(problem.contest_id)
solved.append(
{
"problem": {
"display_id": problem._id,
"title": problem.title,
"contest_id": problem.contest_id,
"contest_title": getattr(problem.contest, "title", ""),
},
"ac_time": timezone.localtime(item["first_ac_time"]).isoformat(),
"rank": rank,
"ac_count": len(ranking_list),
"grade": get_grade(period_rank, len(period_ranking_list), reference_count=len(ranking_list)),
"period_rank": period_rank,
"period_ac_count": len(period_ranking_list),
"difficulty": get_difficulty(problem.difficulty),
}
)
return sorted(solved, key=lambda x: x["ac_time"]), contest_ids
def _calculate_top_tags(self, problems):
tags_counter = defaultdict(int)
for problem in problems:
for tag in problem.tags.all():
if tag.name:
tags_counter[tag.name] += 1
return dict(sorted(tags_counter.items(), key=lambda x: x[1], reverse=True)[:5])
def _calculate_difficulty_distribution(self, problems):
diff_counter = {"Low": 0, "Mid": 0, "High": 0}
for problem in problems:
diff_counter[
problem.difficulty if problem.difficulty in diff_counter else "Mid"
] += 1
return {
get_difficulty(k): v
for k, v in sorted(diff_counter.items(), key=lambda x: x[1], reverse=True)
}
class AIDurationDataAPI(APIView):
@login_required
def get(self, request):
end_iso = request.GET.get("end")
duration = request.GET.get("duration")
username = request.GET.get("username")
user = request.user
if username and request.user.is_teacher_or_above():
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return self.error("User not found")
cache_key = get_cache_key(
"ai_duration", user.id, user.class_name or "", end_iso, duration
)
cached_result = cache.get(cache_key)
if cached_result:
return self.success(cached_result)
class_user_ids = get_class_user_ids(user)
use_class_scope = bool(user.class_name) and len(class_user_ids) > 1
time_config = self._parse_duration(duration)
start = datetime.fromisoformat(end_iso) - time_config["total_delta"]
duration_data = []
for i in range(time_config["show_count"]):
start = start + time_config["delta"]
period_end = start + time_config["delta"]
submission_count = Submission.objects.filter(
user_id=user.id, create_time__gte=start, create_time__lte=period_end
).count()
period_data = {
"unit": time_config["show_unit"],
"index": time_config["show_count"] - 1 - i,
"start": start.isoformat(),
"end": period_end.isoformat(),
"problem_count": 0,
"submission_count": submission_count,
"grade": "",
}
if submission_count > 0:
user_first_ac, _, problem_ids = get_user_first_ac_submissions(
user.id,
start.isoformat(),
period_end.isoformat(),
class_user_ids,
use_class_scope,
include_all_time=False,
)
if user_first_ac:
period_data["problem_count"] = len(problem_ids)
by_problem_period = defaultdict(list)
period_qs = Submission.objects.filter(
result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
problem_id__in=problem_ids,
create_time__gte=start,
create_time__lte=period_end,
)
if use_class_scope and class_user_ids:
period_qs = period_qs.filter(user_id__in=class_user_ids)
for row in period_qs.values("user_id", "problem_id").annotate(
first_ac_time=Min("create_time")
):
by_problem_period[row["problem_id"]].append(row)
for lst in by_problem_period.values():
lst.sort(key=lambda x: (x["first_ac_time"], x["user_id"]))
grades = [
get_grade(
find_user_rank(by_problem_period.get(item["problem_id"], []), user.id),
len(by_problem_period.get(item["problem_id"], [])),
)
for item in user_first_ac
]
period_data["grade"] = calculate_average_grade(grades)
duration_data.append(period_data)
cache.set(cache_key, duration_data, CACHE_TIMEOUT)
return self.success(duration_data)
def _parse_duration(self, duration):
unit, count = duration.split(":")
count = int(count)
configs = {
("months", 2): {
"show_count": 8,
"show_unit": "weeks",
"total_delta": timedelta(weeks=9),
"delta": timedelta(weeks=1),
},
("months", 6): {
"show_count": 6,
"show_unit": "months",
"total_delta": relativedelta(months=7),
"delta": relativedelta(months=1),
},
("years", 1): {
"show_count": 12,
"show_unit": "months",
"total_delta": relativedelta(months=13),
"delta": relativedelta(months=1),
},
}
return configs.get(
(unit, count),
{
"show_count": 4,
"show_unit": "weeks",
"total_delta": timedelta(weeks=5),
"delta": timedelta(weeks=1),
},
)
class AILoginSummaryAPI(APIView):
@login_required
def get(self, request):
user = request.user
end_time = timezone.now()
start_time = self._resolve_start_time(request, user, end_time)
problems_qs = Problem.objects.filter(
create_time__gte=start_time,
create_time__lte=end_time,
contest_id__isnull=True,
visible=True,
)
new_problem_count = problems_qs.count()
submissions_qs = Submission.objects.filter(
user_id=user.id, create_time__gte=start_time, create_time__lte=end_time
)
submission_count = submissions_qs.count()
accepted_count = submissions_qs.filter(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]).count()
solved_count = (
submissions_qs.filter(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])
.values("problem_id")
.distinct()
.count()
)
flowchart_submission_count = FlowchartSubmission.objects.filter(
user_id=user.id, create_time__gte=start_time, create_time__lte=end_time
).count()
summary = {
"start": datetime2str(start_time),
"end": datetime2str(end_time),
"new_problem_count": new_problem_count,
"submission_count": submission_count,
"accepted_count": accepted_count,
"solved_count": solved_count,
"flowchart_submission_count": flowchart_submission_count,
}
analysis = ""
analysis_error = ""
if submission_count >= 3:
analysis, analysis_error = self._get_ai_analysis(summary)
data = {"summary": summary, "analysis": analysis}
if analysis_error:
data["analysis_error"] = analysis_error
return self.success(data)
def _resolve_start_time(self, request, user, end_time):
start_raw = request.session.get("prev_login") or request.GET.get("start")
start_time = parse_datetime(start_raw) if start_raw else None
if start_time and timezone.is_naive(start_time):
start_time = timezone.make_aware(
start_time, timezone.get_current_timezone()
)
if not start_time:
if user.last_login and user.last_login < end_time:
start_time = user.last_login
elif user.create_time:
start_time = user.create_time
else:
start_time = end_time - timedelta(days=7)
if start_time >= end_time:
start_time = end_time - timedelta(days=1)
return start_time
def _get_ai_analysis(self, summary):
try:
client = get_ai_client()
except Exception as exc:
return "", str(exc)
system_prompt = (
"你是 OnlineJudge 的学习助教。"
"请根据统计数据给出简短分析(1-2句),再给出一行结论,"
"结论用“结论:”开头。"
)
user_prompt = (
f"时间范围:{summary['start']}{summary['end']}\n"
f"新题目数:{summary['new_problem_count']}\n"
f"提交次数:{summary['submission_count']}\n"
f"AC 次数:{summary['accepted_count']}\n"
f"AC 题目数:{summary['solved_count']}\n"
f"流程图提交数:{summary['flowchart_submission_count']}\n"
)
try:
completion = client.chat.completions.create(
model="deepseek-v4-flash",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
extra_body={"thinking": {"type": "disabled"}},
)
except Exception as exc:
return "", str(exc)
if not completion.choices:
return "", ""
content = completion.choices[0].message.content or ""
return content.strip(), ""
class AIAnalysisAPI(APIView):
@login_required
def post(self, request):
details = request.data.get("details")
duration = request.data.get("duration")
client = get_async_ai_client()
system_prompt = (
"你是一个风趣的编程老师,学生使用判题狗平台进行编程练习。"
"请根据学生提供的详细数据和每周数据,给出用户的学习建议,最后写一句鼓励学生的话。"
"请使用 markdown 格式输出,不要在代码块中输出。"
)
user_prompt = f"这段时间内的详细数据: {details}\n(其中部分字段含义是 flowcharts:流程图的提交,solved:代码的提交)\n每周或每月的数据: {duration}"
user = request.user
async def on_complete(full_text):
await AIAnalysis.objects.acreate(
user=user,
provider="deepseek",
model="deepseek-v4-flash",
data={"details": details, "duration": duration},
system_prompt=system_prompt,
user_prompt="这段时间内的详细数据,每周或每月的数据。",
analysis=full_text,
)
return make_sse_response(
stream_ai_response(client, system_prompt, user_prompt, on_complete)
)
class ClassPKAnalysisAPI(APIView):
@teacher_admin_required
def post(self, request):
comparisons = request.data.get("comparisons")
time_range_label = request.data.get("time_range_label", "全部时间")
if not comparisons or len(comparisons) < 2:
return self.error("至少需要2个班级的数据")
client = get_async_ai_client()
system_prompt = (
"你是一位经验丰富的编程教育数据分析专家,专注于职业院校计算机编程教学效果评估。"
"请根据在线评测系统OJ提供的班级对比数据给出深入、专业的分析报告。"
"请使用 markdown 格式输出,不要在代码块中输出。"
)
user_prompt = self._build_prompt(comparisons, time_range_label)
return make_sse_response(stream_ai_response(client, system_prompt, user_prompt))
def _build_prompt(self, comparisons, time_range_label):
def fmt_class(name):
return f"{name[:2]}计算机{name[2:]}"
lines = [
f"时间范围:{time_range_label}",
"",
"## 指标说明",
"- 总AC数班级累计通过的题目总数",
'- 平均AC / 中位数AC平均受尖子生影响中位数反映"典型学生"的真实水平',
"- 前10%/中间80%/后10%均值:梯队分层,判断是尖子班还是均衡班",
"- 优秀率 / 及格率:达到优秀线、及格线的学生比例",
"- 参与度:有过任意提交的学生比例(反映主动性)",
"- AC率提交通过率反映代码编写质量",
"- 综合分综合多项指标计算的总评分满分100",
"",
"## 班级数据",
]
for i, c in enumerate(comparisons):
class_display = fmt_class(c["class_name"])
lines.append(f"\n### 第{i + 1}名:{class_display}(综合分 {c['composite_score']:.1f}")
lines.append(f"- 人数:{c['user_count']}")
lines.append(
f"- 总AC数{c['total_ac']},总提交数:{c['total_submission']}AC率{c['ac_rate']:.1f}%"
)
lines.append(
f"- 平均AC{c['avg_ac']:.2f}中位数AC{c['median_ac']:.2f}"
)
lines.append(
f"- Q1{c['q1_ac']:.2f}Q3{c['q3_ac']:.2f}"
f"IQR四分位距{c['iqr']:.2f},标准差:{c['std_dev']:.2f}"
)
lines.append(
f"- 前10%均值:{c['top_10_avg']:.2f}中间80%均值:{c['middle_80_avg']:.2f}"
f"后10%均值:{c['bottom_10_avg']:.2f}"
)
lines.append(
f"- 优秀率:{c['excellent_rate']:.1f}%,及格率:{c['pass_rate']:.1f}%"
f"参与度:{c['active_rate']:.1f}%"
)
if c.get("recent_total_ac") is not None:
lines.append(
f"- 时间段内新增总AC {c['recent_total_ac']}"
f"平均AC {c.get('recent_avg_ac', 0):.2f}"
f"中位数AC {c.get('recent_median_ac', 0):.2f}"
f"前10%平均 {c.get('recent_top_10_avg', 0):.2f}"
f"活跃学生数 {c.get('recent_active_count', 0)}"
)
lines += [
"",
"## 请从以下7个维度分析输出中文报告",
"",
"**1. 总体排名**:直接给出排名,说明各班综合分差距大小。",
"",
"**2. 参与积极性**:对比参与度和总提交数,谁的班学生更积极主动?",
"",
'**3. 典型学生水平**重点用中位数AC数对比而非平均值'
'分析谁班的"普通学生"更强。若均值明显高于中位数,说明均值被少数强者拉高,需指出。',
"",
'**4. 班级内部均衡性**结合标准差、IQR、前10%与后10%差距,'
'判断哪个班是"均衡型",哪个班是"两极型"',
"",
"**5. 梯队深度对比**对比各班前10%均值尖子生天花板和后10%均值(薄弱学生水平),"
"分析各班在培养尖子生和帮扶后进生上的差异。",
"",
'**6. 代码提交质量**对比AC率是否有班级存在"凑提交次数但不思考"的问题?',
"",
"**7. 综合结论与建议**用1句话明确说明胜负"
"对落后班级给出2~3条具体可操作的改进建议点出领先班级1条值得借鉴的做法。",
"",
"分析对象是班级任课教师,语言专业但不过分学术。",
]
return "\n".join(lines)
class SingleClassAnalysisAPI(APIView):
@login_required
def post(self, request):
comparison = request.data.get("comparison")
if not comparison:
return self.error("缺少班级数据")
client = get_async_ai_client()
class_name = comparison.get("class_name", "")
class_display = f"{class_name[:2]}计算机{class_name[2:]}"
system_prompt = (
"你是一位经验丰富的编程教育数据分析专家,专注于职业院校计算机编程教学效果评估。"
"请根据在线评测系统OJ提供的班级数据给出深入、专业的分析报告。"
"请使用 markdown 格式输出,不要在代码块中输出。"
)
lines = [
f"## 班级:{class_display}",
"",
"## 指标说明",
"- 总AC数班级累计通过的题目总数",
'- 平均AC / 中位数AC平均受尖子生影响中位数反映"典型学生"的真实水平',
"- 前10%/中间80%/后10%均值:梯队分层",
"- 优秀率 / 及格率分别基于班级内部Q3/Q1阈值统计",
"- 参与度:有过任意提交的学生比例",
"- AC率提交通过率",
"- 综合分综合多项指标计算的总评分满分100",
"",
"## 班级数据",
f"- 人数:{comparison['user_count']}",
f"- 总AC数{comparison['total_ac']},总提交数:{comparison['total_submission']}AC率{comparison['ac_rate']:.1f}%",
f"- 平均AC{comparison['avg_ac']:.2f}中位数AC{comparison['median_ac']:.2f}",
f"- Q1{comparison['q1_ac']:.2f}Q3{comparison['q3_ac']:.2f}IQR{comparison['iqr']:.2f},标准差:{comparison['std_dev']:.2f}",
f"- 前10%均值:{comparison['top_10_avg']:.2f}中间80%均值:{comparison['middle_80_avg']:.2f}后10%均值:{comparison['bottom_10_avg']:.2f}",
f"- 优秀率:{comparison['excellent_rate']:.1f}%,及格率:{comparison['pass_rate']:.1f}%,参与度:{comparison['active_rate']:.1f}%",
f"- 综合分:{comparison['composite_score']:.1f}",
"",
"## 请从以下5个维度分析输出中文报告",
"",
"**1. 整体水平**基于总AC数、均值与中位数评价班级的整体编程水平。若均值明显高于中位数需指出被少数强者拉高的情况。",
"",
"**2. 参与积极性**结合参与度和AC率评价学生的学习主动性和代码质量。",
"",
'**3. 班级内部均衡性**结合标准差、IQR、前10%与后10%差距,判断是"均衡型"还是"两极型"班级。',
"",
"**4. 梯队分析**分析前10%尖子生、中间80%中坚力量、后10%(需帮扶学生)的水平差异,给出针对性建议。",
"",
"**5. 改进建议**结合优秀率、及格率等数据给出3条具体可操作的教学改进建议。最后用一句话鼓励这个班级。",
"",
"分析对象是班级任课教师,语言专业但不过分学术。",
]
user_prompt = "\n".join(lines)
return make_sse_response(stream_ai_response(client, system_prompt, user_prompt))
class AIPinnedReportAPI(APIView):
@login_required
def get(self, request):
try:
report = AIAnalysis.objects.get(user=request.user, is_pinned=True)
except AIAnalysis.DoesNotExist:
return self.success(None)
return self.success(AIAnalysisDetailSerializer(report).data)
class AIHintAPI(APIView):
@login_required
def post(self, request):
submission_id = request.data.get("submission_id")
if not submission_id:
return self.error("submission_id is required")
try:
submission = Submission.objects.get(id=submission_id, user_id=request.user.id)
except Submission.DoesNotExist:
return self.error("Submission not found")
problem = submission.problem
client = get_async_ai_client()
# 获取参考答案(同语言优先,否则取第一个)
answers = problem.answers or []
ref_answer = next(
(a["code"] for a in answers if a["language"] == submission.language),
answers[0]["code"] if answers else "",
)
system_prompt = (
"你是编程助教。你知道题目的参考答案,请按照以下规则给学生提示:\n\n"
"【核心规则】\n"
"- 【绝对禁止】直接给出答案或核心算法代码,也不能暗示完整解法。\n"
"- 提示要循序渐进:先指出问题所在的方向,再给出一个小的思考点,让学生自己推导。\n"
"- 对照参考答案分析学生代码,找出最关键的一个问题重点提示,不要一次列出所有问题。\n\n"
"【输入处理例外】\n"
"- 如果学生的代码在【读取输入】部分有错误(例如:输入格式解析错误、"
"未正确读取多组输入、split/scanf 使用有误等),"
"则【直接给出正确的输入读取代码片段】,并解释为什么这样写。"
"输入处理不属于算法核心,可以直接告诉学生。\n\n"
"【回复格式】\n"
"语气鼓励,使用 Markdown 格式回复简洁不超过6句话"
)
user_prompt = (
f"题目:{problem.title}\n"
f"题目描述:{problem.description[:500]}\n"
f"参考答案(仅供你分析,不可透露给学生):\n```\n{ref_answer[:2000]}\n```\n"
f"学生提交语言:{submission.language}\n"
f"判题结果:{submission.result}\n"
f"错误信息:{submission.statistic_info.get('err_info', '')}\n"
f"学生代码:\n```\n{submission.code[:2000]}\n```"
)
return make_sse_response(
stream_ai_response(client, system_prompt, user_prompt)
)
class AIHeatmapDataAPI(APIView):
@login_required
def get(self, request):
username = request.GET.get("username")
user = request.user
if username and request.user.is_teacher_or_above():
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return self.error("User not found")
end = timezone.now()
today = end.date().isoformat()
cache_key = get_cache_key("ai_heatmap", user.id, user.class_name or "", today)
cached_result = cache.get(cache_key)
if cached_result:
return self.success(cached_result)
start = end - timedelta(days=365)
# 使用单次查询获取所有数据,按日期分组统计
submission_counts = (
Submission.objects.filter(
user_id=user.id, create_time__gte=start, create_time__lte=end
)
.annotate(date=TruncDate("create_time"))
.values("date")
.annotate(count=Count("id"))
.order_by("date")
)
# 将查询结果转换为字典,便于快速查找
submission_dict = {item["date"]: item["count"] for item in submission_counts}
# 生成365天的热力图数据
heatmap_data = []
current_date = start.date()
for i in range(365):
day_date = current_date + timedelta(days=i)
submission_count = submission_dict.get(day_date, 0)
heatmap_data.append(
{
"timestamp": int(
datetime.combine(day_date, datetime.min.time()).timestamp()
* 1000
),
"value": submission_count,
}
)
cache.set(cache_key, heatmap_data, CACHE_TIMEOUT)
return self.success(heatmap_data)

View File

@@ -1,19 +0,0 @@
# Generated by Django 6.0 on 2026-04-23 20:07
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('announcement', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='announcement',
index=models.Index(fields=['visible', '-top', '-create_time'], name='announcement_list_idx'),
),
]

View File

@@ -18,6 +18,3 @@ class Announcement(models.Model):
class Meta:
db_table = "announcement"
ordering = ("-top", "-create_time",)
indexes = [
models.Index(fields=["visible", "-top", "-create_time"], name="announcement_list_idx"),
]

48
announcement/tests.py Normal file
View File

@@ -0,0 +1,48 @@
from utils.api.tests import APITestCase
from .models import Announcement
class AnnouncementAdminTest(APITestCase):
def setUp(self):
self.user = self.create_super_admin()
self.url = self.reverse("announcement_admin_api")
def test_announcement_list(self):
response = self.client.get(self.url)
self.assertSuccess(response)
def create_announcement(self):
return self.client.post(self.url, data={"title": "test", "content": "test", "visible": True})
def test_create_announcement(self):
resp = self.create_announcement()
self.assertSuccess(resp)
return resp
def test_edit_announcement(self):
data = {"id": self.create_announcement().data["data"]["id"], "title": "ahaha", "content": "test content",
"visible": False}
resp = self.client.put(self.url, data=data)
self.assertSuccess(resp)
resp_data = resp.data["data"]
self.assertEqual(resp_data["title"], "ahaha")
self.assertEqual(resp_data["content"], "test content")
self.assertEqual(resp_data["visible"], False)
def test_delete_announcement(self):
id = self.test_create_announcement().data["data"]["id"]
resp = self.client.delete(self.url + "?id=" + str(id))
self.assertSuccess(resp)
self.assertFalse(Announcement.objects.filter(id=id).exists())
class AnnouncementAPITest(APITestCase):
def setUp(self):
self.user = self.create_super_admin()
Announcement.objects.create(title="title", content="content", visible=True, created_by=self.user)
self.url = self.reverse("announcement_api")
def test_get_announcement_list(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)

View File

@@ -1,11 +1,12 @@
from account.decorators import super_admin_required
from utils.api import APIView, validate_serializer
from announcement.models import Announcement
from announcement.serializers import (
AnnouncementSerializer,
CreateAnnouncementSerializer,
EditAnnouncementSerializer,
)
from utils.api import APIView, validate_serializer
class AnnouncementAdminAPI(APIView):

View File

@@ -1,25 +1,20 @@
from utils.api import APIView
from announcement.models import Announcement
from announcement.serializers import AnnouncementListSerializer, AnnouncementSerializer
from utils.api import AsyncAPIView
from announcement.serializers import AnnouncementSerializer, AnnouncementListSerializer
class AnnouncementAPI(AsyncAPIView):
async def get(self, request):
class AnnouncementAPI(APIView):
def get(self, request):
id = request.GET.get("id")
if id:
try:
announcement = await (
Announcement.objects.select_related("created_by")
.filter(id=id, visible=True)
.afirst()
)
if announcement is None:
raise Announcement.DoesNotExist
return self.success(await self.async_serialize_data(AnnouncementSerializer, announcement))
announcement = Announcement.objects.get(id=id, visible=True)
return self.success(AnnouncementSerializer(announcement).data)
except Announcement.DoesNotExist:
return self.error("Announcement does not exist")
announcements = Announcement.objects.select_related("created_by").filter(visible=True)
announcements = Announcement.objects.filter(visible=True)
return self.success(
await self.async_paginate_data(request, announcements, AnnouncementListSerializer)
self.paginate_data(request, announcements, AnnouncementListSerializer)
)

View File

@@ -1,35 +0,0 @@
from tree_sitter import Parser
from .engines import get_engine
from .mappings import get_language, get_mapping
def check_ast(code: str, language: str, rules: list[dict]) -> tuple[bool, list[dict]]:
if not rules:
return True, []
ts_language = get_language(language)
if ts_language is None:
return True, []
mapping = get_mapping(language)
try:
parser = Parser(ts_language)
tree = parser.parse(code.encode("utf-8"))
except Exception:
return True, []
results = []
all_passed = True
for rule in rules:
engine = get_engine(rule.get("engine", ""))
if engine is None:
continue
errors = engine.check(tree, rule, language, mapping)
passed = len(errors) == 0
if not passed:
all_passed = False
results.append({"description": engine.describe(rule, language, mapping), "passed": passed})
return all_passed, results

View File

@@ -1,23 +0,0 @@
from .function_call import CountFunctionCallEngine, MustCallFunctionEngine, MustNotCallFunctionEngine
from .method_call import MustCallMethodEngine, MustNotCallMethodEngine
from .nesting import MustHaveNestingEngine
from .node_count import CountNodeEngine
from .node_exists import MustExistNodeEngine, MustNotExistNodeEngine
from .operator import MustUseOperatorEngine
ENGINES = {
"must_exist_node": MustExistNodeEngine(),
"must_not_exist_node": MustNotExistNodeEngine(),
"count_node": CountNodeEngine(),
"must_call_function": MustCallFunctionEngine(),
"must_not_call_function": MustNotCallFunctionEngine(),
"count_function_call": CountFunctionCallEngine(),
"must_call_method": MustCallMethodEngine(),
"must_not_call_method": MustNotCallMethodEngine(),
"must_use_operator": MustUseOperatorEngine(),
"must_have_nesting": MustHaveNestingEngine(),
}
def get_engine(name: str):
return ENGINES.get(name)

View File

@@ -1,21 +0,0 @@
class BaseEngine:
@staticmethod
def collect_nodes(node, node_type):
results = []
if node.type == node_type:
results.append(node)
for child in node.children:
results.extend(BaseEngine.collect_nodes(child, node_type))
return results
@staticmethod
def has_node(node, node_type):
if node.type == node_type:
return True
return any(BaseEngine.has_node(child, node_type) for child in node.children)
def check(self, tree, rule, language, mapping) -> list[str]:
raise NotImplementedError
def describe(self, rule, language, mapping) -> str:
raise NotImplementedError

View File

@@ -1,78 +0,0 @@
from .base import BaseEngine
CALL_NODE_TYPES = {
"Python3": "call",
"C": "call_expression",
}
class _FunctionCallBase(BaseEngine):
def _find_function_calls(self, root, func_name, language):
call_type = CALL_NODE_TYPES.get(language, "call")
calls = self.collect_nodes(root, call_type)
matches = []
for call in calls:
func_node = call.child_by_field_name("function")
if func_node and func_node.type == "identifier" and func_node.text.decode() == func_name:
matches.append(call)
return matches
class MustCallFunctionEngine(_FunctionCallBase):
def _message(self, rule):
return rule.get("message") or f"必须调用 {rule['target']}()"
def check(self, tree, rule, language, mapping):
if not self._find_function_calls(tree.root_node, rule["target"], language):
return [self._message(rule)]
return []
def describe(self, rule, language, mapping):
return self._message(rule)
class MustNotCallFunctionEngine(_FunctionCallBase):
def _message(self, rule):
return rule.get("message") or f"不能调用 {rule['target']}()"
def check(self, tree, rule, language, mapping):
if self._find_function_calls(tree.root_node, rule["target"], language):
return [self._message(rule)]
return []
def describe(self, rule, language, mapping):
return self._message(rule)
class CountFunctionCallEngine(_FunctionCallBase):
def _message(self, rule, count):
target = rule["target"]
exact = rule.get("exact")
if exact is not None and count != exact:
return rule.get("message") or f"{target}() 需要调用 {exact} 次,当前 {count}"
min_count = rule.get("min")
max_count = rule.get("max")
if min_count is not None and count < min_count:
return rule.get("message") or f"{target}() 至少调用 {min_count} 次,当前 {count}"
if max_count is not None and count > max_count:
return rule.get("message") or f"{target}() 至多调用 {max_count} 次,当前 {count}"
return None
def check(self, tree, rule, language, mapping):
count = len(self._find_function_calls(tree.root_node, rule["target"], language))
msg = self._message(rule, count)
return [msg] if msg else []
def describe(self, rule, language, mapping):
target = rule["target"]
if rule.get("message"):
return rule["message"]
exact = rule.get("exact")
if exact is not None:
return f"{target}() 调用 {exact}"
parts = []
if rule.get("min") is not None:
parts.append(f"至少 {rule['min']}")
if rule.get("max") is not None:
parts.append(f"至多 {rule['max']}")
return f"{target}() " + "".join(parts)

View File

@@ -1,48 +0,0 @@
from .base import BaseEngine
CALL_NODE_TYPES = {
"Python3": "call",
"C": "call_expression",
}
class _MethodCallBase(BaseEngine):
def _find_method_calls(self, root, method_name, language):
if language == "C":
return []
call_type = CALL_NODE_TYPES.get(language, "call")
calls = self.collect_nodes(root, call_type)
matches = []
for call in calls:
func_node = call.child_by_field_name("function")
if func_node and func_node.type == "attribute":
attr_node = func_node.child_by_field_name("attribute")
if attr_node and attr_node.text.decode() == method_name:
matches.append(call)
return matches
class MustCallMethodEngine(_MethodCallBase):
def _message(self, rule):
return rule.get("message") or f"必须调用 .{rule['target']}()"
def check(self, tree, rule, language, mapping):
if not self._find_method_calls(tree.root_node, rule["target"], language):
return [self._message(rule)]
return []
def describe(self, rule, language, mapping):
return self._message(rule)
class MustNotCallMethodEngine(_MethodCallBase):
def _message(self, rule):
return rule.get("message") or f"不能调用 .{rule['target']}()"
def check(self, tree, rule, language, mapping):
if self._find_method_calls(tree.root_node, rule["target"], language):
return [self._message(rule)]
return []
def describe(self, rule, language, mapping):
return self._message(rule)

View File

@@ -1,34 +0,0 @@
from ast_checker.labels import label
from .base import BaseEngine
class MustHaveNestingEngine(BaseEngine):
def _has_inner_in_subtree(self, node, inner_type):
for child in node.children:
if self.has_node(child, inner_type):
return True
return False
def _message(self, rule):
if rule.get("message"):
return rule["message"]
outer = rule.get("outer", "")
inner = rule.get("inner", "")
outer_label = label(outer)
inner_label = label(inner)
if outer == inner:
return f"必须使用 {outer_label} 嵌套"
return f"必须在 {outer_label} 中嵌套使用 {inner_label}"
def check(self, tree, rule, language, mapping):
outer_type = mapping.get(rule["outer"], rule["outer"])
inner_type = mapping.get(rule["inner"], rule["inner"])
outer_nodes = self.collect_nodes(tree.root_node, outer_type)
for outer_node in outer_nodes:
if self._has_inner_in_subtree(outer_node, inner_type):
return []
return [self._message(rule)]
def describe(self, rule, language, mapping):
return self._message(rule)

View File

@@ -1,39 +0,0 @@
from ast_checker.labels import label
from .base import BaseEngine
class CountNodeEngine(BaseEngine):
def _message(self, rule, count):
name = rule.get("label") or label(rule["target"])
exact = rule.get("exact")
if exact is not None and count != exact:
return rule.get("message") or f"{name} 需要出现 {exact} 次,当前 {count}"
min_count = rule.get("min")
max_count = rule.get("max")
if min_count is not None and count < min_count:
return rule.get("message") or f"{name} 至少出现 {min_count} 次,当前 {count}"
if max_count is not None and count > max_count:
return rule.get("message") or f"{name} 至多出现 {max_count} 次,当前 {count}"
return None
def check(self, tree, rule, language, mapping):
target = rule["target"]
node_type = mapping.get(target, target)
count = len(self.collect_nodes(tree.root_node, node_type))
msg = self._message(rule, count)
return [msg] if msg else []
def describe(self, rule, language, mapping):
name = rule.get("label") or label(rule["target"])
if rule.get("message"):
return rule["message"]
exact = rule.get("exact")
if exact is not None:
return f"{name} 出现 {exact}"
parts = []
if rule.get("min") is not None:
parts.append(f"至少 {rule['min']}")
if rule.get("max") is not None:
parts.append(f"至多 {rule['max']}")
return f"{name} " + "".join(parts)

View File

@@ -1,31 +0,0 @@
from ast_checker.labels import label
from .base import BaseEngine
class MustExistNodeEngine(BaseEngine):
def _message(self, rule):
return rule.get("message") or f"必须使用 {rule.get('label') or label(rule['target'])}"
def check(self, tree, rule, language, mapping):
node_type = mapping.get(rule["target"], rule["target"])
if not self.has_node(tree.root_node, node_type):
return [self._message(rule)]
return []
def describe(self, rule, language, mapping):
return self._message(rule)
class MustNotExistNodeEngine(BaseEngine):
def _message(self, rule):
return rule.get("message") or f"不能使用 {rule.get('label') or label(rule['target'])}"
def check(self, tree, rule, language, mapping):
node_type = mapping.get(rule["target"], rule["target"])
if self.has_node(tree.root_node, node_type):
return [self._message(rule)]
return []
def describe(self, rule, language, mapping):
return self._message(rule)

View File

@@ -1,15 +0,0 @@
from .base import BaseEngine
class MustUseOperatorEngine(BaseEngine):
def _message(self, rule):
return rule.get("message") or f"必须使用 {rule['target']} 运算符"
def check(self, tree, rule, language, mapping):
mapped_op = mapping.get(rule["target"], rule["target"])
if not self.has_node(tree.root_node, mapped_op):
return [self._message(rule)]
return []
def describe(self, rule, language, mapping):
return self._message(rule)

View File

@@ -1,21 +0,0 @@
TARGET_LABELS: dict[str, str] = {
"for_loop": "for 循环",
"while_loop": "while 循环",
"if_statement": "if 条件",
"else_clause": "else 子句",
"function_definition": "函数定义",
"return": "return 语句",
"break": "break 语句",
"continue": "continue 语句",
"list_comprehension": "列表推导式",
"list_literal": "列表",
"dict_literal": "字典",
"set_literal": "集合",
"f_string": "f-string",
"try_except": "try-except",
"class_definition": "类定义",
}
def label(target: str) -> str:
return TARGET_LABELS.get(target, target)

View File

@@ -1,37 +0,0 @@
from tree_sitter import Language
from .c import C_MAPPING
from .python import PYTHON_MAPPING
_MAPPINGS = {
"Python3": PYTHON_MAPPING,
"C": C_MAPPING,
}
_LANGUAGES: dict[str, Language] = {}
def _init_languages():
try:
import tree_sitter_python as tspython
_LANGUAGES["Python3"] = Language(tspython.language())
except ImportError:
pass
try:
import tree_sitter_c as tsc
_LANGUAGES["C"] = Language(tsc.language())
except ImportError:
pass
_init_languages()
def get_mapping(language: str) -> dict:
return _MAPPINGS.get(language, {})
def get_language(language: str) -> Language | None:
return _LANGUAGES.get(language)

View File

@@ -1,39 +0,0 @@
C_MAPPING = {
"for_loop": "for_statement",
"while_loop": "while_statement",
"do_while": "do_statement",
"if_statement": "if_statement",
"else_clause": "else_clause",
"break": "break_statement",
"continue": "continue_statement",
"function_definition": "function_definition",
"return": "return_statement",
"switch_statement": "switch_statement",
"case_statement": "case_statement",
"assignment": "assignment_expression",
"struct": "struct_specifier",
"include": "preproc_include",
"+": "+",
"-": "-",
"*": "*",
"/": "/",
"%": "%",
"+=": "+=",
"-=": "-=",
"*=": "*=",
"/=": "/=",
"%=": "%=",
"==": "==",
"!=": "!=",
">": ">",
">=": ">=",
"<": "<",
"<=": "<=",
"and": "&&",
"or": "||",
"not": "!",
"&": "&",
"|": "|",
"++": "++",
"--": "--",
}

View File

@@ -1,45 +0,0 @@
PYTHON_MAPPING = {
"for_loop": "for_statement",
"while_loop": "while_statement",
"if_statement": "if_statement",
"else_clause": "else_clause",
"elif_clause": "elif_clause",
"break": "break_statement",
"continue": "continue_statement",
"function_definition": "function_definition",
"return": "return_statement",
"try_except": "try_statement",
"with_statement": "with_statement",
"list_comprehension": "list_comprehension",
"list_literal": "list",
"dict_literal": "dictionary",
"set_literal": "set",
"f_string": "format_string",
"import": "import_statement",
"import_from": "import_from_statement",
"assignment": "assignment",
"class_definition": "class_definition",
"+": "+",
"-": "-",
"*": "*",
"/": "/",
"//": "//",
"%": "%",
"**": "**",
"+=": "+=",
"-=": "-=",
"*=": "*=",
"/=": "/=",
"%=": "%=",
"==": "==",
"!=": "!=",
">": ">",
">=": ">=",
"<": "<",
"<=": "<=",
"and": "and",
"or": "or",
"not": "not",
"&": "&",
"|": "|",
}

View File

View File

@@ -1,2 +0,0 @@
# Register your models here.

View File

@@ -1,7 +0,0 @@
from django.apps import AppConfig
class ClassPkConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'class_pk'
verbose_name = '班级PK'

View File

@@ -1,2 +0,0 @@
# 空文件

View File

@@ -1,3 +0,0 @@
# 如果需要存储班级PK历史记录可以在这里定义模型
# 目前暂时不需要,因为都是实时计算

View File

@@ -1,3 +0,0 @@
# 如果需要序列化器,可以在这里定义
# 目前使用APIView的paginate_data方法暂时不需要

View File

@@ -1,2 +0,0 @@
# 空文件

View File

@@ -1,10 +0,0 @@
from django.urls import path
from ..views.oj import ClassPKAPI, ClassRankAPI, UserClassRankAPI
urlpatterns = [
path("class_rank", ClassRankAPI.as_view()),
path("user_class_rank", UserClassRankAPI.as_view()),
path("class_pk", ClassPKAPI.as_view()),
]

View File

@@ -1,371 +0,0 @@
import math
import statistics
from datetime import datetime
from django.db.models import Avg, Sum
from django.utils import timezone
from account.decorators import login_required
from account.models import AdminType, User, UserProfile
from submission.models import JudgeStatus, Submission
from utils.api import APIView
class ClassRankAPI(APIView):
"""获取班级排名列表"""
def get(self, request):
# 获取年级参数
grade = int(request.GET.get("grade"))
# 获取所有有用户的班级
classes = (
User.objects.filter(
class_name__isnull=False,
is_disabled=False,
admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN],
class_name__startswith=str(grade),
)
.values("class_name")
.distinct()
)
class_stats = []
for class_info in classes:
class_name = class_info["class_name"]
users = User.objects.filter(
class_name=class_name,
is_disabled=False,
admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN],
)
user_ids = list(users.values_list("id", flat=True))
profiles = UserProfile.objects.filter(user_id__in=user_ids)
total_ac = profiles.aggregate(total=Sum("accepted_number"))["total"] or 0
total_submission = (
profiles.aggregate(total=Sum("submission_number"))["total"] or 0
)
avg_ac = profiles.aggregate(avg=Avg("accepted_number"))["avg"] or 0
user_count = users.count()
class_stats.append(
{
"class_name": class_name,
"user_count": user_count,
"total_ac": int(total_ac),
"total_submission": int(total_submission),
"avg_ac": round(avg_ac, 2),
"ac_rate": round(total_ac / total_submission * 100, 2)
if total_submission > 0
else 0,
}
)
# 按总AC数排序
class_stats.sort(key=lambda x: (-x["total_ac"], x["total_submission"]))
# 添加排名
for i, stat in enumerate(class_stats):
stat["rank"] = i + 1
return self.success(class_stats)
class UserClassRankAPI(APIView):
"""获取用户在班级中的排名"""
@login_required
def get(self, request):
user = request.user
if not user.class_name:
return self.error("用户没有班级信息")
scope = request.GET.get("scope", "").lower()
show_all = scope == "all"
try:
limit = int(request.GET.get("limit", "10"))
except ValueError:
limit = 10
if limit <= 0 or limit > 250:
limit = 10
try:
offset = int(request.GET.get("offset", "0"))
except ValueError:
offset = 0
if offset < 0:
offset = 0
# 获取同班所有用户
class_users = User.objects.filter(
class_name=user.class_name,
is_disabled=False,
admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN],
).select_related("userprofile")
user_ranks = []
for class_user in class_users:
profile = class_user.userprofile
user_ranks.append(
{
"user_id": class_user.id,
"username": class_user.username,
"accepted_number": profile.accepted_number,
"submission_number": profile.submission_number,
}
)
# 按AC数排序
user_ranks.sort(key=lambda x: (-x["accepted_number"], x["submission_number"]))
# 添加排名
my_rank = -1
for i, rank_info in enumerate(user_ranks):
rank_info["rank"] = i + 1
if rank_info["user_id"] == user.id:
my_rank = i + 1
trimmed_ranks = user_ranks
if not show_all and my_rank > 0 and len(user_ranks) > 10:
center_index = my_rank - 1
start = max(0, center_index - 5)
end = start + 10
if end > len(user_ranks):
end = len(user_ranks)
start = max(0, end - 10)
trimmed_ranks = user_ranks[start:end]
elif show_all:
trimmed_ranks = user_ranks[offset : offset + limit]
return self.success(
{
"class_name": user.class_name,
"my_rank": my_rank,
"total": len(user_ranks),
"ranks": trimmed_ranks,
}
)
class ClassPKAPI(APIView):
"""班级PK比较 - 多维度教育评价"""
def post(self, request):
class_names = request.data.get("class_name", [])
if not class_names or len(class_names) < 1:
return self.error("至少需要选择1个班级")
# 获取时间段参数
start_time = request.data.get("start_time")
end_time = request.data.get("end_time")
# 将时间字符串转换为datetime对象
# 处理空字符串、None 或 undefined 的情况
if start_time and isinstance(start_time, str) and start_time.strip():
try:
start_time = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
if timezone.is_naive(start_time):
start_time = timezone.make_aware(start_time)
except (ValueError, AttributeError):
start_time = None
else:
start_time = None
if end_time and isinstance(end_time, str) and end_time.strip():
try:
end_time = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
if timezone.is_naive(end_time):
end_time = timezone.make_aware(end_time)
except (ValueError, AttributeError):
end_time = None
else:
end_time = None
class_comparisons = []
# 预计算全局阈值所有参与PK班级的学生AC数合并
all_user_ids = list(
User.objects.filter(
class_name__in=class_names,
is_disabled=False,
admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN],
).values_list("id", flat=True)
)
all_ac_list = sorted(
[p.accepted_number for p in UserProfile.objects.filter(user_id__in=all_user_ids)],
reverse=True,
)
if len(all_ac_list) > 1:
_quantiles = statistics.quantiles(all_ac_list, n=4)
global_q1 = _quantiles[0]
global_q3 = _quantiles[2]
else:
global_q1 = all_ac_list[0] if all_ac_list else 0
global_q3 = all_ac_list[0] if all_ac_list else 0
for class_name in class_names:
users = User.objects.filter(
class_name=class_name,
is_disabled=False,
admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN],
)
user_ids = list(users.values_list("id", flat=True))
# 获取所有学生的AC数列表用于统计计算
profiles = UserProfile.objects.filter(user_id__in=user_ids)
ac_list = sorted([p.accepted_number for p in profiles], reverse=True)
submission_list = sorted(
[p.submission_number for p in profiles], reverse=True
)
user_count = len(ac_list)
if user_count == 0:
continue
# 基础统计
total_ac = sum(ac_list)
total_submission = sum(submission_list)
avg_ac = statistics.mean(ac_list) if ac_list else 0
# 中位数和分位数
median_ac = statistics.median(ac_list) if ac_list else 0
q1_ac = statistics.quantiles(ac_list, n=4)[0] if len(ac_list) > 1 else 0
q3_ac = statistics.quantiles(ac_list, n=4)[2] if len(ac_list) > 1 else 0
iqr = q3_ac - q1_ac
# 标准差
std_dev = statistics.stdev(ac_list) if len(ac_list) > 1 else 0
# 前10%和后10%统计
top_10_count = max(1, math.ceil(user_count * 0.10))
bottom_10_count = max(1, math.ceil(user_count * 0.10))
top_10_avg = (
statistics.mean(ac_list[:top_10_count]) if top_10_count > 0 else 0
)
bottom_10_avg = (
statistics.mean(ac_list[-bottom_10_count:])
if bottom_10_count > 0
else 0
)
# 中间80%均值截尾均值去掉前10%和后10%
if top_10_count + bottom_10_count < user_count:
middle_list = ac_list[top_10_count:-bottom_10_count]
else:
middle_list = ac_list
middle_80_avg = statistics.mean(middle_list) if middle_list else avg_ac
# 优秀率AC数 >= 全局Q3即超过PK组所有学生的前25%
excellent_count = sum(1 for ac in ac_list if ac >= global_q3)
excellent_rate = (
(excellent_count / user_count * 100) if user_count > 0 else 0
)
# 及格率AC数 >= 全局Q1即超过PK组所有学生的后25%
pass_count = sum(1 for ac in ac_list if ac >= global_q1)
pass_rate = (pass_count / user_count * 100) if user_count > 0 else 0
# 参与度(有提交记录的学生比例)
active_count = sum(1 for sub in submission_list if sub > 0)
active_rate = (active_count / user_count * 100) if user_count > 0 else 0
# 时间段内的统计(如果提供了时间段)
recent_stats = {}
if start_time and end_time:
submissions = Submission.objects.filter(
user_id__in=user_ids,
create_time__gte=start_time,
create_time__lte=end_time,
)
recent_ac = (
submissions.filter(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])
.values("user_id", "problem_id")
.distinct()
.count()
)
recent_submission = submissions.count()
# 时间段内的用户AC数列表
recent_user_ac = {}
for user_id in user_ids:
user_recent_ac = (
submissions.filter(user_id=user_id, result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])
.values("problem_id")
.distinct()
.count()
)
recent_user_ac[user_id] = user_recent_ac
recent_ac_list = sorted(recent_user_ac.values(), reverse=True)
if recent_ac_list:
recent_stats = {
"recent_total_ac": recent_ac,
"recent_total_submission": recent_submission,
"recent_avg_ac": statistics.mean(recent_ac_list),
"recent_median_ac": statistics.median(recent_ac_list),
"recent_top_10_avg": statistics.mean(
recent_ac_list[: max(1, math.ceil(len(recent_ac_list) * 0.10))]
)
if recent_ac_list
else 0,
"recent_active_count": sum(
1 for ac in recent_ac_list if ac > 0
),
}
class_comparisons.append(
{
"class_name": class_name,
"user_count": user_count,
# 基础统计
"total_ac": int(total_ac),
"total_submission": int(total_submission),
"avg_ac": round(avg_ac, 2),
# 中位数和分位数
"median_ac": round(median_ac, 2),
"q1_ac": round(q1_ac, 2),
"q3_ac": round(q3_ac, 2),
"iqr": round(iqr, 2),
# 标准差
"std_dev": round(std_dev, 2),
# 分层统计
"top_10_avg": round(top_10_avg, 2),
"middle_80_avg": round(middle_80_avg, 2),
"bottom_10_avg": round(bottom_10_avg, 2),
# 比率统计
"excellent_rate": round(excellent_rate, 2),
"pass_rate": round(pass_rate, 2),
"active_rate": round(active_rate, 2),
# 正确率
"ac_rate": round(total_ac / total_submission * 100, 2)
if total_submission > 0
else 0,
# 时间段统计(如果有)
**recent_stats,
}
)
# 计算综合分(需要所有班级数据就绪后才能归一化)
max_median = max((c["median_ac"] for c in class_comparisons), default=1) or 1
max_middle = max((c["middle_80_avg"] for c in class_comparisons), default=1) or 1
for c in class_comparisons:
score = (
0.40 * (c["median_ac"] / max_median * 100)
+ 0.15 * (c["middle_80_avg"] / max_middle * 100)
+ 0.20 * c["active_rate"]
+ 0.15 * c["pass_rate"]
+ 0.10 * c["excellent_rate"]
)
c["composite_score"] = round(score, 1)
# 按综合分排序(主),中位数(次)
class_comparisons.sort(
key=lambda x: (-x["composite_score"], -x["median_ac"])
)
return self.success(
{
"comparisons": class_comparisons,
"has_time_range": bool(start_time and end_time),
}
)

View File

@@ -1,21 +0,0 @@
# Generated by Django 6.0 on 2026-04-23 20:07
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comment', '0001_initial'),
('problem', '0007_problem_problem_visible_idx'),
('submission', '0004_submission_problem_user_idx'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['problem', 'create_time'], name='comment_problem_time_idx'),
),
]

View File

@@ -40,8 +40,5 @@ class Comment(models.Model):
class Meta:
db_table = "comment"
ordering = ("-create_time",)
indexes = [
models.Index(fields=["problem", "create_time"], name="comment_problem_time_idx"),
]

View File

@@ -2,6 +2,7 @@ from django.urls import path
from ..views.admin import CommentAPI
urlpatterns = [
path("comment", CommentAPI.as_view()),
]

View File

@@ -2,6 +2,7 @@ from django.urls import path
from ..views.oj import CommentAPI, CommentStatisticsAPI
urlpatterns = [
path("comment", CommentAPI.as_view()),
path("comment/statistics", CommentStatisticsAPI.as_view()),

View File

@@ -1,8 +1,8 @@
from account.decorators import super_admin_required
from comment.models import Comment
from comment.serializers import CommentListSerializer
from problem.models import Problem
from utils.api import APIView
from comment.models import Comment
class CommentAPI(APIView):
@@ -13,7 +13,7 @@ class CommentAPI(APIView):
if problem_id:
try:
# 这里如果题目不可见,也需要显示该题目的评论
problem = Problem.objects.get(_id__iexact=problem_id, contest_id__isnull=True)
problem = Problem.objects.get(_id=problem_id, contest_id__isnull=True)
except Problem.DoesNotExist:
return self.error("Problem doesn't exist")
comments = comments.filter(problem=problem)

View File

@@ -1,44 +1,42 @@
from django.db.models import Avg, Count
from django.db.models import Avg
from django.db.models.functions import Round
from account.decorators import login_required
from comment.models import Comment
from comment.serializers import CommentSerializer, CreateCommentSerializer
from problem.models import Problem
from submission.models import JudgeStatus, Submission
from utils.api import AsyncAPIView
from utils.api import APIView
from account.decorators import login_required
from utils.api.api import validate_serializer
from utils.async_helpers import async_cache_delete, async_cache_get, async_cache_set
from utils.constants import CacheKey
from comment.serializers import CreateCommentSerializer, CommentSerializer
from submission.models import Submission, JudgeStatus
class CommentAPI(AsyncAPIView):
@login_required
class CommentAPI(APIView):
@validate_serializer(CreateCommentSerializer)
async def post(self, request):
@login_required
def post(self, request):
data = request.data
try:
problem = await Problem.objects.aget(id=data["problem_id"], visible=True)
problem = Problem.objects.get(id=data["problem_id"], visible=True)
except Problem.DoesNotExist:
return self.error("problem is not exists")
self.error("problem is not exists")
submission = await (
Submission.objects.select_related("problem")
.filter(
user_id=request.user.id,
problem_id=data["problem_id"],
result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
try:
submission = (
Submission.objects.select_related("problem")
.filter(
user_id=request.user.id,
problem_id=data["problem_id"],
result=JudgeStatus.ACCEPTED,
)
.first()
)
.afirst()
)
if not submission:
return self.error("submission is not exists or not accepted")
except Submission.DoesNotExist:
self.error("submission is not exists or not accepted")
language = submission.language
if language == "Python3":
language = "Python"
await Comment.objects.acreate(
Comment.objects.create(
user=request.user,
problem=problem,
submission=submission,
@@ -48,44 +46,35 @@ class CommentAPI(AsyncAPIView):
comprehensive_rating=data["comprehensive_rating"],
content=data["content"],
)
await async_cache_delete(f"{CacheKey.comment_stats}:{problem.id}")
return self.success()
@login_required
async def get(self, request):
def get(self, request):
problem_id = request.GET.get("problem_id")
comment = await (
comment = (
Comment.objects.select_related("problem")
.filter(user=request.user, problem_id=problem_id)
.afirst()
.first()
)
if comment:
return self.success(await self.async_serialize_data(CommentSerializer, comment))
return self.success(CommentSerializer(comment).data)
else:
return self.success()
class CommentStatisticsAPI(AsyncAPIView):
async def get(self, request):
class CommentStatisticsAPI(APIView):
def get(self, request):
problem_id = request.GET.get("problem_id")
cache_key = f"{CacheKey.comment_stats}:{problem_id}"
cached = await async_cache_get(cache_key)
if cached is not None:
return self.success(cached)
comments = Comment.objects.select_related("problem").filter(
problem_id=problem_id
)
if comments.count() == 0:
return self.success()
agg = await Comment.objects.filter(problem_id=problem_id).aaggregate(
count=Count("id"),
count = comments.count()
rating = comments.aggregate(
description=Round(Avg("description_rating"), 2),
difficulty=Round(Avg("difficulty_rating"), 2),
comprehensive=Round(Avg("comprehensive_rating"), 2),
)
if not agg["count"]:
return self.success()
data = {"count": agg["count"], "rating": {
"description": agg["description"],
"difficulty": agg["difficulty"],
"comprehensive": agg["comprehensive"],
}}
await async_cache_set(cache_key, data, 3600)
return self.success(data)
return self.success({"count": count, "rating": rating})

View File

@@ -1,98 +0,0 @@
"""
WebSocket consumers for configuration updates
"""
import json
import logging
from channels.generic.websocket import AsyncWebsocketConsumer
logger = logging.getLogger(__name__)
class ConfigConsumer(AsyncWebsocketConsumer):
"""
WebSocket consumer for real-time configuration updates
当管理员修改配置后,通过 WebSocket 实时推送配置变化
"""
async def connect(self):
"""处理 WebSocket 连接"""
self.user = self.scope["user"]
# 只允许认证用户连接
if not self.user.is_authenticated:
await self.close()
return
# 使用全局配置组名,所有用户都能接收配置更新
self.group_name = "config_updates"
# 加入配置更新组
await self.channel_layer.group_add(
self.group_name,
self.channel_name
)
await self.accept()
logger.info(f"Config WebSocket connected: user_id={self.user.id}, channel={self.channel_name}")
async def disconnect(self, close_code):
"""处理 WebSocket 断开连接"""
if hasattr(self, 'group_name'):
await self.channel_layer.group_discard(
self.group_name,
self.channel_name
)
logger.info(f"Config WebSocket disconnected: user_id={self.user.id}, close_code={close_code}")
async def receive(self, text_data):
"""
接收客户端消息
客户端可以发送心跳包或配置更新请求
"""
try:
data = json.loads(text_data)
message_type = data.get("type")
if message_type == "ping":
# 响应心跳包
await self.send(text_data=json.dumps({
"type": "pong",
"timestamp": data.get("timestamp")
}))
elif message_type == "config_update":
# 处理配置更新请求
key = data.get("key")
value = data.get("value")
if key and value is not None:
logger.info(f"User {self.user.id} requested config update: {key}={value}")
# 这里可以添加权限检查,只有管理员才能发送配置更新
if self.user.is_superuser:
# 广播配置更新给所有连接的客户端
await self.channel_layer.group_send(
self.group_name,
{
"type": "config_update",
"data": {
"type": "config_update",
"key": key,
"value": value
}
}
)
except json.JSONDecodeError:
logger.error(f"Invalid JSON received from user {self.user.id}")
except Exception as e:
logger.error(f"Error handling message from user {self.user.id}: {str(e)}")
async def config_update(self, event):
"""
接收来自 channel layer 的配置更新消息并发送给客户端
这个方法名对应 group_send 中的 type 字段
"""
try:
# 从 event 中提取数据并发送给客户端
await self.send(text_data=json.dumps(event["data"]))
logger.debug(f"Sent config update to user {self.user.id}: {event['data']}")
except Exception as e:
logger.error(f"Error sending config update to user {self.user.id}: {str(e)}")

View File

@@ -27,7 +27,6 @@ class CreateEditWebsiteConfigSerializer(serializers.Serializer):
allow_register = serializers.BooleanField()
submission_list_show_all = serializers.BooleanField()
class_list = serializers.ListField(child=serializers.CharField(max_length=64))
enable_maxkb = serializers.BooleanField()
class JudgeServerSerializer(serializers.ModelSerializer):

185
conf/tests.py Normal file
View File

@@ -0,0 +1,185 @@
import hashlib
from unittest import mock
from django.conf import settings
from django.utils import timezone
from options.options import SysOptions
from utils.api.tests import APITestCase
from .models import JudgeServer
class SMTPConfigTest(APITestCase):
def setUp(self):
self.user = self.create_super_admin()
self.url = self.reverse("smtp_admin_api")
self.password = "testtest"
def test_create_smtp_config(self):
data = {"server": "smtp.test.com", "email": "test@test.com", "port": 465,
"tls": True, "password": self.password}
resp = self.client.post(self.url, data=data)
self.assertSuccess(resp)
self.assertTrue("password" not in resp.data)
return resp
def test_edit_without_password(self):
self.test_create_smtp_config()
data = {"server": "smtp1.test.com", "email": "test2@test.com", "port": 465,
"tls": True}
resp = self.client.put(self.url, data=data)
self.assertSuccess(resp)
def test_edit_without_password1(self):
self.test_create_smtp_config()
data = {"server": "smtp.test.com", "email": "test@test.com", "port": 465,
"tls": True, "password": ""}
resp = self.client.put(self.url, data=data)
self.assertSuccess(resp)
def test_edit_with_password(self):
self.test_create_smtp_config()
data = {"server": "smtp1.test.com", "email": "test2@test.com", "port": 465,
"tls": True, "password": "newpassword"}
resp = self.client.put(self.url, data=data)
self.assertSuccess(resp)
@mock.patch("conf.views.send_email")
def test_test_smtp(self, mocked_send_email):
url = self.reverse("smtp_test_api")
self.test_create_smtp_config()
resp = self.client.post(url, data={"email": "test@test.com"})
self.assertSuccess(resp)
mocked_send_email.assert_called_once()
class WebsiteConfigAPITest(APITestCase):
def test_create_website_config(self):
self.create_super_admin()
url = self.reverse("website_config_api")
data = {"website_base_url": "http://test.com", "website_name": "test name",
"website_name_shortcut": "test oj", "website_footer": "<a>test</a>",
"allow_register": True, "submission_list_show_all": False}
resp = self.client.post(url, data=data)
self.assertSuccess(resp)
def test_edit_website_config(self):
self.create_super_admin()
url = self.reverse("website_config_api")
data = {"website_base_url": "http://test.com", "website_name": "test name",
"website_name_shortcut": "test oj", "website_footer": "<img onerror=alert(1) src=#>",
"allow_register": True, "submission_list_show_all": False}
resp = self.client.post(url, data=data)
self.assertSuccess(resp)
self.assertEqual(SysOptions.website_footer, '<img src="#" />')
def test_get_website_config(self):
# do not need to login
url = self.reverse("website_info_api")
resp = self.client.get(url)
self.assertSuccess(resp)
class JudgeServerHeartbeatTest(APITestCase):
def setUp(self):
self.url = self.reverse("judge_server_heartbeat_api")
self.data = {"hostname": "testhostname", "judger_version": "1.0.4", "cpu_core": 4,
"cpu": 90.5, "memory": 80.3, "action": "heartbeat", "service_url": "http://127.0.0.1"}
self.token = "test"
self.hashed_token = hashlib.sha256(self.token.encode("utf-8")).hexdigest()
SysOptions.judge_server_token = self.token
self.headers = {"HTTP_X_JUDGE_SERVER_TOKEN": self.hashed_token, settings.IP_HEADER: "1.2.3.4"}
def test_new_heartbeat(self):
resp = self.client.post(self.url, data=self.data, **self.headers)
self.assertSuccess(resp)
server = JudgeServer.objects.first()
self.assertEqual(server.ip, "127.0.0.1")
def test_update_heartbeat(self):
self.test_new_heartbeat()
data = self.data
data["judger_version"] = "2.0.0"
resp = self.client.post(self.url, data=data, **self.headers)
self.assertSuccess(resp)
self.assertEqual(JudgeServer.objects.get(hostname=self.data["hostname"]).judger_version, data["judger_version"])
class JudgeServerAPITest(APITestCase):
def setUp(self):
self.server = JudgeServer.objects.create(**{"hostname": "testhostname", "judger_version": "1.0.4",
"cpu_core": 4, "cpu_usage": 90.5, "memory_usage": 80.3,
"last_heartbeat": timezone.now()})
self.url = self.reverse("judge_server_api")
self.create_super_admin()
def test_get_judge_server(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
self.assertEqual(len(resp.data["data"]["servers"]), 1)
def test_delete_judge_server(self):
resp = self.client.delete(self.url + "?hostname=testhostname")
self.assertSuccess(resp)
self.assertFalse(JudgeServer.objects.filter(hostname="testhostname").exists())
def test_disabled_judge_server(self):
resp = self.client.put(self.url, data={"is_disabled": True, "id": self.server.id})
self.assertSuccess(resp)
self.assertTrue(JudgeServer.objects.get(id=self.server.id).is_disabled)
class LanguageListAPITest(APITestCase):
def test_get_languages(self):
resp = self.client.get(self.reverse("language_list_api"))
self.assertSuccess(resp)
class TestCasePruneAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("prune_test_case_api")
self.create_super_admin()
def test_get_isolated_test_case(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
@mock.patch("conf.views.TestCasePruneAPI.delete_one")
@mock.patch("conf.views.os.listdir")
@mock.patch("conf.views.Problem")
def test_delete_test_case(self, mocked_problem, mocked_listdir, mocked_delete_one):
valid_id = "1172980672983b2b49820be3a741b109"
mocked_problem.return_value = [valid_id, ]
mocked_listdir.return_value = [valid_id, ".test", "aaa"]
resp = self.client.delete(self.url)
self.assertSuccess(resp)
mocked_delete_one.assert_called_once_with(valid_id)
class ReleaseNoteAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("get_release_notes_api")
self.create_super_admin()
self.latest_data = {"update": [
{
"version": "2099-12-25",
"level": 1,
"title": "Update at 2099-12-25",
"details": ["test get", ]
}
]}
def test_get_versions(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
class DashboardInfoAPITest(APITestCase):
def setUp(self):
self.url = self.reverse("dashboard_info_api")
self.create_admin()
def test_get_info(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
self.assertEqual(resp.data["data"]["user_count"], 1)

View File

@@ -2,22 +2,22 @@ from django.urls import path
from ..views import (
SMTPAPI,
DashboardInfoAPI,
JudgeServerAPI,
RandomUsernameAPI,
ReleaseNotesAPI,
SMTPTestAPI,
TestCasePruneAPI,
WebsiteConfigAPI,
TestCasePruneAPI,
SMTPTestAPI,
ReleaseNotesAPI,
DashboardInfoAPI,
RandomUsernameAPI,
)
urlpatterns = [
path("smtp", SMTPAPI.as_view()), # DEPRECATED: 前端未调用
path("smtp_test", SMTPTestAPI.as_view()), # DEPRECATED: 前端未调用
path("smtp", SMTPAPI.as_view()),
path("smtp_test", SMTPTestAPI.as_view()),
path("website", WebsiteConfigAPI.as_view()),
path("random_user", RandomUsernameAPI.as_view()),
path("judge_server", JudgeServerAPI.as_view()),
path("prune_test_case", TestCasePruneAPI.as_view()),
path("versions", ReleaseNotesAPI.as_view()), # DEPRECATED: 前端未调用
path("versions", ReleaseNotesAPI.as_view()),
path("dashboard_info", DashboardInfoAPI.as_view()),
]

View File

@@ -1,18 +1,11 @@
from django.urls import path
from ..views import (
ClassUsernamesAPI,
HitokotoAPI,
JudgeServerHeartbeatAPI,
LanguagesAPI,
WebsiteConfigAPI,
)
from ..views import HitokotoAPI, JudgeServerHeartbeatAPI, LanguagesAPI, WebsiteConfigAPI
urlpatterns = [
path("website", WebsiteConfigAPI.as_view()),
# 这里必须要有 /
path("judge_server_heartbeat/", JudgeServerHeartbeatAPI.as_view()),
path("languages", LanguagesAPI.as_view()), # DEPRECATED: 前端未调用
path("languages", LanguagesAPI.as_view()),
path("hitokoto", HitokotoAPI.as_view()),
path("class_usernames", ClassUsernamesAPI.as_view()),
]

View File

@@ -1,4 +1,3 @@
import asyncio
import hashlib
import json
import os
@@ -7,10 +6,9 @@ import re
import shutil
import smtplib
import time
from datetime import timedelta
from datetime import datetime
import requests
from asgiref.sync import sync_to_async
from django.conf import settings
from django.utils import timezone
from requests.exceptions import RequestException
@@ -22,25 +20,22 @@ from judge.dispatcher import process_pending_task
from options.options import SysOptions
from problem.models import Problem
from submission.models import Submission
from utils.api import APIView, AsyncAPIView, CSRFExemptAPIView, validate_serializer
from utils.api import APIView, CSRFExemptAPIView, validate_serializer
from utils.cache import JsonDataLoader
from utils.shortcuts import get_env, send_email
from utils.websocket import push_config_update
from utils.shortcuts import send_email, get_env
from utils.xss_filter import XSSHtml
from .models import JudgeServer
from .serializers import (
CreateEditWebsiteConfigSerializer,
CreateSMTPConfigSerializer,
EditJudgeServerSerializer,
EditSMTPConfigSerializer,
JudgeServerHeartbeatSerializer,
JudgeServerSerializer,
TestSMTPConfigSerializer,
EditJudgeServerSerializer,
)
# DEPRECATED: 前端未调用 (2026-05-26)
class SMTPAPI(APIView):
@super_admin_required
def get(self, request):
@@ -69,7 +64,6 @@ class SMTPAPI(APIView):
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class SMTPTestAPI(APIView):
@super_admin_required
@validate_serializer(TestSMTPConfigSerializer)
@@ -101,33 +95,30 @@ class SMTPTestAPI(APIView):
return self.success()
class WebsiteConfigAPI(AsyncAPIView):
async def get(self, request):
ret = await SysOptions.aget_many(
"website_base_url",
"website_name",
"website_name_shortcut",
"website_footer",
"allow_register",
"submission_list_show_all",
"class_list",
"enable_maxkb",
)
class WebsiteConfigAPI(APIView):
def get(self, request):
ret = {
key: getattr(SysOptions, key)
for key in [
"website_base_url",
"website_name",
"website_name_shortcut",
"website_footer",
"allow_register",
"submission_list_show_all",
"class_list",
]
}
return self.success(ret)
@super_admin_required
@validate_serializer(CreateEditWebsiteConfigSerializer)
async def post(self, request):
@sync_to_async
def _update_config(data):
for k, v in data.items():
if k == "website_footer":
with XSSHtml() as parser:
v = parser.clean(v)
setattr(SysOptions, k, v)
push_config_update(k, v)
await _update_config(request.data)
def post(self, request):
for k, v in request.data.items():
if k == "website_footer":
with XSSHtml() as parser:
v = parser.clean(v)
setattr(SysOptions, k, v)
return self.success()
@@ -208,12 +199,12 @@ class JudgeServerHeartbeatAPI(CSRFExemptAPIView):
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class LanguagesAPI(APIView):
def get(self, request):
return self.success(
{
"languages": SysOptions.languages,
"spj_languages": SysOptions.spj_languages,
}
)
@@ -258,7 +249,6 @@ class TestCasePruneAPI(APIView):
shutil.rmtree(test_case_dir, ignore_errors=True)
# DEPRECATED: 前端未调用 (2026-05-26)
class ReleaseNotesAPI(APIView):
def get(self, request):
try:
@@ -276,29 +266,24 @@ class ReleaseNotesAPI(APIView):
return self.success(releases)
class DashboardInfoAPI(AsyncAPIView):
async def get(self, request):
now = timezone.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
(
user_count,
today_submission_count,
recent_contest_count,
judge_servers,
) = await asyncio.gather(
User.objects.acount(),
Submission.objects.filter(create_time__gte=today_start).acount(),
Contest.objects.exclude(end_time__lt=timezone.now()).acount(),
JudgeServer.objects.filter(
last_heartbeat__gte=timezone.now() - timedelta(seconds=6)
).acount(),
class DashboardInfoAPI(APIView):
def get(self, request):
today = datetime.today()
today_submission_count = Submission.objects.filter(
create_time__gte=datetime(today.year, today.month, today.day, 0, 0)
).count()
recent_contest_count = Contest.objects.exclude(
end_time__lt=timezone.now()
).count()
judge_server_count = len(
list(filter(lambda x: x.status == "normal", JudgeServer.objects.all()))
)
return self.success(
{
"user_count": user_count,
"user_count": User.objects.count(),
"recent_contest_count": recent_contest_count,
"today_submission_count": today_submission_count,
"judge_server_count": judge_servers,
"judge_server_count": judge_server_count,
"env": {
"FORCE_HTTPS": get_env("FORCE_HTTPS", default=False),
"STATIC_CDN_HOST": get_env("STATIC_CDN_HOST", default=""),
@@ -307,41 +292,26 @@ class DashboardInfoAPI(AsyncAPIView):
)
class RandomUsernameAPI(AsyncAPIView):
async def get(self, request):
class RandomUsernameAPI(APIView):
def get(self, request):
classroom = request.GET.get("classroom", "")
if not classroom:
return self.error("需要班级号")
usernames = [
u async for u in User.objects.filter(username__istartswith=classroom)
usernames = (
User.objects.filter(username__istartswith=classroom)
.values_list("username", flat=True)
.order_by("?")[:10]
]
return self.success(usernames)
.order_by("?")
)
if len(usernames) > 10:
return self.success(usernames[:10])
else:
return self.success(usernames)
class HitokotoAPI(AsyncAPIView):
async def get(self, request):
try:
categories = JsonDataLoader.load_data(
settings.HITOKOTO_DIR, "categories.json"
)
path = random.choice(categories).get("path")
sentences = JsonDataLoader.load_data(settings.HITOKOTO_DIR, path)
sentence = random.choice(sentences)
return self.success(sentence)
except Exception:
return self.error("获取一言失败,请稍后再试")
class ClassUsernamesAPI(AsyncAPIView):
async def get(self, request):
classroom = request.GET.get("classroom", "")
if not classroom:
return self.error("需要班级号")
prefix = f"ks{classroom}"
names = [
user.username[len(prefix):] if user.username.startswith(prefix) else user.username
async for user in User.objects.filter(class_name=classroom).order_by("-create_time")
]
return self.success(names)
class HitokotoAPI(APIView):
def get(self, request):
categories = JsonDataLoader.load_data(settings.HITOKOTO_DIR, "categories.json")
path = random.choice(categories).get("path")
sentences = JsonDataLoader.load_data(settings.HITOKOTO_DIR, path)
sentence = random.choice(sentences)
return self.success(sentence)

View File

@@ -1,23 +0,0 @@
# Generated by Django 6.0 on 2026-03-30 15:28
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='acmcontestrank',
index=models.Index(fields=['contest', 'accepted_number', 'total_time'], name='acm_rank_order_idx'),
),
migrations.AddIndex(
model_name='oicontestrank',
index=models.Index(fields=['contest', 'total_score'], name='oi_rank_order_idx'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 6.0 on 2026-04-23 20:07
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0002_acmcontestrank_acm_rank_order_idx_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='acmcontestrank',
index=models.Index(fields=['contest', 'user'], name='acm_rank_contest_user_idx'),
),
migrations.AddIndex(
model_name='oicontestrank',
index=models.Index(fields=['contest', 'user'], name='oi_rank_contest_user_idx'),
),
]

View File

@@ -1,35 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-09 08:18
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contest", "0003_acmcontestrank_acm_rank_contest_user_idx_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterUniqueTogether(
name="acmcontestrank",
unique_together=set(),
),
migrations.AlterUniqueTogether(
name="oicontestrank",
unique_together=set(),
),
migrations.AlterField(
model_name="contest",
name="rule_type",
field=models.TextField(choices=[("ACM", "ACM"), ("OI", "OI")]),
),
migrations.AddConstraint(
model_name="acmcontestrank",
constraint=models.UniqueConstraint(fields=("user", "contest"), name="unique_acm_rank_user_contest"),
),
migrations.AddConstraint(
model_name="oicontestrank",
constraint=models.UniqueConstraint(fields=("user", "contest"), name="unique_oi_rank_user_contest"),
),
]

View File

@@ -1,48 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-09 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0004_alter_acmcontestrank_unique_together_and_more'),
]
operations = [
migrations.AlterField(
model_name='acmcontestrank',
name='accepted_number',
field=models.IntegerField(db_default=0, default=0),
),
migrations.AlterField(
model_name='acmcontestrank',
name='submission_info',
field=models.JSONField(db_default=models.Value({}, output_field=models.JSONField()), default=dict),
),
migrations.AlterField(
model_name='acmcontestrank',
name='submission_number',
field=models.IntegerField(db_default=0, default=0),
),
migrations.AlterField(
model_name='acmcontestrank',
name='total_time',
field=models.IntegerField(db_default=0, default=0),
),
migrations.AlterField(
model_name='oicontestrank',
name='submission_info',
field=models.JSONField(db_default=models.Value({}, output_field=models.JSONField()), default=dict),
),
migrations.AlterField(
model_name='oicontestrank',
name='submission_number',
field=models.IntegerField(db_default=0, default=0),
),
migrations.AlterField(
model_name='oicontestrank',
name='total_score',
field=models.IntegerField(db_default=0, default=0),
),
]

View File

@@ -1,13 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("contest", "0005_alter_acmcontestrank_accepted_number_and_more"),
]
operations = [
migrations.DeleteModel(name="OIContestRank"),
migrations.RemoveField(model_name="contest", name="real_time_rank"),
migrations.RemoveField(model_name="contest", name="rule_type"),
]

View File

@@ -1,16 +1,22 @@
from utils.constants import ContestRuleType # noqa
from django.db import models
from django.utils.timezone import now
from utils.models import JSONField
from account.models import User
from utils.constants import ContestStatus, ContestType
from utils.models import JSONField, RichTextField
from account.models import User
from utils.models import RichTextField
class Contest(models.Model):
title = models.TextField()
description = RichTextField()
tag = models.TextField()
# show real time rank or cached rank
real_time_rank = models.BooleanField()
password = models.TextField(null=True)
# enum of ContestRuleType
rule_type = models.TextField()
start_time = models.DateTimeField()
end_time = models.DateTimeField()
create_time = models.DateTimeField(auto_now_add=True)
@@ -40,7 +46,10 @@ class Contest(models.Model):
# 是否有权查看problem 的一些统计信息 诸如submission_number, accepted_number 等
def problem_details_permission(self, user):
return self.status == ContestStatus.CONTEST_ENDED or user.is_authenticated and user.is_contest_admin(self)
return self.rule_type == ContestRuleType.ACM or \
self.status == ContestStatus.CONTEST_ENDED or \
user.is_authenticated and user.is_contest_admin(self) or \
self.real_time_rank
class Meta:
db_table = "contest"
@@ -50,31 +59,35 @@ class Contest(models.Model):
class AbstractContestRank(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
contest = models.ForeignKey(Contest, on_delete=models.CASCADE)
submission_number = models.IntegerField(default=0, db_default=0)
submission_number = models.IntegerField(default=0)
class Meta:
abstract = True
class ACMContestRank(AbstractContestRank):
accepted_number = models.IntegerField(default=0, db_default=0)
accepted_number = models.IntegerField(default=0)
# total_time is only for ACM contest, total_time = ac time + none-ac times * 20 * 60
total_time = models.IntegerField(default=0, db_default=0)
total_time = models.IntegerField(default=0)
# {"23": {"is_ac": True, "ac_time": 8999, "error_number": 2, "is_first_ac": True}}
# key is problem id
submission_info = JSONField(default=dict, db_default=models.Value({}, output_field=models.JSONField()))
submission_info = JSONField(default=dict)
class Meta:
db_table = "acm_contest_rank"
constraints = [
models.UniqueConstraint(fields=["user", "contest"], name="unique_acm_rank_user_contest"),
]
indexes = [
models.Index(fields=["contest", "accepted_number", "total_time"], name="acm_rank_order_idx"),
models.Index(fields=["contest", "user"], name="acm_rank_contest_user_idx"),
]
unique_together = (("user", "contest"),)
class OIContestRank(AbstractContestRank):
total_score = models.IntegerField(default=0)
# {"23": 333}
# key is problem id, value is current score
submission_info = JSONField(default=dict)
class Meta:
db_table = "oi_contest_rank"
unique_together = (("user", "contest"),)
class ContestAnnouncement(models.Model):
contest = models.ForeignKey(Contest, on_delete=models.CASCADE)

View File

@@ -1,6 +1,7 @@
from utils.api import UsernameSerializer, serializers
from .models import ACMContestRank, Contest, ContestAnnouncement
from .models import Contest, ContestAnnouncement, ContestRuleType
from .models import ACMContestRank, OIContestRank
class CreateConetestSeriaizer(serializers.Serializer):
@@ -9,8 +10,10 @@ class CreateConetestSeriaizer(serializers.Serializer):
tag = serializers.CharField()
start_time = serializers.DateTimeField()
end_time = serializers.DateTimeField()
rule_type = serializers.ChoiceField(choices=[ContestRuleType.ACM, ContestRuleType.OI])
password = serializers.CharField(allow_blank=True, max_length=32)
visible = serializers.BooleanField()
real_time_rank = serializers.BooleanField()
allowed_ip_ranges = serializers.ListField(child=serializers.CharField(max_length=32), allow_empty=True)
@@ -23,6 +26,7 @@ class EditConetestSeriaizer(serializers.Serializer):
end_time = serializers.DateTimeField()
password = serializers.CharField(allow_blank=True, allow_null=True, max_length=32)
visible = serializers.BooleanField()
real_time_rank = serializers.BooleanField()
allowed_ip_ranges = serializers.ListField(child=serializers.CharField(max_length=32))
@@ -84,13 +88,23 @@ class ACMContestRankSerializer(serializers.ModelSerializer):
return UsernameSerializer(obj.user, need_real_name=self.is_contest_admin).data
class OIContestRankSerializer(serializers.ModelSerializer):
user = serializers.SerializerMethodField()
class Meta:
model = OIContestRank
fields = "__all__"
def __init__(self, *args, **kwargs):
self.is_contest_admin = kwargs.pop("is_contest_admin", False)
super().__init__(*args, **kwargs)
def get_user(self, obj):
return UsernameSerializer(obj.user, need_real_name=self.is_contest_admin).data
class ACMContesHelperSerializer(serializers.Serializer):
contest_id = serializers.IntegerField()
problem_id = serializers.CharField()
rank_id = serializers.IntegerField()
checked = serializers.BooleanField()
class ContestCloneSerializer(serializers.Serializer):
contest_id = serializers.IntegerField()

162
contest/tests.py Normal file
View File

@@ -0,0 +1,162 @@
import copy
from datetime import datetime, timedelta
from django.utils import timezone
from utils.api.tests import APITestCase
from .models import ContestAnnouncement, ContestRuleType, Contest
DEFAULT_CONTEST_DATA = {"title": "test title", "description": "test description",
"start_time": timezone.localtime(timezone.now()),
"end_time": timezone.localtime(timezone.now()) + timedelta(days=1),
"rule_type": ContestRuleType.ACM,
"password": "123",
"allowed_ip_ranges": [],
"visible": True, "real_time_rank": True}
class ContestAdminAPITest(APITestCase):
def setUp(self):
self.create_super_admin()
self.url = self.reverse("contest_admin_api")
self.data = copy.deepcopy(DEFAULT_CONTEST_DATA)
def test_create_contest(self):
response = self.client.post(self.url, data=self.data)
self.assertSuccess(response)
return response
def test_create_contest_with_invalid_cidr(self):
self.data["allowed_ip_ranges"] = ["127.0.0"]
resp = self.client.post(self.url, data=self.data)
self.assertTrue(resp.data["data"].endswith("is not a valid cidr network"))
def test_update_contest(self):
id = self.test_create_contest().data["data"]["id"]
update_data = {"id": id, "title": "update title",
"description": "update description",
"password": "12345",
"visible": False, "real_time_rank": False}
data = copy.deepcopy(self.data)
data.update(update_data)
response = self.client.put(self.url, data=data)
self.assertSuccess(response)
response_data = response.data["data"]
for k in data.keys():
if isinstance(data[k], datetime):
continue
self.assertEqual(response_data[k], data[k])
def test_get_contests(self):
self.test_create_contest()
response = self.client.get(self.url)
self.assertSuccess(response)
def test_get_one_contest(self):
id = self.test_create_contest().data["data"]["id"]
response = self.client.get("{}?id={}".format(self.url, id))
self.assertSuccess(response)
class ContestAPITest(APITestCase):
def setUp(self):
user = self.create_admin()
self.contest = Contest.objects.create(created_by=user, **DEFAULT_CONTEST_DATA)
self.url = self.reverse("contest_api") + "?id=" + str(self.contest.id)
def test_get_contest_list(self):
url = self.reverse("contest_list_api")
response = self.client.get(url + "?limit=10")
self.assertSuccess(response)
self.assertEqual(len(response.data["data"]["results"]), 1)
def test_get_one_contest(self):
resp = self.client.get(self.url)
self.assertSuccess(resp)
def test_regular_user_validate_contest_password(self):
self.create_user("test", "test123")
url = self.reverse("contest_password_api")
resp = self.client.post(url, {"contest_id": self.contest.id, "password": "error_password"})
self.assertDictEqual(resp.data, {"error": "error", "data": "Wrong password or password expired"})
resp = self.client.post(url, {"contest_id": self.contest.id, "password": DEFAULT_CONTEST_DATA["password"]})
self.assertSuccess(resp)
def test_regular_user_access_contest(self):
self.create_user("test", "test123")
url = self.reverse("contest_access_api")
resp = self.client.get(url + "?contest_id=" + str(self.contest.id))
self.assertFalse(resp.data["data"]["access"])
password_url = self.reverse("contest_password_api")
resp = self.client.post(password_url,
{"contest_id": self.contest.id, "password": DEFAULT_CONTEST_DATA["password"]})
self.assertSuccess(resp)
resp = self.client.get(self.url)
self.assertSuccess(resp)
class ContestAnnouncementAdminAPITest(APITestCase):
def setUp(self):
self.create_super_admin()
self.url = self.reverse("contest_announcement_admin_api")
contest_id = self.create_contest().data["data"]["id"]
self.data = {"title": "test title", "content": "test content", "contest_id": contest_id, "visible": True}
def create_contest(self):
url = self.reverse("contest_admin_api")
data = DEFAULT_CONTEST_DATA
return self.client.post(url, data=data)
def test_create_contest_announcement(self):
response = self.client.post(self.url, data=self.data)
self.assertSuccess(response)
return response
def test_delete_contest_announcement(self):
id = self.test_create_contest_announcement().data["data"]["id"]
response = self.client.delete("{}?id={}".format(self.url, id))
self.assertSuccess(response)
self.assertFalse(ContestAnnouncement.objects.filter(id=id).exists())
def test_get_contest_announcements(self):
self.test_create_contest_announcement()
response = self.client.get(self.url + "?contest_id=" + str(self.data["contest_id"]))
self.assertSuccess(response)
def test_get_one_contest_announcement(self):
id = self.test_create_contest_announcement().data["data"]["id"]
response = self.client.get("{}?id={}".format(self.url, id))
self.assertSuccess(response)
class ContestAnnouncementListAPITest(APITestCase):
def setUp(self):
self.create_super_admin()
self.url = self.reverse("contest_announcement_api")
def create_contest_announcements(self):
contest_id = self.client.post(self.reverse("contest_admin_api"), data=DEFAULT_CONTEST_DATA).data["data"]["id"]
url = self.reverse("contest_announcement_admin_api")
self.client.post(url, data={"title": "test title1", "content": "test content1", "contest_id": contest_id})
self.client.post(url, data={"title": "test title2", "content": "test content2", "contest_id": contest_id})
return contest_id
def test_get_contest_announcement_list(self):
contest_id = self.create_contest_announcements()
response = self.client.get(self.url, data={"contest_id": contest_id})
self.assertSuccess(response)
class ContestRankAPITest(APITestCase):
def setUp(self):
user = self.create_admin()
self.acm_contest = Contest.objects.create(created_by=user, **DEFAULT_CONTEST_DATA)
self.create_user("test", "test123")
self.url = self.reverse("contest_rank_api")
def get_contest_rank(self):
resp = self.client.get(self.url + "?contest_id=" + self.acm_contest.id)
self.assertSuccess(resp)

View File

@@ -1,11 +1,10 @@
from django.urls import path
from ..views.admin import ACMContestHelper, ContestAnnouncementAPI, ContestAPI, ContestCloneAPI, DownloadContestSubmissions
from ..views.admin import ContestAnnouncementAPI, ContestAPI, ACMContestHelper, DownloadContestSubmissions
urlpatterns = [
path("contest", ContestAPI.as_view()),
path("contest/clone", ContestCloneAPI.as_view()),
path("contest/announcement", ContestAnnouncementAPI.as_view()), # DEPRECATED: 前端未调用
path("contest/announcement", ContestAnnouncementAPI.as_view()),
path("contest/acm_helper", ACMContestHelper.as_view()),
path("download_submissions", DownloadContestSubmissions.as_view()), # DEPRECATED: 前端未调用
path("download_submissions", DownloadContestSubmissions.as_view()),
]

View File

@@ -1,12 +1,15 @@
from django.urls import path
from ..views.oj import ContestAccessAPI, ContestAnnouncementListAPI, ContestAPI, ContestListAPI, ContestPasswordVerifyAPI, ContestRankAPI
from ..views.oj import ContestAnnouncementListAPI
from ..views.oj import ContestPasswordVerifyAPI, ContestAccessAPI
from ..views.oj import ContestListAPI, ContestAPI
from ..views.oj import ContestRankAPI
urlpatterns = [
path("contests", ContestListAPI.as_view()),
path("contest", ContestAPI.as_view()),
path("contest/password", ContestPasswordVerifyAPI.as_view()),
path("contest/announcement", ContestAnnouncementListAPI.as_view()), # DEPRECATED: 前端未调用
path("contest/announcement", ContestAnnouncementListAPI.as_view()),
path("contest/access", ContestAccessAPI.as_view()),
path("contest_rank", ContestRankAPI.as_view()),
]

View File

@@ -1,37 +1,28 @@
import copy
import os
import zipfile
from datetime import timedelta
from ipaddress import ip_network
import dateutil.parser
from django.http import FileResponse
from django.utils.timezone import now
from account.decorators import ensure_created_by, super_admin_required, teacher_admin_required
from account.decorators import check_contest_permission, ensure_created_by
from account.models import User
from problem.models import Problem
from submission.models import JudgeStatus, Submission
from submission.models import Submission, JudgeStatus
from utils.api import APIView, validate_serializer
from utils.cache import cache
from utils.constants import CacheKey
from utils.shortcuts import rand_str
from utils.tasks import delete_files
from ..models import ACMContestRank, Contest, ContestAnnouncement
from ..serializers import (
ACMContesHelperSerializer,
ContestAdminSerializer,
ContestAnnouncementSerializer,
ContestCloneSerializer,
CreateConetestSeriaizer,
CreateContestAnnouncementSerializer,
EditConetestSeriaizer,
EditContestAnnouncementSerializer,
)
from ..models import Contest, ContestAnnouncement, ACMContestRank
from ..serializers import (ContestAnnouncementSerializer, ContestAdminSerializer,
CreateConetestSeriaizer, CreateContestAnnouncementSerializer,
EditConetestSeriaizer, EditContestAnnouncementSerializer,
ACMContesHelperSerializer, )
class ContestAPI(APIView):
@validate_serializer(CreateConetestSeriaizer)
@teacher_admin_required
def post(self, request):
data = request.data
data["start_time"] = dateutil.parser.parse(data["start_time"])
@@ -50,14 +41,13 @@ class ContestAPI(APIView):
return self.success(ContestAdminSerializer(contest).data)
@validate_serializer(EditConetestSeriaizer)
@teacher_admin_required
def put(self, request):
data = request.data
try:
contest = Contest.objects.get(id=data.pop("id"))
ensure_created_by(contest, request.user)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
ensure_created_by(contest, request.user)
data["start_time"] = dateutil.parser.parse(data["start_time"])
data["end_time"] = dateutil.parser.parse(data["end_time"])
if data["end_time"] <= data["start_time"]:
@@ -69,12 +59,15 @@ class ContestAPI(APIView):
ip_network(ip_range, strict=False)
except ValueError:
return self.error(f"{ip_range} is not a valid cidr network")
if not contest.real_time_rank and data.get("real_time_rank"):
cache_key = f"{CacheKey.contest_rank_cache}:{contest.id}"
cache.delete(cache_key)
for k, v in data.items():
setattr(contest, k, v)
contest.save()
return self.success(ContestAdminSerializer(contest).data)
@teacher_admin_required
def get(self, request):
contest_id = request.GET.get("id")
if contest_id:
@@ -86,7 +79,7 @@ class ContestAPI(APIView):
return self.error("Contest does not exist")
contests = Contest.objects.all().order_by("-create_time")
if not request.user.is_super_admin():
if request.user.is_admin():
contests = contests.filter(created_by=request.user)
keyword = request.GET.get("keyword")
@@ -95,10 +88,8 @@ class ContestAPI(APIView):
return self.success(self.paginate_data(request, contests, ContestAdminSerializer))
# DEPRECATED: 前端未调用 (2026-05-26)
class ContestAnnouncementAPI(APIView):
@validate_serializer(CreateContestAnnouncementSerializer)
@super_admin_required
def post(self, request):
"""
Create one contest_announcement.
@@ -106,6 +97,7 @@ class ContestAnnouncementAPI(APIView):
data = request.data
try:
contest = Contest.objects.get(id=data.pop("contest_id"))
ensure_created_by(contest, request.user)
data["contest"] = contest
data["created_by"] = request.user
except Contest.DoesNotExist:
@@ -114,7 +106,6 @@ class ContestAnnouncementAPI(APIView):
return self.success(ContestAnnouncementSerializer(announcement).data)
@validate_serializer(EditContestAnnouncementSerializer)
@super_admin_required
def put(self, request):
"""
update contest_announcement
@@ -122,6 +113,7 @@ class ContestAnnouncementAPI(APIView):
data = request.data
try:
contest_announcement = ContestAnnouncement.objects.get(id=data.pop("id"))
ensure_created_by(contest_announcement, request.user)
except ContestAnnouncement.DoesNotExist:
return self.error("Contest announcement does not exist")
for k, v in data.items():
@@ -129,17 +121,19 @@ class ContestAnnouncementAPI(APIView):
contest_announcement.save()
return self.success()
@super_admin_required
def delete(self, request):
"""
Delete one contest_announcement.
"""
contest_announcement_id = request.GET.get("id")
if contest_announcement_id:
ContestAnnouncement.objects.filter(id=contest_announcement_id).delete()
if request.user.is_admin():
ContestAnnouncement.objects.filter(id=contest_announcement_id,
contest__created_by=request.user).delete()
else:
ContestAnnouncement.objects.filter(id=contest_announcement_id).delete()
return self.success()
@super_admin_required
def get(self, request):
"""
Get one contest_announcement or contest_announcement list.
@@ -148,6 +142,7 @@ class ContestAnnouncementAPI(APIView):
if contest_announcement_id:
try:
contest_announcement = ContestAnnouncement.objects.get(id=contest_announcement_id)
ensure_created_by(contest_announcement, request.user)
return self.success(ContestAnnouncementSerializer(contest_announcement).data)
except ContestAnnouncement.DoesNotExist:
return self.error("Contest announcement does not exist")
@@ -156,6 +151,8 @@ class ContestAnnouncementAPI(APIView):
if not contest_id:
return self.error("Parameter error")
contest_announcements = ContestAnnouncement.objects.filter(contest_id=contest_id)
if request.user.is_admin():
contest_announcements = contest_announcements.filter(created_by=request.user)
keyword = request.GET.get("keyword")
if keyword:
contest_announcements = contest_announcements.filter(title__contains=keyword)
@@ -163,40 +160,26 @@ class ContestAnnouncementAPI(APIView):
class ACMContestHelper(APIView):
@teacher_admin_required
@check_contest_permission(check_type="ranks")
def get(self, request):
contest_id = request.GET.get("contest_id")
if not contest_id:
return self.error("Parameter error, contest_id is required")
try:
contest = Contest.objects.get(id=contest_id, visible=True)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
ensure_created_by(contest, request.user)
problems = Problem.objects.filter(contest=contest).values("id", "_id")
problem_id_map = {str(p["id"]): p["_id"] for p in problems}
ranks = ACMContestRank.objects.filter(contest=contest, accepted_number__gt=0).values("id", "user__username", "user__userprofile__real_name", "submission_info")
ranks = ACMContestRank.objects.filter(contest=self.contest, accepted_number__gt=0) \
.values("id", "user__username", "user__userprofile__real_name", "submission_info")
results = []
for rank in ranks:
for problem_id, info in rank["submission_info"].items():
if info["is_ac"]:
results.append(
{
"id": rank["id"],
"username": rank["user__username"],
"real_name": rank["user__userprofile__real_name"],
"problem_id": problem_id,
"problem_display_id": problem_id_map.get(problem_id, problem_id),
"ac_info": info,
"checked": info.get("checked", False),
}
)
results.append({
"id": rank["id"],
"username": rank["user__username"],
"real_name": rank["user__userprofile__real_name"],
"problem_id": problem_id,
"ac_info": info,
"checked": info.get("checked", False)
})
results.sort(key=lambda x: -x["ac_info"]["ac_time"])
return self.success(results)
@teacher_admin_required
@check_contest_permission(check_type="ranks")
@validate_serializer(ACMContesHelperSerializer)
def put(self, request):
data = request.data
@@ -212,13 +195,12 @@ class ACMContestHelper(APIView):
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class DownloadContestSubmissions(APIView):
def _dump_submissions(self, contest, exclude_admin=True):
problem_ids = contest.problem_set.all().values_list("id", "_id")
id2display_id = {k[0]: k[1] for k in problem_ids}
ac_map = {k[0]: False for k in problem_ids}
submissions = Submission.objects.filter(contest=contest, result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]).order_by("-create_time")
submissions = Submission.objects.filter(contest=contest, result=JudgeStatus.ACCEPTED).order_by("-create_time")
user_ids = submissions.values_list("user_id", flat=True)
users = User.objects.filter(id__in=user_ids)
path = f"/tmp/{rand_str()}.zip"
@@ -234,21 +216,19 @@ class DownloadContestSubmissions(APIView):
continue
file_name = f"{user.username}_{id2display_id[submission.problem_id]}.txt"
compression = zipfile.ZIP_DEFLATED
zip_file.writestr(
zinfo_or_arcname=f"{file_name}",
data=submission.code,
compress_type=compression,
)
zip_file.writestr(zinfo_or_arcname=f"{file_name}",
data=submission.code,
compress_type=compression)
user_ac_map[problem_id] = True
return path
@super_admin_required
def get(self, request):
contest_id = request.GET.get("contest_id")
if not contest_id:
return self.error("Parameter error")
try:
contest = Contest.objects.get(id=contest_id)
ensure_created_by(contest, request.user)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
@@ -259,42 +239,3 @@ class DownloadContestSubmissions(APIView):
resp["Content-Type"] = "application/zip"
resp["Content-Disposition"] = f"attachment;filename={os.path.basename(zip_path)}"
return resp
class ContestCloneAPI(APIView):
@validate_serializer(ContestCloneSerializer)
@teacher_admin_required
def post(self, request):
try:
original = Contest.objects.get(id=request.data["contest_id"])
except Contest.DoesNotExist:
return self.error("Contest does not exist")
duration = original.end_time - original.start_time
new_start = now() + timedelta(minutes=10)
new_end = new_start + duration
new_contest = Contest.objects.create(
title=original.title,
description=original.description,
tag=original.tag,
password=original.password,
visible=False,
allowed_ip_ranges=original.allowed_ip_ranges,
start_time=new_start,
end_time=new_end,
created_by=request.user,
)
for problem in Problem.objects.filter(contest=original):
tags = problem.tags.all()
problem.pk = None
problem.contest = new_contest
problem.submission_number = 0
problem.accepted_number = 0
problem.statistic_info = {}
problem.created_by = request.user
problem.save()
problem.tags.set(tags)
return self.success(ContestAdminSerializer(new_contest).data)

View File

@@ -1,70 +1,68 @@
import io
import xlsxwriter
from asgiref.sync import sync_to_async
from django.http import HttpResponse
from django.utils.timezone import now
from django.core.cache import cache
from account.decorators import (
check_contest_password,
check_contest_permission,
login_required,
)
from account.models import AdminType
from problem.models import Problem
from utils.api import AsyncAPIView, validate_serializer
from utils.constants import CONTEST_PASSWORD_SESSION_KEY, ContestStatus
from utils.shortcuts import check_is_id, datetime2str
from utils.api import APIView, validate_serializer
from utils.constants import CacheKey, CONTEST_PASSWORD_SESSION_KEY
from utils.shortcuts import datetime2str, check_is_id
from account.models import AdminType
from account.decorators import (
login_required,
check_contest_permission,
check_contest_password,
)
from ..models import ACMContestRank, Contest, ContestAnnouncement
from ..serializers import ACMContestRankSerializer, ContestAnnouncementSerializer, ContestPasswordVerifySerializer, ContestSerializer
from utils.constants import ContestRuleType, ContestStatus
from ..models import ContestAnnouncement, Contest, OIContestRank, ACMContestRank
from ..serializers import ContestAnnouncementSerializer
from ..serializers import ContestSerializer, ContestPasswordVerifySerializer
from ..serializers import OIContestRankSerializer, ACMContestRankSerializer
# DEPRECATED: 前端未调用 (2026-05-26)
class ContestAnnouncementListAPI(AsyncAPIView):
class ContestAnnouncementListAPI(APIView):
@check_contest_permission(check_type="announcements")
async def get(self, request):
def get(self, request):
contest_id = request.GET.get("contest_id")
if not contest_id:
return self.error("Invalid parameter, contest_id is required")
qs = ContestAnnouncement.objects.select_related("created_by").filter(
data = ContestAnnouncement.objects.select_related("created_by").filter(
contest_id=contest_id, visible=True
)
max_id = request.GET.get("max_id")
if max_id:
qs = qs.filter(id__gt=max_id)
data = await self.async_serialize_data(ContestAnnouncementSerializer, [item async for item in qs], many=True)
return self.success(data)
data = data.filter(id__gt=max_id)
return self.success(ContestAnnouncementSerializer(data, many=True).data)
class ContestAPI(AsyncAPIView):
async def get(self, request):
class ContestAPI(APIView):
def get(self, request):
id = request.GET.get("id")
if not id or not check_is_id(id):
return self.error("Invalid parameter, id is required")
try:
contest = await (
Contest.objects.select_related("created_by")
.filter(id=id, visible=True)
.afirst()
)
if contest is None:
raise Contest.DoesNotExist
contest = Contest.objects.get(id=id, visible=True)
except Contest.DoesNotExist:
return self.error("Contest does not exist")
data = await self.async_serialize_data(ContestSerializer, contest)
data = ContestSerializer(contest).data
data["now"] = datetime2str(now())
return self.success(data)
class ContestListAPI(AsyncAPIView):
async def get(self, request):
class ContestListAPI(APIView):
def get(self, request):
contests = Contest.objects.select_related("created_by").filter(visible=True)
keyword = request.GET.get("keyword")
rule_type = request.GET.get("rule_type")
status = request.GET.get("status")
tag = request.GET.get("tag")
if keyword:
contests = contests.filter(title__icontains=keyword)
if rule_type:
contests = contests.filter(rule_type=rule_type)
if tag:
contests = contests.filter(tag=tag)
if status:
@@ -75,16 +73,16 @@ class ContestListAPI(AsyncAPIView):
contests = contests.filter(end_time__lt=cur)
else:
contests = contests.filter(start_time__lte=cur, end_time__gte=cur)
return self.success(await self.async_paginate_data(request, contests, ContestSerializer))
return self.success(self.paginate_data(request, contests, ContestSerializer))
class ContestPasswordVerifyAPI(AsyncAPIView):
@login_required
class ContestPasswordVerifyAPI(APIView):
@validate_serializer(ContestPasswordVerifySerializer)
async def post(self, request):
@login_required
def post(self, request):
data = request.data
try:
contest = await Contest.objects.aget(
contest = Contest.objects.get(
id=data["contest_id"], visible=True, password__isnull=False
)
except Contest.DoesNotExist:
@@ -92,21 +90,23 @@ class ContestPasswordVerifyAPI(AsyncAPIView):
if not check_contest_password(data["password"], contest.password):
return self.error("Wrong password or password expired")
# password verify OK.
if CONTEST_PASSWORD_SESSION_KEY not in request.session:
request.session[CONTEST_PASSWORD_SESSION_KEY] = {}
request.session[CONTEST_PASSWORD_SESSION_KEY][contest.id] = data["password"]
# https://docs.djangoproject.com/en/dev/topics/http/sessions/#when-sessions-are-saved
request.session.modified = True
return self.success(True)
class ContestAccessAPI(AsyncAPIView):
class ContestAccessAPI(APIView):
@login_required
async def get(self, request):
def get(self, request):
contest_id = request.GET.get("contest_id")
if not contest_id:
return self.error()
try:
contest = await Contest.objects.aget(
contest = Contest.objects.get(
id=contest_id, visible=True, password__isnull=False
)
except Contest.DoesNotExist:
@@ -119,17 +119,28 @@ class ContestAccessAPI(AsyncAPIView):
)
class ContestRankAPI(AsyncAPIView):
class ContestRankAPI(APIView):
def get_rank(self):
return (
ACMContestRank.objects.filter(
contest=self.contest,
user__admin_type__in=[AdminType.REGULAR_USER, AdminType.STUDENT_ADMIN],
user__is_disabled=False,
if self.contest.rule_type == ContestRuleType.ACM:
return (
ACMContestRank.objects.filter(
contest=self.contest,
user__admin_type=AdminType.REGULAR_USER,
user__is_disabled=False,
)
.select_related("user")
.order_by("-accepted_number", "total_time")
)
else:
return (
OIContestRank.objects.filter(
contest=self.contest,
user__admin_type=AdminType.REGULAR_USER,
user__is_disabled=False,
)
.select_related("user")
.order_by("-total_score")
)
.select_related("user")
.order_by("-accepted_number", "total_time")
)
def column_string(self, n):
string = ""
@@ -138,67 +149,95 @@ class ContestRankAPI(AsyncAPIView):
string = chr(65 + remainder) + string
return string
def _build_xlsx(self, data, contest_problems):
problem_id_to_col = {p.id: i for i, p in enumerate(contest_problems)}
f = io.BytesIO()
workbook = xlsxwriter.Workbook(f)
worksheet = workbook.add_worksheet()
worksheet.write("A1", "User ID")
worksheet.write("B1", "Username")
worksheet.write("C1", "Real Name")
worksheet.write("D1", "AC")
worksheet.write("E1", "Total Submission")
worksheet.write("F1", "Total Time")
for i, p in enumerate(contest_problems):
worksheet.write(self.column_string(7 + i) + "1", p.title)
for index, item in enumerate(data):
worksheet.write_string(index + 1, 0, str(item["user"]["id"]))
worksheet.write_string(index + 1, 1, item["user"]["username"])
worksheet.write_string(
index + 1, 2, item["user"]["real_name"] or ""
)
worksheet.write_string(index + 1, 3, str(item["accepted_number"]))
worksheet.write_string(index + 1, 4, str(item["submission_number"]))
worksheet.write_string(index + 1, 5, str(item["total_time"]))
for k, v in item["submission_info"].items():
worksheet.write_string(
index + 1, 6 + problem_id_to_col[int(k)], str(v["is_ac"])
)
workbook.close()
f.seek(0)
return f.read()
@check_contest_permission(check_type="ranks")
async def get(self, request):
def get(self, request):
download_csv = request.GET.get("download_csv")
force_refresh = request.GET.get("force_refresh")
is_contest_admin = (
request.user.is_authenticated
and request.user.is_contest_admin(self.contest)
)
if self.contest.rule_type == ContestRuleType.OI:
serializer = OIContestRankSerializer
else:
serializer = ACMContestRankSerializer
qs = self.get_rank()
# if force_refresh == "1" and is_contest_admin:
if force_refresh == "1":
qs = self.get_rank()
else:
cache_key = f"{CacheKey.contest_rank_cache}:{self.contest.id}"
qs = cache.get(cache_key)
if not qs:
qs = self.get_rank()
cache.set(cache_key, qs)
if download_csv:
rank_list = [item async for item in qs]
data = await self.async_serialize_data(
ACMContestRankSerializer, rank_list, many=True, is_contest_admin=is_contest_admin
)
contest_problems = await sync_to_async(
lambda: list(Problem.objects.filter(contest=self.contest, visible=True).order_by("_id"))
)()
xlsx_bytes = await sync_to_async(self._build_xlsx)(data, contest_problems)
response = HttpResponse(xlsx_bytes)
data = serializer(qs, many=True, is_contest_admin=is_contest_admin).data
contest_problems = Problem.objects.filter(
contest=self.contest, visible=True
).order_by("_id")
problem_ids = [item.id for item in contest_problems]
f = io.BytesIO()
workbook = xlsxwriter.Workbook(f)
worksheet = workbook.add_worksheet()
worksheet.write("A1", "User ID")
worksheet.write("B1", "Username")
worksheet.write("C1", "Real Name")
if self.contest.rule_type == ContestRuleType.OI:
worksheet.write("D1", "Total Score")
for item in range(contest_problems.count()):
worksheet.write(
self.column_string(5 + item) + "1",
f"{contest_problems[item].title}",
)
for index, item in enumerate(data):
worksheet.write_string(index + 1, 0, str(item["user"]["id"]))
worksheet.write_string(index + 1, 1, item["user"]["username"])
worksheet.write_string(
index + 1, 2, item["user"]["real_name"] or ""
)
worksheet.write_string(index + 1, 3, str(item["total_score"]))
for k, v in item["submission_info"].items():
worksheet.write_string(
index + 1, 4 + problem_ids.index(int(k)), str(v)
)
else:
worksheet.write("D1", "AC")
worksheet.write("E1", "Total Submission")
worksheet.write("F1", "Total Time")
for item in range(contest_problems.count()):
worksheet.write(
self.column_string(7 + item) + "1",
f"{contest_problems[item].title}",
)
for index, item in enumerate(data):
worksheet.write_string(index + 1, 0, str(item["user"]["id"]))
worksheet.write_string(index + 1, 1, item["user"]["username"])
worksheet.write_string(
index + 1, 2, item["user"]["real_name"] or ""
)
worksheet.write_string(index + 1, 3, str(item["accepted_number"]))
worksheet.write_string(index + 1, 4, str(item["submission_number"]))
worksheet.write_string(index + 1, 5, str(item["total_time"]))
for k, v in item["submission_info"].items():
worksheet.write_string(
index + 1, 6 + problem_ids.index(int(k)), str(v["is_ac"])
)
workbook.close()
f.seek(0)
response = HttpResponse(f.read())
response["Content-Disposition"] = (
f"attachment; filename=content-{self.contest.id}-rank.xlsx"
)
response["Content-Type"] = "application/xlsx"
return response
page_qs = await self.async_paginate_data(request, qs)
page_qs["results"] = await self.async_serialize_data(
ACMContestRankSerializer,
page_qs = self.paginate_data(request, qs)
page_qs["results"] = serializer(
page_qs["results"], many=True, is_contest_admin=is_contest_admin
)
).data
return self.success(page_qs)

View File

@@ -3,27 +3,27 @@
APP=/app
DATA=/data
mkdir -p "$DATA/log" "$DATA/config" "$DATA/ssl" "$DATA/test_case" "$DATA/public/upload" "$DATA/public/avatar" "$DATA/public/website"
mkdir -p $DATA/log $DATA/config $DATA/ssl $DATA/test_case $DATA/public/upload $DATA/public/avatar $DATA/public/website
if [ ! -f "$DATA/config/secret.key" ]; then
echo "$(head -c 32 /dev/urandom | md5sum | head -c 32)" > "$DATA/config/secret.key"
echo $(cat /dev/urandom | head -1 | md5sum | head -c 32) > "$DATA/config/secret.key"
fi
if [ ! -f "$DATA/public/avatar/default.png" ]; then
cp data/public/avatar/default.png "$DATA/public/avatar"
cp data/public/avatar/default.png $DATA/public/avatar
fi
if [ ! -f "$DATA/public/website/favicon.ico" ]; then
cp data/public/website/favicon.ico "$DATA/public/website"
cp data/public/website/favicon.ico $DATA/public/website
fi
SSL="$DATA/ssl"
if [ ! -f "$SSL/server.key" ]; then
openssl req -x509 -newkey rsa:2048 -keyout "$SSL/server.key" -out "$SSL/server.crt" -days 1000 \
-subj "/C=CN/ST=Beijing/L=Beijing/O=Beijing OnlineJudge Technology Co., Ltd./OU=Service Infrastructure Department/CN=$(hostname)" -nodes
-subj "/C=CN/ST=Beijing/L=Beijing/O=Beijing OnlineJudge Technology Co., Ltd./OU=Service Infrastructure Department/CN=`hostname`" -nodes
fi
cd "$APP/deploy/nginx"
cd $APP/deploy/nginx
ln -sf locations.conf https_locations.conf
if [ -z "$FORCE_HTTPS" ]; then
ln -sf locations.conf http_locations.conf
@@ -31,30 +31,29 @@ else
ln -sf https_redirect.conf http_locations.conf
fi
if [ -n "$LOWER_IP_HEADER" ]; then
if [ ! -z "$LOWER_IP_HEADER" ]; then
sed -i "s/__IP_HEADER__/\$http_$LOWER_IP_HEADER/g" api_proxy.conf;
else
sed -i "s/__IP_HEADER__/\$remote_addr/g" api_proxy.conf;
fi
if [ -z "$MAX_WORKER_NUM" ]; then
CPU_CORE_NUM=$(grep -c ^processor /proc/cpuinfo)
export CPU_CORE_NUM
if [ "$CPU_CORE_NUM" -lt 2 ]; then
export CPU_CORE_NUM=$(grep -c ^processor /proc/cpuinfo)
if [[ $CPU_CORE_NUM -lt 2 ]]; then
export MAX_WORKER_NUM=2
else
export MAX_WORKER_NUM="$CPU_CORE_NUM"
export MAX_WORKER_NUM=$(($CPU_CORE_NUM))
fi
fi
cd "$APP/dist"
if [ -n "$STATIC_CDN_HOST" ]; then
cd $APP/dist
if [ ! -z "$STATIC_CDN_HOST" ]; then
find . -name "*.*" -type f -exec sed -i "s/__STATIC_CDN_HOST__/\/$STATIC_CDN_HOST/g" {} \;
else
find . -name "*.*" -type f -exec sed -i "s/__STATIC_CDN_HOST__\///g" {} \;
fi
cd "$APP"
cd $APP
n=0
while [ $n -lt 5 ]
@@ -64,19 +63,15 @@ do
echo "from options.options import SysOptions; SysOptions.judge_server_token='$JUDGE_SERVER_TOKEN'" | python manage.py shell &&
echo "from conf.models import JudgeServer; JudgeServer.objects.update(task_number=0)" | python manage.py shell &&
break
n=$((n + 1))
n=$(($n+1))
echo "Failed to migrate, going to retry..."
sleep 8
done
if ! getent group spj >/dev/null; then
groupadd --system --gid 903 spj
fi
if ! id -u server >/dev/null 2>&1; then
useradd --system --uid 900 --gid spj --no-create-home --shell /usr/sbin/nologin server
fi
addgroup -g 903 spj
adduser -u 900 -S -G spj server
chown -R server:spj "$DATA" "$APP/dist"
find "$DATA/test_case" -type d -exec chmod 710 {} \;
find "$DATA/test_case" -type f -exec chmod 640 {} \;
chown -R server:spj $DATA $APP/dist
find $DATA/test_case -type d -exec chmod 710 {} \;
find $DATA/test_case -type f -exec chmod 640 {} \;
exec supervisord -c /app/deploy/supervisord.conf

View File

@@ -2,23 +2,6 @@ location /public {
root /data;
}
# WebSocket 支持
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP __IP_HEADER__;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 超时设置
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
location /api {
include api_proxy.conf;
}

View File

@@ -1,4 +1,4 @@
user www-data;
user nginx;
daemon off;
pid /tmp/nginx.pid;
worker_processes auto;
@@ -38,7 +38,7 @@ http {
keepalive 32;
}
add_header X-XSS-Protection "1; mode=block" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
@@ -46,7 +46,7 @@ add_header X-XSS-Protection "1; mode=block" always;
listen 8000 default_server;
server_name _;
include locations.conf;
include http_locations.conf;
}
# server {
@@ -63,3 +63,4 @@ add_header X-XSS-Protection "1; mode=block" always;
# }
}

View File

@@ -1,917 +1,31 @@
# This file was autogenerated by uv via the following command:
# uv export --format requirements.txt
annotated-types==0.7.0 \
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
--hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
# via pydantic
anyio==4.13.0 \
--hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \
--hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc
# via
# httpx
# openai
# watchfiles
asgiref==3.11.1 \
--hash=sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce \
--hash=sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133
# via
# channels
# channels-redis
# django
# onlinejudge
certifi==2026.4.22 \
--hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \
--hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580
# via
# httpcore
# httpx
# requests
# sentry-sdk
channels==4.3.2 \
--hash=sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667 \
--hash=sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176
# via
# channels-redis
# onlinejudge
channels-redis==4.3.0 \
--hash=sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5 \
--hash=sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2
# via onlinejudge
charset-normalizer==3.4.7 \
--hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
--hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
--hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
--hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
--hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
--hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
--hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
--hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
--hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
--hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
--hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
--hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
--hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
--hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
--hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
--hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
--hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
--hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
--hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
--hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
--hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
--hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
--hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
--hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
--hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
--hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
--hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
--hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
--hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
--hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
--hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
--hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
--hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
--hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
--hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
--hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
--hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
--hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
--hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
--hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
--hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
--hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
--hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
--hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
--hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
--hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
--hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
--hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
--hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
--hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
--hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
--hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
--hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
--hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
--hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
--hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
--hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
--hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
--hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
--hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
--hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
--hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
--hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
# via requests
click==8.4.1 \
--hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \
--hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96
# via uvicorn
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via
# click
# qrcode
# tqdm
# uvicorn
distro==1.9.0 \
--hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
--hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
# via openai
django==6.0.4 \
--hash=sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da \
--hash=sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac
# via
# channels
# django-cas-ng
# django-dramatiq
# django-redis
# djangorestframework
# onlinejudge
# sentry-sdk
django-cas-ng==5.1.1 \
--hash=sha256:a1839aed955fc756ee35a479cb18eb3dd1912613888bdade069bcc4c405adb79 \
--hash=sha256:c89a4be2d24ab3fbcab3e59c212a3347a42840b0ad2677036b5655003ad4840c
# via onlinejudge
django-dbconn-retry==0.3.1 \
--hash=sha256:d4b64d915440c3e5902ef8edf836366a6f4c4f027d34902135d7335233d6dbba
# via onlinejudge
django-dramatiq==0.15.0 \
--hash=sha256:23f0bc418a860952adbf822c4aa3b9c46c51d3d9f50be0a8ed3d19a53380df1d \
--hash=sha256:e3cf1b2ac288fe4a7aa198c9450fe242ed312df8850f3f9e18ce01b8acc78b96
# via onlinejudge
django-redis==6.0.0 \
--hash=sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0 \
--hash=sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd
# via onlinejudge
djangorestframework==3.17.1 \
--hash=sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5 \
--hash=sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457
# via onlinejudge
dramatiq==2.1.0 \
--hash=sha256:3ef940c2815722d3679aed79ef96c805f02fd33d4361529b2de30f01511ca44d \
--hash=sha256:cf81550729de6cf64234b05bd63970645654aaf38967faa7a2b6e401384bb090
# via
# django-dramatiq
# onlinejudge
gunicorn==26.0.0 \
--hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \
--hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf
# via onlinejudge
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via
# httpcore
# uvicorn
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
httptools==0.8.0 \
--hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \
--hash=sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b \
--hash=sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527 \
--hash=sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca \
--hash=sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081 \
--hash=sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c \
--hash=sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77 \
--hash=sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09 \
--hash=sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f \
--hash=sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5 \
--hash=sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8 \
--hash=sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681 \
--hash=sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999 \
--hash=sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1 \
--hash=sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d \
--hash=sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d \
--hash=sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d \
--hash=sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745 \
--hash=sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b \
--hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2 \
--hash=sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0 \
--hash=sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150 \
--hash=sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e \
--hash=sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568 \
--hash=sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6 \
--hash=sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b \
--hash=sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7 \
--hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \
--hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0
# via uvicorn
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via openai
idna==3.13 \
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
# via
# anyio
# httpx
# requests
jieba==0.42.1 \
--hash=sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2
# via onlinejudge
jiter==0.14.0 \
--hash=sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5 \
--hash=sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588 \
--hash=sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b \
--hash=sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b \
--hash=sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db \
--hash=sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28 \
--hash=sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2 \
--hash=sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994 \
--hash=sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975 \
--hash=sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674 \
--hash=sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607 \
--hash=sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d \
--hash=sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92 \
--hash=sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d \
--hash=sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560 \
--hash=sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2 \
--hash=sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016 \
--hash=sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a \
--hash=sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844 \
--hash=sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff \
--hash=sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129 \
--hash=sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9 \
--hash=sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9 \
--hash=sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06 \
--hash=sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea \
--hash=sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a \
--hash=sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140 \
--hash=sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc \
--hash=sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de \
--hash=sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1 \
--hash=sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310 \
--hash=sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40 \
--hash=sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e \
--hash=sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a \
--hash=sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7 \
--hash=sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa \
--hash=sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f \
--hash=sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746 \
--hash=sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01 \
--hash=sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f \
--hash=sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9 \
--hash=sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985 \
--hash=sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8 \
--hash=sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3 \
--hash=sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94 \
--hash=sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4 \
--hash=sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02 \
--hash=sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9 \
--hash=sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165 \
--hash=sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb \
--hash=sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a \
--hash=sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615 \
--hash=sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928 \
--hash=sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98 \
--hash=sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f \
--hash=sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a \
--hash=sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab \
--hash=sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e \
--hash=sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057 \
--hash=sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611 \
--hash=sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850 \
--hash=sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9 \
--hash=sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa \
--hash=sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f \
--hash=sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3
# via openai
lxml==6.1.0 \
--hash=sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2 \
--hash=sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773 \
--hash=sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad \
--hash=sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54 \
--hash=sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c \
--hash=sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69 \
--hash=sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd \
--hash=sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405 \
--hash=sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b \
--hash=sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d \
--hash=sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac \
--hash=sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62 \
--hash=sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f \
--hash=sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad \
--hash=sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292 \
--hash=sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491 \
--hash=sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120 \
--hash=sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb \
--hash=sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32 \
--hash=sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2 \
--hash=sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88 \
--hash=sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43 \
--hash=sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03 \
--hash=sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485 \
--hash=sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16 \
--hash=sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb \
--hash=sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9 \
--hash=sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f \
--hash=sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105 \
--hash=sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7 \
--hash=sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5 \
--hash=sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d \
--hash=sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9 \
--hash=sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d \
--hash=sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb \
--hash=sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f \
--hash=sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb \
--hash=sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e \
--hash=sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33 \
--hash=sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5 \
--hash=sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585 \
--hash=sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946 \
--hash=sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e \
--hash=sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a \
--hash=sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28 \
--hash=sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c \
--hash=sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9 \
--hash=sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495 \
--hash=sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45 \
--hash=sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc \
--hash=sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8 \
--hash=sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f \
--hash=sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366 \
--hash=sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814 \
--hash=sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690 \
--hash=sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13 \
--hash=sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819 \
--hash=sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c \
--hash=sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86 \
--hash=sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180 \
--hash=sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe \
--hash=sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181 \
--hash=sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d \
--hash=sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24 \
--hash=sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d \
--hash=sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d \
--hash=sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9 \
--hash=sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2 \
--hash=sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f \
--hash=sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93 \
--hash=sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d \
--hash=sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d \
--hash=sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086
# via python-cas
msgpack==1.1.2 \
--hash=sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014 \
--hash=sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931 \
--hash=sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b \
--hash=sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b \
--hash=sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999 \
--hash=sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029 \
--hash=sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9 \
--hash=sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42 \
--hash=sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e \
--hash=sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7 \
--hash=sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb \
--hash=sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf \
--hash=sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245 \
--hash=sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794 \
--hash=sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af \
--hash=sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff \
--hash=sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939 \
--hash=sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa \
--hash=sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90 \
--hash=sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717 \
--hash=sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a \
--hash=sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2 \
--hash=sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e \
--hash=sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b \
--hash=sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9 \
--hash=sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b \
--hash=sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c \
--hash=sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620 \
--hash=sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69 \
--hash=sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f \
--hash=sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27 \
--hash=sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46 \
--hash=sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00 \
--hash=sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84 \
--hash=sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20 \
--hash=sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e \
--hash=sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162
# via channels-redis
openai==2.34.0 \
--hash=sha256:828b4efcbb126352c2b5eb97d33ae890c92a71ab72511aefc1b7fe64aeccb07b \
--hash=sha256:c996a71b1a210f3569844572ad4c609307e978515fb76877cf449b72596e549e
# via onlinejudge
otpauth==2.2.1 \
--hash=sha256:169a7adbd715fca687f6a66d02ccdbefc229fb49f8a634b958d286f908134d59 \
--hash=sha256:b7eabe0ed91cb67eb3054b7f517e4b4a7495fb30eaf2951897d41c8feef5de73
# via onlinejudge
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via gunicorn
pillow==12.2.0 \
--hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \
--hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \
--hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \
--hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \
--hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \
--hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \
--hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \
--hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \
--hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \
--hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \
--hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \
--hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \
--hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \
--hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \
--hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \
--hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \
--hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \
--hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \
--hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \
--hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \
--hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \
--hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \
--hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \
--hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \
--hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \
--hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \
--hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \
--hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \
--hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \
--hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \
--hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \
--hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \
--hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \
--hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \
--hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \
--hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \
--hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \
--hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \
--hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \
--hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \
--hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \
--hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \
--hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \
--hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \
--hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \
--hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \
--hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \
--hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \
--hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \
--hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \
--hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \
--hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \
--hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \
--hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \
--hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \
--hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \
--hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \
--hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \
--hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \
--hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \
--hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \
--hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5
# via onlinejudge
psycopg==3.3.4 \
--hash=sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a \
--hash=sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc
# via onlinejudge
psycopg-binary==3.3.4 \
--hash=sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070 \
--hash=sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc \
--hash=sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68 \
--hash=sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae \
--hash=sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829 \
--hash=sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c \
--hash=sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97 \
--hash=sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6 \
--hash=sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228 \
--hash=sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e \
--hash=sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4 \
--hash=sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41 \
--hash=sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9 \
--hash=sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089 \
--hash=sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf \
--hash=sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578 \
--hash=sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014 \
--hash=sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e \
--hash=sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31 \
--hash=sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652 \
--hash=sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b \
--hash=sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277 \
--hash=sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7 \
--hash=sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70 \
--hash=sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf \
--hash=sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8 \
--hash=sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3 \
--hash=sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38 \
--hash=sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9 \
--hash=sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d \
--hash=sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16 \
--hash=sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260 \
--hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7
# via onlinejudge
pydantic==2.13.3 \
--hash=sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927 \
--hash=sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d
# via openai
pydantic-core==2.46.3 \
--hash=sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba \
--hash=sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35 \
--hash=sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4 \
--hash=sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505 \
--hash=sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7 \
--hash=sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8 \
--hash=sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37 \
--hash=sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022 \
--hash=sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7 \
--hash=sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c \
--hash=sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb \
--hash=sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3 \
--hash=sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976 \
--hash=sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab \
--hash=sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396 \
--hash=sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c \
--hash=sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c \
--hash=sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5 \
--hash=sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d \
--hash=sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df \
--hash=sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c \
--hash=sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13 \
--hash=sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0 \
--hash=sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1 \
--hash=sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8 \
--hash=sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0 \
--hash=sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374 \
--hash=sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6 \
--hash=sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f \
--hash=sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad \
--hash=sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e \
--hash=sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23 \
--hash=sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1 \
--hash=sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee \
--hash=sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c \
--hash=sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a \
--hash=sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789 \
--hash=sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f \
--hash=sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b \
--hash=sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089 \
--hash=sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b \
--hash=sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67 \
--hash=sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803 \
--hash=sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2 \
--hash=sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f \
--hash=sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687 \
--hash=sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1 \
--hash=sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018 \
--hash=sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba \
--hash=sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c \
--hash=sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf \
--hash=sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47 \
--hash=sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34 \
--hash=sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca \
--hash=sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f \
--hash=sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3 \
--hash=sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64 \
--hash=sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22 \
--hash=sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72 \
--hash=sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec \
--hash=sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d \
--hash=sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395 \
--hash=sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4 \
--hash=sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127 \
--hash=sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56
# via pydantic
python-cas==1.7.2 \
--hash=sha256:1c50e0d8e20b0356e571a48e7f987df780eff93a1039ac895aeb0dc78126073e \
--hash=sha256:228c540186f52f91605016c3921fee677c214de5454c0b6902956d280c47cadc
# via django-cas-ng
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via onlinejudge
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
# via uvicorn
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via uvicorn
qrcode==8.2 \
--hash=sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f \
--hash=sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c
# via onlinejudge
redis==7.4.0 \
--hash=sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad \
--hash=sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec
# via
# channels-redis
# django-redis
requests==2.33.1 \
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
# via python-cas
ruff==0.15.12 \
--hash=sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b \
--hash=sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33 \
--hash=sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0 \
--hash=sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002 \
--hash=sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339 \
--hash=sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e \
--hash=sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847 \
--hash=sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f \
--hash=sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6 \
--hash=sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d \
--hash=sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20 \
--hash=sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd \
--hash=sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c \
--hash=sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5 \
--hash=sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6 \
--hash=sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c \
--hash=sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5 \
--hash=sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5
# via onlinejudge
sentry-sdk==2.59.0 \
--hash=sha256:abcf65ee9a9d9cdebf9ad369782408ecca9c1c792686ef06ba34f5ab233527fe \
--hash=sha256:cd265808ef8bf3f3edf69b527c0a0b2b6b1322762679e55b8987db2e9584aec1
# via onlinejudge
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
sniffio==1.3.1 \
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
# via openai
sqlparse==0.5.5 \
--hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \
--hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e
# via django
tqdm==4.67.3 \
--hash=sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb \
--hash=sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf
# via openai
tree-sitter==0.25.2 \
--hash=sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd \
--hash=sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5 \
--hash=sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358 \
--hash=sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b \
--hash=sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721 \
--hash=sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0 \
--hash=sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897 \
--hash=sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0 \
--hash=sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8 \
--hash=sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9 \
--hash=sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614 \
--hash=sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f \
--hash=sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053 \
--hash=sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87 \
--hash=sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c \
--hash=sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae \
--hash=sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99 \
--hash=sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960 \
--hash=sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab \
--hash=sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601 \
--hash=sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac \
--hash=sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20
# via onlinejudge
tree-sitter-c==0.24.2 \
--hash=sha256:1628584df0299b5a340aa63f8e67b6c97c91517f52fa7e7a4c557e40adb330a9 \
--hash=sha256:4a2f4371cd816cc3153458f69062135ebb2ea5f275ddd90494e5c823d778204a \
--hash=sha256:4d4579a8b54f0a442f903d88d3304cab77cd5c2031d4015baa4f2f8e15d6dcb7 \
--hash=sha256:5041ef67eb68ce6bc8bb0b1f8ef3a5585ce523dae0c7eec109ab0627dd75aede \
--hash=sha256:82842c5a5f2acd93f4de10038c33ac179c8979defc39376f990348d6289e933b \
--hash=sha256:97bc80a224d48215d4e6e6376bf30d114f4c317b8145ff1b02afe785d4ba7bdd \
--hash=sha256:abb549225091f7b25df2dd3a0143ece6e208f7055d8bcb4700b41ee79b9ef1e1 \
--hash=sha256:c098bedcd5ac86ff93fa734d51d1dd86aed40fd5ed7d634c7af11380a0469969 \
--hash=sha256:e2b42e8e22202c251f8629306f9321233542e07a6e01611b5fe83489272143eb
# via onlinejudge
tree-sitter-python==0.25.0 \
--hash=sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb \
--hash=sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361 \
--hash=sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762 \
--hash=sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683 \
--hash=sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5 \
--hash=sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76 \
--hash=sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac \
--hash=sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b \
--hash=sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d
# via onlinejudge
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# anyio
# openai
# psycopg
# pydantic
# pydantic-core
# typing-inspection
typing-inspection==0.4.2 \
--hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
--hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
# via pydantic
tzdata==2026.2 ; sys_platform == 'win32' \
--hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \
--hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7
# via
# django
# psycopg
urllib3==2.6.3 \
--hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \
--hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
# via
# requests
# sentry-sdk
uvicorn==0.48.0 \
--hash=sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad \
--hash=sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37
# via onlinejudge
uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' \
--hash=sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e \
--hash=sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8 \
--hash=sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad \
--hash=sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35 \
--hash=sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289 \
--hash=sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142 \
--hash=sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74 \
--hash=sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0 \
--hash=sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6 \
--hash=sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 \
--hash=sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f \
--hash=sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e \
--hash=sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d \
--hash=sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370 \
--hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4 \
--hash=sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079 \
--hash=sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6 \
--hash=sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3 \
--hash=sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21 \
--hash=sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c \
--hash=sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e \
--hash=sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25 \
--hash=sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88 \
--hash=sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2 \
--hash=sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42
# via uvicorn
watchfiles==1.2.0 \
--hash=sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9 \
--hash=sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98 \
--hash=sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7 \
--hash=sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69 \
--hash=sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242 \
--hash=sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925 \
--hash=sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f \
--hash=sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5 \
--hash=sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427 \
--hash=sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4 \
--hash=sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa \
--hash=sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba \
--hash=sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c \
--hash=sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906 \
--hash=sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65 \
--hash=sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c \
--hash=sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c \
--hash=sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30 \
--hash=sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077 \
--hash=sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374 \
--hash=sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01 \
--hash=sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33 \
--hash=sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831 \
--hash=sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9 \
--hash=sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b \
--hash=sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f \
--hash=sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658 \
--hash=sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579 \
--hash=sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0 \
--hash=sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666 \
--hash=sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103 \
--hash=sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6 \
--hash=sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8 \
--hash=sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898 \
--hash=sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d \
--hash=sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44 \
--hash=sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2 \
--hash=sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5 \
--hash=sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b \
--hash=sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc \
--hash=sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5 \
--hash=sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377 \
--hash=sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add \
--hash=sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281 \
--hash=sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9 \
--hash=sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0 \
--hash=sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28 \
--hash=sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55 \
--hash=sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb \
--hash=sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb \
--hash=sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4 \
--hash=sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0 \
--hash=sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e \
--hash=sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4 \
--hash=sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06 \
--hash=sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26 \
--hash=sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7 \
--hash=sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3 \
--hash=sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3 \
--hash=sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838 \
--hash=sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71 \
--hash=sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488 \
--hash=sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d \
--hash=sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44 \
--hash=sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2 \
--hash=sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2 \
--hash=sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e \
--hash=sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5 \
--hash=sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799 \
--hash=sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7 \
--hash=sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379 \
--hash=sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925 \
--hash=sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72 \
--hash=sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4 \
--hash=sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08 \
--hash=sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4
# via uvicorn
websockets==16.0 \
--hash=sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e \
--hash=sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec \
--hash=sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1 \
--hash=sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206 \
--hash=sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156 \
--hash=sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2 \
--hash=sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8 \
--hash=sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230 \
--hash=sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8 \
--hash=sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea \
--hash=sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641 \
--hash=sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6 \
--hash=sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5 \
--hash=sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f \
--hash=sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00 \
--hash=sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e \
--hash=sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39 \
--hash=sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9 \
--hash=sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79 \
--hash=sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0 \
--hash=sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5 \
--hash=sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c \
--hash=sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8 \
--hash=sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1 \
--hash=sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244 \
--hash=sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3 \
--hash=sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a \
--hash=sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd \
--hash=sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e \
--hash=sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944 \
--hash=sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82 \
--hash=sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d \
--hash=sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4 \
--hash=sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904 \
--hash=sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f \
--hash=sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c \
--hash=sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89 \
--hash=sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4
# via uvicorn
xlsxwriter==3.2.9 \
--hash=sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c \
--hash=sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3
# via onlinejudge
asgiref==3.8.1
certifi==2025.6.15
charset-normalizer==3.4.2
click==8.2.1
django==5.2.3
django-dbconn-retry==0.1.8
django-dramatiq==0.13.0
django-redis==5.4.0
djangorestframework==3.16.0
dramatiq==1.18.0
envelopes==0.4
gunicorn==23.0.0
h11==0.16.0
idna==3.10
otpauth==2.2.1
packaging==25.0
pillow==11.2.1
prometheus-client==0.22.1
psycopg==3.2.9
psycopg-binary==3.2.9
python-dateutil==2.9.0.post0
qrcode==8.2
raven==6.10.0
redis==6.2.0
requests==2.32.4
six==1.17.0
sqlparse==0.5.3
typing-extensions==4.14.0
urllib3==2.4.0
uvicorn==0.35.0
xlsxwriter==3.2.5

View File

@@ -28,7 +28,7 @@ stopwaitsecs = 5
killasgroup=true
[program:gunicorn]
command=gunicorn oj.asgi:application -k uvicorn.workers.UvicornWorker --user server --group spj --bind 127.0.0.1:8080 --workers %(ENV_MAX_WORKER_NUM)s --max-requests-jitter 10000 --max-requests 1000000 --keep-alive 32
command=gunicorn oj.asgi --user server --group spj --bind 127.0.0.1:8080 --workers %(ENV_MAX_WORKER_NUM)s --threads 4 --max-requests-jitter 10000 --max-requests 1000000 --keep-alive 32 --worker-class uvicorn.workers.UvicornWorker
directory=/app/
stdout_logfile=/data/log/gunicorn.log
stderr_logfile=/data/log/gunicorn.log

37
dev.py
View File

@@ -1,37 +0,0 @@
#!/usr/bin/env python
"""
开发服务器启动脚本
使用 uvicorn 统一处理 HTTP 和 WebSocket
"""
import subprocess
import sys
from pathlib import Path
def main():
base_dir = Path(__file__).resolve().parent
venv_python = base_dir / ".venv" / "bin" / "python"
python_exec = str(venv_python) if venv_python.exists() else sys.executable
cmd = [
python_exec,
"-m",
"uvicorn",
"oj.asgi:application",
"--host",
"0.0.0.0",
"--port",
"8000",
"--reload",
]
print("启动 uvicorn 开发服务器 (端口 8000)...")
try:
subprocess.run(cmd, cwd=base_dir)
except KeyboardInterrupt:
print("\n服务器已停止")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More