/**
* 图片标注任务类型,发布者为每个作业上传图片,提出数个问题,由承接者对图片进行文字标注来回答。
*
* 任务和作业本身的多态性是建立在`task.data`和`assignment.data`这两个无schema的数据上的。
*
* 团片标注任务`task.data`主要包含了以下几个字段:
* - question {string} 问题
* - choiceAmount {number} 问题的选项数量
* - choices {string[]} 问题的各个选项
* - progress {number} 当前的委派进度,每创建一个作业加一
* - images {string[]} 解压后的数据的文件名,在dir下
* - submitMultipleTimes {boolean} 可选设置,是否允许一个人参与多次,默认false
* - signupMultipleTimes {boolean} 可选设置,是否允许一个人被拒后再次报名,默认false
* - noSignup {boolean} 可选设置,是否自动完成报名的步骤(即无需报名),默认false
* - submitAutoPass {boolean} 可选设置,是否自动审批任务(即任务提交即接受),默认false
* - dir {string} 内部使用,这是`uploads`目录下属于该工程的文件夹。
* - signedUsers {string[]} 内部使用,对于非noSignup,这是报名了的用户的列表
* - blockedUsers {string[]} 内部使用,对于非noSignup且非signupMultipleTimes,这是封禁用户的列表
* - exported {boolean} 内部使用,项目是否导出
*
* 而`assignment.data`主要包含了以下几个字段,而`assignment.valid`对于非signup类任务,表示能否继续猜:
* - signup {boolean} 表示这是一个用于注册的任务
* - answer {string[]} 对于非signup类任务,这是对图片的标注结果(每张图可能有多个题目,故有多个标注答案)
* @module task-templates/guess-number
*/
const ajv = new (require('ajv'))();
const path = require('path');
const logger = require('winston');
const fs = require('fs');
const {errorsEnum, coreOkay, coreThrow, coreValidate, coreAssert} = require('../src/core/errors');
const {randomAlnumString, promisify} = require('../src/utils');
const rimraf = require('rimraf');
const extract = require('extract-zip');
const multer = require('./multer');
const exportedFilename = 'result.csv';
/**
* 将`task.data`转换为对象发送给对应可见性的人。这个函数会在postTaskData或者getTaskData返回
* undefined的时候默认调用。理论上,调用者要么是任务发布者,要么是订阅者
*
* 对本任务而言,而`exportedResult`只有任务拥有者能获得。
*
* @param task {object}
* @param auth {object}
* @return {object}
*/
function taskDataToPlainObject(task, auth) {
const isPublisher = auth && task.publisher.equals(auth.uid);
const result = {};
if (task.data) {
if (task.data.question !== undefined)
result.data.question = task.data.question;
if (task.data.choiceAmount !== undefined)
result.data.choiceAmount = task.data.choiceAmount;
if (task.data.choices !== undefined)
result.data.choices = task.data.choices;
if (task.data.progress !== undefined)
result.data.progress = task.data.progress;
if (task.data.images !== undefined)
result.data.images = task.data.images;
if (task.data.submitMultipleTimes !== undefined)
result.submitMultipleTimes = task.data.submitMultipleTimes;
if (task.data.signupMultipleTimes !== undefined)
result.signupMultipleTimes = task.data.signupMultipleTimes;
if (task.data.noSignup !== undefined)
result.noSignup = task.data.noSignup;
if (task.data.submitAutoPass !== undefined)
result.submitAutoPass = task.data.submitAutoPass;
if (isPublisher && task.data.exported)
result.exportedResult = '/uploads/' + task.data.dir + '/' + exportedFilename;
}
return result;
}
function cleanFiles(task, dir) {
if (task.data && task.data.dir) {
dir = path.join(dir, task.data.dir);
rimraf(dir, err => {
if (err) {
logger.error(`Failed to delete directory "${dir}".`);
logger.error(err);
}
});
}
}
/**
* 这是postTaskData AJAX请求执行之前的中间件。通常这里用以处理文件上传的multipart请求。缺省
* 情况下直接调用postTaskData。
* 此任务中将上传的压缩包解压至该项目文件夹下。
*
* @param ctx {object}
* @param next {function}
* @return {Promise<void>}
*/
async function postTaskDataMiddleware(ctx, next) {
/*
multer({
destination: ctx.global.config['temp-dir'],
types: ['application/zip', 'application/x-rar-compressed'],
}).single('data');
*/
const {params, global} = ctx;
const {tasks} = global;
const task = await tasks.findById(params.id).notDeleted().select('+data');
if (params.file) {
try {
await extract(path.join(global.config['temp-dir'], params.file.filename),
task.data.dir
);
} catch (err) {
coreThrow(err, 'failed extracting zip');
}
let files;
try {
files = await fs.readdir(task.data.dir);
} catch (err) {
coreThrow(err, 'failed reading images names');
}
coreAssert(files.length === task.total, errorsEnum.INVALID, 'images amount not matching task\'s total');
files.forEach(file => {
task.data.images.push(file);
});
}
await next();
}
const postTaskDataSchema = ajv.compile({
type: 'object',
required: ['total', 'question', 'choiceAmount', 'choices'],
properties: {
question: {type: 'string'},
choiceAmount: {type: 'integer', minimum: 1},
choices: {type: 'array', items: {type: 'string'}},
total: {type: 'integer', minimum: 1, maximum: 100},
submitMultipleTimes: {type: 'boolean'},
signupMultipleTimes: {type: 'boolean'},
noSignup: {type: 'boolean'},
submitAutoPass: {type: 'boolean'}
},
additionalProperties: false
});
/**
* 提交任务的数据,这里已经确保提交者为任务的发布者,且任务处于`EDITING`状态。但该任务可能已经
* 提交过数据,因而应当清除以前的数据。原则上,这个过程结束后,应当重置任务的`total`、`remain`、
* `valid`和`data`状态。原则上,该过程如果没有返回错误码,`valid`应当为true。
*
* 如果这个函数没有返回值,则会依据情况调用taskDataToPlainObject。如果有返回值则会作为响应。
*
* 对于本任务而言,主要上传的数据是一些设置选项以及图片。
*
* @param task {object} 任务对象
* @param params {object} 请求的数据
* - query 请求的query
* - data {boolean} 是否返回数据
* - data 请求的data
* - total {number} 必须,全部的任务(图片)数目,1到100
* - question {string} 必须,问题
* - choiceAmount {number} 必须,选项数量,大于0
* - choices {string[]} 必须,数量须与choiceAmount吻合
* - submitMultipleTimes {boolean} 可选设置,是否允许一个人参与多次,默认false
* - signupMultipleTimes {boolean} 可选设置,是否允许一个人被拒后再次报名,默认false
* - noSignup {boolean} 可选设置,是否无需报名,默认false
* - submitAutoPass {boolean} 可选设置,是否自动审批任务(即任务提交即接受),默认false
* @param global {object}
* @return {Promise<void>}
*/
async function postTaskData(task, params, global) {
const {config} = global;
coreValidate(postTaskDataSchema, params.data);
coreAssert(params.data.choices.length === params.data.choiceAmount,
errorsEnum.SCHEMA,
'Unmatched choice amount.');
cleanFiles(task, config['upload-dir']);
const dirname = randomAlnumString(40);
const dir = path.join(config['upload-dir'], dirname);
await promisify(fs.mkdir)(dirname);
params.data.dir = dir;
if (!params.data.noSignup) {
params.data.signedUsers = [];
if (!params.data.signupMultipleTimes)
params.data.blockedUsers = [];
}
task.valid = true;
task.total = params.data.total;
task.remain = task.total;
delete params.data.total;
task.data = params.data;
task.data.progress = 0;
task.markModified('data');
await task.save();
}
/**
* 获取任务的数据。这里已经确保要么提交者为任务的发布者,要么提交者为订阅者且任务处于`PUBLISHED`状态
* 如果这个函数没有返回值,则会依据情况调用postTaskDataSchema。如果有返回值则会作为响应。
*
* 对于本任务,如果不是发布者,就采用默认的调用taskDataToPlainObject。如果是订阅者且`PUBLISHED`,
* 还会返回在`userStatus`内返回以下几个字段:
* - signed:用户是否已经注册,对于noSignup的任务而言,这永远为真
* - blocked:用户是否被拒绝参加改任务,对于noSignup或signupMultipleTimes的任务而言,这永远为假
* - signing:对于非signed用户而言,这个属性表示用户是否正在报名
* - created:对于任务非submitMultipleTimes且用户signed,表示是否已经创建了一个作业
* @param task {object} 任务对象
* @param params {object}
* @param global {object}
* @return {Promise<object|void>}
*/
async function getTaskData(task, params, global) {
const {users, tasks, assignments} = global;
if (params.auth && (params.auth.role & users.roleEnum.SUBSCRIBER) &&
task.status === tasks.statusEnum.PUBLISHED) {
const userStatus = {};
if (task.data.noSignup) {
userStatus.signed = true;
userStatus.blocked = false;
} else {
userStatus.signed = task.data.signedUsers.indexOf(params.auth.uid) !== -1;
if (userStatus.signed === false)
userStatus.signing = (await assignments.findOne({
task: task._id,
subscriber: params.auth.uid,
status: assignments.statusEnum.SUBMITTED,
'data.signup': true
}).notDeleted()) !== null;
if (task.data.signupMultipleTimes)
userStatus.blocked = false;
else
userStatus.blocked = task.data.blockedUsers.indexOf(params.auth.uid) !== -1;
}
if (userStatus.signed && !task.data.submitMultipleTimes)
userStatus.created = (await assignments.findOne({
task: task._id,
subscriber: params.auth.uid,
'data.signup': {$ne: true}
}).notDeleted()) !== null;
return coreOkay({
data: Object.assign({userStatus}, taskDataToPlainObject(task, params.auth))
});
}
}
/**
* 将`assignment.data`转换为对象发送给对应可见性的人。这个函数会在createAssignment、
* getAssignmentData或者getAssignmentData返回undefined的时候默认调用。
* 理论上,调用者是订阅者或者发布者。
*
* 对本任务而言,只有作业是一个非报名作业才返回数据,如下:
* - sequence {number} 题号
* - finished {boolean} 是否完成
* - answer {number} 答案
* - image {string} 图片名,在task.data.dir下
* @param assignment {object}
* @param auth {object}
* @return {object}
*/
function assignmentDataToPlainObject(assignment, auth) {
if (!assignment.data || assignment.data.signup)
return {};
const result = {
finished: assignment.valid,
sequence: assignment.data.sequence
};
if (assignment.data.answer !== undefined)
result.answer = assignment.data.answer;
return result;
}
const createAssignmentSchema = ajv.compile({
type: 'object',
properties: {
signup: {type: 'boolean'}
},
additionalProperties: false
});
/**
* 创建任务,这里已经确保提交者为订阅者,且任务处于`PUBLISHED`状态。原则上,这个过程结束后,
* 应当设置好`valid`、`status`、`summary`、`data`的字段。如果该函数缺省,则会创建一个
* 不valid、编辑状态、无`summary`和`data`字段的任务。assignment会被保存。
*
* 如果这个函数没有返回值,则依据情况调用assignmentDataToPlainObject。
* 如果有返回值则会作为响应。
*
* 对于本任务而言,主要确认创建的是一个报名作业还是图片标注作业。
*
* @param task {object} 任务对象
* @param assignment {object} 作业对象
* @param params {object} 请求的数据
* - query 请求的query
* - populate {boolean} 是否返回
* - data {boolean} 是否返回数据
* - data 请求的data
* - task {string} 任务
* - data {object} 额外的数据
* - signup {boolean} 是否是报名任务
* @param global {object}
* @return {Promise<void>}
*/
async function createAssignment(task, assignment, params, global) {
const {assignments} = global;
if (params.data.data !== undefined)
coreValidate(createAssignmentSchema, params.data.data);
const signup = params.data.data && params.data.data.signup;
if (signup) {
coreAssert(!task.data.noSignup, errorsEnum.INVALID, 'Task requires no signup');
coreAssert(task.data.signedUsers.indexOf(params.auth.uid) === -1,
errorsEnum.INVALID, 'User has already signed up');
coreAssert(task.data.signupMultipleTimes ||
task.data.blockedUsers.indexOf(params.auth.uid) === -1,
// eslint-disable-next-line
errorsEnum.INVALID, 'User has been blocked');
coreAssert((await assignments.findOne({
task: task._id,
subscriber: params.auth.uid,
status: assignments.statusEnum.SUBMITTED,
'data.signup': true
}).notDeleted()) === null, errorsEnum.INVALID, 'User has already created a signup request');
assignment.valid = true;
assignment.summary = '报名';
assignment.status = assignments.statusEnum.SUBMITTED;
assignment.data = {signup: true};
assignment.markModified('data');
} else {
coreAssert((task.total < 0 || task.remain > 0) &&
(task.deadline === false || Date.now() <= task.deadline.getTime()),
// eslint-disable-next-line
errorsEnum.INVALID, 'Task has completed');
coreAssert(task.progress < task.total, errorsEnum.INVALID, 'Assignments all assigned');
coreAssert(task.data.noSignup || task.data.signedUsers.indexOf(params.auth.uid) !== -1,
errorsEnum.INVALID, 'User has not signed up');
coreAssert(task.data.submitMultipleTimes || (await assignments.findOne({
task: task._id,
subscriber: params.auth.uid,
'data.signup': {$ne: true}
}).notDeleted()) === null, errorsEnum.INVALID, 'User has already created an assignment');
assignment.summary = '未完成';
assignment.data = {
sequence: task.progress + 1,
answer: undefined
};
if (task.images !== undefined)
assignment.image = task.images[task.progress];
task.progress += 1;
task.markModified('data');
await task.save();
assignment.markModified('data');
}
}
/**
* 这个函数是作业状态更改的钩子,确保作业的状态发生更改,可能的更改包括订阅者从编辑到提交,同时作业必须为valid,
* 以及发布者从提交到接受或拒绝。assignment会被保存。
*
* 本任务主要是处理自动pass和验证通过的逻辑的。
*
* @param assignment {object}
* @param params
* @param global
* @return {Promise<void>}
*/
async function assignmentStatusChanged(assignment, params, global) {
if (assignment.data.signup) {
const {tasks, assignments} = global;
const task = await tasks.findById(assignment.task).notDeleted().select('+data');
coreAssert(task, errorsEnum.INVALID, 'Task deleted');
if (assignment.status === assignments.statusEnum.ADMITTED) {
task.data.signedUsers.push(assignment.subscriber.toString());
task.markModified('data.signedUsers');
await task.save();
} else if (!task.data.signupMultipleTimes) {
task.data.blockedUsers.push(assignment.subscriber.toString());
task.markModified('data.signedUsers');
await task.save();
}
} else {
const {tasks, assignments} = global;
const task = await tasks.findById(assignment.task).notDeleted().select('+data');
coreAssert(task, errorsEnum.INVALID, 'Task deleted');
if (task.data.submitAutoPass && assignment.status === assignments.statusEnum.SUBMITTED)
assignment.status = assignments.statusEnum.ADMITTED;
if (task.total > 0 && assignment.status === assignments.statusEnum.ADMITTED)
await tasks.findOneAndUpdate({_id: task._id}, {$inc: {'task.remain': -1}}).notDeleted();
}
}
/**
* 这是postAssignmentData AJAX请求执行之前的中间件。通常这里用以处理文件上传的multipart请求。
* 缺省情况下直接调用postAssignmentData。
*
* 对于本任务的作业而言,没有文件上传。不做任何处理。
*
* @param ctx {object}
* @param next {function}
* @return {Promise<void>}
*/
async function postAssignmentDataMiddleware(ctx, next) {
await next();
}
const postAssignmentDataSchema = ajv.compile({
type: 'object',
required: ['answer'],
properties: {
answer: {type: 'integer', minimum: 1}
},
additionalProperties: false
});
/**
* 提交作业的数据,这里已经确保提交者为作业的订阅者,且作业处于`EDITING`状态。但该作业可能已经
* 提交过数据,因而应当清除以前的数据。原则上,这个过程结束后,应当重置作业的`summary`、
* `valid`和`data`状态。原则上,该过程如果没有返回错误码,`valid`应当为true。
*
* 如果这个函数没有返回值,则会依据情况调用assignmentDataToPlainObject。如果有返回值则会作为响应。
*
* 对于本任务的作业而言,情况略有不同,这是有状态的,不可逆的故而不清除数据。主要上传的数据是猜的答案。
* @param assignment {object} 任务对象
* @param params {object} 请求的数据
* - query 请求的query
* - data {boolean} 是否返回数据
* - data 请求的data
* - answer {number} 必须,介于1到选项数量
* @param global {object}
* @return {Promise<object>}
*/
async function postAssignmentData(assignment, params, global) {
coreValidate(postAssignmentDataSchema, params.data);
coreAssert(params.data.answer <= assignment.data.choiceAmount, errorsEnum.INVALID, 'answer bigger than choice amount');
coreAssert(!assignment.valid, errorsEnum.INVALID, 'Assignment already finished');
assignment.data.answr = params.data.answer;
assignment.summary = '已完成';
assignment.valid = true;
assignment.markModified('data');
await assignment.save();
return coreOkay({
data: params.query.data === 'true'
? assignmentDataToPlainObject(assignment, params.auth)
: undefined
});
}
/**
* 获取作业的数据。这里已经确保要么提交者为作业的订阅者或发布者。如果这个函数没有返回值,
* 则会依据情况调用assignmentDataToPlainObject。如果有返回值则会作为响应。
*
* 对于本任务,就采用默认的assignmentDataToPlainObject。
* @param assignment {object} 任务对象
* @param params {object}
* @param global {object}
* @return {Promise<void>}
*/
async function getAssignmentData(assignment, params, global) {}
module.exports = {
meta: {
id: 'mark-image',
name: '图片标注',
description: '上传图片并设计选择题让订阅者选择答案。'
},
taskDataToPlainObject,
postTaskDataMiddleware,
postTaskData,
getTaskData,
assignmentDataToPlainObject,
createAssignment,
assignmentStatusChanged,
postAssignmentDataMiddleware,
postAssignmentData,
getAssignmentData
};