diff --git a/client/components/PaneMCQ.jsx b/client/components/PaneMCQ.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c77b1cc47063620eb9255b186a229aabfb052617 --- /dev/null +++ b/client/components/PaneMCQ.jsx @@ -0,0 +1,146 @@ +import React from 'react' + +/** + * PanelMCQ is a component that allows the user to generate mcq options + */ +class PanelMCQ extends React.Component { + constructor (props) { + super(props) + this.onChangeNPA = this.onChangeNPA.bind(this) + this.onChangeLabelType = this.onChangeLabelType.bind(this) + this.generateLabels = this.generateLabels.bind(this) + this.state = { + chosenLabelType: 0, + nrPossibleAnswers: 2, + labelTypes: ['None', 'True/False', 'A, B, C ...', '1, 2, 3 ...'] + } + } + + // this function is called when the input is changed for the number of possible answers + onChangeNPA (e) { + let value = parseInt(e.target.value) + if (!isNaN(value)) { + if (this.state.chosenLabelType === 1) { + value = 2 + } + this.setState({ + nrPossibleAnswers: value + }) + } + } + + // this function is called when the input is changed for the desired label type + onChangeLabelType (e) { + let value = parseInt(e.target.value) + if (!isNaN(value)) { + this.setState({ + chosenLabelType: value + }) + if (parseInt(value) === 1) { + this.setState({ + nrPossibleAnswers: 2 + }) + } + } + } + + /** + * This function generates an array with the labels for each option + * @param nrLabels the number of options that need to be generated + * @returns {any[]|string[]|number[]} + */ + generateLabels (nrLabels) { + let type = this.state.chosenLabelType + + switch (type) { + case 1: + return ['T', 'F'] + case 2: + return Array.from(Array(nrLabels).keys()).map((e) => String.fromCharCode(e + 65)) + case 3: + return Array.from(Array(nrLabels).keys()).map(e => e + 1) + default: + return Array(nrLabels).fill(' ') + } + } + + /** + * This function renders the panel with the inputs for generating multiple choice options + * @returns the react component containing the mcq panel + */ + render () { + return ( + <nav className='panel'> + <p className='panel-heading'> + Multiple Choice Question + </p> + <div className='panel-block'> + <div className='field'> + <React.Fragment> + <label className='label'>Number possible answers</label> + <div className='control'> + {(function () { + var optionList = [] + for (var i = 1; i <= this.props.totalNrAnswers; i++) { + const optionElement = <option key={i} value={String(i)}>{i}</option> + optionList.push(optionElement) + } + return (<div className='select is-hovered is-fullwidth'> + <select value={this.state.nrPossibleAnswers} onChange={this.onChangeNPA}>{optionList}</select> + </div>) + }.bind(this)())} + </div> + </React.Fragment> + </div> + </div> + <div className='panel-block'> + <div className='field'> + <React.Fragment> + <label className='label'>Answer boxes labels</label> + <div className='control'> + <div className='select is-hovered is-fullwidth'> + {(function () { + var optionList = this.state.labelTypes.map( + (label, i) => <option key={i} value={String(i)}>{label}</option> + ) + return ( + <div className='select is-hovered is-fullwidth'> + <select value={this.state.chosenLabelType} onChange={this.onChangeLabelType}> + {optionList} + </select> + </div> + ) + }.bind(this)())} + </div> + </div> + </React.Fragment> + </div> + </div> + <div className='panel-block field is-grouped'> + <button + disabled={this.props.disabledGenerateBoxes} + className='button is-link is-fullwidth' + onClick={() => { + let npa = this.state.nrPossibleAnswers + let labels = this.generateLabels(npa) + this.props.onGenerateBoxesClick(labels) + }} + > + Generate + </button> + <button + disabled={this.props.disabledDeleteBoxes} + className='button is-danger is-fullwidth' + onClick={() => { + this.props.onDeleteBoxesClick() + }} + > + Delete + </button> + </div> + </nav> + ) + } +} + +export default PanelMCQ diff --git a/client/components/answer_box.png b/client/components/answer_box.png new file mode 100644 index 0000000000000000000000000000000000000000..eafc3d30a9f99f21693f9075b9d446e43ce933fc Binary files /dev/null and b/client/components/answer_box.png differ diff --git a/client/views/Exam.css b/client/views/Exam.css index f126f590b4b1ecb24c75893ea13582ced0d185a4..e70724da849022d617945ccebe204ea143ad9fea 100644 --- a/client/views/Exam.css +++ b/client/views/Exam.css @@ -1,3 +1,33 @@ +:root { + --option-width:20px; + --label-font-size:14px; +} + +div.mcq-widget { + display:inline-flex; +} + +div.mcq-option { + display: block; + width: var(--option-width); + padding:2px; + box-sizing: content-box; + height: auto; +} + +div.mcq-option div.mcq-option-label { + display:block; + font-family: Arial, Helvetica, sans-serif; + font-size: var(--label-font-size); + text-align: center; +} + +div.mcq-option img.mcq-box { + display: block; + margin:auto; +} + + .editor-content { background-color: #ddd; border-radius: 10px diff --git a/client/views/Exam.jsx b/client/views/Exam.jsx index 4f48dfae59bd06c183e41f997c54f471f3ba299b..3ccbc0087586e63a07fe50a09b7b2900dded0730 100644 --- a/client/views/Exam.jsx +++ b/client/views/Exam.jsx @@ -6,6 +6,7 @@ import Hero from '../components/Hero.jsx' import './Exam.css' import GeneratedExamPreview from '../components/GeneratedExamPreview.jsx' import PanelGenerate from '../components/PanelGenerate.jsx' +import PanelMCQ from '../components/PaneMCQ.jsx' import ExamEditor from './ExamEditor.jsx' import update from 'immutability-helper' import ExamFinalizeMarkdown from './ExamFinalize.md' @@ -23,7 +24,9 @@ class Exams extends React.Component { widgets: [], previewing: false, deletingExam: false, - deletingWidget: false + deletingWidget: false, + deletingMCWidget: false, + showPanelMCQ: false } static getDerivedStateFromProps = (newProps, prevState) => { @@ -38,7 +41,13 @@ class Exams extends React.Component { id: problem.id, page: problem.page, name: problem.name, - graded: problem.graded + graded: problem.graded, + mc_options: problem.mc_options.map((option) => { + option.widget.x -= 7 + option.widget.y -= 21 + return option + }), + isMCQ: problem.mc_options && problem.mc_options.length !== 0 // is the problem a mc question - used to display PanelMCQ } } }) @@ -102,6 +111,19 @@ class Exams extends React.Component { })) } + createNewWidget = (widgetData) => { + this.setState((prevState) => { + return { + selectedWidgetId: widgetData.id, + widgets: update(prevState.widgets, { + [widgetData.id]: { + $set: widgetData + } + }) + } + }) + } + deleteWidget = (widgetId) => { const widget = this.state.widgets[widgetId] if (widget) { @@ -166,17 +188,10 @@ class Exams extends React.Component { selectedWidgetId: widgetId }) }} - createNewWidget={(widgetData) => { - this.setState((prevState) => { - return { - selectedWidgetId: widgetData.id, - widgets: update(prevState.widgets, { - [widgetData.id]: { - $set: widgetData - } - }) - } - }) + createNewWidget={this.createNewWidget} + updateMCWidgetPosition={this.updateMCWidgetPosition} + updateExam={() => { + this.props.updateExam(this.props.examID) }} /> ) @@ -236,6 +251,143 @@ class Exams extends React.Component { }) } + /** + * This function deletes the mc options coupled to a problem. + */ + deleteMCWidget = () => { + const widget = this.state.widgets[this.state.selectedWidgetId] + const options = widget.problem.mc_options + if (options.length > 0) { + options.forEach((option) => { + api.del('mult-choice/' + option.id) + .catch(err => { + console.log(err) + err.json().then(res => { + this.setState({ + deletingMCWidget: false + }) + Notification.error('Could not delete multiple choice option' + + (res.message ? ': ' + res.message : '')) + // update to try and get a consistent state + this.props.updateExam(this.props.examID) + }) + }) + }) + + // remove the mc options from the state + // note that his can happen before they are removed in the DB due to async calls + this.setState((prevState) => { + return { + widgets: update(prevState.widgets, { + [widget.id]: { + problem: { + mc_options: { + $set: [] + } + } + } + }), + deletingMCWidget: false + } + }) + } + } + + /** + * This method creates a widget object and adds it to the corresponding problem + * @param problemWidget The widget the mc option belongs to + * @param data the mc option + */ + createNewMCOWidget = (problemWidget, data) => { + this.setState((prevState) => { + return { + widgets: update(prevState.widgets, { + [this.state.selectedWidgetId]: { + problem: { + mc_options: { + $push: [data] + } + } + } + }) + } + }) + } + + /** + * This method is called when the mcq widget is moved. The positions of the options are stored separately and they + * all need to be updated + * @param widget the problem widget that includes the mcq widget + * @param data the new location of the mcq widget (the location of the top-left corner) + */ + updateMCWidgetPosition = (widget, data) => { + let newMCO = widget.problem.mc_options.map((option, i) => { + return { + 'widget': { + 'x': { + $set: data.x + i * 24 + }, + 'y': { + // each mc option needs to be positioned next to the previous option and should not overlap it + $set: data.y + } + } + } + }) + + // update the state with the new locations + this.setState(prevState => ({ + widgets: update(prevState.widgets, { + [widget.id]: { + 'problem': { + 'mc_options': newMCO + } + } + }) + })) + } + + /** + * This method generates MC options by making the right calls to the api and creating + * the widget object in the mc_options array of the corresponding problem. + * @param problemWidget the problem widget the mc options belong to + * @param labels the labels for the options + * @param index the index in the labels array (the function is recusive, this index is increased) + * @param xPos x position of the current option + * @param yPos y position of the current option + */ + generateAnswerBoxes = (problemWidget, labels, index, xPos, yPos) => { + if (labels.length === index) return + + let data = { + 'label': labels[index], + 'problem_id': problemWidget.problem.id, + 'feedback_id': null, + 'widget': { + 'name': 'mc_option_' + labels[index], + 'x': xPos + 7, + 'y': yPos + 21, + 'type': 'mcq_widget' + } + } + + const formData = new window.FormData() + formData.append('name', data.widget.name) + formData.append('x', data.widget.x) + formData.append('y', data.widget.y) + formData.append('problem_id', data.problem_id) + formData.append('label', data.label) + api.put('mult-choice/', formData).then(result => { + data.id = result.mult_choice_id + data.widget.x -= 7 + data.widget.y -= 21 + this.createNewMCOWidget(problemWidget, data) + this.generateAnswerBoxes(problemWidget, labels, index + 1, xPos + 24, yPos) + }).catch(err => { + console.log(err) + }) + } + SidePanel = (props) => { const selectedWidgetId = this.state.selectedWidgetId let selectedWidget = selectedWidgetId && this.state.widgets[selectedWidgetId] @@ -243,11 +395,16 @@ class Exams extends React.Component { let widgetEditDisabled = this.state.previewing || !problem let isGraded = problem && problem.graded let widgetDeleteDisabled = widgetEditDisabled || isGraded + let totalNrAnswers = 12 // the upper limit for the nr of possible answer boxes + let containsMCOptions = (problem && problem.mc_options.length > 0) || false + let disabledDeleteBoxes = !containsMCOptions + let isMCQ = (problem && problem.isMCQ) || false return ( <React.Fragment> <this.PanelEdit disabledEdit={widgetEditDisabled} + disableIsMCQ={widgetEditDisabled || containsMCOptions} disabledDelete={widgetDeleteDisabled} onDeleteClick={() => { this.setState({deletingWidget: true}) @@ -268,7 +425,42 @@ class Exams extends React.Component { })) }} saveProblemName={this.saveProblemName} + isMCQProblem={isMCQ} + onMCQChange={ + (checked) => { + this.setState(prevState => ({ + changedWidgetId: selectedWidgetId, + widgets: update(prevState.widgets, { + [selectedWidgetId]: { + problem: { + isMCQ: { + $set: checked + } + } + } + }) + })) + } + } /> + { isMCQ ? ( + <PanelMCQ + totalNrAnswers={totalNrAnswers} + disabledGenerateBoxes={containsMCOptions} + disabledDeleteBoxes={disabledDeleteBoxes} + problem={problem} + onGenerateBoxesClick={(labels) => { + let problemWidget = this.state.widgets[this.state.selectedWidgetId] + // position the new mc option widget inside the problem widget + let xPos = problemWidget.x + 2 + let yPos = problemWidget.y + 2 + this.generateAnswerBoxes(problemWidget, labels, 0, xPos, yPos) + }} + onDeleteBoxesClick={() => { + this.setState({deletingMCWidget: true}) + }} + /> + ) : null } <this.PanelExamActions /> </React.Fragment> ) @@ -282,14 +474,18 @@ class Exams extends React.Component { <p className='panel-heading'> Problem details </p> - <div className='panel-block'> - <div className='field'> - {selectedWidgetId === null ? ( - <p style={{margin: '0.625em 0', minHeight: '3em'}}> + {selectedWidgetId === null ? ( + <div className='panel-block'> + <div className='field'> + <p style={{ margin: '0.625em 0', minHeight: '3em' }}> To create a problem, draw a rectangle on the exam. </p> - ) : ( - <React.Fragment> + </div> + </div> + ) : ( + <React.Fragment> + <div className='panel-block'> + <div className='field'> <label className='label'>Name</label> <div className='control'> <input @@ -302,12 +498,24 @@ class Exams extends React.Component { }} onBlur={(e) => { props.saveProblemName(e.target.value) - }} /> + }} + /> </div> - </React.Fragment> - )} - </div> - </div> + </div> + </div> + <div className='panel-block'> + <div className='field'> + <label className='label'> + <input disabled={props.disableIsMCQ} type='checkbox' checked={props.isMCQProblem} onChange={ + (e) => { + props.onMCQChange(e.target.checked) + }} /> + Multiple choice question + </label> + </div> + </div> + </React.Fragment> + )} <div className='panel-block'> <button disabled={props.disabledDelete} @@ -317,7 +525,6 @@ class Exams extends React.Component { Delete problem </button> </div> - </nav> ) } @@ -453,6 +660,18 @@ class Exams extends React.Component { onCancel={() => this.setState({deletingWidget: false})} onConfirm={() => this.deleteWidget(this.state.selectedWidgetId)} /> + <ConfirmationModal + active={this.state.deletingMCWidget && this.state.selectedWidgetId != null} + color='is-danger' + headerText={`Are you sure you want to delete the multiple choice options for problem "${ + this.state.selectedWidgetId && + this.state.widgets[this.state.selectedWidgetId] && + this.state.widgets[this.state.selectedWidgetId].problem && + this.state.widgets[this.state.selectedWidgetId].problem.name}"`} + confirmText='Delete multiple choice options' + onCancel={() => this.setState({deletingMCWidget: false})} + onConfirm={() => this.deleteMCWidget(this.state.selectedWidgetId)} + /> </div> } } diff --git a/client/views/ExamEditor.jsx b/client/views/ExamEditor.jsx index 89cf1d8d163c9ace55510395de54147c9d694b96..311cadafb250703af69f983cae03a8e88438ea5e 100644 --- a/client/views/ExamEditor.jsx +++ b/client/views/ExamEditor.jsx @@ -8,6 +8,7 @@ import studentIdExampleImage from '../components/student_id_example.png' // FIXME! // eslint-disable-next-line import/no-webpack-loader-syntax import studentIdExampleImageSize from '!image-dimensions-loader!../components/student_id_example.png' +import answerBoxImage from '../components/answer_box.png' import EmptyPDF from '../components/EmptyPDF.jsx' import PDFOverlay from '../components/PDFOverlay.jsx' @@ -86,13 +87,16 @@ class ExamEditor extends React.Component { if (selectionBox.width >= this.props.problemMinWidth && selectionBox.height >= this.props.problemMinHeight) { const problemData = { name: 'New problem', // TODO: Name - page: this.props.page + page: this.props.page, + mc_options: [], + isMCQ: false } const widgetData = { x: Math.round(selectionBox.left), y: Math.round(selectionBox.top), width: Math.round(selectionBox.width), - height: Math.round(selectionBox.height) + height: Math.round(selectionBox.height), + type: 'problem_widget' } const formData = new window.FormData() formData.append('exam_id', this.props.examID) @@ -169,131 +173,334 @@ class ExamEditor extends React.Component { } } - renderWidgets = () => { - // Only render when numPage is set - if (this.props.numPages !== null && this.props.widgets) { - const widgets = this.props.widgets.filter(widget => { - if (widget.name === 'student_id_widget' || - widget.name === 'barcode_widget') { - return !this.props.finalized - } else if (widget.problem) { - return widget.problem.page === this.props.page - } else { - return true + /** + * This method is called when the position of a widget has changed. It informs the server about the relocation. + * @param widget the widget that was relocated + * @param data the new location + */ + updateWidgetPositionDB = (widget, data) => { + api.patch('widgets/' + widget.id, data).then(() => { + // ok + }).catch(err => { + console.log(err) + // update to try and get a consistent state + this.props.updateExam() + }) + } + + updateState = (widget, data) => { + this.props.updateMCWidgetPosition(widget, { + x: Math.round(data.x), + y: Math.round(data.y) + }) + } + + updateMCOPosition = (widget, data) => { + this.updateState(widget, data) + + widget.problem.mc_options.forEach( + (option, i) => { + let newData = { + x: Math.round(data.x) + i * 24 + 7, + y: Math.round(data.y) + 21 } + this.updateWidgetPositionDB(option, newData) }) + } + + /** + * This function renders a group of options into one draggable widget + * @returns {*} + */ + renderMCWidget = (widget) => { + let width = 24 * widget.problem.mc_options.length + let height = 38 + let enableResizing = false + const isSelected = widget.id === this.props.selectedWidgetId + let xPos = widget.problem.mc_options[0].widget.x + let yPos = widget.problem.mc_options[0].widget.y + + return ( + <ResizeAndDrag + key={'widget_mc_' + widget.id} + bounds={'[data-key="widget_' + widget.id + '"]'} + minWidth={width} + minHeight={height} + enableResizing={{ + bottom: enableResizing, + bottomLeft: enableResizing, + bottomRight: enableResizing, + left: enableResizing, + right: enableResizing, + top: enableResizing, + topLeft: enableResizing, + topRight: enableResizing + }} + position={{ + x: xPos, + y: yPos + }} + size={{ + width: width, + height: height + }} + onDragStart={() => { + this.props.selectWidget(widget.id) + }} + onDragStop={(e, data) => { + this.updateMCOPosition(widget, data) + }} + > + <div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}> + {widget.problem.mc_options.map((option) => { + return ( + <div key={'widget_mco_' + option.id} className='mcq-option'> + <div className='mcq-option-label'> + {option.label} + </div> + <img className='mcq-box' src={answerBoxImage} /> + </div> + ) + })} + </div> + </ResizeAndDrag> + ) + } + + /** + * Render problem widget and the mc options that correspond to the problem + * @param widget the corresponding widget object from the db + * @returns {Array} + */ + renderProblemWidget = (widget) => { + // Only render when numPage is set + if (widget.problem.page !== this.props.page) return [] + + let enableResizing = true + const isSelected = widget.id === this.props.selectedWidgetId + let minWidth = this.props.problemMinWidth + let minHeight = this.props.problemMinHeight + let elementList = [( + <ResizeAndDrag + key={'widget_' + widget.id} + data-key={'widget_' + widget.id} + bounds='parent' + minWidth={minWidth} + minHeight={minHeight} + enableResizing={{ + bottom: enableResizing, + bottomLeft: enableResizing, + bottomRight: enableResizing, + left: enableResizing, + right: enableResizing, + top: enableResizing, + topLeft: enableResizing, + topRight: enableResizing + }} + position={{ + x: widget.x, + y: widget.y + }} + size={{ + width: widget.width, + height: widget.height + }} + onResize={(e, direction, ref, delta, position) => { + this.props.updateWidget(widget.id, { + width: { $set: ref.offsetWidth }, + height: { $set: ref.offsetHeight }, + x: { $set: Math.round(position.x) }, + y: { $set: Math.round(position.y) } + }) + }} + onResizeStop={(e, direction, ref, delta, position) => { + api.patch('widgets/' + widget.id, { + x: Math.round(position.x), + y: Math.round(position.y), + width: ref.offsetWidth, + height: ref.offsetHeight + }).then(() => { + // ok + }).catch(err => { + console.log(err) + // update to try and get a consistent state + this.props.updateExam() + }) + }} + onDragStart={() => { + this.props.selectWidget(widget.id) + }} + onDrag={(e, data) => { + if (widget.problem.mc_options.length > 0) { + let xPos = widget.problem.mc_options[0].widget.x + let yPos = widget.problem.mc_options[0].widget.y + let width = 24 * widget.problem.mc_options.length + let height = 38 + + if (xPos < data.x) { + xPos = data.x + } else if (xPos + width > data.x + widget.width) { + xPos = data.x + widget.width - width + } - let minWidth - let minHeight - let view - let enableResizing - return widgets.map((widget) => { - const isSelected = widget.id === this.props.selectedWidgetId - - if (widget.problem) { - minWidth = this.props.problemMinWidth - minHeight = this.props.problemMinHeight - view = ( - <div - className={isSelected ? 'widget selected' : 'widget'} - /> - ) - enableResizing = true - } else { - let image - if (widget.name === 'barcode_widget') { - minWidth = barcodeExampleImageSize.width - minHeight = barcodeExampleImageSize.height - image = barcodeExampleImage - } else if (this.props.page === 0 && widget.name === 'student_id_widget') { - minWidth = studentIdExampleImageSize.width - minHeight = studentIdExampleImageSize.height - image = studentIdExampleImage - } else { - return null + if (yPos < data.y) { + yPos = data.y + } else if (yPos + height > data.y + widget.height) { + yPos = data.y + widget.height - height + } + + this.updateState(widget, { x: xPos, y: yPos }) } - view = ( - <div - className={isSelected ? 'widget selected' : 'widget'} - style={{ - boxSizing: 'content-box', - backgroundImage: 'url(' + image + ')', - backgroundRepeat: 'no-repeat' - }} - /> - ) - enableResizing = false + }} + onDragStop={(e, data) => { + this.props.updateWidget(widget.id, { + x: { $set: Math.round(data.x) }, + y: { $set: Math.round(data.y) } + }) + api.patch('widgets/' + widget.id, { + x: Math.round(data.x), + y: Math.round(data.y) + }).then(() => { + if (widget.problem.mc_options.length > 0) { + let xPos = widget.problem.mc_options[0].widget.x + let yPos = widget.problem.mc_options[0].widget.y + let width = 24 * widget.problem.mc_options.length + let height = 38 + + if (xPos < data.x) { + xPos = data.x + } else if (xPos + width > data.x + widget.width) { + xPos = data.x + widget.width - width + } + + if (yPos < data.y) { + yPos = data.y + } else if (yPos + height > data.y + widget.height) { + yPos = data.y + widget.height - height + } + + this.updateMCOPosition(widget, { x: xPos, y: yPos }) + } + }).catch(err => { + console.log(err) + // update to try and get a consistent state + this.props.updateExam() + }) + }} + > + <div + className={isSelected ? 'widget selected' : 'widget'} + /> + </ResizeAndDrag> + )] + + // depending on the rendering option, render the mc_options separately or in a single widget + if (widget.problem.mc_options.length > 0 && !this.props.finalized) { + elementList.push(this.renderMCWidget(widget)) + } + + return elementList + } + + /** + * Render exam widgets. + * @param widget the corresponding widget object from the db + * @returns {Array} + */ + renderExamWidget = (widget) => { + if (this.props.finalized) return [] + + let minWidth, minHeight + let enableResizing = false + const isSelected = widget.id === this.props.selectedWidgetId + let image + if (widget.name === 'barcode_widget') { + minWidth = barcodeExampleImageSize.width + minHeight = barcodeExampleImageSize.height + image = barcodeExampleImage + } else if (this.props.page === 0 && widget.name === 'student_id_widget') { + minWidth = studentIdExampleImageSize.width + minHeight = studentIdExampleImageSize.height + image = studentIdExampleImage + } else { + return [] + } + + return [( + <ResizeAndDrag + key={'widget_' + widget.id} + bounds='parent' + minWidth={minWidth} + minHeight={minHeight} + enableResizing={{ + bottom: enableResizing, + bottomLeft: enableResizing, + bottomRight: enableResizing, + left: enableResizing, + right: enableResizing, + top: enableResizing, + topLeft: enableResizing, + topRight: enableResizing + }} + position={{ + x: widget.x, + y: widget.y + }} + size={{ + width: widget.width, + height: widget.height + }} + onDragStart={() => { + this.props.selectWidget(widget.id) + }} + onDragStop={(e, data) => { + this.props.updateWidget(widget.id, { + x: { $set: Math.round(data.x) }, + y: { $set: Math.round(data.y) } + }) + api.patch('widgets/' + widget.id, { + x: Math.round(data.x), + y: Math.round(data.y) + }).then(() => { + // ok + }).catch(err => { + console.log(err) + // update to try and get a consistent state + this.props.updateExam() + }) + }} + > + <div + className={isSelected ? 'widget selected' : 'widget'} + style={{ + boxSizing: 'content-box', + backgroundImage: 'url(' + image + ')', + backgroundRepeat: 'no-repeat' + }} + /> + </ResizeAndDrag> + )] + } + + /** + * Render all the widgets by calling the right rendering function for each widget type + * @returns {Array} + */ + renderWidgets = () => { + // Only render when numPage is set + if (this.props.numPages !== null && this.props.widgets) { + let widgets = this.props.widgets + let elementList = [] + + widgets.forEach((widget) => { + if (widget.type === 'exam_widget') { + elementList = elementList.concat(this.renderExamWidget(widget)) + } else if (widget.type === 'problem_widget') { + elementList = elementList.concat(this.renderProblemWidget(widget)) } - return ( - <ResizeAndDrag - key={'widget_' + widget.id} - bounds='parent' - minWidth={minWidth} - minHeight={minHeight} - enableResizing={{ - bottom: enableResizing, - bottomLeft: enableResizing, - bottomRight: enableResizing, - left: enableResizing, - right: enableResizing, - top: enableResizing, - topLeft: enableResizing, - topRight: enableResizing - }} - position={{ - x: widget.x, - y: widget.y - }} - size={{ - width: widget.width, - height: widget.height - }} - onResize={(e, direction, ref, delta, position) => { - this.props.updateWidget(widget.id, { - width: { $set: ref.offsetWidth }, - height: { $set: ref.offsetHeight }, - x: { $set: Math.round(position.x) }, - y: { $set: Math.round(position.y) } - }) - }} - onResizeStop={(e, direction, ref, delta, position) => { - api.patch('widgets/' + widget.id, { - x: Math.round(position.x), - y: Math.round(position.y), - width: ref.offsetWidth, - height: ref.offsetHeight - }).then(() => { - // ok - }).catch(err => { - console.log(err) - // update to try and get a consistent state - this.updateExam() - }) - }} - onDragStart={() => { - this.props.selectWidget(widget.id) - }} - onDragStop={(e, data) => { - this.props.updateWidget(widget.id, { - x: { $set: Math.round(data.x) }, - y: { $set: Math.round(data.y) } - }) - api.patch('widgets/' + widget.id, { - x: Math.round(data.x), - y: Math.round(data.y) - }).then(() => { - // ok - }).catch(err => { - console.log(err) - // update to try and get a consistent state - this.updateExam() - }) - }} - > - {view} - </ResizeAndDrag> - ) }) + + return elementList } } diff --git a/data/course.sqlite b/data/course.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..42369d0718bc1a16a99c196ae7dcd845a0a3ab30 Binary files /dev/null and b/data/course.sqlite differ diff --git a/migrations/versions/b46a2994605b_.py b/migrations/versions/b46a2994605b_.py new file mode 100644 index 0000000000000000000000000000000000000000..0c7e740429ee92a25f3feef629ead82d9cf409d1 --- /dev/null +++ b/migrations/versions/b46a2994605b_.py @@ -0,0 +1,35 @@ +"""empty message + +Revision ID: b46a2994605b +Revises: 4204f4a83863 +Create Date: 2019-05-15 15:41:56.615076 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b46a2994605b' +down_revision = '4204f4a83863' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('mc_option', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('label', sa.String(), nullable=True), + sa.Column('feedback_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['feedback_id'], ['feedback_option.id'], ), + sa.ForeignKeyConstraint(['id'], ['widget.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('mc_option') + # ### end Alembic commands ### diff --git a/tests/data/.gitignore b/tests/data/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f0aeb239c4e97d2966b0e58cf21fd8bac24e53cb --- /dev/null +++ b/tests/data/.gitignore @@ -0,0 +1 @@ +submissions \ No newline at end of file diff --git a/tests/data/checkboxes/scanned_page.jpg b/tests/data/checkboxes/scanned_page.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9bcdd3c91e856bf3c474b13e600e1b6f5db90930 Binary files /dev/null and b/tests/data/checkboxes/scanned_page.jpg differ diff --git a/tests/data/cornermarkers/a4-3-markers.png b/tests/data/cornermarkers/a4-3-markers.png new file mode 100644 index 0000000000000000000000000000000000000000..32bdd11a2fbf87fed48625c3a12f0e609e2088a6 Binary files /dev/null and b/tests/data/cornermarkers/a4-3-markers.png differ diff --git a/tests/data/cornermarkers/a4-rotated-3-markers.png b/tests/data/cornermarkers/a4-rotated-3-markers.png new file mode 100644 index 0000000000000000000000000000000000000000..d32cbacac4d4a2d8a327736ce8967623f8aca22d Binary files /dev/null and b/tests/data/cornermarkers/a4-rotated-3-markers.png differ diff --git a/tests/data/cornermarkers/a4-rotated.png b/tests/data/cornermarkers/a4-rotated.png new file mode 100644 index 0000000000000000000000000000000000000000..8dbf8630a73b83a71dcc36eb687933632657f5aa Binary files /dev/null and b/tests/data/cornermarkers/a4-rotated.png differ diff --git a/tests/test_database.py b/tests/test_database.py index c7b99fc983cb80834c95eaf1496708658f80c851..c890a1880b17706ca7d80f152ba80ab788cab21c 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -13,11 +13,11 @@ def test_exam_generate_token_length_uppercase(duplicate_count, monkeypatch): self.duplicates = duplicate_count + 1 def filter(self, *args): - return self + return self def first(self): - self.duplicates -= 1 - return None if self.duplicates else True + self.duplicates -= 1 + return None if self.duplicates else True app = Flask(__name__, static_folder=None) app.config.update( diff --git a/tests/test_pdf_generation.py b/tests/test_pdf_generation.py index 1793442bc672ab1abad1219e8af0ae9c8cf40d0c..4324f34af928bef75447a5ff4471f048db16f0e3 100644 --- a/tests/test_pdf_generation.py +++ b/tests/test_pdf_generation.py @@ -106,6 +106,19 @@ def test_generate_pdfs_num_files(datadir, tmpdir): assert len(tmpdir.listdir()) == num_copies +@pytest.mark.parametrize('checkboxes', [[(300, 100, 1, 'c'), (500, 50, 0, 'd'), (500, 500, 0, 'a'), (250, 200, 1, 'b')], + [], [(250, 100, 0, None)]]) +def test_generate_checkboxes(datadir, tmpdir, checkboxes): + blank_pdf = os.path.join(datadir, 'blank-a4-2pages.pdf') + + num_copies = 1 + copy_nums = range(num_copies) + paths = map(lambda copy_num: os.path.join(tmpdir, f'{copy_num}.pdf'), copy_nums) + pdf_generation.generate_pdfs(blank_pdf, 'ABCDEFGHIJKL', copy_nums, paths, 25, 270, 150, 270, checkboxes) + + assert len(tmpdir.listdir()) == num_copies + + @pytest.mark.parametrize('name', ['a4', 'square'], ids=['a4', 'square']) def test_join_pdfs(mock_generate_datamatrix, mock_generate_id_grid, datadir, tmpdir, name): diff --git a/tests/test_pregrader.py b/tests/test_pregrader.py new file mode 100644 index 0000000000000000000000000000000000000000..895adb3e71b6543f8fc9abdad0375c3221c18780 --- /dev/null +++ b/tests/test_pregrader.py @@ -0,0 +1,50 @@ +import os +import pytest +from PIL import Image +import numpy as np +from zesje import pregrader +from zesje import scans +from zesje import images + +directory_name = "checkboxes" + + +@pytest.fixture +def scanned_image(datadir): + image_filename = os.path.join(datadir, directory_name, "scanned_page.jpg") + image = Image.open(image_filename) + image = np.array(image) + return image + + +@pytest.fixture +def scanned_image_keypoints(scanned_image): + corner_markers = scans.find_corner_marker_keypoints(scanned_image) + fixed_corner_keypoints = images.fix_corner_markers(corner_markers, scanned_image.shape) + return fixed_corner_keypoints + + +@pytest.mark.parametrize('box_coords, result', [((346, 479), True), ((370, 479), False), ((393, 479), True), + ((416, 479), True), ((439, 479), True), ((155, 562), True)], + ids=["1 filled", "2 empty", "3 marked with line", "4 completely filled", + "5 marked with an x", "e marked with a cirle inside"]) +def test_ideal_crops(box_coords, result, scanned_image_keypoints, scanned_image): + assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result + + +@pytest.mark.parametrize('box_coords, result', [((341, 471), True), ((352, 482), True), ((448, 482), True), + ((423, 474), True), ((460, 475), False), ((477, 474), True), + ((87, 548), False)], + ids=["1 filled bottom right", "1 filled top left", "5 filled with a bit of 6", + "4 fully filled with the label", "6 empty with label", + "7 partially cropped, filled and a part of 6", "B empty with cb at the bottom"]) +def test_shifted_crops(box_coords, result, scanned_image_keypoints, scanned_image): + assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result + + +@pytest.mark.parametrize('box_coords, result', [((60, 562), True), ((107, 562), True), + ((131, 562), False)], + ids=["A filled with trailing letter", "C filled with letters close", + "D blank with trailing letter"]) +def test_trailing_text(box_coords, result, scanned_image_keypoints, scanned_image): + assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result diff --git a/tests/test_three_corners.py b/tests/test_three_corners.py new file mode 100644 index 0000000000000000000000000000000000000000..1e3fb938e911061e2a5e741c619f9d86e7454a00 --- /dev/null +++ b/tests/test_three_corners.py @@ -0,0 +1,55 @@ +import cv2 +import os +import numpy as np + +from zesje.images import fix_corner_markers +from zesje.scans import find_corner_marker_keypoints + + +def test_three_straight_corners_1(): + shape = (240, 200, 3) + corner_markers = [(50, 50), (120, 50), (50, 200)] + + corner_markers = fix_corner_markers(corner_markers, shape) + + assert (120, 200) in corner_markers + + +def test_three_straight_corners_2(): + shape = (240, 200, 3) + corner_markers = [(120, 50), (50, 200), (120, 200)] + + corner_markers = fix_corner_markers(corner_markers, shape) + + assert (50, 50) in corner_markers + + +def test_pdf(datadir): + # Max deviation of inferred corner marker and actual location + epsilon = 2 + + # Scan rotated image with 4 corner markers + image_filename1 = 'a4-rotated.png' + image_path = os.path.join(datadir, 'cornermarkers', image_filename1) + page_img = cv2.imread(image_path) + + corners1 = find_corner_marker_keypoints(page_img) + + # Scan the same image with 3 corner markers + image_filename2 = 'a4-rotated-3-markers.png' + image_path = os.path.join(datadir, 'cornermarkers', image_filename2) + page_img = cv2.imread(image_path) + + corners2 = find_corner_marker_keypoints(page_img) + + # Get marker that was removed + diff = [corner for corner in corners1 if corner not in corners2] + diff_marker = min(diff) + + fixed_corners2 = fix_corner_markers(corners2, page_img.shape) + added_marker = [corner for corner in fixed_corners2 if corner not in corners1][0] + + # Check if 'inferred' corner marker is not too far away + dist = np.linalg.norm(np.subtract(added_marker, diff_marker)) + + assert dist < epsilon diff --git a/zesje/api/__init__.py b/zesje/api/__init__.py index 58efb2732556d0daef199abd46a211f7f253fb4f..17dfe1554eee0c8752ef1e0f5e71b713d8d150f1 100644 --- a/zesje/api/__init__.py +++ b/zesje/api/__init__.py @@ -11,6 +11,8 @@ from .feedback import Feedback from .solutions import Solutions from .widgets import Widgets from .emails import EmailTemplate, RenderedEmailTemplate, Email +from .mult_choice import MultipleChoice + from . import signature from . import images from . import summary_plot @@ -48,6 +50,9 @@ api.add_resource(RenderedEmailTemplate, api.add_resource(Email, '/email/<int:exam_id>', '/email/<int:exam_id>/<int:student_id>') +api.add_resource(MultipleChoice, + '/mult-choice/<int:id>', + '/mult-choice/') # Other resources that don't return JSON diff --git a/zesje/api/emails.py b/zesje/api/emails.py index 60039da236d84a9727f4c8d39fb9e61dc156753b..408657ad7f8e2537b61f158b9ab65132a8b9d44b 100644 --- a/zesje/api/emails.py +++ b/zesje/api/emails.py @@ -57,27 +57,27 @@ def render_email(exam_id, student_id, template): def build_email(exam_id, student_id, template, attach, from_address, copy_to=None): - student = Student.query.get(student_id) - if student is None: - abort( - 404, - message=f"Student #{student_id} does not exist" - ) - if not student.email: - abort( - 409, - message=f'Student #{student_id} has no email address' - ) - - return emails.build( - student.email, - render_email(exam_id, student_id, template), - emails.build_solution_attachment(exam_id, student_id) - if attach - else None, - copy_to=copy_to, - email_from=from_address, + student = Student.query.get(student_id) + if student is None: + abort( + 404, + message=f"Student #{student_id} does not exist" ) + if not student.email: + abort( + 409, + message=f'Student #{student_id} has no email address' + ) + + return emails.build( + student.email, + render_email(exam_id, student_id, template), + emails.build_solution_attachment(exam_id, student_id) + if attach + else None, + copy_to=copy_to, + email_from=from_address, + ) class EmailTemplate(Resource): diff --git a/zesje/api/exams.py b/zesje/api/exams.py index 706e7413cfadccc715fec6a7006482f0cc43d500..a1c449a05bd1ad5d5a1f50a75c7e3698d66114e5 100644 --- a/zesje/api/exams.py +++ b/zesje/api/exams.py @@ -25,6 +25,33 @@ def _get_exam_dir(exam_id): ) +def get_cb_data_for_exam(exam): + """ + Returns all multiple choice question check boxes for one specific exam + + Parameters + ---------- + exam: the exam + + Returns + ------- + A list of tuples with checkbox data. + Each tuple is represented as (x, y, page, label) + + Where + x: x position + y: y position + page: page number + label: checkbox label + """ + cb_data = [] + for problem in exam.problems: + page = problem.widget.page + cb_data += [(cb.x, cb.y, page, cb.label) for cb in problem.mc_options] + + return cb_data + + class Exams(Resource): def get(self, exam_id=None): @@ -111,30 +138,30 @@ class Exams(Resource): return dict(status=404, message='Exam does not exist.'), 404 submissions = [ - { - 'id': sub.copy_number, - 'student': { - 'id': sub.student.id, - 'firstName': sub.student.first_name, - 'lastName': sub.student.last_name, - 'email': sub.student.email - } if sub.student else None, - 'validated': sub.signature_validated, - 'problems': [ - { - 'id': sol.problem.id, - 'graded_by': { - 'id': sol.graded_by.id, - 'name': sol.graded_by.name - } if sol.graded_by else None, - 'graded_at': sol.graded_at.isoformat() if sol.graded_at else None, - 'feedback': [ - fb.id for fb in sol.feedback - ], - 'remark': sol.remarks if sol.remarks else "" - } for sol in sub.solutions # Sorted by sol.problem_id - ], - } for sub in exam.submissions + { + 'id': sub.copy_number, + 'student': { + 'id': sub.student.id, + 'firstName': sub.student.first_name, + 'lastName': sub.student.last_name, + 'email': sub.student.email + } if sub.student else None, + 'validated': sub.signature_validated, + 'problems': [ + { + 'id': sol.problem.id, + 'graded_by': { + 'id': sol.graded_by.id, + 'name': sol.graded_by.name + } if sol.graded_by else None, + 'graded_at': sol.graded_at.isoformat() if sol.graded_at else None, + 'feedback': [ + fb.id for fb in sol.feedback + ], + 'remark': sol.remarks if sol.remarks else "" + } for sol in sub.solutions # Sorted by sol.problem_id + ], + } for sub in exam.submissions ] # Sort submissions by selecting those with students assigned, then by # student number, then by copy number. @@ -171,8 +198,22 @@ class Exams(Resource): 'y': prob.widget.y, 'width': prob.widget.width, 'height': prob.widget.height, + 'type': prob.widget.type }, - 'graded': any([sol.graded_by is not None for sol in prob.solutions]) + 'graded': any([sol.graded_by is not None for sol in prob.solutions]), + 'mc_options': [ + { + 'id': mc_option.id, + 'label': mc_option.label, + 'feedback_id': mc_option.feedback_id, + 'widget': { + 'name': mc_option.name, + 'x': mc_option.x, + 'y': mc_option.y, + 'type': mc_option.type + } + } for mc_option in prob.mc_options + ] } for prob in exam.problems # Sorted by prob.id ], 'widgets': [ @@ -181,6 +222,7 @@ class Exams(Resource): 'name': widget.name, 'x': widget.x, 'y': widget.y, + 'type': widget.type } for widget in exam.widgets # Sorted by widget.id ], 'finalized': exam.finalized, @@ -332,13 +374,16 @@ class ExamGeneratedPdfs(Resource): generated_pdfs_dir = self._get_generated_exam_dir(exam_dir) os.makedirs(generated_pdfs_dir, exist_ok=True) + cb_data = get_cb_data_for_exam(exam) + generate_pdfs( exam_path, exam.token, copy_nums, pdf_paths, student_id_widget.x, student_id_widget.y, - barcode_widget.x, barcode_widget.y + barcode_widget.x, barcode_widget.y, + cb_data ) post_parser = reqparse.RequestParser() @@ -488,13 +533,15 @@ class ExamPreview(Resource): exam_path = os.path.join(exam_dir, 'exam.pdf') + cb_data = get_cb_data_for_exam(exam) generate_pdfs( exam_path, exam.token[:5] + 'PREVIEW', [1519], [output_file], student_id_widget.x, student_id_widget.y, - barcode_widget.x, barcode_widget.y + barcode_widget.x, barcode_widget.y, + cb_data ) output_file.seek(0) diff --git a/zesje/api/feedback.py b/zesje/api/feedback.py index 6475bad4f8660207a3aa60548c71f4cd02a52064..4abf904cac58751d6cb5bd3affc837a2ee3eb04f 100644 --- a/zesje/api/feedback.py +++ b/zesje/api/feedback.py @@ -125,6 +125,9 @@ class Feedback(Resource): problem = fb.problem if problem.id != problem_id: return dict(status=409, message="Feedback does not match the problem."), 409 + if fb.mc_option and problem.exam.finalized: + return dict(status=401, message='Cannot delete feedback option' + + ' attached to a multiple choice option in a finalized exam.'), 401 db.session.delete(fb) @@ -137,4 +140,10 @@ class Feedback(Resource): solution.grader_id = None solution.graded_at = None + # Delete mc_options associated with this feedback option + if fb.mc_option: + db.session.delete(fb.mc_option) + db.session.commit() + + return dict(status=200, message=f"Feedback option with id {feedback_id} deleted."), 200 diff --git a/zesje/api/mult_choice.py b/zesje/api/mult_choice.py new file mode 100644 index 0000000000000000000000000000000000000000..29eaa61dfa5363f18a0a7afb91d78893da1a678d --- /dev/null +++ b/zesje/api/mult_choice.py @@ -0,0 +1,161 @@ +from flask_restful import Resource, reqparse + +from ..database import db, MultipleChoiceOption, FeedbackOption + + +def set_mc_data(mc_entry, name, x, y, mc_type, feedback_id, label): + """Sets the data of a MultipleChoiceOption ORM object. + + Parameters: + ----------- + mc_entry: The MultipleChoiceOption object + name: The name of the MultipleChoiceOption widget + x: the x-position of the MultipleChoiceOption object. + y: the y-position of the MultipleChoiceOption object. + type: the polymorphic type used to distinguish the MultipleChoiceOption widget + from other widgets + feedback_id: the feedback the MultipleChoiceOption refers to + label: label for the checkbox that this MultipleChoiceOption represents + """ + mc_entry.name = name + mc_entry.x = x + mc_entry.y = y + mc_entry.type = mc_type + mc_entry.feedback_id = feedback_id + mc_entry.label = label + + +class MultipleChoice(Resource): + + put_parser = reqparse.RequestParser() + + # Arguments that have to be supplied in the request body + put_parser.add_argument('name', type=str, required=True) + put_parser.add_argument('x', type=int, required=True) + put_parser.add_argument('y', type=int, required=True) + put_parser.add_argument('label', type=str, required=False) + put_parser.add_argument('problem_id', type=int, required=True) # Used for FeedbackOption + + def put(self, id=None): + """Adds or updates a multiple choice option to the database + + If the parameter id is not present, a new multiple choice question + will be inserted with the data provided in the request body. + + For each new multiple choice option, a feedback option that links to + the multiple choice option is inserted into the database. The new + feedback option also refers to same problem as the MultipleChoiceOption + + Parameters + ---------- + id: The id of the multiple choice option + """ + + args = self.put_parser.parse_args() + + # Get request arguments + name = args['name'] + x = args['x'] + y = args['y'] + label = args['label'] + problem_id = args['problem_id'] + + # TODO: Set type here or add to request? + mc_type = 'mcq_widget' + + if not id: + # Insert new empty feedback option that links to the same problem, with the label as name + new_feedback_option = FeedbackOption(problem_id=problem_id, text=label) + db.session.add(new_feedback_option) + db.session.commit() + + # Insert new entry into the database + mc_entry = MultipleChoiceOption() + set_mc_data(mc_entry, name, x, y, mc_type, new_feedback_option.id, label) + + db.session.add(mc_entry) + db.session.commit() + + return dict(status=200, mult_choice_id=mc_entry.id, feedback_id=new_feedback_option.id, + message=f'New multiple choice question with id {mc_entry.id} inserted. ' + + f'New feedback option with id {new_feedback_option.id} inserted.'), 200 + + # Update existing entry otherwise + mc_entry = MultipleChoiceOption.query.get(id) + + if not mc_entry: + return dict(status=404, message=f"Multiple choice question with id {id} does not exist"), 404 + + set_mc_data(mc_entry, name, x, y, mc_type, label) + db.session.commit() + + return dict(status=200, message=f'Multiple choice question with id {id} updated'), 200 + + def get(self, id): + """Fetches multiple choice option from the database + + Parameters + ---------- + id: The ID of the multiple choice option in the database + + Returns + ------- + A JSON object with the multiple choice option data + """ + mult_choice = MultipleChoiceOption.query.get(id) + + if not mult_choice: + return dict(status=404, message=f'Multiple choice question with id {id} does not exist.'), 404 + + json = { + 'id': mult_choice.id, + 'name': mult_choice.name, + 'x': mult_choice.x, + 'y': mult_choice.y, + 'type': mult_choice.type, + 'feedback_id': mult_choice.feedback_id + } + + # Nullable database fields + if mult_choice.label: + json['label'] = mult_choice.label + + return json + + def delete(self, id): + """Deletes a multiple choice option from the database. + Also deletes the associated feedback option with this multiple choice option. + + An error will be thrown if the user tries to delete a feedback option + associated with a multiple choice option in a finalized exam. + + Parameters + ---------- + id: The ID of the multiple choice option in the database + + Returns + ------- + A message indicating success or failure + """ + mult_choice = MultipleChoiceOption.query.get(id) + + if not mult_choice: + return dict(status=404, message=f'Multiple choice question with id {id} does not exist.'), 404 + + if not mult_choice.feedback: + return dict(status=404, message=f'Multiple choice question with id {id}' + + ' is not associated with a feedback option.'), 404 + + # Check if the exam is finalized, do not delete the multiple choice option otherwise + exam = mult_choice.feedback.problem.exam + + if exam.finalized: + return dict(status=401, message='Cannot delete feedback option' + + ' attached to a multiple choice option in a finalized exam.'), 401 + + db.session.delete(mult_choice) + db.session.delete(mult_choice.feedback) + db.session.commit() + + return dict(status=200, message=f'Multiple choice question with id {id} deleted.' + + f'Feedback option with id {mult_choice.feedback_id} deleted.'), 200 diff --git a/zesje/api/problems.py b/zesje/api/problems.py index 41c24f2ed1cd5f7909611e86fe2156efe719cf7a..28b005d3f5f9054d6f083dc82f9852c91c66c230 100644 --- a/zesje/api/problems.py +++ b/zesje/api/problems.py @@ -108,6 +108,9 @@ class Problems(Resource): # Delete all solutions associated with this problem for sol in problem.solutions: db.session.delete(sol) + # Delete all multiple choice options associated with this problem + for mc_option in problem.mc_options: + db.session.delete(mc_option) db.session.delete(problem.widget) db.session.delete(problem) db.session.commit() diff --git a/zesje/database.py b/zesje/database.py index 3b6df0459314ad8212776ba1655ea8e1385f3142..5f4bc29a061dab409da4bcb6054f62958c95f06d 100644 --- a/zesje/database.py +++ b/zesje/database.py @@ -8,6 +8,7 @@ from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Foreign from flask_sqlalchemy.model import BindMetaMixin, Model from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base from sqlalchemy.orm.session import object_session +from sqlalchemy.ext.hybrid import hybrid_property # Class for NOT automatically determining table names @@ -100,6 +101,10 @@ class Problem(db.Model): solutions = db.relationship('Solution', backref='problem', lazy=True) widget = db.relationship('ProblemWidget', backref='problem', uselist=False, lazy=True) + @hybrid_property + def mc_options(self): + return [feedback_option.mc_option for feedback_option in self.feedback_options if feedback_option.mc_option] + class FeedbackOption(db.Model): """feedback option""" @@ -109,6 +114,7 @@ class FeedbackOption(db.Model): text = Column(Text, nullable=False) description = Column(Text, nullable=True) score = Column(Integer, nullable=True) + mc_option = db.relationship('MultipleChoiceOption', backref='feedback', cascade='delete', uselist=False, lazy=True) # Table for many to many relationship of FeedbackOption and Solution @@ -160,6 +166,18 @@ class Widget(db.Model): } +class MultipleChoiceOption(Widget): + __tablename__ = 'mc_option' + id = Column(Integer, ForeignKey('widget.id'), primary_key=True, autoincrement=True) + + label = Column(String, nullable=True) + feedback_id = Column(Integer, ForeignKey('feedback_option.id'), nullable=False) + + __mapper_args__ = { + 'polymorphic_identity': 'mcq_widget' + } + + class ExamWidget(Widget): __tablename__ = 'exam_widget' id = Column(Integer, ForeignKey('widget.id'), primary_key=True, nullable=False) diff --git a/zesje/emails.py b/zesje/emails.py index 212952086b4a4c002537e123942c4033ee74eb21..8e3571608e501a0476cfcb357104d3cf2674184c 100644 --- a/zesje/emails.py +++ b/zesje/emails.py @@ -103,7 +103,7 @@ def send( server_type = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP with server_type(server, port) as s: if user and password: - s.login(user, password) + s.login(user, password) for identifier, message in messages.items(): recipients = [ *message['To'].split(','), diff --git a/zesje/images.py b/zesje/images.py index fd867abca0c07cbbcd26b8663cec2ea7e96b6780..ff8669e5b04aa77aa882f0bdb3acbaf9901ab450 100644 --- a/zesje/images.py +++ b/zesje/images.py @@ -2,6 +2,8 @@ import numpy as np +from operator import sub, add + def guess_dpi(image_array): h, *_ = image_array.shape @@ -34,3 +36,97 @@ def get_box(image_array, box, padding=0.3): top, bottom = max(0, min(box[0], h)), max(1, min(box[1], h)) left, right = max(0, min(box[2], w)), max(1, min(box[3], w)) return image_array[top:bottom, left:right] + + +def fix_corner_markers(corner_keypoints, shape): + """ + Corrects the list of corner markers if only three corner markers are found. + This function raises if less than three corner markers are detected. + + Parameters + ---------- + corner_keypoints : + List of corner marker locations as tuples + shape : + Shape of the image in (x, y, dim) + + Returns + ------- + corner_keypoints : + A list of four corner markers. + """ + + if len(corner_keypoints) == 4: + return corner_keypoints + + if len(corner_keypoints) < 3: + raise RuntimeError("Fewer then 3 corner markers found") + + x_sep = shape[1] / 2 + y_sep = shape[0] / 2 + + top_left = [(x, y) for x, y in corner_keypoints if x < x_sep and y < y_sep] + bottom_left = [(x, y) for x, y in corner_keypoints if x < x_sep and y > y_sep] + top_right = [(x, y) for x, y in corner_keypoints if x > x_sep and y < y_sep] + bottom_right = [(x, y) for x, y in corner_keypoints if x > x_sep and y > y_sep] + + missing_point = () + + if not top_left: + # Top left point is missing + (dx, dy) = tuple(map(sub, top_right[0], bottom_right[0])) + missing_point = tuple(map(add, bottom_left[0], (dx, dy))) + + elif not bottom_left: + # Bottom left point is missing + (dx, dy) = tuple(map(sub, top_right[0], bottom_right[0])) + missing_point = tuple(map(sub, top_left[0], (dx, dy))) + + elif not top_right: + # Top right point is missing + (dx, dy) = tuple(map(sub, top_left[0], bottom_left[0])) + missing_point = tuple(map(add, bottom_right[0], (dx, dy))) + + elif not bottom_right: + # bottom right + (dx, dy) = tuple(map(sub, top_left[0], bottom_left[0])) + missing_point = tuple(map(sub, top_right[0], (dx, dy))) + + corner_keypoints.append(missing_point) + return corner_keypoints + + +def box_is_filled(image_array, box_coords, padding=0.3, threshold=150, pixels=False): + """ + Determines if a box is filled + + Parameters: + ----------- + image_array : 2D or 3D array + The image source. + box_coords : 4 floats (top, bottom, left, right) + Coordinates of the bounding box in inches or pixels. By due to differing + traditions, box coordinates are counted from the bottom left of the + image, while image array coordinates are from the top left. + padding : float + Padding around box borders in inches. + threshold : int + Optional threshold value to determine minimal 'darkness' + to consider a box to be filled in + pixels : boolean + Whether the box coordinates are entered as pixels instead of inches. + """ + + # Divide by DPI if pixel coordinates are used + if pixels: + box_coords /= guess_dpi(image_array) + + box_img = get_box(image_array, box_coords, padding) + + # Check if the coordinates are outside of the image + if box_img.size == 0: + raise RuntimeError("Box coordinates are outside of image") + + avg = np.average(box_img) + + return avg < threshold diff --git a/zesje/pdf_generation.py b/zesje/pdf_generation.py index f0bc3ebb00843818081e61bf45913cdf8127ff31..09bf9641bd13e631eb02a163f69ef2959b7955d6 100644 --- a/zesje/pdf_generation.py +++ b/zesje/pdf_generation.py @@ -12,7 +12,7 @@ output_pdf_filename_format = '{0:05d}.pdf' def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x, - id_grid_y, datamatrix_x, datamatrix_y): + id_grid_y, datamatrix_x, datamatrix_y, cb_data=None): """ Generate the final PDFs from the original exam PDF. @@ -24,7 +24,6 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x, If maximum interchangeability with version 1 QR codes is desired (error correction level M), use exam IDs composed of only uppercase letters, and composed of at most 12 letters. - Parameters ---------- exam_pdf_file : file object or str @@ -43,6 +42,9 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x, The x coordinate where the DataMatrix code should be placed datamatrix_y : int The y coordinate where the DataMatrix code should be placed + cb_data : list[ (int, int, int, str)] + The data needed for drawing a checkbox, namely: the x coordinate; y coordinate; page number and label + """ exam_pdf = PdfReader(exam_pdf_file) mediabox = exam_pdf.pages[0].MediaBox @@ -56,7 +58,7 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x, overlay_canv = canvas.Canvas(overlay_file.name, pagesize=pagesize) _generate_overlay(overlay_canv, pagesize, exam_id, copy_num, len(exam_pdf.pages), id_grid_x, id_grid_y, - datamatrix_x, datamatrix_y) + datamatrix_x, datamatrix_y, cb_data) overlay_canv.save() # Merge overlay and exam @@ -151,6 +153,38 @@ def generate_id_grid(canv, x, y): textboxwidth, textboxheight) +def generate_checkbox(canvas, x, y, label): + """ + draw a checkbox and draw a singel character label ontop of the checkbox + + Parameters + ---------- + canvas : reportlab canvas object + + x : int + the x coordinate of the top left corner of the box in points (pt) + y : int + the y coordinate of the top left corner of the box in points (pt) + label: str + A string representing the label that is drawn on top of the box, will only take the first character + + """ + fontsize = 11 # Size of font + margin = 5 # Margin between elements and sides + markboxsize = fontsize - 2 # Size of checkboxes boxes + x_label = x + 1 # location of the label + y_label = y + margin # remove fontsize from the y label since we draw from the bottom left up + box_y = y - markboxsize # remove the markboxsize because the y is the coord of the top + # and reportlab prints from the bottom + + # check that there is a label to print + if (label and not (len(label) == 0)): + canvas.setFont('Helvetica', fontsize) + canvas.drawString(x_label, y_label, label[0]) + + canvas.rect(x, box_y, markboxsize, markboxsize) + + def generate_datamatrix(exam_id, page_num, copy_num): """ Generates a DataMatrix code to be used on a page. @@ -187,7 +221,7 @@ def generate_datamatrix(exam_id, page_num, copy_num): def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x, - id_grid_y, datamatrix_x, datamatrix_y): + id_grid_y, datamatrix_x, datamatrix_y, cb_data=None): """ Generates an overlay ('watermark') PDF, which can then be overlaid onto the exam PDF. @@ -221,6 +255,9 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x, The x coordinate where the DataMatrix codes should be placed datamatrix_y : int The y coordinate where the DataMatrix codes should be placed + cb_data : list[ (int, int, int, str)] + The data needed for drawing a checkbox, namely: the x coordinate; y coordinate; page number and label + """ # Font settings for the copy number (printed under the datamatrix) @@ -233,6 +270,17 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x, # ID grid on first page only generate_id_grid(canv, id_grid_x, id_grid_y) + # create index for list of checkbox data and sort the data on page + if cb_data: + index = 0 + max_index = len(cb_data) + cb_data = sorted(cb_data, key=lambda tup: tup[2]) + # invert the y axis + cb_data = [(cb[0], pagesize[1] - cb[1], cb[2], cb[3]) for cb in cb_data] + else: + index = 0 + max_index = 0 + for page_num in range(num_pages): _add_corner_markers_and_bottom_bar(canv, pagesize) @@ -246,6 +294,13 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x, datamatrix_x, datamatrix_y_adjusted - fontsize, f" # {copy_num}" ) + + # call generate for all checkboxes that belong to the current page + while index < max_index and cb_data[index][2] <= page_num: + x, y, _, label = cb_data[index] + generate_checkbox(canv, x, y, label) + index += 1 + canv.showPage() diff --git a/zesje/pregrader.py b/zesje/pregrader.py new file mode 100644 index 0000000000000000000000000000000000000000..2b02521d437a3594579b3be2cfa91b8188d194d2 --- /dev/null +++ b/zesje/pregrader.py @@ -0,0 +1,139 @@ +import cv2 +import numpy as np + +from .database import db, Solution +from .images import guess_dpi, get_box, fix_corner_markers + + +def add_feedback_to_solution(sub, exam, page, page_img, corner_keypoints): + """ + Adds the multiple choice options that are identified as marked as a feedback option to a solution + + Parameters + ------ + sub : Submission + the current submission + exam : Exam + the current exam + page_img : Image + image of the page + corner_keypoints : array + locations of the corner keypoints as (x, y) tuples + """ + problems_on_page = [problem for problem in exam.problems if problem.widget.page == page] + + fixed_corner_keypoints = fix_corner_markers(corner_keypoints, page_img.shape) + + x_min = min(point[0] for point in fixed_corner_keypoints) + y_min = min(point[1] for point in fixed_corner_keypoints) + top_left_point = (x_min, y_min) + + for problem in problems_on_page: + sol = Solution.query.filter(Solution.problem_id == problem.id, Solution.submission_id == sub.id).one_or_none() + + for mc_option in problem.mc_options: + box = (mc_option.x, mc_option.y) + + if box_is_filled(box, page_img, top_left_point): + feedback = mc_option.feedback + sol.feedback.append(feedback) + db.session.commit() + + +def box_is_filled(box, page_img, corner_keypoints, marker_margin=72/2.54, threshold=225, cut_padding=0.1, box_size=11): + """ + A function that finds the checkbox in a general area and then checks if it is filled in. + + Params + ------ + box: (int, int) + The coordinates of the top left (x,y) of the checkbox in points. + page_img: np.array + A numpy array of the image scan + corner_keypoints: (float,float) + The x coordinate of the left markers and the y coordinate of the top markers, + used as point of reference since scans can deviate from the original. + (x,y) are both in pixels. + marker_margin: float + The margin between the corner markers and the edge of a page when generated. + threshold: int + the threshold needed for a checkbox to be considered marked range is between 0 (fully black) + and 255 (absolutely white). + cut_padding: float + The extra padding when retrieving an area where the checkbox is in inches. + box_size: int + the size of the checkbox in points. + + Output + ------ + True if the box is marked, else False. + """ + + # shouldn't be needed, but some images are drawn a bit weirdly + y_shift = 5 + # create an array with y top, y bottom, x left and x right. use the marker margin to allign to the page. + coords = np.asarray([box[1] - marker_margin + y_shift, box[1] + box_size - marker_margin + y_shift, + box[0] - marker_margin, box[0] + box_size - marker_margin])/72 + + # add the actually margin from the scan to corner markers to the coords in inches + dpi = guess_dpi(page_img) + coords[0] = coords[0] + corner_keypoints[1]/dpi + coords[1] = coords[1] + corner_keypoints[1]/dpi + coords[2] = coords[2] + corner_keypoints[0]/dpi + coords[3] = coords[3] + corner_keypoints[0]/dpi + + # get the box where we think the box is + cut_im = get_box(page_img, coords, padding=cut_padding) + + # convert to grayscale + gray_im = cv2.cvtColor(cut_im, cv2.COLOR_BGR2GRAY) + # apply threshold to only have black or white + _, bin_im = cv2.threshold(gray_im, 150, 255, cv2.THRESH_BINARY) + + h_bin, w_bin, *_ = bin_im.shape + # create a mask that gets applied when floodfill the white + mask = np.zeros((h_bin+2, w_bin+2), np.uint8) + flood_im = bin_im.copy() + # fill the image from the top left + cv2.floodFill(flood_im, mask, (0, 0), 0) + # fill it from the bottom right just in case the top left doesn't cover all the white + cv2.floodFill(flood_im, mask, (h_bin-2, w_bin-2), 0) + + # find white parts + coords = cv2.findNonZero(flood_im) + # Find a bounding box of the white parts + x, y, w, h = cv2.boundingRect(coords) + # cut the image to this box + res_rect = bin_im[y:y+h, x:x+w] + + # the size in pixels we expect the drawn box to + box_size_px = box_size*dpi / 72 + + # if the rectangle is bigger (higher) than expected, cut the image up a bit + if h > 1.5 * box_size_px: + print("in h resize") + y_partition = 0.333 + # try getting another bounding box on bottom 2/3 of the screen + coords2 = cv2.findNonZero(flood_im[y + int(y_partition * h): y + h, x: x+w]) + x2, y2, w2, h2 = cv2.boundingRect(coords2) + # add these coords to create a new bounding box we are looking at + new_y = y+y2 + int(y_partition * h) + new_x = x + x2 + res_rect = bin_im[new_y:new_y + h2, new_x:new_x + w2] + + else: + new_x, new_y, w2, h2 = x, y, w, h + + # do the same for width + if w2 > 1.5 * box_size_px: + # usually the checkbox is somewhere in the bottom left of the bounding box + coords3 = cv2.findNonZero(flood_im[new_y: new_y + h2, new_x: new_x + int(0.66 * w2)]) + x3, y3, w3, h3 = cv2.boundingRect(coords3) + res_rect = bin_im[new_y + y3: new_y + y3 + h3, new_x + x3: new_x + x3 + w3] + + # if the found box is smaller than a certain threshold + # it means that we only found a little bit of white and the box is filled + res_x, res_y, *_ = res_rect.shape + if res_x < 0.333 * box_size_px or res_y < 0.333 * box_size_px: + return True + return np.average(res_rect) < threshold diff --git a/zesje/scans.py b/zesje/scans.py index bcbde9f68e30f93eea06f2403cc5d0a35844f6ce..8d99d0f4186cb4b5f84a3627dfcd584d4af3531c 100644 --- a/zesje/scans.py +++ b/zesje/scans.py @@ -17,7 +17,7 @@ from .database import db, Scan, Exam, Page, Student, Submission, Solution, ExamW from .datamatrix import decode_raw_datamatrix from .images import guess_dpi, get_box from .factory import make_celery - +from .pregrader import add_feedback_to_solution ExtractedBarcode = namedtuple('ExtractedBarcode', ['token', 'copy', 'page']) @@ -54,7 +54,7 @@ def process_pdf(scan_id): # TODO: When #182 is implemented, properly separate user-facing # messages (written to DB) from developer-facing messages, # which should be written into the log. - write_pdf_status(scan_id, 'error', "Unexpected error: " + str(error)) + write_pdf_status(scan_id, 'error', f"Unexpected error: {error}") def _process_pdf(scan_id, app_config): @@ -91,8 +91,8 @@ def _process_pdf(scan_id, app_config): print(description) failures.append(page) except Exception as e: - report_error(f'Error processing page {page}: {e}') - return + report_error(f'Error processing page {e}') + raise except Exception as e: report_error(f"Failed to read pdf: {e}") raise @@ -337,7 +337,13 @@ def process_page(image_data, exam_config, output_dir=None, strict=False): else: return True, "Testing, image not saved and database not updated." - update_database(image_path, barcode) + sub, exam = update_database(image_path, barcode) + + try: + add_feedback_to_solution(sub, exam, barcode.page, image_array, corner_keypoints) + except RuntimeError as e: + if strict: + return False, str(e) if barcode.page == 0: description = guess_student( @@ -385,8 +391,12 @@ def update_database(image_path, barcode): Returns ------- - signature_validated : bool - If the corresponding submission has a validated signature. + sub, exam where + + sub : Submission + the current submission + exam : Exam + the current exam """ exam = Exam.query.filter(Exam.token == barcode.token).first() if exam is None: @@ -406,6 +416,8 @@ def update_database(image_path, barcode): db.session.commit() + return sub, exam + def decode_barcode(image, exam_config): """Extract a barcode from a PIL Image."""