core&ui: contest: add print (#1036)

Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
undefinedandpanda authored and GitHub committed 2025-08-17 05:02:45 +01:00
1 parent 1c7b2275ff
commit 720eb6b7e0
13 files changed
+419 -6

No files matched your search

+76 -2
View File
@@ -1,6 +1,7 @@
import { Readable } from 'stream';
import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js';
import { stringify as toCSV } from 'csv-stringify/sync';
import { readFile } from 'fs-extra';
import { escapeRegExp, pick } from 'lodash';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
@@ -182,6 +183,77 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
}
}
export class ContestPrintHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectId)
async prepare({ domainId }, tid: ObjectId) {
if (!this.tdoc?.allowPrint) throw new NotFoundError();
if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST) && !this.tsdoc?.attend) {
throw new ContestNotAttendedError(domainId, tid);
}
}
async get() {
this.response.body = { tdoc: this.tdoc };
this.response.template = 'contest_print.html';
}
@param('tid', Types.ObjectId)
@param('title', Types.Title, true)
@param('content', Types.Content, true)
async postPrint(domainId: string, tid: ObjectId, title = '', content = '') {
if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid);
if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(domainId, tid);
await this.limitRate('add_print', 3600, 60);
if (this.request.files?.file) {
const file = this.request.files.file;
if (file.size > 1024 * 1024) throw new ValidationError('file');
content = await readFile(file.filepath, 'utf-8');
title ||= file.originalFilename || 'file';
}
if (!content) throw new ValidationError('content');
await contest.addPrintTask(domainId, tid, this.user._id, title, content);
this.back();
}
@param('tid', Types.ObjectId)
async postGetPrintTask(domainId: string, tid: ObjectId) {
const isContestAdmin = this.user.own(this.tdoc) || this.user.hasPerm(PERM.PERM_EDIT_CONTEST);
const tasks = await contest.getMultiPrintTask(domainId, tid, isContestAdmin ? {} : { owner: this.user._id })
.project({ _id: 1, title: 1, owner: 1, status: 1 }).sort({ _id: 1 }).toArray();
const uids = Array.from(new Set(tasks.map((i) => i.owner)));
const udict = await user.getListForRender(
domainId, uids,
this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME)
? ['displayName', 'school', 'studentId']
: [],
);
this.response.body = { tasks, udict };
}
@param('tid', Types.ObjectId)
async postAllocatePrintTask(domainId: string, tid: ObjectId) {
if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) {
throw new PermissionError(PERM.PERM_EDIT_CONTEST);
}
const task = await contest.allocatePrintTask(domainId, tid);
const udoc = task ? await user.getById(domainId, task.owner) : null;
this.response.body = { task, udoc };
}
@param('tid', Types.ObjectId)
@param('taskId', Types.ObjectId)
@param('status', Types.Range(['printed', 'pending']))
async postUpdatePrintTask(domainId: string, tid: ObjectId, taskId: ObjectId, status: 'printed' | 'pending') {
if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) {
throw new PermissionError(PERM.PERM_EDIT_CONTEST);
}
await contest.updatePrintTask(domainId, tid, taskId, {
status: status === 'printed' ? contest.PrintTaskStatus.printed : contest.PrintTaskStatus.pending,
});
this.response.body = { success: true };
}
}
export class ContestProblemListHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectId)
async get(domainId: string, tid: ObjectId) {
@@ -305,12 +377,13 @@ export class ContestEditHandler extends Handler {
@param('contestDuration', Types.Float, true)
@param('maintainer', Types.NumericArray, true)
@param('allowViewCode', Types.Boolean)
@param('allowPrint', Types.Boolean)
@param('langs', Types.CommaSeperatedArray, true)
async postUpdate(
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[] = [], lock: number = null,
contestDuration: number = null, maintainer: number[] = [], allowViewCode = false, langs: string[] = [],
contestDuration: number = null, maintainer: number[] = [], allowViewCode = false, allowPrint = false, langs: string[] = [],
) {
if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i);
@@ -351,7 +424,7 @@ export class ContestEditHandler extends Handler {
});
}
await contest.edit(domainId, tid, {
assign, _code, autoHide, lockAt, maintainer, allowViewCode, langs,
assign, _code, autoHide, lockAt, maintainer, allowViewCode, allowPrint, langs,
});
this.response.body = { tid };
this.response.redirect = this.url('contest_detail', { tid });
@@ -734,6 +807,7 @@ export async function apply(ctx: Context) {
ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_print', '/contest/:tid/print', ContestPrintHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler);
ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
+12
View File
@@ -8,6 +8,7 @@ import type {
FileInfo, RecordJudgeInfo, RecordPayload,
} from '@hydrooj/common/types';
import type { Context } from './context';
import type { PrintTaskStatus } from './model/contest';
import type { DocStatusType } from './model/document';
import type { OauthMap } from './model/oauth';
import type { ProblemDoc } from './model/problem';
@@ -264,6 +265,7 @@ export interface Tdoc extends Document {
assign?: string[];
files?: FileInfo[];
allowViewCode?: boolean;
allowPrint?: boolean;
// For contest
lockAt?: Date;
@@ -383,6 +385,16 @@ export interface ContestClarificationDoc extends Document {
reply: DiscussionTailReplyDoc[];
}
export interface ContestPrintDoc extends Document {
docType: document['TYPE_CONTEST_PRINT'];
docId: ObjectId;
parentType: document['TYPE_CONTEST'];
parentId: ObjectId;
title: string;
content: string;
status: PrintTaskStatus;
}
export interface TokenDoc {
_id: string;
tokenType: number;
+45 -1
View File
@@ -8,7 +8,7 @@ import {
ContestScoreboardHiddenError, ValidationError,
} from '../error';
import {
BaseUserDict, ContestRule, ContestRules, ProblemDict, RecordDoc,
BaseUserDict, ContestPrintDoc, ContestRule, ContestRules, ProblemDict, RecordDoc,
ScoreboardConfig, ScoreboardNode, ScoreboardRow, SubtaskResult, Tdoc,
} from '../interface';
import bus from '../service/bus';
@@ -20,6 +20,13 @@ import * as document from './document';
import problem from './problem';
import user, { User } from './user';
export enum PrintTaskStatus {
pending = 'pending',
printing = 'printing',
printed = 'printed',
failed = 'failed',
}
interface AcmJournal {
rid: ObjectId;
pid: number;
@@ -1047,8 +1054,41 @@ export const statusText = (tdoc: Tdoc, tsdoc?: any) => (
? 'Live...'
: 'Done');
export function addPrintTask(domainId: string, tid: ObjectId, uid: number, name: string, content: string) {
return document.add(domainId, content, uid, document.TYPE_CONTEST_PRINT, null, document.TYPE_CONTEST, tid, {
title: name,
status: PrintTaskStatus.pending,
});
}
export async function updatePrintTask(domainId: string, tid: ObjectId, taskId: ObjectId, $set: Partial<ContestPrintDoc>) {
const res = await document.coll.updateOne({
domainId, docType: document.TYPE_CONTEST_PRINT,
docId: taskId, parentType: document.TYPE_CONTEST, parentId: tid,
}, { $set });
return !!res.modifiedCount;
}
export function allocatePrintTask(domainId: string, tid: ObjectId) {
return document.coll.findOneAndUpdate({
domainId, docType: document.TYPE_CONTEST_PRINT,
parentType: document.TYPE_CONTEST, parentId: tid,
status: PrintTaskStatus.pending,
}, {
$set: {
status: PrintTaskStatus.printing,
},
}, { returnDocument: 'after' });
}
export function getMultiPrintTask(domainId: string, tid: ObjectId, query = {}) {
return document.getMulti(domainId, document.TYPE_CONTEST_PRINT, { parentType: document.TYPE_CONTEST, parentId: tid, ...query })
.sort({ _id: 1 });
}
global.Hydro.model.contest = {
RULES,
PrintTaskStatus,
buildContestRule,
add,
getListStatus,
@@ -1089,4 +1129,8 @@ global.Hydro.model.contest = {
isExtended,
applyProjection,
statusText,
addPrintTask,
updatePrintTask,
allocatePrintTask,
getMultiPrintTask,
};
+4 -1
View File
@@ -4,7 +4,7 @@ import {
} from 'mongodb';
import { Context } from '../context';
import {
Content, ContestClarificationDoc, DiscussionDoc,
Content, ContestClarificationDoc, ContestPrintDoc, DiscussionDoc,
DiscussionReplyDoc, ProblemDoc, ProblemStatusDoc,
Tdoc, TrainingDoc,
} from '../interface';
@@ -27,6 +27,7 @@ export const TYPE_DISCUSSION = 21 as const;
export const TYPE_DISCUSSION_REPLY = 22 as const;
export const TYPE_CONTEST = 30 as const;
export const TYPE_CONTEST_CLARIFICATION = 31 as const;
export const TYPE_CONTEST_PRINT = 32 as const;
export const TYPE_TRAINING = 40 as const;
export interface DocType {
@@ -37,6 +38,7 @@ export interface DocType {
[TYPE_DISCUSSION]: DiscussionDoc;
[TYPE_DISCUSSION_REPLY]: DiscussionReplyDoc;
[TYPE_CONTEST]: Tdoc;
[TYPE_CONTEST_PRINT]: ContestPrintDoc;
[TYPE_CONTEST_CLARIFICATION]: ContestClarificationDoc;
[TYPE_TRAINING]: TrainingDoc;
}
@@ -480,6 +482,7 @@ global.Hydro.model.document = {
TYPE_CONTEST,
TYPE_CONTEST_CLARIFICATION,
TYPE_CONTEST_PRINT,
TYPE_DISCUSSION,
TYPE_DISCUSSION_NODE,
TYPE_DISCUSSION_REPLY,
+3 -1
View File
@@ -162,7 +162,9 @@ export class User {
serialize(h) {
if (!this._isPrivate) {
const fields = ['_id', 'uname', 'mail', 'perm', 'role', 'priv', 'regat', 'loginat', 'tfa', 'authn'];
if (h?.user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME)) fields.push('displayName');
if (h?.user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME)) {
fields.push('displayName', 'school', 'studentId');
}
return pick(this, fields);
}
return JSON.stringify(this, serializer(true, h));