Files
OJ2/apps/api/src/routes/admin/learn.ts
T

276 lines
11 KiB
TypeScript

import {
STUDENT_ROLES,
isLessonDone,
type LearnBoard,
type LearnExerciseAttempt,
type LearnExerciseProgress,
type LearnExerciseProgressList,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { defaultClassFor } from "../../services/ranking"
import { exerciseCountColumn } from "../../services/tutorial"
import { queryInteger, rounded } from "../helpers"
/**
* 自学情况:教程读了没、读了多久。
*
* 路径用 `/learn-analytics` 而不是挂在 `/tutorials` 下,理由同 tag.ts 里那段注释:
* Hono 按注册顺序匹配,`/tutorials/:id` 会把同级的静态段整个吃掉且不报错。
*/
export const adminLearnRoutes = new Hono<AppEnv>()
function tutorialTypeOf(value: string | undefined) {
return value === "c" ? "c" : "python"
}
/**
* 班级筛选。班号形如 `241`(24 级 1 班),只允许纯数字:
* 直接拼进 like 的话,`%` 会变成通配符,把筛选变成「全选」。
*/
function classFilter(className: string | undefined) {
const value = className?.trim()
if (!value) return { ok: true as const, value: null }
if (!/^\d{1,4}$/.test(value)) return { ok: false as const, value: null }
return { ok: true as const, value }
}
/**
* `className` 传 3 位以上是具体班级(精确匹配),传 1-2 位当年级前缀(like)。
* 年级前缀这条是给「24 级整体读得怎么样」用的,不然老师得一个班一个班点。
*/
function classCondition(value: string | null) {
if (!value) return undefined
return value.length >= 3
? eq(schema.user.className, value)
: like(schema.user.className, `${value}%`)
}
/**
* 没有班级的学生**照样统计**(班级列显示为空)。班级是从用户名的数字前缀推出来的,
* 推不出来时就是 null(见 admin/account.ts 的 classNameOf),把这些人过滤掉等于让他们
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
*/
function studentCondition(value: string | null) {
return and(
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
classCondition(value),
)
}
/**
* 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。
*
* 不选班就猜老师在教哪个班(`defaultClassFor`);也能传年级前缀,
* 但一个年级两三百行方块,页面上主要还是按班看。
*/
adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const requested = classFilter(c.req.query("className"))
if (!requested.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const className = requested.value ?? (await defaultClassFor(c.get("user")!.id))
const tutorials = await db
.select({
id: schema.tutorial.id,
title: schema.tutorial.title,
project: schema.tutorial.project,
exerciseTotal: exerciseCountColumn,
})
.from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.orderBy(asc(schema.tutorial.order))
const tutorialIds = tutorials.map((row) => row.id)
if (!className)
return success(c, { className: null, tutorials, students: [] } satisfies LearnBoard)
const students = await db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(studentCondition(className))
.orderBy(asc(schema.user.className), asc(schema.user.username))
const userIds = students.map((row) => row.userId)
// 阅读和练习分两条查再拼:两个一对多挂在同一张表上 join,count 全是错的
const [progressRows, solvedRows] =
userIds.length && tutorialIds.length
? await Promise.all([
db
.select({
userId: schema.tutorialProgress.userId,
tutorialId: schema.tutorialProgress.tutorialId,
viewCount: schema.tutorialProgress.viewCount,
totalSeconds: schema.tutorialProgress.totalSeconds,
lastViewedAt: schema.tutorialProgress.lastViewedAt,
})
.from(schema.tutorialProgress)
.where(
and(
inArray(schema.tutorialProgress.userId, userIds),
inArray(schema.tutorialProgress.tutorialId, tutorialIds),
),
),
db
.select({
userId: schema.exerciseAttempt.userId,
tutorialId: schema.exercise.tutorialId,
solved: count(),
})
.from(schema.exerciseAttempt)
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
.where(
and(
inArray(schema.exerciseAttempt.userId, userIds),
inArray(schema.exercise.tutorialId, tutorialIds),
eq(schema.exerciseAttempt.solved, true),
),
)
.groupBy(schema.exerciseAttempt.userId, schema.exercise.tutorialId),
])
: [[], []]
const progress = new Map<string, (typeof progressRows)[number]>()
for (const row of progressRows) progress.set(`${row.userId}:${row.tutorialId}`, row)
const solved = new Map<string, number>()
for (const row of solvedRows) solved.set(`${row.userId}:${row.tutorialId}`, row.solved)
return success(c, {
className,
tutorials,
students: students.map((student) => {
const opened: number[] = []
const done: number[] = []
let exerciseSolved = 0
let totalSeconds = 0
let lastViewedAt: string | null = null
for (const tutorial of tutorials) {
const key = `${student.userId}:${tutorial.id}`
const row = progress.get(key)
const solvedHere = solved.get(key) ?? 0
exerciseSolved += solvedHere
if (row) {
opened.push(tutorial.id)
totalSeconds += row.totalSeconds
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
}
const lesson = {
viewCount: row?.viewCount ?? 0,
exerciseTotal: tutorial.exerciseTotal,
exerciseSolved: solvedHere,
}
if (isLessonDone(lesson)) done.push(tutorial.id)
}
return { ...student, opened, done, exerciseSolved, totalSeconds, lastViewedAt }
}),
} satisfies LearnBoard)
})
/**
* 按练习:哪道练一练卡住了全班。
*
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
*/
adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db
.select({ value: count() })
.from(schema.user)
.where(studentCondition(className.value))
const rows = await db
.select({
exerciseId: schema.exercise.id,
tutorialId: schema.tutorial.id,
tutorialTitle: schema.tutorial.title,
tutorialOrder: schema.tutorial.order,
type: schema.exercise.type,
order: schema.exercise.order,
// 题干在 jsonb 里,各题型的字段名都叫 question;取不到就给空串,别让整行挂掉
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
solvedUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
firstTryUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(
Number,
),
attempts:
sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
// 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花
avgAttemptsToSolve:
sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
})
.from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(schema.exerciseAttempt, eq(schema.exerciseAttempt.exerciseId, schema.exercise.id))
// 学生条件挂在 join 的 on 上,不是 where 上:写 where 会把没人做过的题整行滤掉
.leftJoin(
schema.user,
and(eq(schema.user.id, schema.exerciseAttempt.userId), studentCondition(className.value)),
)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, {
studentCount: studentCountRow?.value ?? 0,
results: rows.map(
(row) =>
({
...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
}) satisfies LearnExerciseProgress,
),
} satisfies LearnExerciseProgressList)
})
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const rows = await db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
attempts: schema.exerciseAttempt.attempts,
wrongAttempts: schema.exerciseAttempt.wrongAttempts,
solved: schema.exerciseAttempt.solved,
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
})
.from(schema.exerciseAttempt)
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.exerciseAttempt.exerciseId, id), studentCondition(className.value)))
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
return success(c, rows satisfies LearnExerciseAttempt[])
})