Files
OnlineJudge/docs/superpowers/plans/2026-05-11-problem-yearly-ac-rate.md

13 KiB
Raw Blame History

Problem Yearly AC Rate — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add GET /api/problem/yearly_ac endpoint returning per-year AC stats for a single problem, and display results as a line chart below the existing pie chart on the problem info page.

Architecture: Backend adds ProblemYearlyACRateAPI to problem/views/oj.py using Django ORM ExtractYear + Count(filter=...) aggregation, cached in Redis. Frontend adds a ProblemYearlyChart.vue Line chart component and wires it into ProblemInfo.vue.

Tech Stack: Django 6, PostgreSQL, Redis, django.db.models.functions.ExtractYear; Vue 3, TypeScript, vue-chartjs, chart.js


Task 1: Add CacheKey constant and write failing test

Files:

  • Modify: OnlineJudge/utils/constants.py

  • Create: OnlineJudge/problem/tests.py

  • Step 1: Add problem_yearly_ac to CacheKey

In OnlineJudge/utils/constants.py, add one line to CacheKey:

class CacheKey:
    waiting_queue = "waiting_queue"
    contest_rank_cache = "contest_rank_cache"
    website_config = "website_config"
    problem_authors = "problem_authors"
    problem_tags = "problem_tags"
    comment_stats = "comment_stats"
    user_activity_rank = "user_activity_rank"
    problem_yearly_ac = "problem_yearly_ac"   # ← add this line
  • Step 2: Write failing tests

Create OnlineJudge/problem/tests.py:

from datetime import datetime, UTC

from django.contrib.auth import get_user_model
from django.test import Client, TestCase

from problem.models import Problem, ProblemRuleType
from submission.models import JudgeStatus, Submission
from utils.constants import Difficulty

User = get_user_model()


def make_user():
    return User.objects.create_user(
        username="testuser",
        email="test@example.com",
        password="pass",
    )


def make_problem(user, _id="P001"):
    return Problem.objects.create(
        _id=_id,
        title="Test Problem",
        description="",
        input_description="",
        output_description="",
        samples=[],
        test_case_id="abc123",
        test_case_score=[],
        languages=["Python3"],
        template={},
        created_by=user,
        time_limit=1000,
        memory_limit=256,
        rule_type=ProblemRuleType.ACM,
        difficulty=Difficulty.LOW,
    )


def make_submission(problem, result, year):
    sub = Submission.objects.create(
        problem=problem,
        user_id=1,
        username="testuser",
        code="print(1)",
        result=result,
        language="Python3",
    )
    # auto_now_add cannot be passed to create(); update afterward
    Submission.objects.filter(id=sub.id).update(
        create_time=datetime(year, 6, 1, tzinfo=UTC)
    )
    return sub


class ProblemYearlyACRateAPITest(TestCase):
    def setUp(self):
        self.client = Client()
        self.user = make_user()
        self.problem = make_problem(self.user)
        # 2023: 3 judged, 1 AC  → ac_rate = 33.33
        make_submission(self.problem, JudgeStatus.ACCEPTED, 2023)
        make_submission(self.problem, JudgeStatus.WRONG_ANSWER, 2023)
        make_submission(self.problem, JudgeStatus.WRONG_ANSWER, 2023)
        # 2024: 2 judged, 2 AC  → ac_rate = 100.0
        make_submission(self.problem, JudgeStatus.ACCEPTED, 2024)
        make_submission(self.problem, JudgeStatus.ACCEPTED, 2024)

    def test_missing_problem_id_returns_error(self):
        res = self.client.get("/api/problem/yearly_ac")
        self.assertEqual(res.status_code, 200)
        self.assertEqual(res.json()["error"], "error")

    def test_nonexistent_problem_returns_error(self):
        res = self.client.get("/api/problem/yearly_ac?problem_id=NOPE")
        self.assertEqual(res.json()["error"], "error")

    def test_returns_yearly_data_sorted_by_year(self):
        res = self.client.get(f"/api/problem/yearly_ac?problem_id={self.problem._id}")
        body = res.json()
        self.assertIsNone(body["error"])
        rows = body["data"]
        self.assertEqual(len(rows), 2)
        self.assertEqual(rows[0]["year"], 2023)
        self.assertEqual(rows[0]["total"], 3)
        self.assertEqual(rows[0]["accepted"], 1)
        self.assertAlmostEqual(rows[0]["ac_rate"], 33.33, places=1)
        self.assertEqual(rows[1]["year"], 2024)
        self.assertEqual(rows[1]["total"], 2)
        self.assertEqual(rows[1]["accepted"], 2)
        self.assertAlmostEqual(rows[1]["ac_rate"], 100.0)

    def test_excludes_pending_and_judging(self):
        make_submission(self.problem, JudgeStatus.PENDING, 2023)
        make_submission(self.problem, JudgeStatus.JUDGING, 2023)
        res = self.client.get(f"/api/problem/yearly_ac?problem_id={self.problem._id}")
        rows = res.json()["data"]
        # 2023 total must stay 3 (pending/judging excluded)
        self.assertEqual(rows[0]["total"], 3)
  • Step 3: Run test to confirm it fails
