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
+181 -153

No files matched your search

+6 -6
View File
@@ -28,27 +28,27 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"devDependencies": { "devDependencies": {
"@types/cross-spawn": "^6.0.2", "@types/cross-spawn": "^6.0.2",
"@types/node": "^16.11.19", "@types/node": "^16.11.21",
"@types/semver": "^7.3.9", "@types/semver": "^7.3.9",
"@typescript-eslint/eslint-plugin": "5.9.0", "@typescript-eslint/eslint-plugin": "5.10.0",
"@typescript-eslint/parser": "5.9.0", "@typescript-eslint/parser": "5.10.0",
"cac": "^6.7.12", "cac": "^6.7.12",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"esbuild": "0.14.3", "esbuild": "0.14.3",
"eslint": "^8.6.0", "eslint": "^8.7.0",
"eslint-config-airbnb-typescript": "12.3.1", "eslint-config-airbnb-typescript": "12.3.1",
"eslint-import-resolver-typescript": "2.5.0", "eslint-import-resolver-typescript": "2.5.0",
"eslint-plugin-import": "2.25.4", "eslint-plugin-import": "2.25.4",
"eslint-plugin-simple-import-sort": "7.0.0", "eslint-plugin-simple-import-sort": "7.0.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"globby": "11.0.4", "globby": "11.1.0",
"latest-version": "^6.0.0", "latest-version": "^6.0.0",
"mongodb": "^3.7.3", "mongodb": "^3.7.3",
"nmls": "^3.0.1", "nmls": "^3.0.1",
"ora": "5.4.1", "ora": "5.4.1",
"semver": "^7.3.5", "semver": "^7.3.5",
"typedoc": "^0.22.10", "typedoc": "^0.22.11",
"typescript": "4.4.3" "typescript": "4.4.3"
} }
} }
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "hydrooj", "name": "hydrooj",
"version": "3.3.20", "version": "3.4.0",
"bin": "bin/hydrooj.js", "bin": "bin/hydrooj.js",
"main": "src/loader", "main": "src/loader",
"module": "src/loader", "module": "src/loader",
@@ -22,7 +22,7 @@
"detect-browser": "^5.3.0", "detect-browser": "^5.3.0",
"esbuild": "0.14.3", "esbuild": "0.14.3",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"graphql": "^16.2.0", "graphql": "^16.3.0",
"graphql-scalars": "1.14.1", "graphql-scalars": "1.14.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"koa": "^2.13.4", "koa": "^2.13.4",
@@ -40,7 +40,7 @@
"nanoid": "^3.2.0", "nanoid": "^3.2.0",
"nodemailer": "^6.7.2", "nodemailer": "^6.7.2",
"notp": "^2.0.3", "notp": "^2.0.3",
"p-queue": "^7.1.0", "p-queue": "^7.2.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"require-resolve-hook": "^1.1.0", "require-resolve-hook": "^1.1.0",
"semver": "^7.3.5", "semver": "^7.3.5",
@@ -62,7 +62,7 @@
"@types/lodash": "^4.14.178", "@types/lodash": "^4.14.178",
"@types/lru-cache": "^5.1.1", "@types/lru-cache": "^5.1.1",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/minio": "^7.0.11", "@types/minio": "^7.0.12",
"@types/mongodb": "^3.6.20", "@types/mongodb": "^3.6.20",
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/notp": "^2.0.1", "@types/notp": "^2.0.1",
@@ -70,7 +70,7 @@
"@types/serialize-javascript": "^5.0.2", "@types/serialize-javascript": "^5.0.2",
"@types/sockjs": "^0.3.33", "@types/sockjs": "^0.3.33",
"@types/source-map-support": "^0.5.4", "@types/source-map-support": "^0.5.4",
"@types/superagent": "^4.1.14", "@types/superagent": "^4.1.15",
"moment": "^2.29.1", "moment": "^2.29.1",
"pm2": "^5.1.2" "pm2": "^5.1.2"
}, },
+40 -12
View File
@@ -47,13 +47,18 @@ registerResolver(
'Get a contest by ID', '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); const tdoc = await contest.get(doc.domainId, doc.tid);
if (!tdoc) return; if (!tdoc) return;
const tasks = [];
for (const op of doc.operation) {
if (op === 'unhide') {
for (const pid of tdoc.pids) { for (const pid of tdoc.pids) {
// eslint-disable-next-line no-await-in-loop tasks.push(problem.edit(doc.domainId, pid, { hidden: false }));
await 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 { 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); if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
await contest.del(domainId, tid); await contest.del(domainId, tid);
await TaskModel.deleteMany({ await TaskModel.deleteMany({
type: 'schedule', subType: 'contest.problemHide', domainId, tid, type: 'schedule', subType: 'contest', domainId, tid,
}); });
this.response.redirect = this.url('contest_main'); this.response.redirect = this.url('contest_main');
} }
@@ -167,8 +172,13 @@ export class ContestBroadcastHandler extends Handler {
export class ContestScoreboardHandler extends Handler { export class ContestScoreboardHandler extends Handler {
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@param('page', Types.PositiveInt, true) @param('page', Types.PositiveInt, true)
async get(domainId: string, tid: ObjectID, page = 1) { @param('ignoreLock', Types.Boolean, true)
const [tdoc, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page); 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 = [ const path = [
['Hydro', 'homepage'], ['Hydro', 'homepage'],
['contest_main', 'contest_main'], ['contest_main', 'contest_main'],
@@ -185,13 +195,18 @@ export class ContestScoreboardHandler extends Handler {
export class ContestScoreboardDownloadHandler extends Handler { export class ContestScoreboardDownloadHandler extends Handler {
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@param('ext', Types.Range(['csv', 'html'])) @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); await this.limitRate('scoreboard_download', 120, 3);
const getContent = { const getContent = {
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value).join(','))).join('\n')}`, 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 }), 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}`); 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('code', Types.String, true)
@param('autoHide', Types.String, true) @param('autoHide', Types.String, true)
@param('assign', Types.CommaSeperatedArray, true) @param('assign', Types.CommaSeperatedArray, true)
@param('lock', Types.UnsignedInt, true)
async post( async post(
domainId: string, tid: ObjectID, beginAtDate: string, beginAtTime: string, duration: number, domainId: string, tid: ObjectID, beginAtDate: string, beginAtTime: string, duration: number,
title: string, content: string, rule: string, _pids: string, rated = false, 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); if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i); 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(); const endAt = beginAtMoment.clone().add(duration, 'hours').toDate();
if (beginAtMoment.isSameOrAfter(endAt)) throw new ValidationError('duration'); if (beginAtMoment.isSameOrAfter(endAt)) throw new ValidationError('duration');
const beginAt = beginAtMoment.toDate(); 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); await problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, this.user.group, true);
if (tid) { if (tid) {
await contest.edit(domainId, tid, { await contest.edit(domainId, tid, {
title, content, rule, beginAt, endAt, pids, rated, title, content, rule, beginAt, endAt, pids, rated,
}); });
if (this.tdoc.beginAt !== beginAt || this.tdoc.endAt !== endAt 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); await contest.recalcStatus(domainId, this.tdoc.docId);
} }
} else { } else {
tid = await contest.add(domainId, title, content, this.user._id, rule, beginAt, endAt, pids, rated); tid = await contest.add(domainId, title, content, this.user._id, rule, beginAt, endAt, pids, rated);
} }
const task = { const task = {
type: 'schedule', subType: 'contest.problemHide', domainId, tid, type: 'schedule', subType: 'contest', domainId, tid,
}; };
await TaskModel.deleteMany(task); await TaskModel.deleteMany(task);
if (Date.now() <= endAt.getTime() && autoHide) { 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 Promise.all(pids.map((pid) => problem.edit(domainId, pid, { hidden: true })));
await TaskModel.add({ await TaskModel.add({
...task, ...task,
operation: ['unhide'],
executeAfter: endAt, 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.body = { tid };
this.response.redirect = this.url('contest_detail', { 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; _code?: string;
assign?: string[]; assign?: string[];
// For contest
lockAt?: Date;
// For homework // For homework
penaltySince?: Date; penaltySince?: Date;
penaltyRules?: PenaltyRules; penaltyRules?: PenaltyRules;
@@ -478,19 +481,21 @@ export interface ContestStat extends Record<string, any> {
detail: any, detail: any,
} }
export interface ContestRule { export interface ContestRule<T = any> {
_originalRule?: Partial<ContestRule<T>>;
TEXT: string; TEXT: string;
check: (args: any) => any; check: (args: any) => any;
statusSort: any; statusSort: any;
submitAfterAccept: boolean;
showScoreboard: (tdoc: Tdoc<30>, now: Date) => boolean; showScoreboard: (tdoc: Tdoc<30>, now: Date) => boolean;
showSelfRecord: (tdoc: Tdoc<30>, now: Date) => boolean; showSelfRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
showRecord: (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: ( scoreboard: (
isExport: boolean, _: (s: string) => string, this: ContestRule<T>, isExport: boolean, _: (s: string) => string,
tdoc: Tdoc<30>, pdict: ProblemDict, cursor: Cursor<any>, page: number, tdoc: Tdoc<30>, pdict: ProblemDict, cursor: Cursor<ContestStat & T>, page: number,
) => Promise<[board: ScoreboardRow[], udict: Udict, nPages: 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>; export type ContestRules = Dictionary<ContestRule>;
+73 -44
View File
@@ -1,4 +1,5 @@
import { FilterQuery, ObjectID } from 'mongodb'; import { FilterQuery, ObjectID } from 'mongodb';
import { Counter } from '@hydrooj/utils/lib/utils';
import { import {
ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotFoundError, ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotFoundError,
ContestScoreboardHiddenError, ValidationError, ContestScoreboardHiddenError, ValidationError,
@@ -11,6 +12,7 @@ import {
import * as misc from '../lib/misc'; import * as misc from '../lib/misc';
import ranked from '../lib/rank'; import ranked from '../lib/rank';
import * as bus from '../service/bus'; import * as bus from '../service/bus';
import type { Handler } from '../service/server';
import { PERM, STATUS } from './builtin'; import { PERM, STATUS } from './builtin';
import * as document from './document'; import * as document from './document';
import problem from './problem'; import problem from './problem';
@@ -29,42 +31,52 @@ interface AcmDetail extends AcmJournal {
real: number; 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', TEXT: 'ACM/ICPC',
check: () => { }, check: () => { },
statusSort: { accept: -1, time: 1 }, statusSort: { accept: -1, time: 1 },
submitAfterAccept: false,
showScoreboard: () => true, showScoreboard: () => true,
showSelfRecord: () => true, showSelfRecord: () => true,
showRecord: (tdoc, now) => now > tdoc.endAt, showRecord: (tdoc, now) => now > tdoc.endAt,
stat: (tdoc, journal: AcmJournal[]) => { stat(tdoc, journal: AcmJournal[], ignoreLock = false) {
const naccept: Record<number, number> = {}; const naccept = Counter<number>();
const effective: Record<number, AcmJournal> = {}; const effective: Record<number, AcmJournal> = {};
const detail: AcmDetail[] = []; const detail: AcmDetail[] = [];
let accept = 0; let accept = 0;
let time = 0; let time = 0;
for (const j of journal) { for (const j of filterEffective(tdoc, journal, ignoreLock)) {
if (tdoc.pids.includes(j.pid) if (!this.submitAfterAccept && effective[j.pid]?.status === STATUS.STATUS_ACCEPTED) continue;
&& effective[j.pid]?.status !== STATUS.STATUS_ACCEPTED) {
effective[j.pid] = j; effective[j.pid] = j;
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR].includes(j.status)) { if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR].includes(j.status)) {
naccept[j.pid] = (naccept[j.pid] || 0) + 1; naccept[j.pid]++;
} }
} }
} for (const pid in effective) {
for (const key in effective) { const j = effective[pid];
const j = effective[key];
const real = j.rid.generationTime - Math.floor(tdoc.beginAt.getTime() / 1000); 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({ 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) { for (const d of detail.filter((i) => i.status === STATUS.STATUS_ACCEPTED)) {
if (d.status === STATUS.STATUS_ACCEPTED) { accept++;
accept += 1;
time += d.time; time += d.time;
} }
}
return { accept, time, detail }; return { accept, time, detail };
}, },
async scoreboard(isExport, _, tdoc, pdict, cursor, page) { async scoreboard(isExport, _, tdoc, pdict, cursor, page) {
@@ -80,7 +92,6 @@ const acm: ContestRule = {
columns.push( columns.push(
{ type: 'total_time', value: _('Penalty') }, { type: 'total_time', value: _('Penalty') },
{ type: 'total_time', value: _('Total Time (Seconds)') }, { type: 'total_time', value: _('Total Time (Seconds)') },
{ type: 'total_time_str', value: _('Total Time') },
); );
} }
for (let i = 1; i <= tdoc.pids.length; i++) { for (let i = 1; i <= tdoc.pids.length; i++) {
@@ -108,41 +119,43 @@ const acm: ContestRule = {
}); });
} }
} }
// Find first accept
const first = {}; const first = {};
for (const pid of tdoc.pids) first[pid] = new ObjectID().generationTime; for (const pid of tdoc.pids) first[pid] = new ObjectID().generationTime;
for (const [, tsdoc] of rankedTsdocs) { for (const [, tsdoc] of rankedTsdocs) {
const tsddict = {}; 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) { for (const pid of tdoc.pids) {
if (tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid].rid.generationTime < first[pid]) { if (tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid].rid.generationTime < first[pid]) {
first[pid] = tsddict[pid].rid.generationTime; first[pid] = tsddict[pid].rid.generationTime;
} }
} }
} }
const rows: ScoreboardRow[] = [columns]; const rows: ScoreboardRow[] = [columns];
for (const [rank, tsdoc] of rankedTsdocs) { for (const [rank, tsdoc] of rankedTsdocs) {
const tsddict = {}; const tsddict: Record<number, AcmDetail> = {};
for (const item of tsdoc.detail || []) tsddict[item.pid] = item; for (const item of tsdoc.detail) tsddict[item.pid] = item;
const row: ScoreboardRow = [ const row: ScoreboardRow = [
{ type: 'string', value: rank.toString() }, { type: 'string', value: rank.toString() },
{ type: 'user', value: udict[tsdoc.uid].uname, raw: tsdoc.uid }, { type: 'user', value: udict[tsdoc.uid].uname, raw: tsdoc.uid },
{ type: 'string', value: tsdoc.accept || 0 }, { type: 'string', value: tsdoc.accept || 0 },
]; ];
if (isExport) { 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( row.push(
{ type: 'string', value: penalty.toString() }, { type: 'string', value: penalty.toString() },
{ type: 'string', value: tsdoc.time || 0.0 }, { type: 'string', value: tsdoc.time || 0.0 },
{ type: 'string', value: tsdoc.time || 0.0 },
); );
} }
for (const pid of tdoc.pids) { 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 accept = doc.status === STATUS.STATUS_ACCEPTED;
const rid = accept ? doc.rid : null; const rid = accept ? doc.rid : null;
const colAccepted = `${(accept && isExport) ? `${_('Accepted')} ` : ''}${doc.naccept ? ` (-${doc.naccept})` : ''}`; const colAccepted = `${(accept && isExport) ? `${_('Accepted')} ` : ''}${doc.naccept ? ` (-${doc.naccept})` : ''}`;
const colTime = accept ? doc.time : '-'; const colTime = accept ? doc.time.toString() : '-';
const colTimeStr = accept ? misc.formatSeconds(colTime) : '-'; const colTimeStr = accept ? misc.formatSeconds(doc.time) : '-';
if (isExport) { if (isExport) {
row.push( row.push(
{ type: 'string', value: colAccepted }, { type: 'string', value: colAccepted },
@@ -161,7 +174,6 @@ const acm: ContestRule = {
}); });
} }
} }
row.raw = tsdoc;
rows.push(row); rows.push(row);
} }
return [rows, udict, nPages]; return [rows, udict, nPages];
@@ -169,17 +181,18 @@ const acm: ContestRule = {
async ranked(tdoc, cursor) { async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score); return await ranked.all(cursor, (a, b) => a.score === b.score);
}, },
}; });
const oi: ContestRule = { const oi = buildContestRule({
TEXT: 'OI', TEXT: 'OI',
check: () => { }, check: () => { },
submitAfterAccept: true,
statusSort: { score: -1 }, statusSort: { score: -1 },
stat: (tdoc, journal) => { stat(tdoc, journal) {
const detail = {}; const detail = {};
let score = 0; let score = 0;
for (const j of journal) { for (const j of journal.filter((i) => tdoc.pids.includes(i.pid))) {
if (tdoc.pids.includes(j.pid)) detail[j.pid] = j; if (detail[j.pid]?.status === STATUS.STATUS_ACCEPTED && !this.submitAfterAccept) detail[j.pid] = j;
} }
for (const i in detail) score += detail[i].score; for (const i in detail) score += detail[i].score;
return { score, detail }; return { score, detail };
@@ -278,19 +291,21 @@ const oi: ContestRule = {
async ranked(tdoc, cursor) { async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score); return await ranked.all(cursor, (a, b) => a.score === b.score);
}, },
}; });
const ioi: ContestRule = { const ioi = buildContestRule({
...oi, ...oi,
TEXT: 'IOI', TEXT: 'IOI',
submitAfterAccept: false,
showRecord: (tdoc, now) => now > tdoc.endAt, showRecord: (tdoc, now) => now > tdoc.endAt,
showSelfRecord: () => true, showSelfRecord: () => true,
showScoreboard: () => true, showScoreboard: () => true,
}; });
const homework: ContestRule = { const homework = buildContestRule({
TEXT: 'Assignment', TEXT: 'Assignment',
check: () => { }, check: () => { },
submitAfterAccept: false,
statusSort: { penaltyScore: -1, time: 1 }, statusSort: { penaltyScore: -1, time: 1 },
stat: (tdoc, journal) => { stat: (tdoc, journal) => {
const effective = {}; const effective = {};
@@ -434,7 +449,7 @@ const homework: ContestRule = {
async ranked(tdoc, cursor) { async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score); return await ranked.all(cursor, (a, b) => a.score === b.score);
}, },
}; });
export const RULES: ContestRules = { export const RULES: ContestRules = {
acm, oi, homework, ioi, acm, oi, homework, ioi,
@@ -449,12 +464,12 @@ export async function add(
rule: string, beginAt = new Date(), endAt = new Date(), pids: number[] = [], rule: string, beginAt = new Date(), endAt = new Date(), pids: number[] = [],
rated = false, data: Partial<Tdoc> = {}, 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'); if (beginAt >= endAt) throw new ValidationError('beginAt', 'endAt');
Object.assign(data, { Object.assign(data, {
content, owner, title, rule, beginAt, endAt, pids, attend: 0, 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); await bus.serial('contest/before-add', data);
const res = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, { const res = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, {
...data, title, rule, beginAt, endAt, pids, attend: 0, rated, ...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) { 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); const tdoc = await document.get(domainId, document.TYPE_CONTEST, tid);
if (!tdoc) throw new ContestNotFoundError(domainId, 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); 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(); return await document.getMulti(domainId, document.TYPE_CONTEST, { pids: pid }).toArray();
} }
export function getStatus(domainId: string, tid: ObjectID, uid: number) { export async function getStatus(domainId: string, tid: ObjectID, uid: number) {
return document.getStatus(domainId, document.TYPE_CONTEST, tid, uid); 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( export async function updateStatus(
@@ -556,6 +577,12 @@ export function isDone(tdoc: Tdoc) {
return tdoc.endAt <= new Date(); 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) { export function isExtended(tdoc: Tdoc) {
const now = new Date().getTime(); const now = new Date().getTime();
return tdoc.penaltySince.getTime() <= now && now < tdoc.endAt.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( export async function getScoreboard(
domainId: string, tid: ObjectID, this: Handler, domainId: string, tid: ObjectID,
isExport = false, page: number, isExport = false, page: number, ignoreLock = false,
): Promise<[Tdoc<30 | 60>, ScoreboardRow[], Udict, ProblemDict, number]> { ): Promise<[Tdoc<30 | 60>, ScoreboardRow[], Udict, ProblemDict, number]> {
const tdoc = await get(domainId, tid); const tdoc = await get(domainId, tid);
if (!canShowScoreboard.call(this, tdoc)) throw new ContestScoreboardHiddenError(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 tsdocsCursor = getMultiStatus(domainId, { docId: tid }).sort(RULES[tdoc.rule].statusSort);
const pdict = await problem.getList(domainId, tdoc.pids, true); const pdict = await problem.getList(domainId, tdoc.pids, true);
const [rows, udict, nPages] = await RULES[tdoc.rule].scoreboard( const [rows, udict, nPages] = await RULES[tdoc.rule].scoreboard(
@@ -684,6 +712,7 @@ global.Hydro.model.contest = {
isNotStarted, isNotStarted,
isOngoing, isOngoing,
isDone, isDone,
isLocked,
isExtended, isExtended,
statusText, statusText,
getStatusText, getStatusText,
+1
View File
@@ -286,6 +286,7 @@ export class ProblemModel {
group: string[] = [], doThrow = true, projection = ProblemModel.PROJECTION_PUBLIC, group: string[] = [], doThrow = true, projection = ProblemModel.PROJECTION_PUBLIC,
indexByDocIdOnly = false, indexByDocIdOnly = false,
): Promise<ProblemDict> { ): Promise<ProblemDict> {
if (!pids?.length) return [];
const r: Record<number, ProblemDoc> = {}; const r: Record<number, ProblemDoc> = {};
const l: Record<string, ProblemDoc> = {}; const l: Record<string, ProblemDoc> = {};
const q: any = { docId: { $in: pids } }; const q: any = { docId: { $in: pids } };
+4 -1
View File
@@ -70,7 +70,10 @@ class WorkerService implements BaseService {
try { try {
logger.debug('Worker task: %o', doc); logger.debug('Worker task: %o', doc);
const start = Date.now(); 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; const spent = Date.now() - start;
if (spent > 500) logger.warn('Slow worker task (%d ms): %s', spent, doc); if (spent > 500) logger.warn('Slow worker task (%d ms): %s', spent, doc);
} catch (e) { } catch (e) {
+9
View File
@@ -21,6 +21,7 @@ import problem from './model/problem';
import RecordModel from './model/record'; import RecordModel from './model/record';
import StorageModel from './model/storage'; import StorageModel from './model/storage';
import * as system from './model/system'; import * as system from './model/system';
import TaskModel from './model/task';
import user from './model/user'; import user from './model/user';
import { import {
iterateAllDomain, iterateAllProblem, iterateAllPsdoc, iterateAllUser, iterateAllDomain, iterateAllProblem, iterateAllPsdoc, iterateAllUser,
@@ -673,6 +674,14 @@ const scripts: UpgradeScript[] = [
); );
return true; 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; export default scripts;
@@ -27,7 +27,7 @@ class ChangeThemeAction extends EditorAction {
super({ super({
id: 'hydro.changeEditorTheme', id: 'hydro.changeEditorTheme',
label: i18n('Change Theme'), label: i18n('Change Theme'),
alias: i18n('Change Theme'), alias: [i18n('Change Theme'), 'Change Theme'],
}); });
} }
+1
View File
@@ -663,6 +663,7 @@ Saving file...: 正在保存文件...
score canceled: 成绩取消 score canceled: 成绩取消
Score: 分数 Score: 分数
Scoreboard (Hidden): 成绩表 (隐藏) Scoreboard (Hidden): 成绩表 (隐藏)
Scoreboard locked at {0}: 比赛已于 {0} 封榜。
Scoreboard: 成绩表 Scoreboard: 成绩表
Scripts: 脚本 Scripts: 脚本
Search: 搜索 Search: 搜索
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hydrooj/ui-default", "name": "@hydrooj/ui-default",
"version": "4.30.9", "version": "4.30.10",
"author": "undefined <i@undefined.moe>", "author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "hydro.js", "main": "hydro.js",
@@ -25,15 +25,15 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"clean-webpack-plugin": "^4.0.0", "clean-webpack-plugin": "^4.0.0",
"clipboard": "^2.0.8", "clipboard": "^2.0.9",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"css-loader": "^4.3.0", "css-loader": "^4.3.0",
"diff-dom": "^4.2.3", "diff-dom": "^4.2.3",
"echarts": "^5.2.2", "echarts": "^5.3.0",
"emojis-keywords": "2.0.0", "emojis-keywords": "2.0.0",
"emojis-list": "2.1.0", "emojis-list": "2.1.0",
"esbuild-loader": "^2.18.0", "esbuild-loader": "^2.18.0",
"eslint": "^8.7.0", "eslint": "^8.8.0",
"eslint-config-airbnb": "^18.2.1", "eslint-config-airbnb": "^18.2.1",
"eslint-import-resolver-webpack": "^0.13.2", "eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
@@ -92,7 +92,7 @@
"through2": "^4.0.2", "through2": "^4.0.2",
"timeago-react": "^3.0.4", "timeago-react": "^3.0.4",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"vditor": "^3.8.10", "vditor": "^3.8.11",
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
"web-streams-polyfill": "^3.2.0", "web-streams-polyfill": "^3.2.0",
"webpack": "^4.46.0", "webpack": "^4.46.0",
@@ -116,7 +116,7 @@
"markdown-it-table-of-contents": "^0.6.0", "markdown-it-table-of-contents": "^0.6.0",
"mongodb": "^3.7.3", "mongodb": "^3.7.3",
"nunjucks": "^3.2.3", "nunjucks": "^3.2.3",
"react-query": "^3.34.8", "react-query": "^3.34.12",
"streamsaver": "^2.0.5", "streamsaver": "^2.0.5",
"xss": "^1.0.10" "xss": "^1.0.10"
} }
@@ -1,57 +1,8 @@
import { NamedPage } from 'vj/misc/Page'; import { NamedPage } from 'vj/misc/Page';
import code from 'vj/components/highlighter/code-example';
import i18n from 'vj/utils/i18n'; import i18n from 'vj/utils/i18n';
import tpl from 'vj/utils/tpl'; import tpl from 'vj/utils/tpl';
const page = new NamedPage('home_preference', async () => { const page = new NamedPage('home_preference', () => {
async function mountComponent() {
const [{ default: prismjs }, React, { render }] = await Promise.all([
import('vj/components/highlighter/prismjs'),
import('react'),
import('react-dom'),
]);
class AstylePreview extends React.PureComponent {
constructor(props) {
super(props);
this.state = { highlight: prismjs.highlight(this.preview, prismjs.Prism.languages.cpp, 'C++') };
}
render() {
return (
<>
<div className="medium-4 columns form__item end">
<label htmlFor="astyleOptions_input">
{i18n('astyleOptions')}
<div id="astyleOptions_input" name="form_item_astyleOptions" className="textbox-container">
<textarea
type="textarea"
spellCheck={false}
name="astyleOptions"
value={this.state.value}
style={{ height: '400px' }}
onChange={(ev) => {
ev.stopPropagation();
const highlight = prismjs.highlight(code, prismjs.Prism.languages.cpp, 'C++');
this.setState({ value: ev.target.value, highlight });
}}
className="textbox"
/>
</div>
</label>
<p className="help-text">{i18n('astyle formatting options')}</p>
</div>
<div className="medium-8 columns form__item end">
<label htmlFor="astylePreview">
{i18n('Preview') /* eslint-disable-next-line */}
<pre id="astylePreview" style={{ height: '400px' }}><code dangerouslySetInnerHTML={{ __html: this.state.highlight }}></code></pre>
</label>
</div>
</>
);
}
}
function setOptions($el, options) { function setOptions($el, options) {
$el.empty(); $el.empty();
$.each(options, (key, value) => { $.each(options, (key, value) => {
@@ -115,14 +66,6 @@ const page = new NamedPage('home_preference', async () => {
$('#codelang-sub-select').on('change', function () { $('#codelang-sub-select').on('change', function () {
$('[name="codeLang"]').val(this.value); $('[name="codeLang"]').val(this.value);
}); });
render(
<AstylePreview></AstylePreview>,
$('[name="form_item_astyleOptions"]').get(0).parentNode.parentNode.parentNode,
);
}
mountComponent();
}); });
export default page; export default page;
-10
View File
@@ -40,16 +40,6 @@ formatCode:
desc: Auto format code desc: Auto format code
category: preference category: preference
value: true value: true
astyleOptions:
type: text
family: highlight
desc: astyle formatting options
category: preference
value: >
style=java attach-namespaces attach-classes attach-inlines attach-extern-c attach-closing-while
indent-col1-comments break-blocks pad-oper pad-comma pad-header unpad-paren align-pointer=name
break-one-line-headers attach-return-type attach-return-type-decl convert-tabs close-templates
max-code-length=110 break-after-logical
about: about:
type: markdown type: markdown
name: about name: about
@@ -92,6 +92,9 @@
disabled:not handler.user.hasPerm(perm.PERM_EDIT_PROBLEM), disabled:not handler.user.hasPerm(perm.PERM_EDIT_PROBLEM),
row:false row:false
}) }} }) }}
{% if tdoc.lockAt %}
<input type="hidden" name="lock" value="{{ ((tdoc.endAt - tdoc.lockAt)/60000)|int }}">
{% endif %}
</div> </div>
{{ form.form_textarea({ {{ form.form_textarea({
columns:null, columns:null,
@@ -12,6 +12,13 @@
<span class="icon icon-download"></span> {{ _('Export as CSV') }} <span class="icon icon-download"></span> {{ _('Export as CSV') }}
</a> </a>
</div> </div>
{% if model.contest.isLocked(tdoc) %}
<div class="section__body no-padding">
<blockquote class="note">
{{ _('Scoreboard locked at {0}').format(datetimeSpan(tdoc.lockAt))|safe }}
</blockquote>
</div>
{% endif %}
<div class="section__body no-padding overflow-hidden-horizontal"> <div class="section__body no-padding overflow-hidden-horizontal">
<table class="data-table"> <table class="data-table">
<colgroup> <colgroup>
+12 -3
View File
@@ -305,7 +305,7 @@ function _digit2(number: number) {
return number < 10 ? `0${number}` : number.toString(); return number < 10 ? `0${number}` : number.toString();
} }
export function formatSeconds(_seconds = '0') { export function formatSeconds(_seconds: string | number = '0') {
const seconds = +_seconds; const seconds = +_seconds;
return '{0}:{1}:{2}'.format( return '{0}:{1}:{2}'.format(
_digit2(Math.floor(seconds / 3600)), _digit2(Math.floor(seconds / 3600)),
@@ -431,11 +431,20 @@ export function sortFiles(files: { _id: string }[] | string[]) {
return isString ? result.map((x) => x.name) : result; return isString ? result.map((x) => x.name) : result;
} }
export const htmlEncode = (str) => str.replace(/[&<>'"]/g, export const htmlEncode = (str: string) => str.replace(/[&<>'"]/g,
(tag) => ({ (tag: string) => ({
'&': '&amp;', '&': '&amp;',
'<': '&lt;', '<': '&lt;',
'>': '&gt;', '>': '&gt;',
"'": '&#39;', "'": '&#39;',
'"': '&quot;', '"': '&quot;',
}[tag])); }[tag]));
export function Counter<T extends (string | number) = string>() {
return new Proxy({}, {
get: (target, prop) => {
if (target[prop] === undefined) return 0;
return target[prop];
},
}) as Record<T, number>;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hydrooj/utils", "name": "@hydrooj/utils",
"version": "1.1.11", "version": "1.1.13",
"description": "hydrooj utils", "description": "hydrooj utils",
"main": "package.json", "main": "package.json",
"repository": "https://github.com/hydro-dev/Hydro.git", "repository": "https://github.com/hydro-dev/Hydro.git",
@@ -12,7 +12,7 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"moment": "^2.29.1", "moment": "^2.29.1",
"mongodb": "^3.7.3", "mongodb": "^3.7.3",
"systeminformation": "^5.9.17" "systeminformation": "^5.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/fs-extra": "^9.0.13", "@types/fs-extra": "^9.0.13",