356 lines
14 KiB
Python
356 lines
14 KiB
Python
"""成就指标注册表。
|
||
|
||
这里定义"能测量什么",后台定义"多少算达成"。管理员在后台看到的指标下拉框
|
||
就是 METRIC_REGISTRY 的 key 列表。加一个新维度必须改本文件并部署,
|
||
之后在该维度上加任意多条成就都是纯配置。
|
||
|
||
约定:指标从未产生过有效值时,recompute 返回 None,调用方删除该 key。
|
||
metrics 字典里 key 不存在 == 未达标,判定时直接跳过。
|
||
"""
|
||
|
||
import logging
|
||
|
||
from django.db.models import Count, Q
|
||
from django.utils import timezone
|
||
|
||
from submission.models import JudgeStatus, Submission, is_accepted
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
METRIC_REGISTRY = {}
|
||
META_METRICS = set()
|
||
|
||
# AC 口径与全项目一致:AST_CHECK_FAILED(代码结构检查未通过但测试点全过)也算通过。
|
||
# 见 submission/models.py 的 is_accepted()。ORM 过滤用这个常量,标量比较用 is_accepted()。
|
||
ACCEPTED_RESULTS = (JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED)
|
||
|
||
|
||
class BaseMetric:
|
||
key = ""
|
||
name = ""
|
||
help_text = ""
|
||
|
||
def on_submission(self, metrics, sub, ctx):
|
||
"""判题完成后增量更新 metrics(原地修改)。"""
|
||
raise NotImplementedError
|
||
|
||
def recompute(self, user):
|
||
"""全量重算。返回 None 表示该用户此指标无有效值。"""
|
||
raise NotImplementedError
|
||
|
||
def recompute_state(self, user):
|
||
"""重算时一并重建增量所需的辅助键,返回 {key: value}。
|
||
|
||
默认无辅助状态。凡是 on_submission 依赖 `_` 前缀辅助键的指标都必须重写
|
||
本方法:否则全量重算把辅助键丢掉后,下一次判题会从零重新累积,指标当场
|
||
回退(active_days 从 45 掉回 1),且要等到下一次重算才恢复。
|
||
"""
|
||
return {}
|
||
|
||
|
||
def metric(key, name, help_text="", meta=False):
|
||
def deco(cls):
|
||
cls.key = key
|
||
cls.name = name
|
||
cls.help_text = help_text
|
||
METRIC_REGISTRY[key] = cls()
|
||
if meta:
|
||
META_METRICS.add(key)
|
||
return cls
|
||
|
||
return deco
|
||
|
||
|
||
def _practice_submissions(user_id):
|
||
"""成就只统计平时练习的提交,比赛提交不计入。"""
|
||
return Submission.objects.filter(user_id=user_id, contest_id__isnull=True)
|
||
|
||
|
||
def build_ctx(user_id, sub):
|
||
"""判题后预查一次,所有指标复用,避免每个指标各自查库。
|
||
|
||
比赛提交不参与成就统计,此时返回 skip=True,调用方直接跳过整轮判定。
|
||
"""
|
||
if sub.contest_id is not None:
|
||
return {"skip": True}
|
||
|
||
prior = _practice_submissions(user_id).filter(problem_id=sub.problem_id).exclude(id=sub.id)
|
||
prior_stats = prior.aggregate(
|
||
total=Count("id"),
|
||
accepted=Count("id", filter=Q(result__in=ACCEPTED_RESULTS)),
|
||
)
|
||
local_now = timezone.localtime(sub.create_time)
|
||
sub_is_accepted = is_accepted(sub.result)
|
||
return {
|
||
"skip": False,
|
||
"is_accepted": sub_is_accepted,
|
||
# 该题此前的提交次数与 AC 次数
|
||
"prior_count": prior_stats["total"],
|
||
"prior_accepted": prior_stats["accepted"],
|
||
# 首次 AC 这道题(此前从未 AC 过)
|
||
"is_first_ac_of_problem": sub_is_accepted and prior_stats["accepted"] == 0,
|
||
# 一发入魂:此前无任何提交且本次 AC
|
||
"is_first_try_ac": sub_is_accepted and prior_stats["total"] == 0,
|
||
"local_date": local_now.date().isoformat(),
|
||
"local_hour": local_now.hour,
|
||
}
|
||
|
||
|
||
@metric("accepted_count", "AC 题目数", "去重后通过的题目数量(不含比赛)")
|
||
class AcceptedCount(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
if ctx["is_first_ac_of_problem"]:
|
||
metrics["accepted_count"] = metrics.get("accepted_count", 0) + 1
|
||
|
||
def recompute(self, user):
|
||
return _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values("problem_id").distinct().count()
|
||
|
||
|
||
@metric("submission_count", "提交总数", "提交次数(不含比赛)")
|
||
class SubmissionCount(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
metrics["submission_count"] = metrics.get("submission_count", 0) + 1
|
||
|
||
def recompute(self, user):
|
||
return _practice_submissions(user.id).count()
|
||
|
||
|
||
@metric("active_days", "活跃天数", "有过提交的累计天数")
|
||
class ActiveDays(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
seen = metrics.get("_active_dates", [])
|
||
if ctx["local_date"] not in seen:
|
||
seen.append(ctx["local_date"])
|
||
metrics["_active_dates"] = seen
|
||
metrics["active_days"] = len(seen)
|
||
|
||
def recompute(self, user):
|
||
dates = {timezone.localtime(t).date().isoformat() for t in _practice_submissions(user.id).values_list("create_time", flat=True)}
|
||
return len(dates)
|
||
|
||
def recompute_state(self, user):
|
||
dates = sorted({timezone.localtime(t).date().isoformat() for t in _practice_submissions(user.id).values_list("create_time", flat=True)})
|
||
return {"_active_dates": dates}
|
||
|
||
|
||
@metric("max_ac_streak_days", "最长连续 AC 天数", "连续每天至少 AC 一题的最长天数")
|
||
class MaxAcStreakDays(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
if not ctx["is_accepted"]:
|
||
return
|
||
today = ctx["local_date"]
|
||
last = metrics.get("_last_ac_date")
|
||
if last == today:
|
||
return
|
||
current = metrics.get("_current_ac_streak", 0)
|
||
if last and (timezone.datetime.fromisoformat(today) - timezone.datetime.fromisoformat(last)).days == 1:
|
||
current += 1
|
||
else:
|
||
current = 1
|
||
metrics["_last_ac_date"] = today
|
||
metrics["_current_ac_streak"] = current
|
||
metrics["max_ac_streak_days"] = max(metrics.get("max_ac_streak_days", 0), current)
|
||
|
||
def recompute(self, user):
|
||
dates = sorted({timezone.localtime(t).date() for t in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values_list("create_time", flat=True)})
|
||
if not dates:
|
||
return None
|
||
best = current = 1
|
||
for prev, cur in zip(dates, dates[1:]):
|
||
current = current + 1 if (cur - prev).days == 1 else 1
|
||
best = max(best, current)
|
||
return best
|
||
|
||
def recompute_state(self, user):
|
||
dates = sorted({timezone.localtime(t).date() for t in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values_list("create_time", flat=True)})
|
||
if not dates:
|
||
return {}
|
||
current = 1
|
||
for prev, cur in zip(dates, dates[1:]):
|
||
current = current + 1 if (cur - prev).days == 1 else 1
|
||
return {"_last_ac_date": dates[-1].isoformat(), "_current_ac_streak": current}
|
||
|
||
|
||
@metric("languages_used", "使用语言数", "用过多少种编程语言")
|
||
class LanguagesUsed(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
seen = metrics.get("_languages", [])
|
||
if sub.language not in seen:
|
||
seen.append(sub.language)
|
||
metrics["_languages"] = seen
|
||
metrics["languages_used"] = len(seen)
|
||
|
||
def recompute(self, user):
|
||
return _practice_submissions(user.id).values("language").distinct().count()
|
||
|
||
def recompute_state(self, user):
|
||
return {"_languages": list(_practice_submissions(user.id).values_list("language", flat=True).distinct())}
|
||
|
||
|
||
@metric("contest_joined", "参赛场次", "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)")
|
||
class ContestJoined(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
# 比赛提交在 build_ctx 就被跳过,本指标只走 recompute
|
||
return
|
||
|
||
def recompute(self, user):
|
||
return Submission.objects.filter(user_id=user.id, contest_id__isnull=False).values("contest_id").distinct().count()
|
||
|
||
|
||
@metric("badge_count", "题单奖章数", "获得的题单奖章数量")
|
||
class BadgeCount(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
# 奖章由题单流程颁发,本指标只走 recompute,见 Task 7
|
||
return
|
||
|
||
def recompute(self, user):
|
||
from problemset.models import UserBadge
|
||
|
||
return UserBadge.objects.filter(user=user).count()
|
||
|
||
|
||
@metric("problemset_completed", "完成题单数", "完成的题单数量")
|
||
class ProblemSetCompleted(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
return
|
||
|
||
def recompute(self, user):
|
||
from problemset.models import ProblemSetProgress
|
||
|
||
return ProblemSetProgress.objects.filter(user=user, complete_time__isnull=False).count()
|
||
|
||
|
||
@metric("first_try_ac_count", "一发入魂次数", "首次提交即通过的次数")
|
||
class FirstTryAcCount(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
if ctx["is_first_try_ac"]:
|
||
metrics["first_try_ac_count"] = metrics.get("first_try_ac_count", 0) + 1
|
||
|
||
def recompute(self, user):
|
||
count = 0
|
||
seen = set()
|
||
for s in _practice_submissions(user.id).order_by("create_time").values("problem_id", "result"):
|
||
if s["problem_id"] in seen:
|
||
continue
|
||
seen.add(s["problem_id"])
|
||
if is_accepted(s["result"]):
|
||
count += 1
|
||
return count
|
||
|
||
|
||
@metric("midnight_submissions", "凌晨提交次数", "0:00–5:00 之间的提交次数")
|
||
class MidnightSubmissions(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
if 0 <= ctx["local_hour"] < 5:
|
||
metrics["midnight_submissions"] = metrics.get("midnight_submissions", 0) + 1
|
||
|
||
def recompute(self, user):
|
||
return sum(1 for t in _practice_submissions(user.id).values_list("create_time", flat=True) if 0 <= timezone.localtime(t).hour < 5)
|
||
|
||
|
||
@metric("compile_error_count", "编译错误次数", "累计编译错误的次数")
|
||
class CompileErrorCount(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
if sub.result == JudgeStatus.COMPILE_ERROR:
|
||
metrics["compile_error_count"] = metrics.get("compile_error_count", 0) + 1
|
||
|
||
def recompute(self, user):
|
||
return _practice_submissions(user.id).filter(result=JudgeStatus.COMPILE_ERROR).count()
|
||
|
||
|
||
@metric("max_wa_before_ac", "屡败屡战", "单题失败最多多少次后终于通过")
|
||
class MaxWaBeforeAc(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
if ctx["is_first_ac_of_problem"]:
|
||
metrics["max_wa_before_ac"] = max(metrics.get("max_wa_before_ac", 0), ctx["prior_count"])
|
||
|
||
def recompute(self, user):
|
||
best = None
|
||
attempts = {}
|
||
for s in _practice_submissions(user.id).order_by("create_time").values("problem_id", "result"):
|
||
pid = s["problem_id"]
|
||
if pid in attempts and attempts[pid] is None:
|
||
continue
|
||
if is_accepted(s["result"]):
|
||
best = max(best or 0, attempts.get(pid, 0))
|
||
attempts[pid] = None
|
||
else:
|
||
attempts[pid] = attempts.get(pid, 0) + 1
|
||
return best
|
||
|
||
|
||
@metric("max_ac_in_one_day", "单日最多 AC", "一天之内最多通过多少题")
|
||
class MaxAcInOneDay(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
if not ctx["is_first_ac_of_problem"]:
|
||
return
|
||
counts = metrics.get("_ac_per_day", {})
|
||
counts[ctx["local_date"]] = counts.get(ctx["local_date"], 0) + 1
|
||
metrics["_ac_per_day"] = counts
|
||
metrics["max_ac_in_one_day"] = max(counts.values())
|
||
|
||
def recompute(self, user):
|
||
counts = {}
|
||
seen = set()
|
||
for s in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by("create_time").values("problem_id", "create_time"):
|
||
if s["problem_id"] in seen:
|
||
continue
|
||
seen.add(s["problem_id"])
|
||
day = timezone.localtime(s["create_time"]).date().isoformat()
|
||
counts[day] = counts.get(day, 0) + 1
|
||
return max(counts.values()) if counts else None
|
||
|
||
def recompute_state(self, user):
|
||
counts = {}
|
||
seen = set()
|
||
for s in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by("create_time").values("problem_id", "create_time"):
|
||
if s["problem_id"] in seen:
|
||
continue
|
||
seen.add(s["problem_id"])
|
||
day = timezone.localtime(s["create_time"]).date().isoformat()
|
||
counts[day] = counts.get(day, 0) + 1
|
||
return {"_ac_per_day": counts}
|
||
|
||
|
||
@metric("min_ac_code_chars", "最短 AC 代码", "通过的代码里最短的字符数(配小于等于使用)")
|
||
class MinAcCodeChars(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
if not ctx["is_accepted"]:
|
||
return
|
||
length = len(sub.code)
|
||
cur = metrics.get("min_ac_code_chars")
|
||
metrics["min_ac_code_chars"] = length if cur is None else min(cur, length)
|
||
|
||
def recompute(self, user):
|
||
lengths = [len(c) for c in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values_list("code", flat=True)]
|
||
return min(lengths) if lengths else None
|
||
|
||
|
||
@metric("max_code_lines", "最长代码行数", "提交过的最长代码有多少行")
|
||
class MaxCodeLines(BaseMetric):
|
||
def on_submission(self, metrics, sub, ctx):
|
||
lines = len(sub.code.splitlines())
|
||
metrics["max_code_lines"] = max(metrics.get("max_code_lines", 0), lines)
|
||
|
||
def recompute(self, user):
|
||
counts = [len(c.splitlines()) for c in _practice_submissions(user.id).values_list("code", flat=True)]
|
||
return max(counts) if counts else None
|
||
|
||
|
||
@metric("achievement_unlocked_count", "已解锁成就数", "已解锁的成就数量(不含白金档)", meta=True)
|
||
class AchievementUnlockedCount(BaseMetric):
|
||
"""自引用指标:解锁成就会改变它。
|
||
|
||
因此判定流程限定为最多两轮(见 checker.py),且口径排除白金档自身,
|
||
避免「集齐 N 个成就」这类白金奖杯把自己算进分子。
|
||
"""
|
||
|
||
def on_submission(self, metrics, sub, ctx):
|
||
# 由 checker 在第一轮解锁后显式重算,不参与增量更新
|
||
return
|
||
|
||
def recompute(self, user):
|
||
from achievement.models import Rarity, UserAchievement
|
||
|
||
return UserAchievement.objects.filter(user=user).exclude(achievement__rarity=Rarity.PLATINUM).count()
|