core: ScoreboardView API

This commit is contained in:
undefined committed 2024-11-07 10:13:17 +08:00
1 parent 4d8c2c019c
commit b0714494f3
2 files changed
+198 -115

No files matched your search

+193 -113
View File
@@ -3,14 +3,15 @@ import { statSync } from 'fs-extra';
import { pick } from 'lodash';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
import Schema from 'schemastery';
import {
Counter, sortFiles, streamToBuffer, Time, yaml,
} from '@hydrooj/utils/lib/utils';
import type { Context } from '../context';
import { Context, Service } from '../context';
import {
BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
InvalidTokenError, NotAssignedError, PermissionError, ValidationError,
InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
} from '../error';
import { ScoreboardConfig, Tdoc } from '../interface';
import { PERM, PRIV, STATUS } from '../model/builtin';
@@ -238,115 +239,6 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler {
}
}
export class ContestScoreboardHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectId)
@param('ext', Types.Range(['csv', 'html', 'ghost']), true)
@param('realtime', Types.Boolean)
async get(domainId: string, tid: ObjectId, ext = '', realtime) {
if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid);
if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
if (realtime && !this.user.own(this.tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
if (ext) {
await this.exportScoreboard(domainId, tid, ext);
return;
}
const config: ScoreboardConfig = { isExport: false, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) };
if (!realtime && this.tdoc.lockAt && !this.tdoc.unlocked) {
config.lockAt = this.tdoc.lockAt;
}
const allGroups = (this.user.hasPerm(PERM.PERM_EDIT_CONTEST_SELF) && this.user.own(this.tdoc)) || this.user.hasPerm(PERM.PERM_EDIT_CONTEST);
const [[, rows, udict, pdict], groups] = await Promise.all([
contest.getScoreboard.call(this, domainId, tid, config),
user.listGroup(domainId, allGroups ? undefined : this.user._id),
]);
// eslint-disable-next-line @typescript-eslint/naming-convention
const page_name = this.tdoc.rule === 'homework'
? 'homework_scoreboard'
: 'contest_scoreboard';
this.response.body = {
tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), rows, udict, pdict, page_name, groups,
};
this.response.pjax = 'partials/scoreboard.html';
this.response.template = 'contest_scoreboard.html';
}
async exportGhost(domainId: string, tid: ObjectId) {
const tdoc = this.tdoc;
const [pdict, teams] = await Promise.all([
problem.getList(domainId, tdoc.pids, true, false, problem.PROJECTION_LIST, true),
contest.getMultiStatus(domainId, { docId: tid }).toArray(),
]);
const udict = await user.getList(domainId, teams.map((i) => i.uid));
const teamIds: Record<number, number> = {};
for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i;
const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second);
const pid = (i: number) => String.fromCharCode(65 + i);
const escape = (i: string) => i.replace(/[",]/g, '');
const unknownSchool = this.translate('Unknown School');
const statusMap = {
[STATUS.STATUS_ACCEPTED]: 'AC',
[STATUS.STATUS_WRONG_ANSWER]: 'WA',
[STATUS.STATUS_COMPILE_ERROR]: 'CE',
[STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TL',
[STATUS.STATUS_RUNTIME_ERROR]: 'RT',
};
const submissions = teams.flatMap((i, idx) => {
if (!i.journal) return [];
const journal = i.journal.filter((s) => tdoc.pids.includes(s.pid));
const c = Counter();
return journal.map((s) => {
const id = pid(tdoc.pids.indexOf(s.pid));
c[id]++;
return `@s ${idx + 1},${id},${c[id]},${time(s.rid)},${statusMap[s.status] || 'RJ'}`;
});
});
const res = [
`@contest "${escape(tdoc.title)}"`,
`@contlen ${Math.floor((tdoc.endAt.getTime() - tdoc.beginAt.getTime()) / Time.minute)}`,
`@problems ${tdoc.pids.length}`,
`@teams ${tdoc.attend}`,
`@submissions ${submissions.length}`,
].concat(
tdoc.pids.map((i, idx) => `@p ${pid(idx)},${escape(pdict[i]?.title || 'Unknown Problem')},20,0`),
teams.map((i, idx) => {
const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(udict[i.uid].uname)}`;
return `@t ${idx + 1},0,1,${teamName}`;
}),
submissions,
);
this.binary(res.join('\n'), `${this.tdoc.title}.ghost`);
}
async exportScoreboard(domainId: string, tid: ObjectId, ext: string) {
await this.limitRate('scoreboard_download', 60, 3);
if (ext === 'ghost') {
if (contest.isLocked(this.tdoc) && !this.user.own(this.tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
await this.exportGhost(domainId, tid);
return;
}
const getContent = {
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value?.toString().replace(/\n/g, ' ')).join(','))).join('\n')}`,
html: (rows, tdoc) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }),
};
const [, rows] = await contest.getScoreboard.call(this, domainId, tid, {
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
});
this.binary(await getContent[ext](rows, this.tdoc), `${this.tdoc.title}.${ext}`);
}
@param('tid', Types.ObjectId)
async postUnlock(domainId: string, tid: ObjectId) {
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
if (!contest.isDone(this.tdoc)) throw new ContestNotEndedError(domainId, tid);
await contest.unlockScoreboard(domainId, tid);
this.back();
}
}
export class ContestEditHandler extends Handler {
tdoc: Tdoc;
@@ -721,14 +613,110 @@ export class ContestBalloonHandler extends ContestManagementBaseHandler {
}
}
type BuiltinInput = {
tdoc: Tdoc;
groups: any[];
};
type AnyFunction = (...args: any) => any;
type ParseArgs<T extends { [key: string]: keyof BuiltinInput | AnyFunction }> = {
[key in keyof T]: T[key] extends keyof BuiltinInput ? BuiltinInput[T[key]] : T[key] extends AnyFunction ? ReturnType<T[key]> : never
};
export interface ScoreboardView<T extends { [key: string]: keyof BuiltinInput | AnyFunction }> {
id: string;
name: string;
supportedRules: string[];
cacheTime?: number; // in seconds
args: T;
display: (this: ContestScoreboardHandler, args: ParseArgs<T>) => Promise<void>;
}
export class ContestScoreboardHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectId)
@param('view', Types.String, true)
async get(domainId: string, tid: ObjectId, viewId = 'default') {
if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid);
if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
const view = this.ctx.scoreboard.getView(viewId);
if (!view) throw new NotFoundError(`View ${viewId} not found`);
const args = {};
const fetcher = {
tdoc: () => this.tdoc,
groups: async () => {
const allGroups = (this.user.hasPerm(PERM.PERM_EDIT_CONTEST_SELF) && this.user.own(this.tdoc))
|| this.user.hasPerm(PERM.PERM_EDIT_CONTEST);
return await user.listGroup(domainId, allGroups ? undefined : this.user._id);
},
};
for (const key in view.args) {
if (typeof view.args[key] === 'function') {
try {
args[key] = view.args[key](this.args);
} catch (e) {
throw new ValidationError(key);
}
} else if (fetcher[key]) {
args[key] = await fetcher[key](); // eslint-disable-line no-await-in-loop
}
}
await view.display.call(this, args);
}
@param('tid', Types.ObjectId)
async postUnlock(domainId: string, tid: ObjectId) {
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
if (!contest.isDone(this.tdoc)) throw new ContestNotEndedError(domainId, tid);
await contest.unlockScoreboard(domainId, tid);
this.back();
}
}
class ScoreboardService extends Service {
views: Record<string, ScoreboardView<any>> = {};
constructor(ctx: Context) {
super(ctx, 'scoreboard', true);
ctx.set('scoreboard', this);
}
addView<T extends { [key: string]: keyof BuiltinInput | AnyFunction }>(
id: string, name: string, args: T,
{ display, supportedRules, cacheTime }: {
display: (this: ContestScoreboardHandler, args: ParseArgs<T>) => Promise<void>,
supportedRules: string[],
cacheTime?: number,
},
) {
if (this.views[id]) throw new Error(`View ${id} already exists`);
this.ctx.effect(() => {
this.views[id] = {
id, name, args, display, supportedRules, cacheTime,
};
return () => {
delete this.views[id];
};
});
}
getAvailableViews(rule: string) {
return Object.values(this.views).filter((i) => i.supportedRules.includes(rule) || i.supportedRules.includes('*'));
}
getView(id: string) {
return this.views[id];
}
}
declare module '../context' {
interface Context {
scoreboard: ScoreboardService;
}
}
export async function apply(ctx: Context) {
ctx.Route('contest_create', '/contest/create', ContestEditHandler);
ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
ctx.Route('contest_scoreboard_download', '/contest/:tid/export/:ext', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler);
ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
@@ -774,4 +762,96 @@ export async function apply(ctx: Context) {
'Get a contest by ID',
);
});
ctx.plugin(ScoreboardService);
ctx.inject(['scoreboard'], ({ Route, scoreboard }) => {
Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
Route('contest_scoreboard', '/contest/:tid/scoreboard/:viewId', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
scoreboard.addView('default', 'Default', { tdoc: 'tdoc', groups: 'groups', realtime: Schema.boolean().default(false) }, {
async display({ realtime, tdoc, groups }) {
if (realtime && !this.user.own(tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
const config: ScoreboardConfig = { isExport: false, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) };
if (!realtime && this.tdoc.lockAt && !this.tdoc.unlocked) {
config.lockAt = this.tdoc.lockAt;
}
const [, rows, udict, pdict] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, config);
// eslint-disable-next-line @typescript-eslint/naming-convention
const page_name = tdoc.rule === 'homework'
? 'homework_scoreboard'
: 'contest_scoreboard';
this.response.body = {
tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), rows, udict, pdict, page_name, groups,
};
this.response.pjax = 'partials/scoreboard.html';
this.response.template = 'contest_scoreboard.html';
},
supportedRules: ['*'],
});
scoreboard.addView('ghost', 'Ghost', { tdoc: 'tdoc' }, {
async display({ tdoc }) {
if (contest.isLocked(tdoc) && !this.user.own(tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
const [pdict, teams] = await Promise.all([
problem.getList(tdoc.domainId, tdoc.pids, true, false, problem.PROJECTION_LIST, true),
contest.getMultiStatus(tdoc.domainId, { docId: tdoc._id }).toArray(),
]);
const udict = await user.getList(tdoc.domainId, teams.map((i) => i.uid));
const teamIds: Record<number, number> = {};
for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i;
const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second);
const pid = (i: number) => String.fromCharCode(65 + i);
const escape = (i: string) => i.replace(/[",]/g, '');
const unknownSchool = this.translate('Unknown School');
const statusMap = {
[STATUS.STATUS_ACCEPTED]: 'AC',
[STATUS.STATUS_WRONG_ANSWER]: 'WA',
[STATUS.STATUS_COMPILE_ERROR]: 'CE',
[STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TL',
[STATUS.STATUS_RUNTIME_ERROR]: 'RT',
};
const submissions = teams.flatMap((i, idx) => {
if (!i.journal) return [];
const journal = i.journal.filter((s) => tdoc.pids.includes(s.pid));
const c = Counter();
return journal.map((s) => {
const id = pid(tdoc.pids.indexOf(s.pid));
c[id]++;
return `@s ${idx + 1},${id},${c[id]},${time(s.rid)},${statusMap[s.status] || 'RJ'}`;
});
});
const res = [
`@contest "${escape(tdoc.title)}"`,
`@contlen ${Math.floor((tdoc.endAt.getTime() - tdoc.beginAt.getTime()) / Time.minute)}`,
`@problems ${tdoc.pids.length}`,
`@teams ${tdoc.attend}`,
`@submissions ${submissions.length}`,
].concat(
tdoc.pids.map((i, idx) => `@p ${pid(idx)},${escape(pdict[i]?.title || 'Unknown Problem')},20,0`),
teams.map((i, idx) => {
const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(udict[i.uid].uname)}`;
return `@t ${idx + 1},0,1,${teamName}`;
}),
submissions,
);
this.binary(res.join('\n'), `${this.tdoc.title}.ghost`);
},
supportedRules: ['*'],
});
scoreboard.addView('export', 'Export', { tdoc: 'tdoc', ext: Schema.union(['csv', 'html']).required() }, {
async display({ tdoc, ext }) {
await this.limitRate('scoreboard_download', 60, 3);
const getContent = {
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value?.toString().replace(/\n/g, ' ')).join(','))).join('\n')}`,
html: (rows) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }),
};
const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, {
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
});
this.binary(await getContent[ext](rows), `${this.tdoc.title}.${ext}`);
},
supportedRules: ['*'],
});
});
}
@@ -8,11 +8,14 @@
<div class="row"><div class="medium-12 columns scoreboard--{{ tdoc.rule }}">
<div class="section visible">
<div class="section__header">
{%- for ext in ['HTML','CSV','Ghost'] -%}
<a class="button" target="_blank" href="{{ url(type + '_scoreboard_download', tid=tdoc.docId, ext=ext.toLowerCase()) }}">
{%- for ext in ['HTML','CSV'] -%}
<a class="button" target="_blank" href="{{ url(type + '_scoreboard', tid=tdoc.docId, view='export', ext=ext.toLowerCase()) }}">
<span class="icon icon-download"></span> {{ _('Export as {0}').format(ext) }}
</a>
{%- endfor -%}
<a class="button" target="_blank" href="{{ url(type + '_scoreboard', tid=tdoc.docId, view='ghost') }}">
<span class="icon icon-download"></span> {{ _('Export as Ghost') }}
</a>
{% if model.contest.isDone(tdoc) and tdoc.lockAt and handler.user.own(tdoc) and not tdoc.unlocked %}
<form method="POST">
<input type="hidden" name="operation" value="unlock">