cd OnlineJudge && python manage.py test problem.tests.ProblemYearlyACRateAPITest -v 2

Expected: Failure with 404 or ImportError — endpoint does not exist yet.


Task 2: Implement view and register URL

Files:

  • Modify: OnlineJudge/problem/views/oj.py

  • Modify: OnlineJudge/problem/urls/oj.py

  • Step 1: Add ExtractYear import to problem/views/oj.py

At the top of OnlineJudge/problem/views/oj.py, the existing imports already include from django.db.models import Count, Q. Add one more:

from django.db.models.functions import ExtractYear

Also confirm from utils.constants import CacheKey is present (it is, via existing cache usage).

  • Step 2: Add ProblemYearlyACRateAPI class at end of file

Append to OnlineJudge/problem/views/oj.py:

class ProblemYearlyACRateAPI(APIView):
    def get(self, request):
        problem_id = request.GET.get("problem_id")
        if not problem_id:
            return self.error("problem_id is required")

        cache_key = f"{CacheKey.problem_yearly_ac}:{problem_id}"
        cached = cache.get(cache_key)
        if cached is not None:
            return self.success(cached)

        try:
            problem = Problem.objects.get(
                _id=problem_id, contest_id__isnull=True, visible=True
            )
        except Problem.DoesNotExist:
            return self.error("Problem does not exist")

        rows = (
            Submission.objects.filter(
                problem_id=problem.id,
                contest_id__isnull=True,
            )
            .exclude(result__in=[JudgeStatus.PENDING, JudgeStatus.JUDGING])
            .annotate(year=ExtractYear("create_time"))
            .values("year")
            .annotate(
                total=Count("id"),
                accepted=Count("id", filter=Q(result=JudgeStatus.ACCEPTED)),
            )
            .order_by("year")
        )

        data = [
            {
                "year": row["year"],
                "total": row["total"],
                "accepted": row["accepted"],
                "ac_rate": round(row["accepted"] / row["total"] * 100, 2)
                if row["total"] > 0
                else 0.0,
            }
            for row in rows
        ]

        cache.set(cache_key, data, 3600)
        return self.success(data)
  • Step 3: Register URL in problem/urls/oj.py

Replace the full contents of OnlineJudge/problem/urls/oj.py with:

from django.urls import path

from ..views.oj import (
    ContestProblemAPI,
    PickOneAPI,
    ProblemAPI,
    ProblemAuthorAPI,
    ProblemSolvedPeopleCount,
    ProblemTagAPI,
    ProblemYearlyACRateAPI,
    SimilarProblemAPI,
)

urlpatterns = [
    path("problem/tags", ProblemTagAPI.as_view()),
    path("problem", ProblemAPI.as_view()),
    path("problem/beat_count", ProblemSolvedPeopleCount.as_view()),
    path("problem/similar", SimilarProblemAPI.as_view()),
    path("problem/author", ProblemAuthorAPI.as_view()),
    path("problem/yearly_ac", ProblemYearlyACRateAPI.as_view()),
    path("pickone", PickOneAPI.as_view()),
    path("contest/problem", ContestProblemAPI.as_view()),
]
  • Step 4: Run tests — expect all 4 to pass
cd OnlineJudge && python manage.py test problem.tests.ProblemYearlyACRateAPITest -v 2

Expected output:

test_excludes_pending_and_judging ... ok
test_missing_problem_id_returns_error ... ok
test_nonexistent_problem_returns_error ... ok
test_returns_yearly_data_sorted_by_year ... ok

Ran 4 tests in ...s

OK
  • Step 5: Commit
git -C OnlineJudge add utils/constants.py problem/views/oj.py problem/urls/oj.py problem/tests.py
git -C OnlineJudge commit -m "feat: add problem yearly AC rate API endpoint"

Task 3: Frontend — add API function

Files:

  • Modify: ojnext/src/oj/api.ts

  • Step 1: Add YearlyACData type and getProblemYearlyAC function

