mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 17:36:08 +08:00
core: contest_scoreboard.lock
This commit is contained in:
1 parent
5271a7e590
commit
d9fdcc9b7c
5 files changed
+68
-48
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
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(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, 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', {
|
||||
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 });
|
||||
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">
|
||||
|
||||
Reference in new issue
Block a user