core&ui: contest: acm: lock scoreboard

This commit is contained in:
undefined committed 2022-02-02 00:53:06 +08:00
1 parent cb02b8dafe
commit e67ff511bb
17 files changed
+224 -196

No files matched your search

+41 -13
View File
@@ -47,13 +47,18 @@ registerResolver(
'Get a contest by ID',
);
TaskModel.Worker.addHandler('contest.problemHide', async (doc) => {
TaskModel.Worker.addHandler('contest', async (doc) => {
const tdoc = await contest.get(doc.domainId, doc.tid);
if (!tdoc) return;
for (const pid of tdoc.pids) {
// eslint-disable-next-line no-await-in-loop
await problem.edit(doc.domainId, pid, { hidden: false });
const tasks = [];
for (const op of doc.operation) {
if (op === 'unhide') {
for (const pid of tdoc.pids) {
tasks.push(problem.edit(doc.domainId, pid, { hidden: false }));
}
} else if (op === 'unlock') tasks.push(contest.recalcStatus(doc.domainId, doc.tid));
}
await Promise.all(tasks);
});
export class ContestListHandler extends Handler {
@@ -129,7 +134,7 @@ export class ContestDetailHandler extends Handler {
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
await contest.del(domainId, tid);
await TaskModel.deleteMany({
type: 'schedule', subType: 'contest.problemHide', domainId, tid,
type: 'schedule', subType: 'contest', domainId, tid,
});
this.response.redirect = this.url('contest_main');
}
@@ -167,8 +172,13 @@ export class ContestBroadcastHandler extends Handler {
export class ContestScoreboardHandler extends Handler {
@param('tid', Types.ObjectID)
@param('page', Types.PositiveInt, true)
async get(domainId: string, tid: ObjectID, page = 1) {
const [tdoc, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page);
@param('ignoreLock', Types.Boolean, true)
async get(domainId: string, tid: ObjectID, page = 1, ignoreLock = false) {
const tdoc = await contest.get(domainId, tid);
if (ignoreLock && !this.user.own(tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
const [, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page, ignoreLock);
const path = [
['Hydro', 'homepage'],
['contest_main', 'contest_main'],
@@ -185,13 +195,18 @@ export class ContestScoreboardHandler extends Handler {
export class ContestScoreboardDownloadHandler extends Handler {
@param('tid', Types.ObjectID)
@param('ext', Types.Range(['csv', 'html']))
async get(domainId: string, tid: ObjectID, ext: string) {
@param('ignoreLock', Types.Boolean, true)
async get(domainId: string, tid: ObjectID, ext: string, ignoreLock = false) {
await this.limitRate('scoreboard_download', 120, 3);
const getContent = {
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value).join(','))).join('\n')}`,
html: (rows, tdoc) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }),
};
const [tdoc, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0);
const tdoc = await contest.get(domainId, tid);
if (ignoreLock && !this.user.own(tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
const [, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0, ignoreLock);
this.binary(await getContent[ext](rows, tdoc), `${tdoc.title}.${ext}`);
}
}
@@ -240,10 +255,11 @@ export class ContestEditHandler extends Handler {
@param('code', Types.String, true)
@param('autoHide', Types.String, true)
@param('assign', Types.CommaSeperatedArray, true)
@param('lock', Types.UnsignedInt, true)
async post(
domainId: string, tid: ObjectID, beginAtDate: string, beginAtTime: string, duration: number,
title: string, content: string, rule: string, _pids: string, rated = false,
_code = '', autoHide = false, assign: string[] = null,
_code = '', autoHide = false, assign: string[] = null, lock: number = null,
) {
if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i);
@@ -252,20 +268,22 @@ export class ContestEditHandler extends Handler {
const endAt = beginAtMoment.clone().add(duration, 'hours').toDate();
if (beginAtMoment.isSameOrAfter(endAt)) throw new ValidationError('duration');
const beginAt = beginAtMoment.toDate();
const lockAt = lock ? moment(endAt).add(-lock, 'minutes').toDate() : null;
await problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, this.user.group, true);
if (tid) {
await contest.edit(domainId, tid, {
title, content, rule, beginAt, endAt, pids, rated,
});
if (this.tdoc.beginAt !== beginAt || this.tdoc.endAt !== endAt
|| Array.isDiff(this.tdoc.pids, pids) || this.tdoc.rule !== rule) {
|| Array.isDiff(this.tdoc.pids, pids) || this.tdoc.rule !== rule
|| lockAt !== this.tdoc.lockAt) {
await contest.recalcStatus(domainId, this.tdoc.docId);
}
} else {
tid = await contest.add(domainId, title, content, this.user._id, rule, beginAt, endAt, pids, rated);
}
const task = {
type: 'schedule', subType: 'contest.problemHide', domainId, tid,
type: 'schedule', subType: 'contest', domainId, tid,
};
await TaskModel.deleteMany(task);
if (Date.now() <= endAt.getTime() && autoHide) {
@@ -273,10 +291,20 @@ export class ContestEditHandler extends Handler {
await Promise.all(pids.map((pid) => problem.edit(domainId, pid, { hidden: true })));
await TaskModel.add({
...task,
operation: ['unhide'],
executeAfter: endAt,
});
}
await contest.edit(domainId, tid, { assign, _code, autoHide });
if (lock && lockAt <= endAt) {
await TaskModel.add({
...task,
operation: ['unlock'],
executeAfter: endAt,
});
}
await contest.edit(domainId, tid, {
assign, _code, autoHide, lockAt,
});
this.response.body = { tid };
this.response.redirect = this.url('contest_detail', { tid });
}
+10 -5
View File
@@ -348,6 +348,9 @@ export interface Tdoc<docType = document['TYPE_CONTEST'] | document['TYPE_TRAINI
_code?: string;
assign?: string[];
// For contest
lockAt?: Date;
// For homework
penaltySince?: Date;
penaltyRules?: PenaltyRules;
@@ -478,19 +481,21 @@ export interface ContestStat extends Record<string, any> {
detail: any,
}
export interface ContestRule {
export interface ContestRule<T = any> {
_originalRule?: Partial<ContestRule<T>>;
TEXT: string;
check: (args: any) => any;
statusSort: any;
submitAfterAccept: boolean;
showScoreboard: (tdoc: Tdoc<30>, now: Date) => boolean;
showSelfRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
showRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
stat: (tdoc: Tdoc<30>, journal: any[]) => ContestStat;
stat: (this: ContestRule<T>, tdoc: Tdoc<30>, journal: any[], ignoreLock?: boolean) => ContestStat & T;
scoreboard: (
isExport: boolean, _: (s: string) => string,
tdoc: Tdoc<30>, pdict: ProblemDict, cursor: Cursor<any>, page: number,
this: ContestRule<T>, isExport: boolean, _: (s: string) => string,
tdoc: Tdoc<30>, pdict: ProblemDict, cursor: Cursor<ContestStat & T>, page: number,
) => Promise<[board: ScoreboardRow[], udict: Udict, nPages: number]>;
ranked: (tdoc: Tdoc<30>, cursor: Cursor<any>) => Promise<any[]>;
ranked: (tdoc: Tdoc<30>, cursor: Cursor<ContestStat & T>) => Promise<[Array<[number, ContestStat & T]>, number]>;
}
export type ContestRules = Dictionary<ContestRule>;
+76 -47
View File
@@ -1,4 +1,5 @@
import { FilterQuery, ObjectID } from 'mongodb';
import { Counter } from '@hydrooj/utils/lib/utils';
import {
ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotFoundError,
ContestScoreboardHiddenError, ValidationError,
@@ -11,6 +12,7 @@ import {
import * as misc from '../lib/misc';
import ranked from '../lib/rank';
import * as bus from '../service/bus';
import type { Handler } from '../service/server';
import { PERM, STATUS } from './builtin';
import * as document from './document';
import problem from './problem';
@@ -29,41 +31,51 @@ interface AcmDetail extends AcmJournal {
real: number;
}
const acm: ContestRule = {
function buildContestRule<T>(def: ContestRule<T>): ContestRule<T> {
def._originalRule = { scoreboard: def.scoreboard, stat: def.stat };
def.scoreboard = (def._originalRule?.scoreboard || def.scoreboard).bind(def);
def.stat = (def._originalRule?.stat || def.stat).bind(def);
return def;
}
function filterEffective<T extends AcmJournal>(tdoc: Tdoc, journal: T[], ignoreLock = false): T[] {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (isLocked(tdoc) && !ignoreLock) journal = journal.filter((i) => i.rid.generationTime * 1000 < tdoc.lockAt.getTime());
return journal.filter((i) => tdoc.pids.includes(i.pid));
}
const acm = buildContestRule({
TEXT: 'ACM/ICPC',
check: () => { },
statusSort: { accept: -1, time: 1 },
submitAfterAccept: false,
showScoreboard: () => true,
showSelfRecord: () => true,
showRecord: (tdoc, now) => now > tdoc.endAt,
stat: (tdoc, journal: AcmJournal[]) => {
const naccept: Record<number, number> = {};
stat(tdoc, journal: AcmJournal[], ignoreLock = false) {
const naccept = Counter<number>();
const effective: Record<number, AcmJournal> = {};
const detail: AcmDetail[] = [];
let accept = 0;
let time = 0;
for (const j of journal) {
if (tdoc.pids.includes(j.pid)
&& effective[j.pid]?.status !== STATUS.STATUS_ACCEPTED) {
effective[j.pid] = j;
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR].includes(j.status)) {
naccept[j.pid] = (naccept[j.pid] || 0) + 1;
}
for (const j of filterEffective(tdoc, journal, ignoreLock)) {
if (!this.submitAfterAccept && effective[j.pid]?.status === STATUS.STATUS_ACCEPTED) continue;
effective[j.pid] = j;
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR].includes(j.status)) {
naccept[j.pid]++;
}
}
for (const key in effective) {
const j = effective[key];
for (const pid in effective) {
const j = effective[pid];
const real = j.rid.generationTime - Math.floor(tdoc.beginAt.getTime() / 1000);
const penalty = 20 * 60 * (naccept[j.pid] || 0);
const penalty = 20 * 60 * naccept[j.pid];
detail.push({
...j, naccept: naccept[j.pid] || 0, time: real + penalty, real, penalty,
...j, naccept: naccept[j.pid], time: real + penalty, real, penalty,
});
}
for (const d of detail) {
if (d.status === STATUS.STATUS_ACCEPTED) {
accept += 1;
time += d.time;
}
for (const d of detail.filter((i) => i.status === STATUS.STATUS_ACCEPTED)) {
accept++;
time += d.time;
}
return { accept, time, detail };
},
@@ -80,7 +92,6 @@ const acm: ContestRule = {
columns.push(
{ type: 'total_time', value: _('Penalty') },
{ type: 'total_time', value: _('Total Time (Seconds)') },
{ type: 'total_time_str', value: _('Total Time') },
);
}
for (let i = 1; i <= tdoc.pids.length; i++) {
@@ -108,41 +119,43 @@ const acm: ContestRule = {
});
}
}
// Find first accept
const first = {};
for (const pid of tdoc.pids) first[pid] = new ObjectID().generationTime;
for (const [, tsdoc] of rankedTsdocs) {
const tsddict = {};
for (const item of tsdoc.journal || []) tsddict[item.pid] = item;
for (const item of tsdoc.detail || []) tsddict[item.pid] = item;
for (const pid of tdoc.pids) {
if (tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid].rid.generationTime < first[pid]) {
first[pid] = tsddict[pid].rid.generationTime;
}
}
}
const rows: ScoreboardRow[] = [columns];
for (const [rank, tsdoc] of rankedTsdocs) {
const tsddict = {};
for (const item of tsdoc.detail || []) tsddict[item.pid] = item;
const tsddict: Record<number, AcmDetail> = {};
for (const item of tsdoc.detail) tsddict[item.pid] = item;
const row: ScoreboardRow = [
{ type: 'string', value: rank.toString() },
{ type: 'user', value: udict[tsdoc.uid].uname, raw: tsdoc.uid },
{ type: 'string', value: tsdoc.accept || 0 },
];
if (isExport) {
const penalty = Math.sum(tdoc.pids.map((i) => (tsddict[i]?.naccept || 0))) * 20 * 60;
const penalty = Math.sum(tdoc.pids.map((i) => tsddict[i]?.naccept || 0)) * 20 * 60;
row.push(
{ type: 'string', value: penalty.toString() },
{ type: 'string', value: tsdoc.time || 0.0 },
{ type: 'string', value: tsdoc.time || 0.0 },
);
}
for (const pid of tdoc.pids) {
const doc = tsddict[pid] || {};
const doc = tsddict[pid] || {} as Partial<AcmDetail>;
const accept = doc.status === STATUS.STATUS_ACCEPTED;
const rid = accept ? doc.rid : null;
const colAccepted = `${(accept && isExport) ? `${_('Accepted')} ` : ''}${doc.naccept ? ` (-${doc.naccept})` : ''}`;
const colTime = accept ? doc.time : '-';
const colTimeStr = accept ? misc.formatSeconds(colTime) : '-';
const colTime = accept ? doc.time.toString() : '-';
const colTimeStr = accept ? misc.formatSeconds(doc.time) : '-';
if (isExport) {
row.push(
{ type: 'string', value: colAccepted },
@@ -161,7 +174,6 @@ const acm: ContestRule = {
});
}
}
row.raw = tsdoc;
rows.push(row);
}
return [rows, udict, nPages];
@@ -169,17 +181,18 @@ const acm: ContestRule = {
async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score);
},
};
});
const oi: ContestRule = {
const oi = buildContestRule({
TEXT: 'OI',
check: () => { },
submitAfterAccept: true,
statusSort: { score: -1 },
stat: (tdoc, journal) => {
stat(tdoc, journal) {
const detail = {};
let score = 0;
for (const j of journal) {
if (tdoc.pids.includes(j.pid)) detail[j.pid] = j;
for (const j of journal.filter((i) => tdoc.pids.includes(i.pid))) {
if (detail[j.pid]?.status === STATUS.STATUS_ACCEPTED && !this.submitAfterAccept) detail[j.pid] = j;
}
for (const i in detail) score += detail[i].score;
return { score, detail };
@@ -278,19 +291,21 @@ const oi: ContestRule = {
async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score);
},
};
});
const ioi: ContestRule = {
const ioi = buildContestRule({
...oi,
TEXT: 'IOI',
submitAfterAccept: false,
showRecord: (tdoc, now) => now > tdoc.endAt,
showSelfRecord: () => true,
showScoreboard: () => true,
};
});
const homework: ContestRule = {
const homework = buildContestRule({
TEXT: 'Assignment',
check: () => { },
submitAfterAccept: false,
statusSort: { penaltyScore: -1, time: 1 },
stat: (tdoc, journal) => {
const effective = {};
@@ -434,7 +449,7 @@ const homework: ContestRule = {
async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score);
},
};
});
export const RULES: ContestRules = {
acm, oi, homework, ioi,
@@ -449,12 +464,12 @@ export async function add(
rule: string, beginAt = new Date(), endAt = new Date(), pids: number[] = [],
rated = false, data: Partial<Tdoc> = {},
) {
if (!this.RULES[rule]) throw new ValidationError('rule');
if (!RULES[rule]) throw new ValidationError('rule');
if (beginAt >= endAt) throw new ValidationError('beginAt', 'endAt');
Object.assign(data, {
content, owner, title, rule, beginAt, endAt, pids, attend: 0,
});
this.RULES[rule].check(data);
RULES[rule].check(data);
await bus.serial('contest/before-add', data);
const res = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, {
...data, title, rule, beginAt, endAt, pids, attend: 0, rated,
@@ -464,10 +479,10 @@ export async function add(
}
export async function edit(domainId: string, tid: ObjectID, $set: any) {
if ($set.rule && !this.RULES[$set.rule]) throw new ValidationError('rule');
if ($set.rule && !RULES[$set.rule]) throw new ValidationError('rule');
const tdoc = await document.get(domainId, document.TYPE_CONTEST, tid);
if (!tdoc) throw new ContestNotFoundError(domainId, tid);
this.RULES[$set.rule || tdoc.rule].check(Object.assign(tdoc, $set));
RULES[$set.rule || tdoc.rule].check(Object.assign(tdoc, $set));
return await document.set(domainId, document.TYPE_CONTEST, tid, $set);
}
@@ -489,8 +504,14 @@ export async function getRelated(domainId: string, pid: number) {
return await document.getMulti(domainId, document.TYPE_CONTEST, { pids: pid }).toArray();
}
export function getStatus(domainId: string, tid: ObjectID, uid: number) {
return document.getStatus(domainId, document.TYPE_CONTEST, tid, uid);
export async function getStatus(domainId: string, tid: ObjectID, uid: number) {
const [tdoc, status] = await Promise.all([
get(domainId, tid),
document.getStatus(domainId, document.TYPE_CONTEST, tid, uid),
]);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (isLocked(tdoc)) Object.assign(status, RULES[tdoc.rule].stat(tdoc, status.journal || [], true));
return status;
}
export async function updateStatus(
@@ -556,6 +577,12 @@ export function isDone(tdoc: Tdoc) {
return tdoc.endAt <= new Date();
}
export function isLocked(tdoc: Tdoc) {
if (!tdoc.lockAt) return false;
const now = new Date();
return (tdoc.lockAt < now && now < tdoc.endAt);
}
export function isExtended(tdoc: Tdoc) {
const now = new Date().getTime();
return tdoc.penaltySince.getTime() <= now && now < tdoc.endAt.getTime();
@@ -627,11 +654,12 @@ export function canShowScoreboard(tdoc: Tdoc<30>, allowPermOverride = true) {
}
export async function getScoreboard(
domainId: string, tid: ObjectID,
isExport = false, page: number,
this: Handler, domainId: string, tid: ObjectID,
isExport = false, page: number, ignoreLock = false,
): Promise<[Tdoc<30 | 60>, ScoreboardRow[], Udict, ProblemDict, number]> {
const tdoc = await get(domainId, tid);
if (!canShowScoreboard.call(this, tdoc)) throw new ContestScoreboardHiddenError(tid);
if (ignoreLock) delete tdoc.lockAt;
const tsdocsCursor = getMultiStatus(domainId, { docId: tid }).sort(RULES[tdoc.rule].statusSort);
const pdict = await problem.getList(domainId, tdoc.pids, true);
const [rows, udict, nPages] = await RULES[tdoc.rule].scoreboard(
@@ -684,6 +712,7 @@ global.Hydro.model.contest = {
isNotStarted,
isOngoing,
isDone,
isLocked,
isExtended,
statusText,
getStatusText,
+1
View File
@@ -286,6 +286,7 @@ export class ProblemModel {
group: string[] = [], doThrow = true, projection = ProblemModel.PROJECTION_PUBLIC,
indexByDocIdOnly = false,
): Promise<ProblemDict> {
if (!pids?.length) return [];
const r: Record<number, ProblemDoc> = {};
const l: Record<string, ProblemDoc> = {};
const q: any = { docId: { $in: pids } };
+4 -1
View File
@@ -70,7 +70,10 @@ class WorkerService implements BaseService {
try {
logger.debug('Worker task: %o', doc);
const start = Date.now();
await this.handlers[doc.subType](doc);
await Promise.race([
this.handlers[doc.subType](doc),
new Promise((resolve) => setTimeout(resolve, 300000)),
]);
const spent = Date.now() - start;
if (spent > 500) logger.warn('Slow worker task (%d ms): %s', spent, doc);
} catch (e) {
+9
View File
@@ -21,6 +21,7 @@ import problem from './model/problem';
import RecordModel from './model/record';
import StorageModel from './model/storage';
import * as system from './model/system';
import TaskModel from './model/task';
import user from './model/user';
import {
iterateAllDomain, iterateAllProblem, iterateAllPsdoc, iterateAllUser,
@@ -673,6 +674,14 @@ const scripts: UpgradeScript[] = [
);
return true;
},
async function _58_59() {
const _FRESH_INSTALL_IGNORE = 1;
const tasks = await db.collection('task').find({ type: 'schedule', subType: 'contest.problemHide' }).toArray();
for (const task of tasks) {
await TaskModel.add({ ...task, subType: 'contest', operation: ['unhide'] });
}
return true;
},
];
export default scripts;