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 60d0c33a5f54df9d4324b01e573898102bd32752..f70b4a402a5693079c0f3fb4b832b4888a7e6677 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 } } }) @@ -100,6 +109,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) { @@ -164,17 +186,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) }} /> ) @@ -234,6 +249,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] @@ -241,11 +393,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}) @@ -266,7 +423,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> ) @@ -280,14 +472,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 @@ -300,12 +496,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} @@ -315,7 +523,6 @@ class Exams extends React.Component { Delete problem </button> </div> - </nav> ) } @@ -451,6 +658,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 index 234038aee11424456894a18d144754cc951e9843..42369d0718bc1a16a99c196ae7dcd845a0a3ab30 100644 Binary files a/data/course.sqlite and b/data/course.sqlite differ diff --git a/migrations/versions/f97aa3c73453_.py b/migrations/versions/b46a2994605b_.py similarity index 62% rename from migrations/versions/f97aa3c73453_.py rename to migrations/versions/b46a2994605b_.py index 76de0d35d7718d93261c72f43e275626b2951b04..0c7e740429ee92a25f3feef629ead82d9cf409d1 100644 --- a/migrations/versions/f97aa3c73453_.py +++ b/migrations/versions/b46a2994605b_.py @@ -1,7 +1,8 @@ -""" empty message +"""empty message -Revision ID: f97aa3c73453 +Revision ID: b46a2994605b Revises: 4204f4a83863 +Create Date: 2019-05-15 15:41:56.615076 """ from alembic import op @@ -9,25 +10,26 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'f97aa3c73453' +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('x', sa.Integer(), nullable=False), - sa.Column('y', sa.Integer(), nullable=False), sa.Column('label', sa.String(), nullable=True), - sa.Column('problem_id', sa.Integer(), nullable=False), - sa.Column('feedback_id', sa.Integer(), nullable=True), + sa.Column('feedback_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['feedback_id'], ['feedback_option.id'], ), - sa.ForeignKeyConstraint(['problem_id'], ['solution.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/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/zesje/api/__init__.py b/zesje/api/__init__.py index db4ba0882a8f66cf80ab0579b15430ce8aba9e21..17dfe1554eee0c8752ef1e0f5e71b713d8d150f1 100644 --- a/zesje/api/__init__.py +++ b/zesje/api/__init__.py @@ -12,6 +12,7 @@ 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 diff --git a/zesje/api/exams.py b/zesje/api/exams.py index 3b354f92eb6e1b1b0269d986e9d020dea749a680..4f77a35e0cde58d7d170f56f29a57b89eabeabbf 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): @@ -93,30 +120,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. @@ -153,8 +180,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': [ @@ -163,6 +204,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, @@ -313,13 +355,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() @@ -469,13 +514,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 index 42f23d4f3c31fb339ccdd6d9d6d513c495df03de..8a1b2c9ffd72ba1445b18a029efd6c62503626b8 100644 --- a/zesje/api/mult_choice.py +++ b/zesje/api/mult_choice.py @@ -1,23 +1,26 @@ from flask_restful import Resource, reqparse -from ..database import db, MultipleChoiceOption +from ..database import db, MultipleChoiceOption, FeedbackOption -def set_mc_data(mc_entry, x, y, problem_id, feedback_id, label): +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. - problem_id: the problem the MultipleChoiceOption refers to + 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.problem_id = problem_id + mc_entry.type = mc_type mc_entry.feedback_id = feedback_id mc_entry.label = label @@ -27,11 +30,11 @@ 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) - put_parser.add_argument('feedback_id', type=int, required=True) + 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 @@ -39,6 +42,10 @@ class MultipleChoice(Resource): 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 @@ -47,21 +54,31 @@ class MultipleChoice(Resource): 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'] - feedback_id = args['feedback_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 + new_feedback_option = FeedbackOption(problem_id=problem_id, text='') + db.session.add(new_feedback_option) + db.session.commit() + # Insert new entry into the database mc_entry = MultipleChoiceOption() - set_mc_data(mc_entry, x, y, problem_id, feedback_id, label) + 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, message=f'New multiple choice question with id {mc_entry.id} inserted'), 200 + 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) @@ -69,7 +86,7 @@ class MultipleChoice(Resource): 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, x, y, problem_id, feedback_id, label) + 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 @@ -88,17 +105,57 @@ class MultipleChoice(Resource): mult_choice = MultipleChoiceOption.query.get(id) if not mult_choice: - return dict(status=404, message='Multiple choice question does not exist.'), 404 + return dict(status=404, message=f'Multiple choice question with id {id} does not exist.'), 404 json = { - "id": mult_choice.id, - "x": mult_choice.x, - "y": mult_choice.y, - "problem_id": mult_choice.problem_id, - "feedback_id": mult_choice.feedback_id + '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 8ef0b3394772510a4dc16b2b8a4b47ce07700ac9..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,16 +166,16 @@ class Widget(db.Model): } -class MultipleChoiceOption(db.Model): +class MultipleChoiceOption(Widget): __tablename__ = 'mc_option' - id = Column(Integer, primary_key=True, autoincrement=True) + id = Column(Integer, ForeignKey('widget.id'), primary_key=True, autoincrement=True) - x = Column(Integer, nullable=False) - y = Column(Integer, nullable=False) label = Column(String, nullable=True) + feedback_id = Column(Integer, ForeignKey('feedback_option.id'), nullable=False) - problem_id = Column(Integer, ForeignKey('problem.id'), nullable=False) - feedback_id = Column(Integer, ForeignKey('feedback_option.id'), nullable=True) + __mapper_args__ = { + 'polymorphic_identity': 'mcq_widget' + } class ExamWidget(Widget): 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()