In ojnext/src/oj/api.ts, add after the getSimilarProblems function (near the // ==================== 相似题目推荐 ==================== section):

export interface YearlyACData {
  year: number
  total: number
  accepted: number
  ac_rate: number
}

export function getProblemYearlyAC(problemId: string) {
  return http.get<YearlyACData[]>("problem/yearly_ac", {
    params: { problem_id: problemId },
  })
}
  • Step 2: Commit
git -C ojnext add src/oj/api.ts
git -C ojnext commit -m "feat: add getProblemYearlyAC API function"

Task 4: Frontend — create ProblemYearlyChart.vue

Files:

  • Create: ojnext/src/oj/problem/components/ProblemYearlyChart.vue

  • Step 1: Create the component

Create ojnext/src/oj/problem/components/ProblemYearlyChart.vue with the following content:

<template>
  <div class="yearly-chart" v-if="props.data.length > 1">
    <Line :data="chartData" :options="chartOptions" />
  </div>
</template>

<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
  Chart as ChartJS,
  CategoryScale,
  Filler,
  LinearScale,
  LineElement,
  PointElement,
  Title,
  Tooltip,
} from "chart.js"
import type { YearlyACData } from "oj/api"

ChartJS.register(
  CategoryScale,
  Filler,
  LinearScale,
  LineElement,
  PointElement,
  Title,
  Tooltip,
)

const props = defineProps<{ data: YearlyACData[] }>()

const chartData = computed(() => ({
  labels: props.data.map((d) => String(d.year)),
  datasets: [
    {
      label: "AC 率",
      data: props.data.map((d) => d.ac_rate),
      fill: true,
      tension: 0.3,
      backgroundColor: "rgba(99, 179, 237, 0.2)",
      borderColor: "rgba(99, 179, 237, 1)",
      pointBackgroundColor: "rgba(99, 179, 237, 1)",
    },
  ],
}))

const chartOptions = computed(() => ({
  responsive: true,
  maintainAspectRatio: false,
  plugins: {
    title: {
      display: true,
      text: "历年 AC 率",
      font: { size: 20 },
    },
    tooltip: {
      callbacks: {
        label: (context: any) => {
          const d = props.data[context.dataIndex]
          return [`AC 率: ${d.ac_rate}%`, `通过: ${d.accepted} / ${d.total}`]
        },
      },
    },
  },
  scales: {
    y: {
      min: 0,
      max: 100,
      ticks: {
        callback: (value: any) => `${value}%`,
      },
    },
  },
}))
</script>

<style scoped>
.yearly-chart {
  width: 100%;
  max-width: 500px;
  height: 250px;
  margin: 24px auto;
}
</style>
  • Step 2: Commit
git -C ojnext add src/oj/problem/components/ProblemYearlyChart.vue
git -C ojnext commit -m "feat: add ProblemYearlyChart component"

Task 5: Frontend — integrate chart into ProblemInfo.vue

Files:

  • Modify: ojnext/src/oj/problem/components/ProblemInfo.vue

  • Step 1: Add imports

In the <script setup lang="ts"> block, add two imports alongside the existing ones:

import { getProblemYearlyAC, type YearlyACData } from "oj/api"
import ProblemYearlyChart from "./ProblemYearlyChart.vue"
  • Step 2: Add reactive data and fetch function

After const beatRate = ref("0"), add:

const yearlyACData = ref<YearlyACData[]>([])

Add a new async function:

async function getYearlyAC() {
  const res = await getProblemYearlyAC(problem.value!._id)
  yearlyACData.value = res.data
}
  • Step 3: Replace onMounted call

Replace the existing:

onMounted(getBeatRate)

With:

onMounted(() => {
  getBeatRate()
  getYearlyAC()
})
  • Step 4: Add chart to template

In the <template>, after the existing .pie div, add <ProblemYearlyChart>:

  <div class="pie" v-if="problem && problem.submission_number > 0">
    <Pie :data="data" :options="options" />
  </div>
  <ProblemYearlyChart :data="yearlyACData" />
</template>
  • Step 5: Start dev server and verify manually
cd ojnext && npm start

Open a problem page and navigate to the info/stats tab. Verify:

  1. A problem with submissions spanning multiple years: line chart appears below the pie chart, Y-axis shows 0100%, hovering shows "AC 率: X%" and "通过: Y / Z"
  2. A problem with submissions only in one year (or zero): chart is hidden (v-if="props.data.length > 1")
  3. No console errors in the browser devtools
  • Step 6: Commit
git -C ojnext add src/oj/problem/components/ProblemInfo.vue
git -C ojnext commit -m "feat: show yearly AC rate chart on problem info page"