refactor: python-dateutil 换成标准库

- contest 后台创建/编辑:dateutil.parser.parse 换成
  datetime.fromisoformat。serializer 里 start_time/end_time 声明的是
  DateTimeField,而 validate_serializer 做的是 request.data = s.data,
  即重新序列化一遍,所以视图拿到的必定是 DRF 规范化后的 ISO 8601,
  dateutil 的宽容解析能力在这里从来没用上
- AI 学情统计:relativedelta 换成 shift_months 辅助函数,复刻月末
  收缩语义(1月31日 + 1个月 = 2月28/29日)。_parse_duration 的配置
  由 delta 对象改为 rewind/advance 可调用,周档和月档统一

删除依赖:python-dateutil、six

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 23:05:50 -06:00
parent 989b33d827
commit 8557f8a5f5
5 changed files with 30 additions and 50 deletions

View File

@@ -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),
},
)

View File

@@ -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"]:

View File

@@ -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

View File

@@ -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",

23
uv.lock generated
View File

@@ -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"