mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
core&ui: add homework files & enhance delete operation (#755)
This commit is contained in:
1 parent
61e56adead
commit
f0219116f3
12 files changed
+175
-13
No files matched your search
@@ -515,6 +515,7 @@ export class ContestEditHandler extends Handler {
|
|||||||
ScheduleModel.deleteMany({
|
ScheduleModel.deleteMany({
|
||||||
type: 'schedule', subType: 'contest', domainId, tid,
|
type: 'schedule', subType: 'contest', domainId, tid,
|
||||||
}),
|
}),
|
||||||
|
storage.del(this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id),
|
||||||
]));
|
]));
|
||||||
this.response.redirect = this.url('contest_main');
|
this.response.redirect = this.url('contest_main');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
|
import { statSync } from 'fs-extra';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
import { pick } from 'lodash';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import { ObjectId } from 'mongodb';
|
import { ObjectId } from 'mongodb';
|
||||||
import { Time } from '@hydrooj/utils/lib/utils';
|
import { sortFiles, Time } from '@hydrooj/utils/lib/utils';
|
||||||
import {
|
import {
|
||||||
ContestNotFoundError, HomeworkNotLiveError, NotAssignedError, ValidationError,
|
ContestNotFoundError, FileLimitExceededError, FileUploadError, HomeworkNotLiveError, NotAssignedError, ValidationError,
|
||||||
} from '../error';
|
} from '../error';
|
||||||
import { PenaltyRules } from '../interface';
|
import { PenaltyRules, Tdoc } from '../interface';
|
||||||
import { PERM } from '../model/builtin';
|
import { PERM } from '../model/builtin';
|
||||||
import * as contest from '../model/contest';
|
import * as contest from '../model/contest';
|
||||||
import * as discussion from '../model/discussion';
|
import * as discussion from '../model/discussion';
|
||||||
|
import * as oplog from '../model/oplog';
|
||||||
import problem from '../model/problem';
|
import problem from '../model/problem';
|
||||||
import record from '../model/record';
|
import record from '../model/record';
|
||||||
|
import storage from '../model/storage';
|
||||||
|
import * as system from '../model/system';
|
||||||
import user from '../model/user';
|
import user from '../model/user';
|
||||||
import { Handler, param, Types } from '../service/server';
|
import {
|
||||||
|
Handler, param, post, Types,
|
||||||
|
} from '../service/server';
|
||||||
import { ContestCodeHandler, ContestScoreboardHandler } from './contest';
|
import { ContestCodeHandler, ContestScoreboardHandler } from './contest';
|
||||||
|
|
||||||
const validatePenaltyRules = (input: string) => yaml.load(input);
|
const validatePenaltyRules = (input: string) => yaml.load(input);
|
||||||
@@ -22,7 +29,7 @@ class HomeworkMainHandler extends Handler {
|
|||||||
@param('group', Types.Name, true)
|
@param('group', Types.Name, true)
|
||||||
@param('page', Types.PositiveInt, true)
|
@param('page', Types.PositiveInt, true)
|
||||||
async get(domainId: string, group = '', page = 1) {
|
async get(domainId: string, group = '', page = 1) {
|
||||||
const groups = (await user.listGroup(domainId, this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST) ? undefined : this.user._id))
|
const groups = (await user.listGroup(domainId, this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_HOMEWORK) ? undefined : this.user._id))
|
||||||
.map((i) => i.name);
|
.map((i) => i.name);
|
||||||
if (group && !groups.includes(group)) throw new NotAssignedError(group);
|
if (group && !groups.includes(group)) throw new NotAssignedError(group);
|
||||||
const cursor = contest.getMulti(domainId, {
|
const cursor = contest.getMulti(domainId, {
|
||||||
@@ -93,6 +100,9 @@ class HomeworkDetailHandler extends Handler {
|
|||||||
this.response.body = {
|
this.response.body = {
|
||||||
tdoc, tsdoc, udict, ddocs, page, dpcount, dcount,
|
tdoc, tsdoc, udict, ddocs, page, dpcount, dcount,
|
||||||
};
|
};
|
||||||
|
this.response.body.tdoc.content = this.response.body.tdoc.content
|
||||||
|
.replace(/\(file:\/\//g, `(./${tdoc.docId}/file/`)
|
||||||
|
.replace(/="file:\/\//g, `="./${tdoc.docId}/file/`);
|
||||||
if (
|
if (
|
||||||
(contest.isNotStarted(tdoc) || (!tsdoc?.attend && !contest.isDone(tdoc)))
|
(contest.isNotStarted(tdoc) || (!tsdoc?.attend && !contest.isDone(tdoc)))
|
||||||
&& !this.user.own(tdoc)
|
&& !this.user.own(tdoc)
|
||||||
@@ -226,11 +236,89 @@ class HomeworkEditHandler extends Handler {
|
|||||||
async postDelete(domainId: string, tid: ObjectId) {
|
async postDelete(domainId: string, tid: ObjectId) {
|
||||||
const tdoc = await contest.get(domainId, tid);
|
const tdoc = await contest.get(domainId, tid);
|
||||||
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK);
|
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK);
|
||||||
await contest.del(domainId, tid);
|
await Promise.all([
|
||||||
|
record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' }),
|
||||||
|
contest.del(domainId, tid),
|
||||||
|
storage.del(tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id),
|
||||||
|
]);
|
||||||
this.response.redirect = this.url('homework_main');
|
this.response.redirect = this.url('homework_main');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class HomeworkFilesHandler extends Handler {
|
||||||
|
tdoc: Tdoc;
|
||||||
|
|
||||||
|
@param('tid', Types.ObjectId)
|
||||||
|
async prepare(domainId: string, tid: ObjectId) {
|
||||||
|
this.tdoc = await contest.get(domainId, tid);
|
||||||
|
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK);
|
||||||
|
else this.checkPerm(PERM.PERM_EDIT_HOMEWORK_SELF);
|
||||||
|
}
|
||||||
|
|
||||||
|
@param('tid', Types.ObjectId)
|
||||||
|
async get(domainId: string, tid: ObjectId) {
|
||||||
|
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK);
|
||||||
|
this.response.body = {
|
||||||
|
tdoc: this.tdoc,
|
||||||
|
tsdoc: await contest.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('homework_file_download', { tid, filename }),
|
||||||
|
};
|
||||||
|
this.response.pjax = 'partials/files.html';
|
||||||
|
this.response.template = 'homework_files.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
@param('tid', Types.ObjectId)
|
||||||
|
@post('filename', Types.Filename, 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');
|
||||||
|
}
|
||||||
|
await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id);
|
||||||
|
const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@param('tid', Types.ObjectId)
|
||||||
|
@post('files', Types.ArrayOf(Types.Filename))
|
||||||
|
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) {
|
||||||
|
await Promise.all([
|
||||||
|
storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id),
|
||||||
|
contest.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
|
||||||
|
]);
|
||||||
|
this.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeworkFileDownloadHandler extends Handler {
|
||||||
|
@param('tid', Types.ObjectId)
|
||||||
|
@param('filename', Types.Filename)
|
||||||
|
@param('noDisposition', Types.Boolean)
|
||||||
|
async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false) {
|
||||||
|
this.response.addHeader('Cache-Control', 'public');
|
||||||
|
const target = `contest/${domainId}/${tid}/${filename}`;
|
||||||
|
const file = await storage.getMeta(target);
|
||||||
|
await oplog.log(this, 'download.file.contest', {
|
||||||
|
target,
|
||||||
|
size: file?.size || 0,
|
||||||
|
});
|
||||||
|
this.response.redirect = await storage.signDownloadLink(
|
||||||
|
target, noDisposition ? undefined : filename, false, 'user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function apply(ctx) {
|
export async function apply(ctx) {
|
||||||
ctx.Route('homework_main', '/homework', HomeworkMainHandler, PERM.PERM_VIEW_HOMEWORK);
|
ctx.Route('homework_main', '/homework', HomeworkMainHandler, PERM.PERM_VIEW_HOMEWORK);
|
||||||
ctx.Route('homework_create', '/homework/create', HomeworkEditHandler);
|
ctx.Route('homework_create', '/homework/create', HomeworkEditHandler);
|
||||||
@@ -242,4 +330,6 @@ export async function apply(ctx) {
|
|||||||
);
|
);
|
||||||
ctx.Route('homework_code', '/homework/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_HOMEWORK);
|
ctx.Route('homework_code', '/homework/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_HOMEWORK);
|
||||||
ctx.Route('homework_edit', '/homework/:tid/edit', HomeworkEditHandler);
|
ctx.Route('homework_edit', '/homework/:tid/edit', HomeworkEditHandler);
|
||||||
|
ctx.Route('homework_files', '/homework/:tid/file', HomeworkFilesHandler, PERM.PERM_VIEW_HOMEWORK);
|
||||||
|
ctx.Route('homework_file_download', '/homework/:tid/file/:filename', HomeworkFileDownloadHandler, PERM.PERM_VIEW_HOMEWORK);
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,10 @@ class TrainingDetailHandler extends Handler {
|
|||||||
async postDelete(domainId: string, tid: ObjectId) {
|
async postDelete(domainId: string, tid: ObjectId) {
|
||||||
const tdoc = await training.get(domainId, tid);
|
const tdoc = await training.get(domainId, tid);
|
||||||
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_TRAINING);
|
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_TRAINING);
|
||||||
await training.del(domainId, tid);
|
await Promise.all([
|
||||||
|
training.del(domainId, tid),
|
||||||
|
storage.del(tdoc.files?.map((i) => `training/${domainId}/${tid}/${i.name}`) || [], this.user._id),
|
||||||
|
]);
|
||||||
this.response.redirect = this.url('training_main');
|
this.response.redirect = this.url('training_main');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +278,7 @@ export class TrainingFilesHandler extends Handler {
|
|||||||
@post('files', Types.ArrayOf(Types.Filename))
|
@post('files', Types.ArrayOf(Types.Filename))
|
||||||
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) {
|
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id),
|
storage.del(files.map((t) => `training/${domainId}/${tid}/${t}`), this.user._id),
|
||||||
training.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
|
training.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
|
||||||
]);
|
]);
|
||||||
this.back();
|
this.back();
|
||||||
|
|||||||
@@ -760,7 +760,6 @@ export async function del(domainId: string, tid: ObjectId) {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
document.deleteOne(domainId, document.TYPE_CONTEST, tid),
|
document.deleteOne(domainId, document.TYPE_CONTEST, tid),
|
||||||
document.deleteMultiStatus(domainId, document.TYPE_CONTEST, { docId: tid }),
|
document.deleteMultiStatus(domainId, document.TYPE_CONTEST, { docId: tid }),
|
||||||
document.deleteMulti(domainId, document.TYPE_DISCUSSION, { parentType: document.TYPE_CONTEST, parentId: tid }),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import ScheduleModel from './model/schedule';
|
|||||||
import StorageModel from './model/storage';
|
import StorageModel from './model/storage';
|
||||||
import * as system from './model/system';
|
import * as system from './model/system';
|
||||||
import TaskModel from './model/task';
|
import TaskModel from './model/task';
|
||||||
|
import * as training from './model/training';
|
||||||
import user from './model/user';
|
import user from './model/user';
|
||||||
import {
|
import {
|
||||||
iterateAllContest,
|
iterateAllContest,
|
||||||
@@ -639,6 +640,23 @@ const scripts: UpgradeScript[] = [
|
|||||||
await domain.setRoles(ddoc._id, ddoc.roles);
|
await domain.setRoles(ddoc._id, ddoc.roles);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async function _86_87() {
|
||||||
|
logger.info('Removing unused files...');
|
||||||
|
return await iterateAllDomain(async ({ _id }) => {
|
||||||
|
logger.info('Processing domain %s', _id);
|
||||||
|
const contestFilesList = await StorageModel.list(`contest/${_id}`);
|
||||||
|
const trainingFilesList = await StorageModel.list(`training/${_id}`);
|
||||||
|
const tdocs = await contest.getMulti(_id, {}).toArray();
|
||||||
|
const trdocs = await training.getMulti(_id, {}).toArray();
|
||||||
|
let existsFiles = [];
|
||||||
|
for (const tdoc of tdocs) existsFiles = existsFiles.concat((tdoc.files || []).map((i) => `contest/${_id}/${tdoc.docId}/${i.name}`));
|
||||||
|
await StorageModel.del(contestFilesList.filter((i) => !existsFiles.includes(i.name)).map((i) => i.name));
|
||||||
|
existsFiles = [];
|
||||||
|
for (const tdoc of trdocs) existsFiles = existsFiles.concat((tdoc.files || []).map((i) => `training/${_id}/${tdoc.docId}/${i.name}`));
|
||||||
|
await StorageModel.del(trainingFilesList.filter((i) => !existsFiles.includes(i.name)).map((i) => i.name));
|
||||||
|
logger.info('Domain %s done', _id);
|
||||||
|
});
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default scripts;
|
export default scripts;
|
||||||
@@ -39,7 +39,7 @@ const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create',
|
|||||||
window.location.href = res.url;
|
window.location.href = res.url;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const message = `Confirm deleting this ${pagename.split('_')[0]}? Its status will be deleted as well.`;
|
const message = `Confirm deleting this ${pagename.split('_')[0]}? Its files and status will be deleted as well.`;
|
||||||
return new ConfirmDialog({
|
return new ConfirmDialog({
|
||||||
$body: tpl.typoMsg(i18n(message)),
|
$body: tpl.typoMsg(i18n(message)),
|
||||||
}).open().then((action) => {
|
}).open().then((action) => {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function handleDrop(ev) {
|
|||||||
handleClickUpload(files);
|
handleClickUpload(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = new NamedPage(['home_files', 'contest_manage', 'training_files'], () => {
|
const page = new NamedPage(['home_files', 'contest_manage', 'training_files', 'homework_files'], () => {
|
||||||
$(document).on('click', '[name="upload_file"]', () => handleClickUpload());
|
$(document).on('click', '[name="upload_file"]', () => handleClickUpload());
|
||||||
$(document).on('click', '[name="remove_selected"]', () => handleClickRemoveSelected());
|
$(document).on('click', '[name="remove_selected"]', () => handleClickRemoveSelected());
|
||||||
$(document).on('dragover', '.files', (ev) => handleDragOver(ev));
|
$(document).on('dragover', '.files', (ev) => handleDragOver(ev));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.page--home_files, .page--training_files, .page--contest_detail, .page--contest_manage
|
.page--home_files, .page--training_files, .page--contest_detail, .page--contest_manage, .page--homework_files
|
||||||
.col--checkbox
|
.col--checkbox
|
||||||
width: 60px
|
width: 60px
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import $ from 'jquery';
|
||||||
|
import { ConfirmDialog } from 'vj/components/dialog';
|
||||||
|
import { NamedPage } from 'vj/misc/Page';
|
||||||
|
import { i18n, request, tpl } from 'vj/utils';
|
||||||
|
|
||||||
|
export default new NamedPage('homework_edit', () => {
|
||||||
|
let confirmed = false;
|
||||||
|
$(document).on('click', '[value="delete"]', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (confirmed) {
|
||||||
|
return request.post('', { operation: 'delete' }).then((res) => {
|
||||||
|
window.location.href = res.url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const message = 'Confirm deleting this homework? Its files and status will be deleted as well.';
|
||||||
|
return new ConfirmDialog({
|
||||||
|
$body: tpl.typoMsg(i18n(message)),
|
||||||
|
}).open().then((action) => {
|
||||||
|
if (action !== 'yes') return;
|
||||||
|
confirmed = true;
|
||||||
|
ev.target.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ export default new NamedPage('training_edit', () => {
|
|||||||
window.location.href = res.url;
|
window.location.href = res.url;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const message = 'Confirm deleting this training? Its status will be deleted as well.';
|
const message = 'Confirm deleting this training? Its files and status will be deleted as well.';
|
||||||
return new ConfirmDialog({
|
return new ConfirmDialog({
|
||||||
$body: tpl.typoMsg(i18n(message)),
|
$body: tpl.typoMsg(i18n(message)),
|
||||||
}).open().then((action) => {
|
}).open().then((action) => {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% 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">
|
||||||
|
{% include "partials/homework_sidebar.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
<li class="menu__item"><a class="menu__link" href="{{ url('homework_edit', tid=tdoc.docId) }}">
|
<li class="menu__item"><a class="menu__link" href="{{ url('homework_edit', tid=tdoc.docId) }}">
|
||||||
<span class="icon icon-edit"></span> {{ _('Edit Homework') }}
|
<span class="icon icon-edit"></span> {{ _('Edit Homework') }}
|
||||||
</a></li>
|
</a></li>
|
||||||
|
<li class="menu__item"><a class="menu__link" href="{{ url('homework_files', tid=tdoc.docId) }}">
|
||||||
|
<span class="icon icon-file"></span> {{ _('Files') }}
|
||||||
|
</a></li>
|
||||||
{% endif %}
|
{% 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 handler.user.own(tdoc) or handler.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE) or handler.user.hasPerm(perm.PERM_READ_RECORD_CODE) %}
|
||||||
<li class="menu__item"><a class="menu__link" href="{{ url('homework_code', tid=tdoc.docId) }}" data-no-instant>
|
<li class="menu__item"><a class="menu__link" href="{{ url('homework_code', tid=tdoc.docId) }}" data-no-instant>
|
||||||
|
|||||||
Reference in new issue
Block a user