diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index b72adc46..17144e2a 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -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, diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index c5bb7f66..aaedcdb4 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -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); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 577b976d..6a3ead8e 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -352,9 +352,12 @@ export interface Tdoc; + nAccept?: Record; // For contest lockAt?: Date; + unlocked?: boolean; /** * In hours * 在比赛有效时间内选择特定的 X 小时参加比赛(从首次打开比赛算起) @@ -494,7 +497,7 @@ export interface ContestRule { showScoreboard: (tdoc: Tdoc<30>, now: Date) => boolean; showSelfRecord: (tdoc: Tdoc<30>, now: Date) => boolean; showRecord: (tdoc: Tdoc<30>, now: Date) => boolean; - stat: (this: ContestRule, tdoc: Tdoc<30>, journal: any[], ignoreLock?: boolean) => ContestStat & T; + stat: (this: ContestRule, tdoc: Tdoc<30>, journal: any[]) => ContestStat & T; scoreboardHeader: ( this: ContestRule, isExport: boolean, _: (s: string) => string, tdoc: Tdoc<30>, pdict: ProblemDict, diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 0845f7da..e3196f8e 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -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(def: ContestRule): ContestRule { return def; } -function filterEffective(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(); const effective: Record = {}; const detail: Record = {}; 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, diff --git a/packages/ui-default/templates/contest_scoreboard.html b/packages/ui-default/templates/contest_scoreboard.html index ea77baf1..9dc79707 100644 --- a/packages/ui-default/templates/contest_scoreboard.html +++ b/packages/ui-default/templates/contest_scoreboard.html @@ -12,6 +12,14 @@ {{ _('Export as CSV') }} + {% if model.contest.isEnded(tdoc) and tdoc.lockAt and handler.user.own(tdoc) and not tdoc.unlocked %} +
+ + +
+ {% endif %} {% if model.contest.isLocked(tdoc) %}
@@ -19,6 +27,12 @@ {{ _('Scoreboard locked at {0}').format(datetimeSpan(tdoc.lockAt))|safe }}
+ {% elif model.contest.isEnded(tdoc) and tdoc.lockAt and not tdoc.unlocked %} +
+
+ {{ _('Please wait until contest host unlock the scoreboard.') }} +
+
{% endif %}