core: contest_scoreboard.lock

pull/462/head
undefined 2 years ago
parent 5271a7e590
commit d9fdcc9b7c

@ -5,7 +5,7 @@ import moment from 'moment-timezone';
import { ObjectID } from 'mongodb';
import { sortFiles, Time } from '@hydrooj/utils/lib/utils';
import {
BadRequestError, ContestNotFoundError, ContestNotLiveError,
BadRequestError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
ForbiddenError, InvalidTokenError, PermissionError,
ValidationError,
} from '../error';
@ -62,7 +62,7 @@ ScheduleModel.Worker.addHandler('contest', async (doc) => {
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);
});
@ -231,22 +231,16 @@ export class ContestBroadcastHandler extends ContestDetailBaseHandler {
export class ContestScoreboardHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectID)
@param('ext', Types.Range(['csv', 'html']), true)
@param('ignoreLock', Types.Boolean, true)
async get(domainId: string, tid: ObjectID, ext = '', ignoreLock = false) {
if (ignoreLock && !this.user.own(this.tdoc)) {
this.checkPerm(this.tdoc.rule === 'homework'
? PERM.PERM_VIEW_HOMEWORK_HIDDEN_SCOREBOARD
: PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
async get(domainId: string, tid: ObjectID, ext = '') {
if (ext) {
await this.exportScoreboard(domainId, tid, ext, ignoreLock);
await this.exportScoreboard(domainId, tid, ext);
return;
}
const pdict = await problem.getList(domainId, this.tdoc.pids, true, undefined, false, [
// Problem statistics display is allowed as we can view submission info in scoreboard.
...problem.PROJECTION_CONTEST_LIST, 'nSubmit', 'nAccept',
]);
const [, rows, udict] = await contest.getScoreboard.call(this, domainId, tid, false, ignoreLock);
const [, rows, udict] = await contest.getScoreboard.call(this, domainId, tid, false);
this.response.template = 'contest_scoreboard.html';
// eslint-disable-next-line @typescript-eslint/naming-convention
const page_name = this.tdoc.rule === 'homework'
@ -257,15 +251,23 @@ export class ContestScoreboardHandler extends ContestDetailBaseHandler {
};
}
async exportScoreboard(domainId: string, tid: ObjectID, ext: string, ignoreLock: boolean) {
async exportScoreboard(domainId: string, tid: ObjectID, ext: string) {
await this.limitRate('scoreboard_download', 120, 3);
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, true, ignoreLock);
const [, rows] = await contest.getScoreboard.call(this, domainId, tid, true);
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 {
@ -355,7 +357,6 @@ export class ContestEditHandler extends Handler {
await Promise.all(pids.map((pid) => problem.edit(domainId, pid, { hidden: true })));
operation.push('unhide');
}
if (lock && lockAt <= endAt) operation.push('unlock');
if (operation.length) {
await ScheduleModel.add({
...task,

@ -328,13 +328,12 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler {
if (!this.tdoc?.pids?.includes(this.pdoc.docId)) throw new ContestNotFoundError(domainId, tid);
if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(tid);
if (!contest.isDone(this.tdoc, this.tsdoc) && (!this.tsdoc?.attend || !this.tsdoc.startAt)) throw new ContestNotAttendedError(tid);
// Delete problem-related info in contest mode
this.pdoc.tag.length = 0;
if (!contest.canShowScoreboard.call(this, this.tdoc) || !contest.isLocked(this.tdoc)) {
delete this.pdoc.nAccept;
delete this.pdoc.nSubmit;
delete this.pdoc.difficulty;
delete this.pdoc.stats;
}
delete this.pdoc.nAccept;
delete this.pdoc.nSubmit;
delete this.pdoc.difficulty;
delete this.pdoc.stats;
} else if (!problem.canViewBy(this.pdoc, this.user)) {
throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN);
}

@ -352,9 +352,12 @@ export interface Tdoc<docType = document['TYPE_CONTEST'] | document['TYPE_TRAINI
assign?: string[];
files?: FileInfo[];
allowViewCode?: boolean;
nSubmit?: Record<number, number>;
nAccept?: Record<number, number>;
// For contest
lockAt?: Date;
unlocked?: boolean;
/**
* In hours
* X
@ -494,7 +497,7 @@ export interface ContestRule<T = any> {
showScoreboard: (tdoc: Tdoc<30>, now: Date) => boolean;
showSelfRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
showRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
stat: (this: ContestRule<T>, tdoc: Tdoc<30>, journal: any[], ignoreLock?: boolean) => ContestStat & T;
stat: (this: ContestRule<T>, tdoc: Tdoc<30>, journal: any[]) => ContestStat & T;
scoreboardHeader: (
this: ContestRule<T>, isExport: boolean, _: (s: string) => string,
tdoc: Tdoc<30>, pdict: ProblemDict,

@ -2,20 +2,21 @@ import { sumBy } from 'lodash';
import { FilterQuery, ObjectID } from 'mongodb';
import { Counter, formatSeconds, Time } from '@hydrooj/utils/lib/utils';
import {
ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotFoundError,
ContestAlreadyAttendedError, ContestNotFoundError,
ContestScoreboardHiddenError, ValidationError,
} from '../error';
import {
ContestRule, ContestRules, ProblemDict,
ScoreboardNode, ScoreboardRow, Tdoc,
Udict,
ScoreboardNode, ScoreboardRow, Tdoc, Udict,
} from '../interface';
import ranked from '../lib/rank';
import * as bus from '../service/bus';
import type { Handler } from '../service/server';
import { buildProjection } from '../utils';
import { PERM, STATUS } from './builtin';
import * as document from './document';
import problem from './problem';
import RecordModel from './record';
import user from './user';
interface AcmJournal {
@ -46,12 +47,6 @@ function buildContestRule<T>(def: ContestRule<T>): ContestRule<T> {
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: () => { },
@ -60,13 +55,13 @@ const acm = buildContestRule({
showScoreboard: (tdoc, now) => now > tdoc.beginAt,
showSelfRecord: () => true,
showRecord: (tdoc, now) => now > tdoc.endAt,
stat(tdoc, journal: AcmJournal[], ignoreLock = false) {
stat(tdoc, journal: AcmJournal[]) {
const naccept = Counter<number>();
const effective: Record<number, AcmJournal> = {};
const detail: Record<number, AcmDetail> = {};
let accept = 0;
let time = 0;
for (const j of filterEffective(tdoc, journal, ignoreLock)) {
for (const j of journal) {
if (!this.submitAfterAccept && effective[j.pid]?.status === STATUS.STATUS_ACCEPTED) continue;
effective[j.pid] = j;
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR, STATUS.STATUS_FORMAT_ERROR].includes(j.status)) {
@ -545,26 +540,26 @@ export async function getStatus(domainId: string, tid: ObjectID, uid: number) {
get(domainId, tid),
document.getStatus(domainId, document.TYPE_CONTEST, tid, uid),
]);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (status && isLocked(tdoc)) Object.assign(status, RULES[tdoc.rule].stat(tdoc, status.journal || [], true));
return status;
}
export async function updateStatus(
domainId: string, tid: ObjectID, uid: number, rid: ObjectID, pid: number,
status = STATUS.STATUS_WRONG_ANSWER, score = 0,
) {
const [tdoc, otsdoc] = await Promise.all([
get(domainId, tid),
getStatus(domainId, tid, uid),
]);
if (!otsdoc.attend) throw new ContestNotAttendedError(tid, uid);
const tsdoc = await document.revPushStatus(domainId, document.TYPE_CONTEST, tid, uid, 'journal', {
async function _updateStatus(tdoc: Tdoc<30>, uid: number, rid: ObjectID, pid: number, status: STATUS, score: number) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (isLocked(tdoc)) status = STATUS.STATUS_WAITING;
const tsdoc = await document.revPushStatus(tdoc.domainId, document.TYPE_CONTEST, tdoc.docId, uid, 'journal', {
rid, pid, status, score,
}, 'rid');
const journal = _getStatusJournal(tsdoc);
const stats = RULES[tdoc.rule].stat(tdoc, journal);
return await document.revSetStatus(domainId, document.TYPE_CONTEST, tid, uid, tsdoc.rev, { journal, ...stats });
return await document.revSetStatus(tdoc.domainId, document.TYPE_CONTEST, tdoc.docId, uid, tsdoc.rev, { journal, ...stats });
}
export async function updateStatus(
domainId: string, tid: ObjectID, uid: number, rid: ObjectID, pid: number,
status = STATUS.STATUS_WRONG_ANSWER, score = 0,
) {
const tdoc = await get(domainId, tid);
return await _updateStatus(tdoc, uid, rid, pid, status, score);
}
export async function getListStatus(domainId: string, uid: number, tids: ObjectID[]) {
@ -670,6 +665,15 @@ export async function recalcStatus(domainId: string, tid: ObjectID) {
return await Promise.all(tasks);
}
export async function unlockScoreboard(domainId: string, tid: ObjectID) {
const tdoc = await document.get(domainId, document.TYPE_CONTEST, tid);
if (!tdoc.lockAt || tdoc.unlocked) return;
const rdocs = await RecordModel.getMulti(domainId, { tid, _id: { $gte: Time.getObjectID(tdoc.lockAt) } })
.project(buildProjection(['_id', 'uid', 'pid', 'status', 'score'])).toArray();
await Promise.all(rdocs.map((rdoc) => _updateStatus(tdoc, rdoc.uid, rdoc._id, rdoc.pid, rdoc.status, rdoc.score)));
await edit(domainId, tid, { unlocked: true });
}
export function canViewHiddenScoreboard() {
return this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
@ -693,12 +697,10 @@ export function canShowScoreboard(tdoc: Tdoc<30>, allowPermOverride = true) {
}
export async function getScoreboard(
this: Handler, domainId: string, tid: ObjectID,
isExport = false, ignoreLock = false,
this: Handler, domainId: string, tid: ObjectID, isExport = false,
): Promise<[Tdoc<30>, ScoreboardRow[], Udict, ProblemDict]> {
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] = await RULES[tdoc.rule].scoreboard(
@ -735,6 +737,7 @@ global.Hydro.model.contest = {
setStatus,
getAndListStatus,
recalcStatus,
unlockScoreboard,
canShowRecord,
canShowSelfRecord,
canShowScoreboard,

@ -12,6 +12,14 @@
<a class="button" href="{{ url(type + '_scoreboard_download', tid=tdoc.docId, ext='csv') }}">
<span class="icon icon-download"></span> {{ _('Export as CSV') }}
</a>
{% if model.contest.isEnded(tdoc) and tdoc.lockAt and handler.user.own(tdoc) and not tdoc.unlocked %}
<form method="POST">
<input type="hidden" name="operation" value="unlock">
<button type="submit" class="button" href="{{ url(type + '_scoreboard_download', tid=tdoc.docId, ext='csv') }}">
<span class="icon icon-feeling-lucky"></span> {{ _('Unlock scoreboard') }}
</button>
</form>
{% endif %}
</div>
{% if model.contest.isLocked(tdoc) %}
<div class="section__body no-padding">
@ -19,6 +27,12 @@
{{ _('Scoreboard locked at {0}').format(datetimeSpan(tdoc.lockAt))|safe }}
</blockquote>
</div>
{% elif model.contest.isEnded(tdoc) and tdoc.lockAt and not tdoc.unlocked %}
<div class="section__body no-padding">
<blockquote class="note">
{{ _('Please wait until contest host unlock the scoreboard.') }}
</blockquote>
</div>
{% endif %}
<div class="section__body no-padding overflow-hidden-horizontal">
<table class="data-table">

Loading…
Cancel
Save