diff --git a/ai/views/oj.py b/ai/views/oj.py index 6152b82..b7ec8a2 100644 --- a/ai/views/oj.py +++ b/ai/views/oj.py @@ -1,9 +1,9 @@ +import calendar 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 @@ -47,6 +47,15 @@ GRADE_WEIGHTS = {"S": 4, "A": 3, "B": 2, "C": 1} AVERAGE_GRADE_THRESHOLDS = [(3.5, "S"), (2.5, "A"), (1.5, "B")] +def shift_months(dt, months): + """按月平移。落到不存在的日期时收缩到当月最后一天(1月31日 + 1个月 = 2月28/29日)""" + month_index = dt.month - 1 + months + year = dt.year + month_index // 12 + month = month_index % 12 + 1 + day = min(dt.day, calendar.monthrange(year, month)[1]) + return dt.replace(year=year, month=month, day=day) + + def get_cache_key(prefix, *args): return hashlib.md5(f"{prefix}:{'_'.join(map(str, args))}".encode()).hexdigest() @@ -429,12 +438,12 @@ class AIDurationDataAPI(APIView): 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"] + start = time_config["rewind"](datetime.fromisoformat(end_iso)) duration_data = [] for i in range(time_config["show_count"]): - start = start + time_config["delta"] - period_end = start + time_config["delta"] + start = time_config["advance"](start) + period_end = time_config["advance"](start) submission_count = Submission.objects.filter(user_id=user.id, create_time__gte=start, create_time__lte=period_end).count() @@ -490,24 +499,26 @@ class AIDurationDataAPI(APIView): unit, count = duration.split(":") count = int(count) + # rewind 把结束时间倒推到区间起点,advance 前进一格。 + # 按月的档位不能用 timedelta(月长不固定),用 shift_months 处理 configs = { ("months", 2): { "show_count": 8, "show_unit": "weeks", - "total_delta": timedelta(weeks=9), - "delta": timedelta(weeks=1), + "rewind": lambda dt: dt - timedelta(weeks=9), + "advance": lambda dt: dt + timedelta(weeks=1), }, ("months", 6): { "show_count": 6, "show_unit": "months", - "total_delta": relativedelta(months=7), - "delta": relativedelta(months=1), + "rewind": lambda dt: shift_months(dt, -7), + "advance": lambda dt: shift_months(dt, 1), }, ("years", 1): { "show_count": 12, "show_unit": "months", - "total_delta": relativedelta(months=13), - "delta": relativedelta(months=1), + "rewind": lambda dt: shift_months(dt, -13), + "advance": lambda dt: shift_months(dt, 1), }, } @@ -516,8 +527,8 @@ class AIDurationDataAPI(APIView): { "show_count": 4, "show_unit": "weeks", - "total_delta": timedelta(weeks=5), - "delta": timedelta(weeks=1), + "rewind": lambda dt: dt - timedelta(weeks=5), + "advance": lambda dt: dt + timedelta(weeks=1), }, ) diff --git a/contest/views/admin.py b/contest/views/admin.py index dc59fd9..4085edc 100644 --- a/contest/views/admin.py +++ b/contest/views/admin.py @@ -1,10 +1,9 @@ import copy import os import zipfile -from datetime import timedelta +from datetime import datetime, timedelta from ipaddress import ip_network -import dateutil.parser from django.http import FileResponse from django.utils.timezone import now @@ -34,8 +33,9 @@ class ContestAPI(APIView): @teacher_admin_required def post(self, request): data = request.data - data["start_time"] = dateutil.parser.parse(data["start_time"]) - data["end_time"] = dateutil.parser.parse(data["end_time"]) + # DRF 的 DateTimeField 已经校验并规范化过,这里拿到的必定是 ISO 8601 字符串 + data["start_time"] = datetime.fromisoformat(data["start_time"]) + data["end_time"] = datetime.fromisoformat(data["end_time"]) data["created_by"] = request.user if data["end_time"] <= data["start_time"]: return self.error("Start time must occur earlier than end time") @@ -58,8 +58,9 @@ class ContestAPI(APIView): 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"]) + # DRF 的 DateTimeField 已经校验并规范化过,这里拿到的必定是 ISO 8601 字符串 + data["start_time"] = datetime.fromisoformat(data["start_time"]) + data["end_time"] = datetime.fromisoformat(data["end_time"]) if data["end_time"] <= data["start_time"]: return self.error("Start time must occur earlier than end time") if not data["password"]: diff --git a/deploy/requirements.txt b/deploy/requirements.txt index cf1aa1f..903295c 100644 --- a/deploy/requirements.txt +++ b/deploy/requirements.txt @@ -427,10 +427,6 @@ pydantic-core==2.46.3 \ --hash=sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127 \ --hash=sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56 # via pydantic -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 @@ -505,10 +501,6 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index e210c7d..c9cc3ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ "pillow>=12.2.0,<13", "psycopg>=3.3.4,<4", "psycopg-binary>=3.3.4,<4", - "python-dateutil>=2.9.0.post0,<3", "sentry-sdk[django]>=2.0.0,<3", "tree-sitter>=0.25.2", "tree-sitter-c>=0.24.2", diff --git a/uv.lock b/uv.lock index 3c36c52..1c17856 100644 --- a/uv.lock +++ b/uv.lock @@ -397,7 +397,6 @@ dependencies = [ { name = "pillow" }, { name = "psycopg" }, { name = "psycopg-binary" }, - { name = "python-dateutil" }, { name = "sentry-sdk", extra = ["django"] }, { name = "tree-sitter" }, { name = "tree-sitter-c" }, @@ -428,7 +427,6 @@ requires-dist = [ { name = "pillow", specifier = ">=12.2.0,<13" }, { name = "psycopg", specifier = ">=3.3.4,<4" }, { name = "psycopg-binary", specifier = ">=3.3.4,<4" }, - { name = "python-dateutil", specifier = ">=2.9.0.post0,<3" }, { name = "sentry-sdk", extras = ["django"], specifier = ">=2.0.0,<3" }, { name = "tree-sitter", specifier = ">=0.25.2" }, { name = "tree-sitter-c", specifier = ">=0.24.2" }, @@ -680,18 +678,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -799,15 +785,6 @@ django = [ { name = "django" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sniffio" version = "1.3.1"