core&ui: add homework files & enhance delete operation (#755)

This commit is contained in:
panda authored and GitHub committed 2024-03-29 10:36:55 +08:00
1 parent 61e56adead
commit f0219116f3
12 files changed
+175 -13

No files matched your search

+1
View File
@@ -515,6 +515,7 @@ export class ContestEditHandler extends Handler {
ScheduleModel.deleteMany({
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');
}
+96 -6
View File
@@ -1,18 +1,25 @@
import { statSync } from 'fs-extra';
import yaml from 'js-yaml';
import { pick } from 'lodash';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
import { Time } from '@hydrooj/utils/lib/utils';
import { sortFiles, Time } from '@hydrooj/utils/lib/utils';
import {
ContestNotFoundError, HomeworkNotLiveError, NotAssignedError, ValidationError,
ContestNotFoundError, FileLimitExceededError, FileUploadError, HomeworkNotLiveError, NotAssignedError, ValidationError,
} from '../error';
import { PenaltyRules } from '../interface';
import { PenaltyRules, Tdoc } from '../interface';
import { PERM } from '../model/builtin';
import * as contest from '../model/contest';
import * as discussion from '../model/discussion';
import * as oplog from '../model/oplog';
import problem from '../model/problem';
import record from '../model/record';
import storage from '../model/storage';
import * as system from '../model/system';
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';
const validatePenaltyRules = (input: string) => yaml.load(input);
@@ -22,7 +29,7 @@ class HomeworkMainHandler extends Handler {
@param('group', Types.Name, true)
@param('page', Types.PositiveInt, true)
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);
if (group && !groups.includes(group)) throw new NotAssignedError(group);
const cursor = contest.getMulti(domainId, {
@@ -93,6 +100,9 @@ class HomeworkDetailHandler extends Handler {
this.response.body = {
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 (
(contest.isNotStarted(tdoc) || (!tsdoc?.attend && !contest.isDone(tdoc)))
&& !this.user.own(tdoc)
@@ -226,11 +236,89 @@ class HomeworkEditHandler extends Handler {
async postDelete(domainId: string, tid: ObjectId) {
const tdoc = await contest.get(domainId, tid);
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');
}
}
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) {
ctx.Route('homework_main', '/homework', HomeworkMainHandler, PERM.PERM_VIEW_HOMEWORK);
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_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);
}
+5 -2
View File
@@ -173,7 +173,10 @@ class TrainingDetailHandler extends Handler {
async postDelete(domainId: string, tid: ObjectId) {
const tdoc = await training.get(domainId, tid);
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');
}
}
@@ -275,7 +278,7 @@ export class TrainingFilesHandler extends Handler {
@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),
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)) }),
]);
this.back();
-1
View File
@@ -760,7 +760,6 @@ export async function del(domainId: string, tid: ObjectId) {
await Promise.all([
document.deleteOne(domainId, document.TYPE_CONTEST, tid),
document.deleteMultiStatus(domainId, document.TYPE_CONTEST, { docId: tid }),
document.deleteMulti(domainId, document.TYPE_DISCUSSION, { parentType: document.TYPE_CONTEST, parentId: tid }),
]);
}
+18
View File
@@ -20,6 +20,7 @@ import ScheduleModel from './model/schedule';
import StorageModel from './model/storage';
import * as system from './model/system';
import TaskModel from './model/task';
import * as training from './model/training';
import user from './model/user';
import {
iterateAllContest,
@@ -639,6 +640,23 @@ const scripts: UpgradeScript[] = [
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;
@@ -39,7 +39,7 @@ const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create',
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({
$body: tpl.typoMsg(i18n(message)),
}).open().then((action) => {
+1 -1
View File
@@ -92,7 +92,7 @@ function handleDrop(ev) {
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="remove_selected"]', () => handleClickRemoveSelected());
$(document).on('dragover', '.files', (ev) => handleDragOver(ev));
+1 -1
View File
@@ -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
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;
});
}
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({
$body: tpl.typoMsg(i18n(message)),
}).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) }}">
<span class="icon icon-edit"></span> {{ _('Edit Homework') }}
</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 %}
{% 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>