Compare commits
10 Commits
yuetsh
...
a1b51ebb9e
| Author | SHA1 | Date | |
|---|---|---|---|
| a1b51ebb9e | |||
| a9a6b87fef | |||
| 2d3588c755 | |||
| a2bfc28ac7 | |||
| 6aac767641 | |||
| 73af9d96b2 | |||
| 8a2fa11afc | |||
| 3f1c7250bd | |||
| bd0a7f30f8 | |||
| 8a043d2ffa |
@@ -1,9 +1,4 @@
|
||||
venv
|
||||
.venv
|
||||
.idea
|
||||
.git
|
||||
.DS_Store
|
||||
__pycache__
|
||||
*.pyc
|
||||
.ruff_cache
|
||||
.pytest_cache
|
||||
|
||||
10
.flake8
Normal file
10
.flake8
Normal 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
12
.github/issue_template.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
在提交issue之前请
|
||||
|
||||
- 认真阅读文档 http://docs.onlinejudge.me/#/
|
||||
- 搜索和查看历史issues
|
||||
- 安全类问题请不要在 GitHub 上公布,请发送邮件到 `admin@qduoj.com`,根据漏洞危害程度发送红包感谢。
|
||||
|
||||
然后提交issue请写清楚下列事项
|
||||
|
||||
- 进行什么操作的时候遇到了什么问题,最好能有复现步骤
|
||||
- 错误提示是什么,如果看不到错误提示,请去data文件夹查看相应log文件。大段的错误提示请包在代码块标记里面。
|
||||
- 你尝试修复问题的操作
|
||||
- 页面问题请写清浏览器版本,尽量有截图
|
||||
31
.github/workflows/deploy.yml
vendored
31
.github/workflows/deploy.yml
vendored
@@ -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
54
.github/workflows/release.yml
vendored
Normal 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
144
CLAUDE.md
@@ -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.
|
||||
43
Dockerfile
43
Dockerfile
@@ -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" ]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
646
account/tests.py
Normal 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)
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'ai'
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
19
ai/models.py
19
ai/models.py
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from ..views.admin import AIAnalysisAdminAPI
|
||||
|
||||
urlpatterns = [
|
||||
path("ai/reports", AIAnalysisAdminAPI.as_view()),
|
||||
]
|
||||
@@ -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()),
|
||||
]
|
||||
@@ -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})
|
||||
973
ai/views/oj.py
973
ai/views/oj.py
@@ -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)
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
48
announcement/tests.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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": "!",
|
||||
"&": "&",
|
||||
"|": "|",
|
||||
"++": "++",
|
||||
"--": "--",
|
||||
}
|
||||
@@ -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",
|
||||
"&": "&",
|
||||
"|": "|",
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
# Register your models here.
|
||||
@@ -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'
|
||||
@@ -1,2 +0,0 @@
|
||||
# 空文件
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
# 如果需要存储班级PK历史记录,可以在这里定义模型
|
||||
# 目前暂时不需要,因为都是实时计算
|
||||
@@ -1,3 +0,0 @@
|
||||
# 如果需要序列化器,可以在这里定义
|
||||
# 目前使用APIView的paginate_data方法,暂时不需要
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# 空文件
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.urls import path
|
||||
|
||||
from ..views.admin import CommentAPI
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("comment", CommentAPI.as_view()),
|
||||
]
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)}")
|
||||
@@ -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
185
conf/tests.py
Normal 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)
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
140
conf/views.py
140
conf/views.py
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
162
contest/tests.py
Normal 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)
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
# }
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
37
dev.py
@@ -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
Reference in New Issue
Block a user