core&ui: contest enhancement (#472)

Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
undefinedandpanda authored and GitHub committed 2022-12-24 19:40:57 +08:00
commit d13f8892d7
55 files changed
+965 -282

No files matched your search

+20
View File
@@ -40,6 +40,26 @@ Telegram [@webpack_exports_undefined](https://t.me/webpack_exports_undefined)
<details>
<summary><h2>更新日志(点击展开)</h2></summary>
### Hydro 4.5.0 / UI 4.44.0
- fps: 修复题目中含空文件导致导入失败的问题
- core: 封禁用户时支持附加理由
- vjudge: codeforces: 跳过无法访问的 1769 和 1772 比赛
- ui: 收藏题目操作不再触发页面刷新
- core: 重测时检查题目配置文件有效性
- core: 退出时自动清理临时文件
- core: 禁止使用 . 作为文件名
- import-qduoj: 跳过不合法的题目
- core: 修复提交答案题的比赛代码导出
- judge: 添加 stdioLimit 项
- ui: 修复 message.FLAG_ALERT 显示
- core: training 可上传文件
- ui: 优化比赛导航栏
- ui: 比赛成绩表支持关注队伍
- core: 允许克隆比赛/作业
- ui: 比赛编辑页面添加功能入口
- core: 支持打星参赛
- core: 整题重测时跳过已取消成绩的提交
### Hydro 4.4.5 / UI 4.43.0
- core: 修复比赛基于 ID 搜索题目的功能
- judge: 修复 testlib 错误信息显示异常的问题
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/fps-importer",
"version": "1.5.2",
"version": "1.5.3",
"description": "Import FPS problems",
"main": "index.ts",
"repository": "https://github.com/hydro-dev/Hydro.git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hydrooj/hydrojudge",
"bin": "bin/hydrojudge.js",
"version": "3.1.12",
"version": "3.1.13",
"main": "package.json",
"author": "undefined <i@undefined.moe>",
"repository": "https://github.com/hydro-dev/Hydro.git",
+2
View File
@@ -8,7 +8,9 @@ contest_detail: Contest Detail
contest_edit: Contest Edit
contest_files: Contest Files
contest_main: Contest
contest_problemlist: Problem List
contest_scoreboard: Scoreboard
contest_user: Attendee Manage
discussion_create: Create Discussion
discussion_detail: Discussion Detail
discussion_edit: Discussion Edit
+1 -2
View File
@@ -294,8 +294,7 @@ Evaluated difficulty: 평가된 난이도
expand: 확장
Expire: 만료
Export All Code: 모든 코드 내보내기
Export as CSV: CSV로 내보내기
Export as HTML: HTML로 내보내기
Export as {0}: '{0}로 내보내기'
Extension (days): 추가 (날짜)
Extension Score Penalty: 추가 패널티
Failed to join the domain. You are already a member.: 그룹 참여 실패. 이미 참여함.
+6 -4
View File
@@ -88,6 +88,7 @@ Attended: 已参加
author: 作者
Auto format code: 自动格式化代码
Avatar: 头像
Back to contest list: 返回比赛列表
Be Copied: 被复制
Begin at: 开始于
Begin Date: 开始日期
@@ -159,7 +160,9 @@ contest_detail: 比赛详情
contest_edit: 编辑比赛
contest_files: 比赛文件
contest_main: 比赛
contest_problemlist: 题目列表
contest_scoreboard: 比赛成绩表
contest_user: 参赛者管理
Contest: 比赛
Contests per page: 每页展示的比赛数量
Continue: 继续
@@ -322,8 +325,7 @@ Evaluated difficulty: 估计的难度
expand: 展开
Expire: 过期
Export All Code: 导出所有代码
Export as CSV: 导出为 CSV
Export as HTML: 导出为 HTML
Export as {0}: 导出为 {0}
Extension (days): 最长延期 (日)
Extension Score Penalty: 延期递交扣分规则
Failed to join the domain. You are already a member.: 加入域失败,您已是该域的成员。
@@ -437,8 +439,8 @@ List View: 列表视图
Live: 正在进行
Live...: 正在进行…
Location: 位置
Lock Topic: 锁定主题
Lock discussions: 锁定讨论
Lock Topic: 锁定主题
Login to Attend Contest: 登录后参加比赛
Login to Claim Homework: 登录后认领作业
Login to Create a Discussion: 登录后创建讨论
@@ -868,8 +870,8 @@ View or create discussion: 查看与发表讨论
View or submit solution: 查看与发表题解
View Owned Problems: 查看自己的题目
View Owned Training Plans: 查看自己的训练计划
View problem solutions: 查看题解
View problem solutions after accept: 题目通过后查看题解
View problem solutions: 查看题解
View Problem: 查看题目
View problems: 查看题目
View ranking: 查看排名
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hydrooj",
"version": "4.4.5",
"version": "4.5.0",
"bin": "bin/hydrooj.js",
"main": "src/plugin-api",
"module": "src/plugin-api",
+149 -54
View File
@@ -7,9 +7,9 @@ import {
Counter, sortFiles, streamToBuffer, Time,
} from '@hydrooj/utils/lib/utils';
import {
BadRequestError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, InvalidTokenError,
NotAssignedError, PermissionError, ValidationError,
BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, ForbiddenError,
InvalidTokenError, NotAssignedError, PermissionError, ValidationError,
} from '../error';
import { Tdoc } from '../interface';
import paginate from '../lib/paginate';
@@ -112,32 +112,33 @@ export class ContestDetailBaseHandler extends Handler {
@param('tid', Types.ObjectID, true)
async after(domainId: string, tid: ObjectID) {
if (!tid || this.tdoc.rule === 'homework' || !contest.isOngoing(this.tdoc, this.tsdoc)) return;
if (!tid || this.tdoc.rule === 'homework') return;
if (this.request.json || !this.response.template) return;
const pdoc = 'pdoc' in this ? (this as any).pdoc : {};
this.response.body.overrideNav = [
{ name: 'homepage', args: { prefix: 'homepage' }, checker: () => true },
{
name: 'contest_main',
args: {},
displayName: 'Back to contest list',
checker: () => true,
},
{
name: 'contest_detail',
displayName: this.tdoc.title,
args: { tid, prefix: 'contest_detail' },
checker: () => true,
},
{
name: 'contest_problemlist',
args: { tid, prefix: 'contest_problemlist' },
checker: () => contest.isDone(this.tdoc, this.tsdoc)
|| (contest.isOngoing(this.tdoc, this.tsdoc) && this.tsdoc?.attend),
},
{
name: 'contest_scoreboard',
args: { tid, prefix: 'contest_scoreboard' },
checker: () => contest.canShowScoreboard.call(this, this.tdoc, true),
},
{
name: 'record_main',
args: {
prefix: 'record',
query: contest.canShowRecord.call(this, this.tdoc, true)
? { tid }
: { tid, uidOrName: this.user._id },
},
checker: () => contest.canShowSelfRecord.call(this, this.tdoc, true),
},
{
displayName: `${String.fromCharCode(65 + this.tdoc.pids.indexOf(pdoc.docId))}. ${pdoc.title}`,
args: { query: { tid }, pid: pdoc.docId, prefix: 'contest_detail_problem' },
@@ -147,10 +148,26 @@ export class ContestDetailBaseHandler extends Handler {
}
}
export class ContestDetailHandler extends ContestDetailBaseHandler {
export class ContestDetailHandler extends Handler {
tdoc?: Tdoc<30>;
tsdoc?: any;
@param('tid', Types.ObjectID)
async prepare(domainId: string, tid: ObjectID) {
[this.tdoc, this.tsdoc] = await Promise.all([
contest.get(domainId, tid),
contest.getStatus(domainId, tid, this.user._id),
]);
if (contest.RULES[this.tdoc.rule].hidden) throw new ContestNotFoundError(domainId, tid);
if (this.tdoc.assign?.length && !this.user.own(this.tdoc)) {
const groups = await user.listGroup(domainId, this.user._id);
if (!Set.intersection(this.tdoc.assign, groups.map((i) => i.name)).size) {
throw new NotAssignedError('contest', tid);
}
}
if (this.tdoc.duration && this.tsdoc?.startAt) {
this.tsdoc.endAt = moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate();
}
}
@param('tid', Types.ObjectID)
@@ -160,32 +177,41 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
this.response.body = {
tdoc: this.tdoc, tsdoc: pick(this.tsdoc, ['attend', 'startAt']), udict,
};
if (!this.request.json) {
this.response.body.tdoc.content = this.response.body.tdoc.content
.replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/`)
.replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/`);
}
if (
(contest.isNotStarted(this.tdoc) || (!this.tsdoc?.attend && !contest.isDone(this.tdoc)))
&& !this.user.own(this.tdoc)
&& !this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD)
) return;
const pdict = await problem.getList(domainId, this.tdoc.pids, true, true, problem.PROJECTION_CONTEST_LIST);
let psdict = {};
let rdict = {};
if (this.tsdoc) {
if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) {
await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() });
this.tsdoc.startAt = new Date();
}
psdict = this.tsdoc.detail || {};
if (contest.canShowSelfRecord.call(this, this.tdoc)) {
rdict = await record.getList(domainId, Object.values(psdict).map((i: any) => i.rid));
} else {
for (const i in psdict) rdict[psdict[i].rid] = { _id: psdict[i].rid };
}
}
Object.assign(this.response.body, { pdict, psdict, rdict });
if (this.request.json) return;
this.response.body.tdoc.content = this.response.body.tdoc.content
.replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/`)
.replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/`);
const pdoc = 'pdoc' in this ? (this as any).pdoc : {};
this.response.body.overrideNav = [
{
name: 'contest_main',
args: {},
displayName: 'Back to contest list',
checker: () => true,
},
{
name: 'contest_detail',
displayName: this.tdoc.title,
args: { tid, prefix: 'contest_detail' },
checker: () => true,
},
{
name: 'contest_problemlist',
args: { tid },
checker: () => contest.isDone(this.tdoc, this.tsdoc)
|| (contest.isOngoing(this.tdoc, this.tsdoc) && this.tsdoc?.attend),
},
{
name: 'contest_scoreboard',
args: { tid, prefix: 'contest_scoreboard' },
checker: () => contest.canShowScoreboard.call(this, this.tdoc, true),
},
{
displayName: `${String.fromCharCode(65 + this.tdoc.pids.indexOf(pdoc.docId))}. ${pdoc.title}`,
args: { query: { tid }, pid: pdoc.docId, prefix: 'contest_detail_problem' },
checker: () => 'pdoc' in this,
},
];
}
@param('tid', Types.ObjectID)
@@ -196,16 +222,36 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
await contest.attend(domainId, tid, this.user._id);
this.back();
}
}
export class ContestProblemListHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectID)
async postDelete(domainId: string, tid: ObjectID) {
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
await contest.del(domainId, tid);
await record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' });
await ScheduleModel.deleteMany({
type: 'schedule', subType: 'contest', domainId, tid,
});
this.response.redirect = this.url('contest_main');
async get(domainId: string, tid: ObjectID) {
if (contest.isNotStarted(this.tdoc) || (!this.tsdoc?.attend && !contest.isDone(this.tdoc))) throw new ForbiddenError();
const [pdict, udict] = await Promise.all([
problem.getList(domainId, this.tdoc.pids, true, true, problem.PROJECTION_CONTEST_LIST),
user.getList(domainId, [this.tdoc.owner, this.user._id]),
]);
this.response.body = {
pdict, psdict: {}, udict, rdict: {}, tdoc: this.tdoc, tsdoc: this.tsdoc,
};
this.response.template = 'contest_problemlist.html';
if (!this.tsdoc) return;
if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) {
await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() });
this.tsdoc.startAt = new Date();
}
this.response.body.psdict = this.tsdoc.detail || {};
const psdocs: any[] = Object.values(this.response.body.psdict);
if (contest.canShowSelfRecord.call(this, this.tdoc)) {
[this.response.body.rdict, this.response.body.rdocs] = await Promise.all([
record.getList(domainId, psdocs.map((i: any) => i.rid)),
await record.getMulti(domainId, { tid, uid: this.user._id }).sort({ _id: -1 }).toArray(),
]);
this.response.body.canViewRecord = true;
} else {
for (const i of psdocs) this.response.body.rdict[i.rid] = { _id: i.rid };
}
}
}
@@ -360,11 +406,13 @@ export class ContestEditHandler extends Handler {
@param('assign', Types.CommaSeperatedArray, true)
@param('lock', Types.UnsignedInt, true)
@param('contestDuration', Types.Float, true)
async post(
@param('maintainer', Types.NumericArray, true)
@param('allowViewCode', Types.Boolean, 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[] = null, lock: number = null,
contestDuration: number = null,
contestDuration: number = null, maintainer: number[] = null, allowViewCode = true,
) {
if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i);
@@ -405,11 +453,22 @@ export class ContestEditHandler extends Handler {
});
}
await contest.edit(domainId, tid, {
assign, _code, autoHide, lockAt,
assign, _code, autoHide, lockAt, maintainer, allowViewCode,
});
this.response.body = { tid };
this.response.redirect = this.url('contest_detail', { tid });
}
@param('tid', Types.ObjectID)
async postDelete(domainId: string, tid: ObjectID) {
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
await contest.del(domainId, tid);
await record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' });
await ScheduleModel.deleteMany({
type: 'schedule', subType: 'contest', domainId, tid,
});
this.response.redirect = this.url('contest_main');
}
}
export class ContestCodeHandler extends Handler {
@@ -498,7 +557,7 @@ export class ContestFilesHandler extends ContestDetailBaseHandler {
if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename');
await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id);
const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`);
const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
if (!meta) throw new FileUploadError();
await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] });
this.back();
@@ -533,10 +592,45 @@ export class ContestFileDownloadHandler extends ContestDetailBaseHandler {
}
}
export class ContestUserHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectID)
async get(domainId: string, tid: ObjectID) {
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).toArray();
tsdocs.forEach((i) => {
i.endAt = this.tdoc.duration ? moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate() : null;
});
const udict = await user.getListForRender(domainId, [this.tdoc.owner, ...tsdocs.map((i) => i.uid)]);
this.response.body = { tdoc: this.tdoc, tsdocs, udict };
this.response.pjax = 'partials/contest_user.html';
this.response.template = 'contest_user.html';
}
@param('tid', Types.ObjectID)
@param('uids', Types.NumericArray)
@param('unrank', Types.Boolean)
async postAddUser(domainId: string, tid: ObjectID, uids: number[], unrank = false) {
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
await Promise.all(uids.map((uid) => contest.attend(domainId, tid, uid, { unrank })));
this.back();
}
@param('tid', Types.ObjectID)
@param('uid', Types.PositiveInt)
async postRank(domainId: string, tid: ObjectID, uid: number) {
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
const tsdoc = await contest.getStatus(domainId, tid, uid);
if (!tsdoc) throw new ContestNotAttendedError(uid);
await contest.setStatus(domainId, tid, uid, { unrank: !tsdoc.unrank });
this.back();
}
}
export async function apply(ctx) {
ctx.Route('contest_create', '/contest/create', ContestEditHandler);
ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST);
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_broadcast', '/contest/:tid/broadcast', ContestBroadcastHandler);
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
@@ -544,4 +638,5 @@ export async function apply(ctx) {
ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_files', '/contest/:tid/file', ContestFilesHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST);
}
+1
View File
@@ -442,6 +442,7 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler {
const rdocs = await record.getMulti(domainId, {
pid,
contest: { $ne: new ObjectID('0'.repeat(24)) },
status: { $ne: STATUS.STATUS_CANCELED },
'files.hack': { $exists: false },
}).project({ _id: 1, contest: 1 }).toArray();
if (!this.pdoc.config || typeof this.pdoc.config === 'string') throw new ProblemConfigError();
+4 -1
View File
@@ -216,7 +216,10 @@ class RecordDetailHandler extends ContestDetailBaseHandler {
record.update(domainId, rid, $set),
TaskModel.deleteMany({ rid: this.rdoc._id }),
]);
if (latest) await postJudge(latest);
if (latest) {
this.ctx.broadcast('record/change', latest);
await postJudge(latest);
}
this.back();
}
}
+91 -2
View File
@@ -1,15 +1,24 @@
import assert from 'assert';
import { statSync } from 'fs-extra';
import { pick } from 'lodash';
import { FilterQuery, ObjectID } from 'mongodb';
import { ProblemNotFoundError, ValidationError } from '../error';
import { sortFiles } from '@hydrooj/utils/lib/utils';
import {
FileLimitExceededError, FileUploadError, ProblemNotFoundError, ValidationError,
} from '../error';
import { Tdoc, TrainingDoc } from '../interface';
import paginate from '../lib/paginate';
import { PERM, PRIV, STATUS } from '../model/builtin';
import * as oplog from '../model/oplog';
import problem from '../model/problem';
import storage from '../model/storage';
import * as system from '../model/system';
import * as training from '../model/training';
import user from '../model/user';
import * as bus from '../service/bus';
import { Handler, param, Types } from '../service/server';
import {
Handler, param, post, Types,
} from '../service/server';
async function _parseDagJson(domainId: string, _dag: string): Promise<Tdoc['dag']> {
const parsed = [];
@@ -149,6 +158,9 @@ class TrainingDetailHandler extends Handler {
this.response.body = {
tdoc, tsdoc, pids, pdict, psdict, ndict, nsdict, udoc, udict, selfPsdict,
};
this.response.body.tdoc.description = this.response.body.tdoc.description
.replace(/\(file:\/\//g, `(./${tdoc.docId}/file/`)
.replace(/="file:\/\//g, `="./${tdoc.docId}/file/`);
this.response.pjax = 'partials/training_detail.html';
this.response.template = 'training_detail.html';
}
@@ -219,9 +231,86 @@ class TrainingEditHandler extends Handler {
}
}
export class TrainingFilesHandler extends Handler {
tdoc: TrainingDoc;
@param('tid', Types.ObjectID)
async prepare(domainId: string, tid: ObjectID) {
this.tdoc = await training.get(domainId, tid);
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_TRAINING);
else this.checkPerm(PERM.PERM_EDIT_TRAINING_SELF);
}
@param('tid', Types.ObjectID)
async get(domainId: string, tid: ObjectID) {
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_TRAINING);
this.response.body = {
tdoc: this.tdoc,
tsdoc: await training.getStatus(domainId, this.tdoc.docId, this.user._id),
udoc: await user.getById(domainId, this.tdoc.owner),
files: sortFiles(this.tdoc.files || []),
urlForFile: (filename: string) => this.url('training_file_download', { tid, filename }),
};
this.response.pjax = 'partials/files.html';
this.response.template = 'training_files.html';
}
@param('tid', Types.ObjectID)
@post('filename', Types.Name, true)
async postUploadFile(domainId: string, tid: ObjectID, filename: string) {
if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) {
throw new FileLimitExceededError('count');
}
const file = this.request.files?.file;
if (!file) throw new ValidationError('file');
const f = statSync(file.filepath);
const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + f.size;
if (size >= system.get('limit.contest_files_size')) {
throw new FileLimitExceededError('size');
}
if (!filename) filename = file.originalFilename || String.random(16);
if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename');
await storage.put(`training/${domainId}/${tid}/${filename}`, file.filepath, this.user._id);
const meta = await storage.getMeta(`training/${domainId}/${tid}/${filename}`);
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
if (!meta) throw new FileUploadError();
await training.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] });
this.back();
}
@param('tid', Types.ObjectID)
@post('files', Types.Array)
async postDeleteFiles(domainId: string, tid: ObjectID, files: string[]) {
await Promise.all([
storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id),
training.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
]);
this.back();
}
}
export class TrainingFileDownloadHandler extends Handler {
@param('tid', Types.ObjectID)
@param('filename', Types.Name)
@param('noDisposition', Types.Boolean)
async get(domainId: string, tid: ObjectID, filename: string, noDisposition = false) {
this.response.addHeader('Cache-Control', 'public');
const target = `training/${domainId}/${tid}/${filename}`;
const file = await storage.getMeta(target);
await oplog.log(this, 'download.file.training', {
target,
size: file?.size || 0,
});
this.response.redirect = await storage.signDownloadLink(
target, noDisposition ? undefined : filename, false, 'user',
);
}
}
export async function apply(ctx) {
ctx.Route('training_main', '/training', TrainingMainHandler, PERM.PERM_VIEW_TRAINING);
ctx.Route('training_create', '/training/create', TrainingEditHandler);
ctx.Route('training_detail', '/training/:tid', TrainingDetailHandler, PERM.PERM_VIEW_TRAINING);
ctx.Route('training_edit', '/training/:tid/edit', TrainingEditHandler);
ctx.Route('training_files', '/training/:tid/file', TrainingFilesHandler, PERM.PERM_VIEW_TRAINING);
ctx.Route('training_file_download', '/training/:tid/file/:filename', TrainingFileDownloadHandler, PERM.PERM_VIEW_TRAINING);
}
+3
View File
@@ -366,6 +366,8 @@ export interface Tdoc<docType = document['TYPE_CONTEST'] | document['TYPE_TRAINI
// For contest
lockAt?: Date;
unlocked?: boolean;
autoHide?: boolean;
/**
* In hours
* 在比赛有效时间内选择特定的 X 小时参加比赛(从首次打开比赛算起)
@@ -493,6 +495,7 @@ export interface OplogDoc extends Record<string, any> {
export interface ContestStat extends Record<string, any> {
detail: Record<number, Record<string, any>>,
unrank?: boolean,
}
export interface ContestRule<T = any> {
+5 -1
View File
@@ -1,6 +1,6 @@
import { Cursor } from 'mongodb';
type ranked = <T>(cursor: Cursor<T>, equ: ((a: T, b: T) => boolean)) => Promise<[number, T][]>;
type ranked = <T extends Record<string, any>>(cursor: Cursor<T>, equ: ((a: T, b: T) => boolean)) => Promise<[number, T][]>;
const ranked: ranked = async (cursor, equ) => {
let last = null;
@@ -9,6 +9,10 @@ const ranked: ranked = async (cursor, equ) => {
const results = [];
const docs = await cursor.toArray();
for (const doc of docs) {
if (doc.unrank) {
results.push([0, doc]);
continue;
}
count++;
if (!last || !equ(last, doc)) r = count;
last = doc;
+3 -3
View File
@@ -533,7 +533,7 @@ export async function add(
return res;
}
export async function edit(domainId: string, tid: ObjectID, $set: any) {
export async function edit(domainId: string, tid: ObjectID, $set: Partial<Tdoc>) {
if ($set.rule && !RULES[$set.rule]) throw new ValidationError('rule');
const tdoc = await document.get(domainId, document.TYPE_CONTEST, tid);
if (!tdoc) throw new ContestNotFoundError(domainId, tid);
@@ -593,9 +593,9 @@ export async function getListStatus(domainId: string, uid: number, tids: ObjectI
return r;
}
export async function attend(domainId: string, tid: ObjectID, uid: number) {
export async function attend(domainId: string, tid: ObjectID, uid: number, payload: any = {}) {
try {
await document.cappedIncStatus(domainId, document.TYPE_CONTEST, tid, uid, 'attend', 1, 0, 1);
await document.cappedIncStatus(domainId, document.TYPE_CONTEST, tid, uid, 'attend', 1, 0, 1, payload);
} catch (e) {
throw new ContestAlreadyAttendedError(tid, uid);
}
+4 -1
View File
@@ -350,12 +350,15 @@ export async function setStatusIfNotCondition<T extends keyof DocStatusType>(
export async function cappedIncStatus<T extends keyof DocStatusType>(
domainId: string, docType: T, docId: DocStatusType[T]['docId'], uid: number,
key: NumberKeys<DocStatusType[T]>, value: number, minValue = -1, maxValue = 1,
setPayload: Partial<DocStatusType[T]> = {},
): Promise<DocStatusType[T]> {
assert(value !== 0);
const $not = value > 0 ? { $gte: maxValue } : { $lte: minValue };
const operation = { $inc: { [key]: value } } as UpdateQuery<DocStatusType[T]>;
if (Object.keys(setPayload).length) operation.$set = setPayload;
const res = await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid, [key]: { $not } },
{ $inc: { [key]: value } },
operation,
{ upsert: true, returnDocument: 'after' },
);
return res.value;
+2 -2
View File
@@ -214,8 +214,8 @@ SystemSetting(
Setting('setting_limits', 'limit.problem_files_max_size', 256 * 1024 * 1024, 'number', 'limit.problem_files_max_size', 'Max files size per problem'),
Setting('setting_limits', 'limit.user_files', 100, 'number', 'limit.user_files', 'Max files for user'),
Setting('setting_limits', 'limit.user_files_size', 128 * 1024 * 1024, 'number', 'limit.user_files_size', 'Max total file size for user'),
Setting('setting_limits', 'limit.contest_files', 100, 'number', 'limit.contest_files', 'Max files for contest'),
Setting('setting_limits', 'limit.contest_files_size', 128 * 1024 * 1024, 'number', 'limit.contest_files_size', 'Max total file size for contest'),
Setting('setting_limits', 'limit.contest_files', 100, 'number', 'limit.contest_files', 'Max files for contest or training'),
Setting('setting_limits', 'limit.contest_files_size', 128 * 1024 * 1024, 'number', 'limit.contest_files_size', 'Max total file size for contest or training'),
Setting('setting_limits', 'limit.submission', 60, 'number', 'limit.submission', 'Max submission count per minute'),
Setting('setting_limits', 'limit.submission_user', 15, 'number', 'limit.submission_user', 'Max submission count per user per minute'),
Setting('setting_limits', 'limit.pretest', 60, 'number', 'limit.pretest', 'Max pretest count per minute'),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/import-qduoj",
"version": "1.5.0",
"version": "1.5.1",
"description": "Import QDUOJ problem export",
"main": "index.ts",
"repository": "https://github.com/hydro-dev/Hydro.git",
+1
View File
@@ -2,6 +2,7 @@ export * from './utils';
export { default as Notification } from './components/notification';
export * from './components/dialog';
export * as bus from './bus';
export { default as AnsiUp } from 'ansi_up';
export { default as loadMonaco } from './components/monaco/loader';
export * as redux from 'react-redux';
export * from './components/zipDownloader';
@@ -105,6 +105,7 @@ module.exports = function plugin(md) {
const katexInline = function (latex) {
options.displayMode = false;
try {
latex = latex.replace(/\\def{\\([a-zA-Z0-9]+)}/g, '\\def\\$1');
return katex.renderToString(latex, options);
} catch (error) {
if (options.throwOnError) logger.error(error);
@@ -117,6 +118,7 @@ module.exports = function plugin(md) {
const katexBlock = function (latex) {
options.displayMode = true;
try {
latex = latex.replace(/\\def{\\([a-zA-Z0-9]+)}/g, '\\def\\$1');
return `<p>${katex.renderToString(latex, options)}</p>`;
} catch (error) {
if (options.throwOnError) logger.error(error);
@@ -2,23 +2,36 @@
display: none !important
.autocomplete-wrapper
width: 300px
border: 1px solid #d9d9d9
background-color: #fff
padding: 1px
display: flex
flex-wrap: wrap
overflow-y: auto
form-styles()
background-color: $input-background-color
outline: $input-outline
transition: outline-color .2s, border-color .2s
transition-timing-function: ease-out-cubic
&:focus, &.focused
border-color: $input-focus-border-color
outline: $input-focus-outline
outline-offset: 0
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2)
// Overwrite, for IE compatibility
&:read-only
outline: 0
&:disabled
opacity: 0.5
&:hover
border-color: #40a9ff
&.focused
border-color: #40a9ff
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2)
input
background-color: #fff
background-color: $input-background-color
color: rgba(0,0,0,.85)
height: 30px
box-sizing: border-box
@@ -274,7 +274,7 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
};
return (
<div style={{ display: 'inline-block', width: '100%' }}>
<div style={{ display: 'inline-block', width: '100%', marginBottom: '1rem' }}>
<div
className={focused ? 'autocomplete-wrapper focused' : 'autocomplete-wrapper'}
style={{ width, height }}
@@ -3,16 +3,15 @@
$checkbox-size = 16px
.checkbox
font-size: rem($font-size-secondary)
line-height: 1.4
font-size: rem($font-size-small) !important
line-height: 1.4 !important
border: 0
display: inline-block
display: inline-block !important
vertical-align: middle
cursor: pointer
user-select: none
color: lighten($text-1-color, 30%)
margin: $input-margin
padding: ((rem($form-control-height) - (@line-height * @font-size)) / 2) 0
color: lighten($text-1-color, 30%) !important
margin: $input-margin !important
&:hover
color: $text-1-color
@@ -20,13 +19,12 @@ $checkbox-size = 16px
input
appearance: none
border: 1px solid #D0D0D0
display: inline-block
border-radius: 1px
vertical-align: top
width: rem($checkbox-size)
height: rem($checkbox-size)
margin: ((@line-height * @font-size - rem($checkbox-size)) / 2) 0
margin-right: rem(5px)
margin: ((@line-height * @font-size - rem($checkbox-size)) / 2) 0 !important
margin-right: rem(5px) !important
position: relative
outline: 0
transition: background .1s linear
@@ -65,13 +63,14 @@ $checkbox-size = 16px
margin-bottom: 0
line-height: 1.2
padding: ((rem($compact-control-height) - (@line-height * @font-size)) / 2) 0
margin: 0 !important
input
margin: ((@line-height * @font-size - rem($checkbox-size)) / 2) 0
margin-right: rem(5px)
label.checkbox.inverse
color: rgba($immersive-text-color, 0.8)
color: rgba($immersive-text-color, 0.8) !important
input
color: $immersive-text-color
+1 -2
View File
@@ -299,8 +299,7 @@ Evaluated difficulty: 평가된 난이도
expand: 확장
Expire: 만료
Export All Code: 모든 코드 내보내기
Export as CSV: CSV로 내보내기
Export as HTML: HTML로 내보내기
Export as {0}: '{0}로 내보내기'
Extension (days): 추가 (날짜)
Extension Score Penalty: 추가 패널티
Failed to join the domain. You are already a member.: 그룹 참여 실패. 이미 참여함.
+21 -2
View File
@@ -59,6 +59,7 @@ Accepted Problems: 通过的题目
Accepted Ratio: 通过率
Accepted: 已通过
Access Control: 访问控制
According to the contest rules, you cannot view your submission details at current.: 根据比赛规则,你现在不能查看你的评测记录详情。
Account Settings: 账户设置
Action: 动作
Active Sessions: 活动会话
@@ -73,7 +74,10 @@ All Homeworks: 所有作业
All Languages: 所有语言
All Submissions: 所有递交
All Training Plans: 所有训练计划
All Users: 所有用户
All: 全部
Allow to maintain this contest: 允许管理此比赛
Allow user view others code after contest: 允许用户赛后查看其他人的代码
Always granted all privileges: 总是拥有所有权限
An error has occurred.: 发生了一个错误。
An example of dataset: 测试数据集的一个例子
@@ -82,14 +86,18 @@ Any user is allowed to join this domain: 任意用户都可以加入此域
API: API
Argument {0} is invalid.: 非法的参数 {0} 。
Arguments: 参数
Assign User or Group: 分配给用户或小组
Assign: 分配给
Assignment: 作业
At least 4 characters: 至少 4 个字符
Attend Contest: 参加比赛
Attend contests: 参加比赛
Attended: 已参加
Attendee Manage: 参赛者管理
author: 作者
Auto hide problems during the contest: 在比赛过程中自动隐藏题目
Auto Read Tasks: 自动识别任务
Basic Info: 基础信息
Be Copied: 被复制
Begin at: 开始于
Begin Date: 开始日期
@@ -131,6 +139,7 @@ Claimed: 已认领
Clear Marks: 清除标记
Click here to chat with me: 点击这里与我聊天
click to add: 点击添加
Clone: 复制
CLOSE: 关闭
Code blocks: 代码区块
Code language: 代码语言
@@ -159,8 +168,10 @@ Contact: 联系
Content copied to clipboard!: 内容已复制到剪贴板!
content: 内容
Content: 内容
Contest Maintainer: 比赛管理员
Contest scoreboard is not visible.: 当前比赛成绩表隐藏,暂不可显示。
Contest Scoreboard: 比赛成绩表
Contest Settings: 比赛设置
contest_create: 创建比赛
contest_detail_problem_submit: 递交比赛题目
contest_edit: 编辑比赛
@@ -328,8 +339,7 @@ Exit: 退出
expand: 展开
Expire: 过期
Export All Code: 导出所有代码
Export as CSV: 导出为 CSV
Export as HTML: 导出为 HTML
Export as {0}: 导出为 {0}
Extension (days): 最长延期 (日)
Extension Score Penalty: 延期递交扣分规则
Extra Files Config: 额外文件设置
@@ -452,6 +462,7 @@ LaTeX Expressions: LaTeX 表达式
Learn More: 了解更多
Leave blank to use numeric pid.: 留空来使用数字题号。
Light: 亮色
Limit user to finish contest within N hours: 限制用户在N小时内完成比赛
Limitations: 限制
Links: 链接
List View: 列表视图
@@ -460,6 +471,7 @@ Live...: 正在进行…
Loading editor...: 正在加载编辑器...
Loading file...: 正在加载文件内容...
Location: 位置
Lock scoreboard on last N minutes: 在赛事最后N分钟时锁定成绩表
Login to Attend Contest: 登录后参加比赛
Login to Claim Homework: 登录后认领作业
Login to Create a Discussion: 登录后创建讨论
@@ -541,6 +553,7 @@ objective: 客观题
Oh, the user doesn't have any contributions!: 啊哦,这个用户还没贡献过题目~
Oh, the user hasn't created any discussions yet!: 这个用户还没有发布过讨论
Oh, the user hasn't submitted yet!: 这个用户还没有交过题 _(:зゝ∠)_
Oh, there is no submission!: 喔,还没有评测记录呢!
Oh, there is no task in the queue!: 喔,队列中目前没有任务。
Oh, there is no task that matches the filter!: 喔,目前没有符合过滤条件的任务。
Ok: 确定
@@ -582,6 +595,7 @@ perm_problem_solution: 题解
perm_problem: 题库
perm_record: 记录
perm_training: 训练
Permission Control: 权限控制
Permission: 权限
Permissions: 权限
Pick a problem randomly based on current filter: 根据当前过滤条件随机选择一道题
@@ -647,6 +661,7 @@ QQ Visibility: QQ 可见性
Quit Scratchpad: 退出在线编程模式
Quote: 引用
Rank: 排名
Ranked Users: 排名用户
ranking: 排名
Read data of own problems: 读取自己题目的数据
Read data of problem: 读取题目数据
@@ -686,6 +701,7 @@ Reply problem solutions: 回复题解
reply: 回复
Reply: 回复
Report: 举报
Require Invitation Code: 需要邀请码
required: 必填
Reset Password or Find Username: 重置密码或找回用户名
Reset Password: 重置密码
@@ -769,6 +785,7 @@ Sort by: 排序
Source type: 来源类型
Star Topic: 收藏讨论
Star: 收藏
Starred Users: 关注用户
Start at: 开始于
Statistics: 统计
Status is hidden: 状态已隐藏
@@ -853,6 +870,7 @@ Unknown field {0}.: 未知字段 {0}。
Unknown: 未知
Unlimited: 无限制
Unordered List: 无序列表
Unrank: 打星
Unstar Topic: 取消收藏
Unstar: 取消收藏
Unsupported configure this type of problem. Please refer to the documentation.: 暂不支持快捷配置此类题目。请参照文档操作。
@@ -917,6 +935,7 @@ View Problem: 查看题目
View problems: 查看题目
View this domain: 查看此域
View training plans: 查看训练计划
View Training: 查看训练计划
View: 查看
Visible to registered users: 对注册用户可见
Visit: 查看
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.43.0",
"version": "4.44.0",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "index.ts",
@@ -107,3 +107,55 @@ $highlight-button-color = #F6DF45
.detail-button
width: auto
.page--contest_problemlist
.col--status
width: rem(150px)
.col--submit-at
width: rem(150px)
.col--problem
border-left: 1px solid $table-border-color
+mobile()
.col--submit-at
display: none
.page--contest_scoreboard
.main
.row
max-width: 100vw !important;
.col--rank
width: rem(60px)
text-align: center
border-right: 1px solid $table-border-color
.col--user
min-width: rem(150px)
.col--solved, .col--time
width: rem(70px)
text-align: center
.col--total_score
width: rem(100px)
text-align: center
.col--problem
width: rem(100px)
border-left: 1px solid $table-border-color
text-align: center
.scoreboard--acm
.col--problem
width: rem(70px) !important
+mobile()
.col--user
width: auto
.col--problem
display: none
@@ -1,13 +0,0 @@
.page--contest_detail
.col--status
width: rem(150px)
.col--submit-at
width: rem(150px)
.col--problem
border-left: 1px solid $table-border-color
+mobile()
.col--submit-at
display: none
@@ -1,6 +0,0 @@
.page--contest_edit
.page--contest_create
label
&.checkbox
margin-top: 24px
+26 -2
View File
@@ -1,17 +1,41 @@
import $ from 'jquery';
import moment from 'moment';
import ProblemSelectAutoComplete from 'vj/components/autocomplete/ProblemSelectAutoComplete';
import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete';
import { ConfirmDialog } from 'vj/components/dialog';
import { NamedPage } from 'vj/misc/Page';
import { i18n, request, tpl } from 'vj/utils';
const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create', 'homework_edit'], (pagename) => {
ProblemSelectAutoComplete.getOrConstruct($('[name="pids"]'), { multi: true, clearDefaultValue: false });
UserSelectAutoComplete.getOrConstruct<true>($('[name="maintainer"]'), { multi: true, clearDefaultValue: false });
$('[name="rule"]').on('change', () => {
const rule = $('[name="rule"]').val();
$('.contest-rule-settings input').attr('disabled', 'disabled');
$('.contest-rule-settings').hide();
$(`.contest-rule--${rule} input`).removeAttr('disabled');
$(`.contest-rule--${rule}`).show();
}).trigger('change');
$('[name="beginAtDate"], [name="beginAtTime"], [name="duration"]').on('change', () => {
const beginAtDate = $('[name="beginAtDate"]').val();
const beginAtTime = $('[name="beginAtTime"]').val();
const duration = $('[name="duration"]').val();
const endAt = moment(`${beginAtDate} ${beginAtTime}`).add(+duration, 'hours').toDate();
if (endAt) $('[name="endAt"]').val(moment(endAt).format('YYYY-MM-DD HH:mm'));
});
$('[name="permission"]').removeAttr('disabled').on('change', () => {
const type = $('[name="permission"]').val();
$('[data-perm] input').attr('disabled', 'disabled');
$('[data-perm]').hide();
$(`[data-perm="${type}"] input`).removeAttr('disabled');
$(`[data-perm="${type}"]`).show();
}).trigger('change');
if (pagename.endsWith('edit')) {
let confirmed = false;
$(document).on('click', '[name="operation"]', (ev) => {
$(document).on('click', '[value="delete"]', (ev) => {
ev.preventDefault();
if (confirmed) {
return request.post('.', { operation: 'delete' }).then((res) => {
return request.post('', { operation: 'delete' }).then((res) => {
window.location.href = res.url;
});
}
@@ -1,36 +0,0 @@
.page--contest_scoreboard
.main
.row
max-width: 100vw !important;
.col--rank
width: rem(60px)
text-align: center
border-right: 1px solid $table-border-color
.col--user
min-width: rem(150px)
.col--solved, .col--time
width: rem(70px)
text-align: center
.col--total_score
width: rem(100px)
text-align: center
.col--problem
width: rem(100px)
border-left: 1px solid $table-border-color
text-align: center
.scoreboard--acm
.col--problem
width: rem(70px) !important
+mobile()
.col--user
width: auto
.col--problem
display: none
@@ -0,0 +1,39 @@
import $ from 'jquery';
import { NamedPage } from 'vj/misc/Page';
const page = new NamedPage('contest_scoreboard', () => {
const { tdoc } = UiContext;
const key = `scoreboard-star/${tdoc.domainId}/${tdoc.docId}`;
const read = () => JSON.parse(localStorage.getItem(key) || '[]');
const write = (data) => localStorage.setItem(key, JSON.stringify(data));
read().forEach((uid) => $(`.star.user--${uid}`).addClass('activated'));
$('.star').on('click', (e) => {
const star = read();
const $target = $(e.currentTarget);
const uid = $target.data('uid');
if (star.includes(uid)) {
$target.removeClass('activated');
star.splice(star.indexOf(uid), 1);
} else {
$target.addClass('activated');
star.push(uid);
}
write(star);
});
$('.select.filter').on('change', (e) => {
const val = $(e.target).val();
if (val === 'all') {
$('.data-table tbody tr').show();
} else if (val === 'star') {
$('.data-table tbody tr').hide();
read().forEach((uid) => $(`.star.user--${uid}`).closest('tr').show());
} else {
$('.data-table tbody tr').show();
$('.rank--unrank').closest('tr').hide();
}
});
});
export default page;
@@ -0,0 +1,97 @@
import $ from 'jquery';
import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete';
import { ActionDialog } from 'vj/components/dialog';
import Notification from 'vj/components/notification';
import { NamedPage } from 'vj/misc/Page';
import {
i18n, pjax, request, tpl,
} from 'vj/utils';
const page = new NamedPage('contest_user', () => {
const addUserDialogContent = $(tpl`
<div>
<div class="row"><div class="columns">
<h1>${i18n('Add User')}</h1>
</div></div>
<div class="row"><div class="columns">
<label>
${i18n('Users')}
<input name="add_user_users" type="text" class="textbox" autocomplete="off">
</label>
</div></div>
<div class="row"><div class="columns">
<label>
${i18n('Rank')}
<br />
<label class="checkbox">
<input type="checkbox" name="unrank" class="checkbox">${i18n('UnRank')}
</label>
</label>
</div></div>
</div>
`);
addUserDialogContent.appendTo(document.body);
const userSelect = UserSelectAutoComplete.getOrConstruct<UserSelectAutoComplete<true>>(
addUserDialogContent.find('[name="add_user_users"]'),
{ multi: true, height: 'auto' },
);
const addUserDialog = new ActionDialog({
$body: addUserDialogContent,
onDispatch(action) {
if (action === 'ok' && !userSelect.value()) {
userSelect.focus();
return false;
}
return true;
},
});
addUserDialog.clear = function () {
userSelect.clear();
addUserDialog.$dom.find('[name="unrank"]').prop('checked', false);
return this;
};
async function handleClickAddUser() {
const action = await addUserDialog.clear().open();
if (action !== 'ok') return;
const unrank = addUserDialog.$dom.find('[name="unrank"]').prop('checked');
const uids = userSelect.value();
try {
const res = await request.post('', {
operation: 'add_user',
uids: uids.join(','),
unrank,
});
if (res.url && res.url !== window.location.href) window.location.href = res.url;
else {
Notification.success(i18n('User has been added.'));
pjax.request({ push: false });
}
} catch (error) {
Notification.error([error.message, ...error.params].join(' '));
}
}
async function handleEditRank(ev) {
const uid = $(ev.target).data('uid');
try {
const res = await request.post('', {
operation: 'rank',
uid,
});
if (res.url && res.url !== window.location.href) window.location.href = res.url;
else {
Notification.success(i18n('User Rank has been edited.'));
pjax.request({ push: false });
}
} catch (error) {
Notification.error([error.message, ...error.params].join(' '));
}
}
$('[name="add_user"]').on('click', () => handleClickAddUser());
$(document).on('click', '[name="edit_rank"]', handleEditRank);
});
export default page;
+1 -1
View File
@@ -92,7 +92,7 @@ function handleDrop(ev) {
handleClickUpload(files);
}
const page = new NamedPage(['home_files', 'contest_files'], () => {
const page = new NamedPage(['home_files', 'contest_files', 'training_files'], () => {
$(document).on('click', '[name="upload_file"]', () => handleClickUpload());
$(document).on('click', '[name="remove_selected"]', () => handleClickRemoveSelected());
$(document).on('dragover', '.files', (ev) => handleDragOver(ev));
+1 -1
View File
@@ -1,4 +1,4 @@
.page--home_files,.page--contest_files
.page--home_files,.page--contest_files,.page--training_files
.col--checkbox
width: 60px
@@ -199,13 +199,13 @@
<label class="checkbox">
<input type="checkbox" name="{{ args.name }}"{% if args.value %} checked{% endif %}
{{ form_attr('checkbox', args) }}
>{{ _(args.label) }}
>{% if args.placeholder %}{{ _(args.placeholder) }}{% endif %}
</label>
</div>
{% endmacro %}
{% macro form_checkbox(args) %}
{{ form_begin({columns:5, no_label:true}|assign(args)) }}
{{ form_begin({columns:5}|assign(args)) }}
{{ checkbox(args) }}
{{ form_end({no_label:true}|assign(args)) }}
{{ form_end(args) }}
{% endmacro %}
@@ -37,66 +37,6 @@
{% endif %}
</div>
</div>
<div class="section">
<div class="section__header">
<h1 class="section__title">{{ _('Problem') }}</h1>
</div>
{% if pdict %}
<div class="section__body no-padding">
<table class="data-table">
<colgroup>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<col class="col--status">
<col class="col--submit-at">
{% endif %}
<col class="col--problem">
</colgroup>
<thead>
<tr>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<th class="col--status record-status--border">{{ _('Status') }}</th>
<th class="col--submit-at">{{ _('Last Submit At') }}</th>
{% endif %}
<th class="col--problem">{{ _('Problem') }}</th>
</tr>
</thead>
<tbody>
{% set isAdmin = handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD) %}
{% set ntdoc = model.contest.isDone(tdoc) or (tsdoc.attend and not model.contest.isNotStarted(tdoc)) %}
{%- for pid in tdoc.pids -%}
<tr>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
{% if psdict[pid] and psdict[pid].rid %}
{% set rdoc = rdict[psdict[pid].rid] %}
{% if model.contest.canShowSelfRecord.call(handler, tdoc) %}
{{ record.render_status_td(rdoc) }}
{% else %}
<td class="col--status record-status--border"><a href="{{ url('record_detail', rid=rdoc._id) }}">{{ _('Submitted') }}</a></td>
{% endif %}
<td class="col--submit-at">{{ datetimeSpan(rdoc._id)|safe }}</td>
{% else %}
<td class="col--status record-status--border">{{ _('No Submissions') }}</td>
<td class="col--submit-at">-</td>
{% endif %}
{% endif %}
<td class="col--problem col--problem-name">
{% if isAdmin and not ntdoc %}
{{ problem.render_problem_title(pdict[pid], show_invisible_flag=false, show_tags=false) }}
{% else %}
{{ problem.render_problem_title(pdict[pid], tdoc=tdoc, show_invisible_flag=false, show_tags=false) }}
{% endif %}
</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% elif tsdoc.attend %}
{{ nothing.render('This contest is not live.') }}
{% else %}
{{ nothing.render('Please attend contest to see the problems.') }}
{% endif %}
</div>
</div>
<div class="medium-3 columns">
{% set owner_udoc = udict[tdoc.owner] %}
+124 -42
View File
@@ -3,8 +3,11 @@
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__body">
<form method="post">
<form method="post">
<div class="section__header">
<h2 class="section__title">{{ _('Basic Info') }}</h2>
</div>
<div class="section__body">
<div class="row">
{{ form.form_select({
columns:3,
@@ -15,7 +18,7 @@
row:false
}) }}
{{ form.form_text({
columns:4,
columns:9,
label:'Title',
name:'title',
placeholder:_('title'),
@@ -23,22 +26,6 @@
autofocus:true,
row:false
}) }}
{{ form.form_text({
columns:3,
label:'Invitation Code',
name:'code',
placeholder:_('(leave blank if none)'),
value:tdoc._code|default(''),
row:false
}) }}
{{ form.form_checkbox({
columns:2,
label:'Rated',
name:'rated',
placeholder:_('Is rated'),
value:tdoc.rated|default(true),
row:false
}) }}
</div>
<div class="row">
{{ form.form_text({
@@ -68,56 +55,151 @@
}) }}
{{ form.form_text({
columns:4,
label:'Assign',
name:'assign',
placeholder:_('Group / UID'),
value:tdoc.assign|default(''),
label:'End Time',
name:'endAt',
placeholder:'YYYY-mm-dd HH:MM',
value:beginAt.add(duration, 'hours').format('YYYY-M-D HH:mm'),
disabled:true,
row:false
}) }}
</div>
<div class="row">
{{ form.form_text({
columns:9,
columns:12,
label:'Problems',
name:'pids',
value:pids,
placeholder:_("Seperated with ','"),
row:false
row:true
}) }}
{{ form.form_checkbox({
columns:3,
label:'Auto hide-and show',
name:'autoHide',
placeholder:'',
value:handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) and (tdoc.autoHide|default(true)),
disabled:not handler.user.hasPerm(perm.PERM_EDIT_PROBLEM),
row:false
}) }}
{% if tdoc.lockAt %}
<input type="hidden" name="lock" value="{{ ((tdoc.endAt - tdoc.lockAt)/60000)|int }}">
{% endif %}
</div>
{{ form.form_textarea({
columns:null,
label:'Content',
label:'Description',
name:'content',
value:tdoc['content']|default(''),
markdown:true
}) }}
</div>
<div style="padding-top: 0" class="section__header">
<h2 class="section__title">{{ _('Permission Control') }}</h2>
</div>
<div class="section__body">
{{ form.form_text({
columns:12,
label:'Contest Maintainer',
name:'maintainer',
help_text:_('Allow to maintain this contest'),
value:tdoc.maintainer|default('')
}) }}
<div class="row">
{{ form.form_select({
columns:3,
label:'Permission Control',
name:'permission',
options:{
'public': _('Public'),
'invite': _('Require Invitation Code'),
'assign': _('Assign User or Group')
},
value: ("public" if not tdoc._code else "invite") if not tdoc.assign else "assign",
disabled:true,
row:false
}) }}
<div style="display: none" data-perm="assign">
{{ form.form_text({
columns:9,
label:'Assign',
name:'assign',
placeholder:_('Group / UID'),
value:tdoc.assign|default(''),
disabled:true,
row:false
}) }}
</div>
<div style="display: none" data-perm="invite">
{{ form.form_text({
columns:9,
label:'Invitation Code',
name:'code',
placeholder:_('(leave blank if none)'),
value:tdoc._code|default(''),
disabled:true,
row:false
}) }}
</div>
</div>
</div>
<div style="padding-top: 0" class="section__header">
<h2 class="section__title">{{ _('Contest Settings') }}</h2>
</div>
<div class="section__body">
<div class="row">
{{ form.form_checkbox({
columns:2,
label:'Rated',
name:'rated',
placeholder:_('Is rated'),
value:tdoc.rated|default(true),
row:false
}) }}
{{ form.form_checkbox({
columns:3,
label:'Auto hide-and show',
name:'autoHide',
placeholder:_('Auto hide problems during the contest'),
value:handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) and (tdoc.autoHide|default(true)),
disabled:not handler.user.hasPerm(perm.PERM_EDIT_PROBLEM),
row:false
}) }}
{{ form.form_checkbox({
columns:3,
label:'Allow View Code',
name:'allowViewCode',
placeholder:_('Allow user view others code after contest'),
value:tdoc.allowViewCode|default(true),
row:false
}) }}
<div style="display: none" class="contest-rule-settings contest-rule--acm">
{{ form.form_text({
columns:4,
label:'Lock (minutes)',
name:'lock',
placeholder:_('(leave blank if none)'),
value:(((tdoc.endAt - tdoc.lockAt)/60000)|int) if tdoc.lockAt else '',
help_text:_('Lock scoreboard on last N minutes'),
disabled:true,
row:false
}) }}
</div>
<div style="display: none" class="contest-rule-settings contest-rule--oi">
{{ form.form_text({
columns:4,
label:'Flexable Duration',
name:'contestDuration',
placeholder:_('(leave blank if none)'),
value:tdoc.duration|default(''),
help_text:_('Limit user to finish contest within N hours'),
disabled:true,
row:false
}) }}
</div>
</div>
<div class="row">
<div class="columns">
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
{% if page_name == 'contest_edit' %}
<button type="submit" class="rounded primary button">
<button name="operation" value="update" type="submit" class="rounded primary button">
{{ _('Update') }}
</button>
<button name="operation" value="update" type="submit" formaction="{{ url('contest_create') }}" class="rounded button">
{{ _('Clone') }}
</button>
{% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_CONTEST) %}
<button name="operation" value="delete" type="submit" class="rounded button">
{{ _('Delete') }}
</button>
{% endif %}
{% else %}
<button type="submit" class="rounded primary button">
<button name="operation" value="update" type="submit" class="rounded primary button">
{{ _('Create') }}
</button>
{% endif %}
@@ -1,7 +1,4 @@
{% extends "layout/basic.html" %}
{% import "components/contest.html" as contest with context %}
{% import "components/record.html" as record with context %}
{% import "components/problem.html" as problem with context %}
{% block content %}
{{ set(UiContext, 'tdoc', tdoc) }}
<div class="row">
@@ -0,0 +1,104 @@
{% extends "layout/basic.html" %}
{% import "components/contest.html" as contest with context %}
{% import "components/record.html" as record with context %}
{% import "components/problem.html" as problem with context %}
{% block content %}
{{ set(UiContext, 'tdoc', tdoc) }}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__header">
<h1 class="section__title">{{ _('Problem') }}</h1>
</div>
<div class="section__body no-padding">
<table class="data-table">
<colgroup>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<col class="col--status">
<col class="col--submit-at">
{% endif %}
<col class="col--problem">
</colgroup>
<thead>
<tr>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<th class="col--status record-status--border">{{ _('Status') }}</th>
<th class="col--submit-at">{{ _('Last Submit At') }}</th>
{% endif %}
<th class="col--problem">{{ _('Problem') }}</th>
</tr>
</thead>
<tbody>
{%- for pid in tdoc.pids -%}
<tr>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
{% if psdict[pid] and psdict[pid].rid %}
{% set rdoc = rdict[psdict[pid].rid] %}
{% if model.contest.canShowSelfRecord.call(handler, tdoc) %}
{{ record.render_status_td(rdoc) }}
{% else %}
<td class="col--status record-status--border"><a href="{{ url('record_detail', rid=rdoc._id) }}">{{ _('Submitted') }}</a></td>
{% endif %}
<td class="col--submit-at">{{ datetimeSpan(rdoc._id)|safe }}</td>
{% else %}
<td class="col--status record-status--border">{{ _('No Submissions') }}</td>
<td class="col--submit-at">-</td>
{% endif %}
{% endif %}
<td class="col--problem col--problem-name">
<a href="{{ url('problem_detail', pid=pid, query={tid:tdoc.docId}) }}">
<b>{{ String.fromCharCode(65+loop.index0) }}</b>&nbsp;&nbsp;{{ pdict[pid].title }}
</a>
</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
</div>
<div class="section">
<div class="section__body no-padding">
{% if not canViewRecord %}
{{ nothing.render('According to the contest rules, you cannot view your submission details at current.') }}'
{% elif not rdocs.length %}
{{ nothing.render('Oh, there is no submission!') }}
{% else %}
<table class="data-table record_main__table">
<colgroup>
<col class="col--status">
<col class="col--problem">
<col class="col--submit-by">
<col class="col--time">
<col class="col--memory">
<col class="col--lang">
<col class="col--submit-at">
</colgroup>
<thead>
<tr>
<th class="col--status record-status--border">{{ _('Status') }}</th>
<th class="col--problem">{{ _('Problem') }}</th>
<th class="col--submit-by">{{ _('Submit By') }}</th>
<th class="col--time">{{ _('Time') }}</th>
<th class="col--memory">{{ _('Memory') }}</th>
<th class="col--lang">{{ _('Language') }}</th>
<th class="col--submit-at">{{ _('Submit At') }}</th>
</tr>
</thead>
<tbody>
{%- for rdoc in rdocs -%}
{% set udoc = udict[rdoc['uid']] %}
{% set pdoc = pdict[rdoc['pid']] %}
{% include 'record_main_tr.html' %}
{%- endfor -%}
</tbody>
</table>
{% endif %}
</div>
</div>
</div>
<div class="medium-3 columns">
{% set owner_udoc = udict[tdoc.owner] %}
{% include "partials/contest_sidebar.html" %}
</div>
</div>
{% endblock %}
@@ -16,17 +16,24 @@
<div class="section__header">
{%- for ext in ['HTML','CSV','Ghost'] -%}
<a class="button" target="_blank" href="{{ url(type + '_scoreboard_download', tid=tdoc.docId, ext=ext.toLowerCase()) }}">
<span class="icon icon-download"></span> {{ _('Export as '+ext) }}
<span class="icon icon-download"></span> {{ _('Export as {0}').format(ext) }}
</a>
{%- endfor -%}
{% if model.contest.isDone(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') }}">
<button type="submit" class="button">
<span class="icon icon-feeling-lucky"></span> {{ _('Unlock scoreboard') }}
</button>
</form>
{% endif %}
<div class="section__tools">
<select class="select filter" name="list">
<option value="all">{{ _('All Users') }}</option>
<option value="star">{{ _('Starred Users') }}</option>
<option value="rank">{{ _('Ranked Users') }}</option>
</select>
</div>
</div>
{% if model.contest.isLocked(tdoc) %}
<div class="section__body no-padding">
@@ -69,8 +76,14 @@
<tr>
{%- for column in row -%}
<td class="col--{{ rows[0][loop.index0]['type'] }}"{% if column.style %} style="{{ column.style }}"{% endif %}>
{%- if column.type == 'user' -%}
{%- if column.type == 'rank' -%}
<span class="{{ 'rank--unrank' if column.value == '0' else 'rank--normal' }}">{{ '*' if column.value == '0' else column.value|string|nl2br|safe }}</span>
{%- elif column.type == 'user' -%}
{%- set canView = canViewAll or handler.user._id == column.raw -%}
<button class="star user--{{ column.raw }}" data-uid="{{ column.raw }}">
<span class="starred--hide"><span class="icon icon-star--outline" data-tooltip="{{ _('Star') }}"></span></span>
<span class="starred--show"><span class="icon icon-star" data-tooltip="{{ _('Unstar') }}"></span></span>
</button>
{{ user.render_inline(udict[column.raw], badge=false) }}
{%- elif column.type == 'record' and column.raw -%}
<a href="{{ url('record_detail', rid=column.raw) if canView else '' }}" data-tooltip="{{ column.hover }}">
@@ -0,0 +1,47 @@
{% extends "layout/basic.html" %}
{% import "components/contest.html" as contest with context %}
{% import "components/record.html" as record with context %}
{% import "components/problem.html" as problem with context %}
{% block content %}
{{ set(UiContext, 'tdoc', tdoc) }}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__header">
<h1 class="section__title">{{ _('Attendee Manage') }}</h1>
<div class="section__tools">
<button class="primary rounded button" name="add_user"><span class="icon icon-search"></span>{{ _('Add User') }}</button>
</div>
</div>
{{ noscript_note.render() }}
<div class="section__body no-padding">
<table class="data-table">
<colgroup>
<col class="col--uid">
<col class="col--user">
<col class="col--start-time">
<col class="col--end-time">
<col class="col--rank">
<col class="col--actions">
</colgroup>
<thead>
<tr>
<th class="col--uid">{{ _('User ID') }}</th>
<th class="col--user">{{ _('Username') }}</th>
<td class="col--start-time">{{ _('Begin Time') }}</th>
<th class="col--end-time">{{ _('End Time') }}</th>
<th class="col--rank">{{ _('Rank') }}</th>
<th class="col--actions">{{ _('Actions') }}</th>
</tr>
</thead>
{% include "partials/contest_user.html" %}
</table>
</div>
</div>
</div>
<div class="medium-3 columns">
{% set owner_udoc = udict[tdoc.owner] %}
{% include "partials/contest_sidebar.html" %}
</div>
</div>
{% endblock %}
@@ -95,6 +95,9 @@
<button type="submit" class="rounded primary button">
{{ _('Update') }}
</button>
<button name="operation" value="update" type="submit" formaction="{{ url('homework_create') }}" class="rounded button">
{{ _('Clone') }}
</button>
{% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_HOMEWORK) %}
<button name="operation" value="delete" type="submit" class="rounded button">
{{ _('Delete') }}
@@ -8,7 +8,7 @@
<html data-page="{{ page_name }}" data-layout="{{ layout_name }}" class="layout--{{ layout_name }} page--{{ page_name }} theme--{{ (handler.user|default({})).theme|default('default') }} nojs" lang="{{ UserContext.viewLang }}" data-app="Hydro">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta http-equiv="X-UA-Compatible" content="chrome=1"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
@@ -75,6 +75,9 @@
<li class="menu__item"><a class="menu__link" href="{{ url('contest_files', tid=tdoc.docId) }}">
<span class="icon icon-file"></span> {{ _('Files') }}
</a></li>
<li class="menu__item"><a class="menu__link" href="{{ url('contest_user', tid=tdoc.docId) }}">
<span class="icon icon-user"></span> {{ _('Attendee Manage') }}
</a></li>
{% endif %}
{% if handler.user.own(tdoc) or handler.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE) or handler.user.hasPerm(perm.PERM_READ_RECORD_CODE) %}
{% if model.contest.canShowRecord.call(handler, tdoc, true) %}
@@ -0,0 +1,16 @@
{% import "components/user.html" as user with context %}
{% import "components/contest.html" as contest with context %}
<tbody data-fragment-id="constest_user-tbody">
{%- for tsdoc in tsdocs -%}
<tr data-uid="{{ tsdoc.uid }}">
<td class="col--uid">{{ tsdoc.uid }}</td>
<td class="col--user">{{ user.render_inline(udict[tsdoc.uid], badge=false) }}</td>
<td class="col--start-time">{{ contest.render_time(tsdoc.startAt or tdoc.beginAt) }}</td>
<td class="col--end-time">{{ contest.render_time(tsdoc.endAt or tdoc.endAt) }}</td>
<td class="col--rank">{{ _('UnRank') if tsdoc.unrank else _('Rank') }}</td>
<td class="col--actions">
<a name="edit_rank" data-uid="{{ tsdoc.uid }}">{{ _('Rank') if tsdoc.unrank else _('UnRank') }}</a>
</td>
</tr>
{%- endfor -%}
</tbody>
@@ -11,10 +11,9 @@
</div>
<div class="medium-2 columns">
<label>
{{ _('Highlight') }}
<br>
<label class="checkbox">
<input type="checkbox" name="highlight" value="on"
<input type="checkbox" name="highlight" value="on" class="checkbox"
{%- if ddoc.highlight %} checked{% endif -%}
{%- if not handler.user.hasPerm(perm.PERM_HIGHLIGHT_DISCUSSION) %} disabled{% endif -%}
>{{ _('Highlight') }}
@@ -23,10 +22,9 @@
</div>
<div class="medium-2 columns">
<label>
{{ _('Pin') }}
<br>
<label class="checkbox">
<input type="checkbox" name="pin" value="on"
<input type="checkbox" name="pin" value="on" class="checkbox"
{%- if ddoc.pin %} checked{% endif -%}
{%- if not handler.user.hasPerm(perm.PERM_PIN_DISCUSSION) %} disabled{% endif -%}
>{{ _('Pin') }}
@@ -35,7 +35,7 @@
</div></div>
<div class="row"><div class="columns">
<label class="checkbox">
<input type="checkbox" name="rememberme">{{ _('Remember me') }}
<input type="checkbox" name="rememberme" class="checkbox">{{ _('Remember me') }}
</label>
</div></div>
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
@@ -69,7 +69,7 @@
{% set _val = current[setting.key] %}
{{ form.form_checkbox({
label:setting.name,
help_text:setting.desc,
placeholder:setting.desc,
name:setting.key,
value:setting.value if (_val === undefined or _val === null) else _val,
disabled:setting.flag|bitand(2)
@@ -78,7 +78,8 @@
label:setting.name,
name:'booleanKeys.' + setting.key,
value:true,
extra_class:'display-hidden'
extra_class:'display-hidden',
no_label:true
})}}
{% endif %}
{% endif %}
@@ -44,7 +44,7 @@
</button>
</form>
{% endif %}
#{{ pdoc.pid|default(pdoc.docId) }}. {{ pdoc.title }}
#{{ String.fromCharCode(65+tdoc.pids.indexOf(pdoc.docId)) if tdoc else pdoc.pid|default(pdoc.docId) }}. {{ pdoc.title }}
</h1>
{% if tdoc %}
<div class="section__tools contest-problems">
@@ -25,7 +25,7 @@
<label>
<br>
<label class="checkbox">
<input type="checkbox" name="hidden" value="on"{% if pdoc['hidden'] %} checked{% endif %}>{{ _('Hidden') }}
<input type="checkbox" name="hidden" value="on"{% if pdoc['hidden'] %} checked{% endif %} class="checkbox">{{ _('Hidden') }}
</label>
</label>
</div>
@@ -75,6 +75,9 @@
<li class="menu__item"><a class="menu__link" href="{{ url('training_edit', tid=tdoc.docId) }}">
<span class="icon icon-edit"></span> {{ _('Edit') }}
</a></li>
<li class="menu__item"><a class="menu__link" href="{{ url('training_files', tid=tdoc.docId) }}">
<span class="icon icon-file"></span> {{ _('Files') }}
</a></li>
{% endif %}
<li class="menu__item"><a class="menu__link" href="{{ url('wiki_help', anchor='training') }}">
<span class="icon icon-help"></span> {{ _('Help') }}
@@ -6,7 +6,7 @@
<div class="section__body">
<form method="post">
<div class="row">
<div class="medium-5 columns">
<div class="medium-10 columns">
<label>
{{ _('Title') }}
<input name="title" placeholder="{{ _('title') }}" value="{{ tdoc.title|default('') }}" class="textbox" autofocus>
@@ -16,7 +16,7 @@
<label>
<br>
<label class="checkbox">
<input type="checkbox" name="pin" value="on"{% if tdoc['hidden'] %} checked{% endif %}>{{ _('Pin') }}
<input type="checkbox" name="pin" value="on"{% if tdoc['hidden'] %} checked{% endif %} class="checkbox">{{ _('Pin') }}
</label>
</label>
</div>
@@ -0,0 +1,65 @@
{% extends "layout/basic.html" %}
{% block content %}
{{ set(UiContext, 'tdoc', tdoc) }}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__header">
<h1 class="section__title">{{ _('Files') }}</h1>
<div class="section__tools">
<button class="primary rounded button" name="upload_file">{{ _('Upload File') }}</button>
</div>
</div>
{{ noscript_note.render() }}
{% include "partials/files.html" %}
<div class="section__body">
<button class="rounded button" name="remove_selected">{{ _('Remove Selected') }}</button>
</div>
</div>
</div>
<div class="medium-3 columns">
<div class="section side">
<div>
<ol class="menu">
<li class="menu__item"><a class="menu__link" href="{{ url('training_detail', tid=tdoc.docId) }}">
<span class="icon icon-award"></span> {{ _('View Training') }}
</a></li>
{% if not tsdoc['enroll'] and handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<li class="menu__item">
<form action="" method="POST">
<input type="hidden" name="operation" value="enroll">
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
<button class="menu__link" type="submit">
<span class="icon icon-add"></span> {{ _('Enroll Training') }}
</button>
</form>
</li>
{% endif %}
{% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_TRAINING) %}
<li class="menu__item"><a class="menu__link" href="{{ url('training_edit', tid=tdoc.docId) }}">
<span class="icon icon-edit"></span> {{ _('Edit') }}
</a></li>
<li class="menu__item"><a class="menu__link" href="{{ url('training_files', tid=tdoc.docId) }}">
<span class="icon icon-file"></span> {{ _('Files') }}
</a></li>
{% endif %}
<li class="menu__item"><a class="menu__link" href="{{ url('wiki_help', anchor='training') }}">
<span class="icon icon-help"></span> {{ _('Help') }}
</a></li>
<li class="menu__seperator"></li>
</ol>
</div>
<div class="section__body typo">
<dl class="large horizontal">
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<dt>{{ _('Status') }}</dt><dd>{% if tsdoc['enroll'] %}{{ _('Completed' if tsdoc['done'] else 'In Progress') }}{% else %}{{ _('Not Enrolled') }}{% endif %}</dd>
{% endif %}
<dt>{{ _('Enrollees') }}</dt><dd>{{ tdoc.attend|default(0) }}</dd>
<dt>{{ _('Created By') }}</dt>
<dd>{{ user.render_inline(udoc, badge=false) }}</dd>
</dl>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -24,8 +24,8 @@
</label>
</div></div>
<div class="row"><div class="columns">
<label class="inverse material checkbox">
<input type="checkbox" name="rememberme">{{ _('Remember me') }}
<label class="inverse material checkbox" style="color:#fff;">
<input type="checkbox" name="rememberme" class="checkbox">{{ _('Remember me') }}
</label>
</div></div>
<div class="row"><div class="columns">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/vjudge",
"version": "1.7.1",
"version": "1.7.2",
"description": "Submit problems to remote oj",
"main": "package.json",
"repository": "https://github.com/hydro-dev/Hydro.git",