mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
feat: add xcpcio
This commit is contained in:
8 files changed
+239
-24
No files matched your search
@@ -60,3 +60,5 @@ __*
|
|||||||
|
|
||||||
# Monokle
|
# Monokle
|
||||||
.monokle
|
.monokle
|
||||||
|
|
||||||
|
packages/*/public/assets
|
||||||
@@ -27,7 +27,7 @@ import storage from '../model/storage';
|
|||||||
import * as system from '../model/system';
|
import * as system from '../model/system';
|
||||||
import user from '../model/user';
|
import user from '../model/user';
|
||||||
import {
|
import {
|
||||||
Handler, param, post, Types,
|
Handler, param, post, Type, Types,
|
||||||
} from '../service/server';
|
} from '../service/server';
|
||||||
|
|
||||||
export class ContestListHandler extends Handler {
|
export class ContestListHandler extends Handler {
|
||||||
@@ -618,10 +618,10 @@ type BuiltinInput = {
|
|||||||
groups: any[];
|
groups: any[];
|
||||||
};
|
};
|
||||||
type AnyFunction = (...args: any) => any;
|
type AnyFunction = (...args: any) => any;
|
||||||
type ParseArgs<T extends { [key: string]: keyof BuiltinInput | AnyFunction }> = {
|
type ParseArgs<T extends { [key: string]: keyof BuiltinInput | AnyFunction | Type<any> }> = {
|
||||||
[key in keyof T]: T[key] extends keyof BuiltinInput ? BuiltinInput[T[key]] : T[key] extends AnyFunction ? ReturnType<T[key]> : never
|
[key in keyof T]: T[key] extends keyof BuiltinInput ? BuiltinInput[T[key]] : T[key] extends AnyFunction ? ReturnType<T[key]> : any
|
||||||
};
|
};
|
||||||
export interface ScoreboardView<T extends { [key: string]: keyof BuiltinInput | AnyFunction }> {
|
export interface ScoreboardView<T extends { [key: string]: keyof BuiltinInput | AnyFunction | Type<any> }> {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
supportedRules: string[];
|
supportedRules: string[];
|
||||||
@@ -650,12 +650,16 @@ export class ContestScoreboardHandler extends ContestDetailBaseHandler {
|
|||||||
for (const key in view.args) {
|
for (const key in view.args) {
|
||||||
if (typeof view.args[key] === 'function') {
|
if (typeof view.args[key] === 'function') {
|
||||||
try {
|
try {
|
||||||
args[key] = view.args[key](this.args);
|
args[key] = view.args[key](this.args[key]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ValidationError(key);
|
throw new ValidationError(key);
|
||||||
}
|
}
|
||||||
} else if (fetcher[key]) {
|
} else if (view.args[key] instanceof Array) {
|
||||||
args[key] = await fetcher[key](); // eslint-disable-line no-await-in-loop
|
if (this.args[key] === undefined && view.args[key].find((i) => i === true)) continue;
|
||||||
|
if (view.args[key][1] && !view.args[key][1](this.args[key])) throw new ValidationError(key);
|
||||||
|
args[key] = view.args[key][0](this.args[key]);
|
||||||
|
} else if (fetcher[view.args[key]]) {
|
||||||
|
args[key] = await fetcher[view.args[key]](); // eslint-disable-line no-await-in-loop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await view.display.call(this, args);
|
await view.display.call(this, args);
|
||||||
@@ -677,7 +681,7 @@ class ScoreboardService extends Service {
|
|||||||
ctx.set('scoreboard', this);
|
ctx.set('scoreboard', this);
|
||||||
}
|
}
|
||||||
|
|
||||||
addView<T extends { [key: string]: keyof BuiltinInput | AnyFunction }>(
|
addView<T extends { [key: string]: keyof BuiltinInput | AnyFunction | Type<any> }>(
|
||||||
id: string, name: string, args: T,
|
id: string, name: string, args: T,
|
||||||
{ display, supportedRules, cacheTime }: {
|
{ display, supportedRules, cacheTime }: {
|
||||||
display: (this: ContestScoreboardHandler, args: ParseArgs<T>) => Promise<void>,
|
display: (this: ContestScoreboardHandler, args: ParseArgs<T>) => Promise<void>,
|
||||||
@@ -697,7 +701,8 @@ class ScoreboardService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAvailableViews(rule: string) {
|
getAvailableViews(rule: string) {
|
||||||
return Object.values(this.views).filter((i) => i.supportedRules.includes(rule) || i.supportedRules.includes('*'));
|
return Object.fromEntries(Object.values(this.views).filter((i) => i.supportedRules.includes(rule) || i.supportedRules.includes('*'))
|
||||||
|
.map((i) => [i.id, i.name]));
|
||||||
}
|
}
|
||||||
|
|
||||||
getView(id: string) {
|
getView(id: string) {
|
||||||
@@ -765,8 +770,8 @@ export async function apply(ctx: Context) {
|
|||||||
ctx.plugin(ScoreboardService);
|
ctx.plugin(ScoreboardService);
|
||||||
ctx.inject(['scoreboard'], ({ Route, scoreboard }) => {
|
ctx.inject(['scoreboard'], ({ Route, scoreboard }) => {
|
||||||
Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
|
Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
|
||||||
Route('contest_scoreboard', '/contest/:tid/scoreboard/:viewId', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
|
Route('contest_scoreboard_view', '/contest/:tid/scoreboard/:view', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
|
||||||
scoreboard.addView('default', 'Default', { tdoc: 'tdoc', groups: 'groups', realtime: Schema.boolean().default(false) }, {
|
scoreboard.addView('default', 'Default', { tdoc: 'tdoc', groups: 'groups', realtime: Types.Boolean }, {
|
||||||
async display({ realtime, tdoc, groups }) {
|
async display({ realtime, tdoc, groups }) {
|
||||||
if (realtime && !this.user.own(tdoc)) {
|
if (realtime && !this.user.own(tdoc)) {
|
||||||
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||||
@@ -780,8 +785,9 @@ export async function apply(ctx: Context) {
|
|||||||
const page_name = tdoc.rule === 'homework'
|
const page_name = tdoc.rule === 'homework'
|
||||||
? 'homework_scoreboard'
|
? 'homework_scoreboard'
|
||||||
: 'contest_scoreboard';
|
: 'contest_scoreboard';
|
||||||
|
const availableViews = scoreboard.getAvailableViews(tdoc.rule);
|
||||||
this.response.body = {
|
this.response.body = {
|
||||||
tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), rows, udict, pdict, page_name, groups,
|
tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), rows, udict, pdict, page_name, groups, availableViews,
|
||||||
};
|
};
|
||||||
this.response.pjax = 'partials/scoreboard.html';
|
this.response.pjax = 'partials/scoreboard.html';
|
||||||
this.response.template = 'contest_scoreboard.html';
|
this.response.template = 'contest_scoreboard.html';
|
||||||
@@ -839,17 +845,27 @@ export async function apply(ctx: Context) {
|
|||||||
},
|
},
|
||||||
supportedRules: ['*'],
|
supportedRules: ['*'],
|
||||||
});
|
});
|
||||||
scoreboard.addView('export', 'Export', { tdoc: 'tdoc', ext: Schema.union(['csv', 'html']).required() }, {
|
scoreboard.addView('html', 'HTML', { tdoc: 'tdoc' }, {
|
||||||
async display({ tdoc, ext }) {
|
async display({ tdoc }) {
|
||||||
await this.limitRate('scoreboard_download', 60, 3);
|
await this.limitRate('scoreboard_download', 60, 3);
|
||||||
const getContent = {
|
|
||||||
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value?.toString().replace(/\n/g, ' ')).join(','))).join('\n')}`,
|
|
||||||
html: (rows) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }),
|
|
||||||
};
|
|
||||||
const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, {
|
const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, {
|
||||||
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
|
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
|
||||||
});
|
});
|
||||||
this.binary(await getContent[ext](rows), `${this.tdoc.title}.${ext}`);
|
this.binary(await this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }), `${this.tdoc.title}.html`);
|
||||||
|
},
|
||||||
|
supportedRules: ['*'],
|
||||||
|
});
|
||||||
|
scoreboard.addView('csv', 'CSV', { tdoc: 'tdoc' }, {
|
||||||
|
async display({ tdoc }) {
|
||||||
|
await this.limitRate('scoreboard_download', 60, 3);
|
||||||
|
const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, {
|
||||||
|
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
|
||||||
|
});
|
||||||
|
const escapeValue = (i: string) => `"${i.toString().replace(/\n/g, ' ').replace(/"/g, '\\"')}"`;
|
||||||
|
this.binary(
|
||||||
|
`\uFEFF${rows.map((c) => (c.map((i) => escapeValue(i.value)).join(','))).join('\n')}`,
|
||||||
|
`${this.tdoc.title}.csv`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
supportedRules: ['*'],
|
supportedRules: ['*'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ export interface Tdoc extends Document {
|
|||||||
lockAt?: Date;
|
lockAt?: Date;
|
||||||
unlocked?: boolean;
|
unlocked?: boolean;
|
||||||
autoHide?: boolean;
|
autoHide?: boolean;
|
||||||
balloon?: Record<number, string>;
|
balloon?: Record<number, string | { color: string, name: string }>;
|
||||||
score?: Record<number, number>;
|
score?: Record<number, number>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
ContestModel, Context, fs, Handler, ObjectId, Schema, STATUS, Types, UserModel,
|
||||||
|
} from 'hydrooj';
|
||||||
|
|
||||||
|
const file = fs.readFileSync(path.join(__dirname, 'public/assets/board.html'), 'utf8');
|
||||||
|
const indexJs = file.match(/index-([A-Za-z0-9_-]+)\.js"/)?.[1];
|
||||||
|
const indexCss = file.match(/index-([A-Za-z0-9_-]+)\.css"/)?.[1];
|
||||||
|
|
||||||
|
class XcpcioHandler extends Handler {
|
||||||
|
async get() {
|
||||||
|
this.response.body = {
|
||||||
|
js: indexJs,
|
||||||
|
css: indexCss,
|
||||||
|
};
|
||||||
|
this.response.template = 'xcpcio_board.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = {
|
||||||
|
[STATUS.STATUS_WRONG_ANSWER]: 'WRONG_ANSWER',
|
||||||
|
[STATUS.STATUS_ACCEPTED]: 'CORRECT',
|
||||||
|
[STATUS.STATUS_COMPILING]: 'PENDING',
|
||||||
|
[STATUS.STATUS_WAITING]: 'PENDING',
|
||||||
|
[STATUS.STATUS_JUDGING]: 'PENDING',
|
||||||
|
[STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TIME_LIMIT_EXCEEDED',
|
||||||
|
[STATUS.STATUS_MEMORY_LIMIT_EXCEEDED]: 'MEMORY_LIMIT_EXCEEDED',
|
||||||
|
[STATUS.STATUS_RUNTIME_ERROR]: 'RUNTIME_ERROR',
|
||||||
|
[STATUS.STATUS_SYSTEM_ERROR]: 'SYSTEM_ERROR',
|
||||||
|
[STATUS.STATUS_COMPILE_ERROR]: 'COMPILATION_ERROR',
|
||||||
|
[STATUS.STATUS_FETCHED]: 'PENDING',
|
||||||
|
[STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED]: 'OUTPUT_LIMIT_EXCEEDED',
|
||||||
|
[STATUS.STATUS_ETC]: 'SYSTEM_ERROR',
|
||||||
|
[STATUS.STATUS_CANCELED]: 'CANCELED',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function apply(ctx: Context) {
|
||||||
|
ctx.Route('board_xcpcio', '/board', XcpcioHandler);
|
||||||
|
ctx.inject(['scoreboard'], ({ scoreboard }) => {
|
||||||
|
scoreboard.addView('xcpcio', 'XCPCIO', {
|
||||||
|
tdoc: 'tdoc',
|
||||||
|
groups: 'groups',
|
||||||
|
json: Types.Boolean,
|
||||||
|
gold: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||||
|
silver: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||||
|
bronze: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||||
|
}, {
|
||||||
|
async display({
|
||||||
|
tdoc, groups, json, gold, silver, bronze,
|
||||||
|
}) {
|
||||||
|
if (json || this.request.json) {
|
||||||
|
const tsdocs = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray();
|
||||||
|
const udict = await UserModel.getList(tdoc.domainId, tsdocs.map((i) => i.uid));
|
||||||
|
const teams = tsdocs.map((i) => {
|
||||||
|
const udoc = udict[i.uid];
|
||||||
|
return {
|
||||||
|
team_id: `${udoc._id}`,
|
||||||
|
name: udoc.uname,
|
||||||
|
organization: udoc.school,
|
||||||
|
members: [],
|
||||||
|
coach: '',
|
||||||
|
group: [
|
||||||
|
...(udoc.group || []),
|
||||||
|
i.unrank ? 'unofficial' : 'official',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const relatedGroups = teams.flatMap((i) => i.group);
|
||||||
|
this.response.body = {
|
||||||
|
contest: {
|
||||||
|
contest_name: tdoc.title,
|
||||||
|
start_time: Math.floor(tdoc.beginAt.getTime() / 1000),
|
||||||
|
end_time: Math.floor(tdoc.endAt.getTime() / 1000),
|
||||||
|
frozen_time: tdoc.lockAt ? Math.floor((tdoc.endAt.getTime() - tdoc.lockAt.getTime()) / 1000) : 0,
|
||||||
|
penalty: 1200,
|
||||||
|
problem_quantity: tdoc.pids.length,
|
||||||
|
problem_id: tdoc.pids.map((i, idx) => String.fromCharCode(65 + idx)),
|
||||||
|
group: {
|
||||||
|
official: '正式队伍',
|
||||||
|
unofficial: '打星队伍',
|
||||||
|
...Object.fromEntries(groups.filter((i) => relatedGroups.includes(i.name)).map((i) => [i.name, i.name])),
|
||||||
|
},
|
||||||
|
organization: 'School',
|
||||||
|
status_time_display: {
|
||||||
|
'correct': true,
|
||||||
|
'incorrect': true,
|
||||||
|
'pending': true,
|
||||||
|
},
|
||||||
|
medal: {
|
||||||
|
official: {
|
||||||
|
gold,
|
||||||
|
silver,
|
||||||
|
bronze,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
balloon_color: tdoc.balloon
|
||||||
|
? tdoc.pids.map((i) => ({
|
||||||
|
color: '#000',
|
||||||
|
background_color: typeof tdoc.balloon[i] === 'string' ? tdoc.balloon[i] : tdoc.balloon[i].color,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
logo: {
|
||||||
|
preset: 'ICPC',
|
||||||
|
},
|
||||||
|
// banner: {
|
||||||
|
// url: 'assets/banner.png',
|
||||||
|
// },
|
||||||
|
options: {
|
||||||
|
submission_timestamp_unit: 'millisecond',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submissions: tsdocs.flatMap((i) => (i.journal || []).map((j) => ({
|
||||||
|
problem_id: tdoc.pids.indexOf(j.pid),
|
||||||
|
team_id: `${i.uid}`,
|
||||||
|
timestamp: Math.floor(new ObjectId(j.rid).getTimestamp().getTime() - tdoc.beginAt.getTime()),
|
||||||
|
status: status[j.status] || 'SYSTEM_ERROR',
|
||||||
|
language: j.lang || '',
|
||||||
|
submission_id: j.rid,
|
||||||
|
}))),
|
||||||
|
teams,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.response.template = 'xcpcio_board.html';
|
||||||
|
let query = '';
|
||||||
|
if (gold || silver || bronze) {
|
||||||
|
query = `&gold=${gold}&silver=${silver}&bronze=${bronze}`;
|
||||||
|
}
|
||||||
|
this.response.body = {
|
||||||
|
dataSource: `/d/${tdoc.domainId}/contest/${tdoc.docId}/scoreboard/xcpcio?json=true${query}#allInOne=true`,
|
||||||
|
js: indexJs,
|
||||||
|
css: indexCss,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportedRules: ['acm'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const dist = path.join(path.dirname(require.resolve('@xcpcio/board-app/package.json')), 'dist');
|
||||||
|
const target = path.join(__dirname, 'public');
|
||||||
|
if (fs.existsSync(target)) {
|
||||||
|
fs.rmSync(target, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(target, { recursive: true });
|
||||||
|
fs.cpSync(path.join(dist, 'assets'), path.join(target, 'assets'), { recursive: true });
|
||||||
|
fs.cpSync(path.join(dist, 'index.html'), path.join(target, 'assets/board.html'));
|
||||||
|
console.log('Copied board app to', target);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@hydrooj/scoreboard-xcpcio",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"install": "node -r @hydrooj/register install.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xcpcio/board-app": "^0.53.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% set page_name = 'contest_scoreboard_xcpcio' %}
|
||||||
|
{% extends "layout/basic.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<script>
|
||||||
|
function normalizePath(_) {
|
||||||
|
for (; _.endsWith("/");)_ = _.slice(0, -1);
|
||||||
|
return `${_}/`;
|
||||||
|
}
|
||||||
|
window.CDN_HOST = "/";
|
||||||
|
window.__toAssetUrl = url => window.CDN_HOST + url;
|
||||||
|
window.DATA_HOST = "/";
|
||||||
|
window.DATA_REGION = "CN";
|
||||||
|
window.DEFAULT_LANG = "{{ 'zh-CN' if _('__id').startsWith('zh') else 'en' }}";
|
||||||
|
window.DATA_SOURCE = "{{ dataSource }}";
|
||||||
|
if (document.documentElement.classList.contains("theme--dark")) document.documentElement.classList.toggle("dark", !0);
|
||||||
|
setInterval(() => {
|
||||||
|
const location = window.location.href;
|
||||||
|
if (location.includes("%2F")) {
|
||||||
|
window.history.replaceState(null, "", location.replace(/%2F/g, "/"));
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
#app .nav, .omnibar-toggle { display: none; }
|
||||||
|
table { border-spacing: 2px; }
|
||||||
|
th { text-align: center; }
|
||||||
|
td.unattempted, td.empty { background-color: transparent !important; }
|
||||||
|
</style>
|
||||||
|
<script type="module" crossorigin src="/assets/index-{{ js }}.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-{{ css }}.css">
|
||||||
|
<div id="app"></div>
|
||||||
|
<noscript>This website requires JavaScript to function properly. Please enable JavaScript tocontinue.</noscript>
|
||||||
|
{% endblock %}
|
||||||
@@ -8,14 +8,16 @@
|
|||||||
<div class="row"><div class="medium-12 columns scoreboard--{{ tdoc.rule }}">
|
<div class="row"><div class="medium-12 columns scoreboard--{{ tdoc.rule }}">
|
||||||
<div class="section visible">
|
<div class="section visible">
|
||||||
<div class="section__header">
|
<div class="section__header">
|
||||||
{%- for ext in ['HTML','CSV'] -%}
|
{%- for ext in ['HTML', 'CSV', 'Ghost'] -%}
|
||||||
<a class="button" target="_blank" href="{{ url(type + '_scoreboard', tid=tdoc.docId, view='export', ext=ext.toLowerCase()) }}">
|
<a class="button" target="_blank" href="{{ url(type + '_scoreboard_view', tid=tdoc.docId, view=ext.toLowerCase()) }}">
|
||||||
<span class="icon icon-download"></span> {{ _('Export as {0}').format(ext) }}
|
<span class="icon icon-download"></span> {{ _('Export as {0}').format(ext) }}
|
||||||
</a>
|
</a>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
<a class="button" target="_blank" href="{{ url(type + '_scoreboard', tid=tdoc.docId, view='ghost') }}">
|
{% for id, name in availableViews %}{% if not ['html', 'csv', 'default', 'ghost'].includes(id) %}
|
||||||
<span class="icon icon-download"></span> {{ _('Export as Ghost') }}
|
<a class="button" target="_blank" href="{{ url(type + '_scoreboard_view', tid=tdoc.docId, view=id) }}">
|
||||||
|
<span class="icon icon-copy"></span> {{ _(name) }}
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
{% if model.contest.isDone(tdoc) and tdoc.lockAt and handler.user.own(tdoc) and not tdoc.unlocked %}
|
{% if model.contest.isDone(tdoc) and tdoc.lockAt and handler.user.own(tdoc) and not tdoc.unlocked %}
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="operation" value="unlock">
|
<input type="hidden" name="operation" value="unlock">
|
||||||
|
|||||||
Reference in new issue
Block a user