diff --git a/client/views/grade/EditPanel.jsx b/client/components/feedback/EditPanel.jsx similarity index 87% rename from client/views/grade/EditPanel.jsx rename to client/components/feedback/EditPanel.jsx index f1f574ac6f383042b186a8ac1107caae34f00389..63eb950ab7b52b8f1f9cd6bb810675d5ab001638 100644 --- a/client/views/grade/EditPanel.jsx +++ b/client/components/feedback/EditPanel.jsx @@ -40,16 +40,19 @@ class EditPanel extends React.Component { } static getDerivedStateFromProps (nextProps, prevState) { + // In case nothing is set, use an empty function that no-ops + const updateCallback = nextProps.updateCallback || (_ => {}) if (nextProps.feedback && prevState.id !== nextProps.feedback.id) { const fb = nextProps.feedback return { id: fb.id, name: fb.name, description: fb.description, - score: fb.score + score: fb.score, + updateCallback: updateCallback } } - return null + return {updateCallback: updateCallback} } changeText = (event) => { @@ -84,10 +87,15 @@ class EditPanel extends React.Component { if (this.state.id) { fb.id = this.state.id api.put(uri, fb) - .then(() => this.props.goBack()) + .then(() => { + this.state.updateCallback(fb) + this.props.goBack() + }) } else { api.post(uri, fb) - .then(() => { + .then((response) => { + // Response is the feedback option + this.state.updateCallback(response) this.setState({ id: null, name: '', @@ -101,14 +109,20 @@ class EditPanel extends React.Component { deleteFeedback = () => { if (this.state.id) { api.del('feedback/' + this.props.problemID + '/' + this.state.id) - .then(() => this.props.goBack()) + .then(() => { + this.state.updateCallback({ + id: this.state.id, + deleted: true + }) + this.props.goBack() + }) } } render () { return ( - <nav className='panel'> - <p className='panel-heading'> + <React.Fragment> + <p className={this.props.grading ? 'panel-heading' : 'panel-heading is-radiusless'}> Manage feedback </p> @@ -168,7 +182,7 @@ class EditPanel extends React.Component { onCancel={() => { this.setState({deleting: false}) }} /> </div> - </nav> + </React.Fragment> ) } } diff --git a/client/views/grade/FeedbackBlock.jsx b/client/components/feedback/FeedbackBlock.jsx similarity index 84% rename from client/views/grade/FeedbackBlock.jsx rename to client/components/feedback/FeedbackBlock.jsx index 7bc7ba60798917605cb34fa14e589de666679d28..af1429fd855a49d6aad28bd3a5fe9d39f68f058b 100644 --- a/client/views/grade/FeedbackBlock.jsx +++ b/client/components/feedback/FeedbackBlock.jsx @@ -32,7 +32,9 @@ class FeedbackBlock extends React.Component { render () { const shortcut = (this.props.index < 11 ? '' : 'shift + ') + this.props.index % 10 return ( - <a className='panel-block is-active' onClick={this.toggle} + <a + className={this.props.grading ? 'panel-block is-active' : 'panel-block'} + onClick={this.props.grading ? this.toggle : this.props.editFeedback} style={this.props.selected ? {backgroundColor: '#209cee'} : {}} > <span @@ -40,7 +42,9 @@ class FeedbackBlock extends React.Component { ? ' tooltip is-tooltip-active is-tooltip-left' : '')} data-tooltip={shortcut} > - <i className={'fa fa-' + (this.props.checked ? 'check-square-o' : 'square-o')} /> + {this.props.grading && + <i className={'fa fa-' + (this.props.checked ? 'check-square-o' : 'square-o')} /> + } </span> <span style={{ width: '80%' }}> {this.props.feedback.name} diff --git a/client/views/grade/FeedbackPanel.jsx b/client/components/feedback/FeedbackPanel.jsx similarity index 77% rename from client/views/grade/FeedbackPanel.jsx rename to client/components/feedback/FeedbackPanel.jsx index ee5266e6f25447fdb6dcbc3040666bb411f09e98..e2e1ce622ea373b7772b6f14deb5df8f1f47177c 100644 --- a/client/views/grade/FeedbackPanel.jsx +++ b/client/components/feedback/FeedbackPanel.jsx @@ -4,7 +4,7 @@ import Notification from 'react-bulma-notification' import * as api from '../../api.jsx' -import withShortcuts from '../../components/ShortcutBinder.jsx' +import withShortcuts from '../ShortcutBinder.jsx' import FeedbackBlock from './FeedbackBlock.jsx' class FeedbackPanel extends React.Component { @@ -35,7 +35,7 @@ class FeedbackPanel extends React.Component { static getDerivedStateFromProps (nextProps, prevState) { if (prevState.problemID !== nextProps.problem.id || prevState.submissionID !== nextProps.submissionID) { return { - remark: nextProps.solution.remark, + remark: nextProps.grading && nextProps.solution.remark, problemID: nextProps.problem.id, submissionID: nextProps.submissionID, selectedFeedbackIndex: null @@ -90,29 +90,34 @@ class FeedbackPanel extends React.Component { const blockURI = this.props.examID + '/' + this.props.submissionID + '/' + this.props.problem.id let totalScore = 0 - for (let i = 0; i < this.props.solution.feedback.length; i++) { - const probIndex = this.props.problem.feedback.findIndex(fb => fb.id === this.props.solution.feedback[i]) - if (probIndex >= 0) totalScore += this.props.problem.feedback[probIndex].score + if (this.props.grading) { + for (let i = 0; i < this.props.solution.feedback.length; i++) { + const probIndex = this.props.problem.feedback.findIndex(fb => fb.id === this.props.solution.feedback[i]) + if (probIndex >= 0) totalScore += this.props.problem.feedback[probIndex].score + } } let selectedFeedbackId = this.state.selectedFeedbackIndex !== null && this.props.problem.feedback[this.state.selectedFeedbackIndex].id return ( - <nav className='panel'> - <p className='panel-heading'> - Total: <b>{totalScore}</b> - </p> + <React.Fragment> + {this.props.grading && + <p className='panel-heading'> + Total: <b>{totalScore}</b> + </p>} {this.props.problem.feedback.map((feedback, index) => <FeedbackBlock key={feedback.id} uri={blockURI} graderID={this.props.graderID} - feedback={feedback} checked={this.props.solution.feedback.includes(feedback.id)} + feedback={feedback} checked={this.props.grading && this.props.solution.feedback.includes(feedback.id)} editFeedback={() => this.props.editFeedback(feedback)} updateSubmission={this.props.updateSubmission} - ref={(selectedFeedbackId === feedback.id) ? this.feedbackBlock : null} + ref={(selectedFeedbackId === feedback.id) ? this.feedbackBlock : null} grading={this.props.grading} selected={selectedFeedbackId === feedback.id} showIndex={this.props.showTooltips} index={index + 1} /> )} - <div className='panel-block'> - <textarea className='textarea' rows='2' placeholder='remark' value={this.state.remark} onBlur={this.saveRemark} onChange={this.changeRemark} /> - </div> + {this.props.grading && + <div className='panel-block'> + <textarea className='textarea' rows='2' placeholder='remark' value={this.state.remark} onBlur={this.saveRemark} onChange={this.changeRemark} /> + </div> + } <div className='panel-block'> <button className='button is-link is-outlined is-fullwidth' onClick={() => this.props.editFeedback()}> <span className='icon is-small'> @@ -121,7 +126,7 @@ class FeedbackPanel extends React.Component { <span>option</span> </button> </div> - </nav> + </React.Fragment> ) } } diff --git a/client/views/Exam.css b/client/views/Exam.css index e70724da849022d617945ccebe204ea143ad9fea..d1ffaddc6ffb8bd2fdb04f2dc4bae869d4325a2d 100644 --- a/client/views/Exam.css +++ b/client/views/Exam.css @@ -30,7 +30,8 @@ div.mcq-option img.mcq-box { .editor-content { background-color: #ddd; - border-radius: 10px + border-radius: 10px; + height: 100%; } .selection-area { diff --git a/client/views/Exam.jsx b/client/views/Exam.jsx index 3ccbc0087586e63a07fe50a09b7b2900dded0730..b51eb7fe61e101dba054bcd2b37f889d43eacc67 100644 --- a/client/views/Exam.jsx +++ b/client/views/Exam.jsx @@ -11,6 +11,8 @@ import ExamEditor from './ExamEditor.jsx' import update from 'immutability-helper' import ExamFinalizeMarkdown from './ExamFinalize.md' import ConfirmationModal from '../components/ConfirmationModal.jsx' +import FeedbackPanel from '../components/feedback/FeedbackPanel.jsx' +import EditPanel from '../components/feedback/EditPanel.jsx' import * as api from '../api.jsx' @@ -18,6 +20,9 @@ class Exams extends React.Component { state = { examID: null, page: 0, + editActive: false, + feedbackToEdit: null, + problemIdToEditFeedbackOf: null, numPages: null, selectedWidgetId: null, changedWidgetId: null, @@ -42,11 +47,16 @@ class Exams extends React.Component { page: problem.page, name: problem.name, graded: problem.graded, + feedback: problem.feedback || [], mc_options: problem.mc_options.map((option) => { - option.widget.x -= 7 - option.widget.y -= 21 + option.cbOffsetX = 7 // checkbox offset relative to option position on x axis + option.cbOffsetY = 21 // checkbox offset relative to option position on y axis + option.widget.x -= option.cbOffsetX + option.widget.y -= option.cbOffsetY return option }), + widthMCO: 24, + heightMCO: 38, isMCQ: problem.mc_options && problem.mc_options.length !== 0 // is the problem a mc question - used to display PanelMCQ } } @@ -66,7 +76,6 @@ class Exams extends React.Component { previewing: false } } - return null } @@ -78,6 +87,10 @@ class Exams extends React.Component { // The onBlur event is not fired when the input field is being disabled if (prevState.selectedWidgetId !== this.state.selectedWidgetId) { this.saveProblemName() + this.setState({ + editActive: false, + problemIdToEditFeedbackOf: false + }) } } @@ -94,6 +107,43 @@ class Exams extends React.Component { } } + editFeedback = (feedback) => { + this.setState({ + editActive: true, + feedbackToEdit: feedback, + problemIdToEditFeedbackOf: this.state.selectedWidgetId + }) + } + + updateFeedback = (feedback) => { + let problemWidget = this.state.widgets[this.state.selectedWidgetId] + const index = problemWidget.problem.feedback.findIndex(e => { return e.id === feedback.id }) + this.updateFeedbackAtIndex(feedback, problemWidget, index) + } + + updateFeedbackAtIndex = (feedback, problemWidget, idx) => { + if (idx === -1) { + problemWidget.problem.feedback.push(feedback) + } else { + if (feedback.deleted) problemWidget.problem.feedback.splice(idx, 1) + else problemWidget.problem.feedback[idx] = feedback + } + this.setState({ + widgets: this.state.widgets + }) + } + + backToFeedback = () => { + this.props.updateExam(this.props.exam.id) + this.setState({ + editActive: false + }) + } + + isProblemWidget = (widget) => { + return widget && this.state.widgets[widget].problem + } + saveProblemName = () => { const changedWidgetId = this.state.changedWidgetId if (!changedWidgetId) return @@ -134,6 +184,8 @@ class Exams extends React.Component { selectedWidgetId: null, changedWidgetId: null, deletingWidget: false, + editActive: false, + problemIdToEditFeedbackOf: null, widgets: update(prevState.widgets, { $unset: [widgetId] }) @@ -182,6 +234,7 @@ class Exams extends React.Component { numPages={this.state.numPages} onPDFLoad={this.onPDFLoad} updateWidget={this.updateWidget} + updateMCWidget={this.updateMCWidget} selectedWidgetId={this.state.selectedWidgetId} selectWidget={(widgetId) => { this.setState({ @@ -189,7 +242,6 @@ class Exams extends React.Component { }) }} createNewWidget={this.createNewWidget} - updateMCWidgetPosition={this.updateMCWidgetPosition} updateExam={() => { this.props.updateExam(this.props.examID) }} @@ -271,11 +323,16 @@ class Exams extends React.Component { // update to try and get a consistent state this.props.updateExam(this.props.examID) }) + }).then(res => { + let index = widget.problem.feedback.findIndex(e => { return e.id === res.feedback_id }) + let feedback = widget.problem.feedback[index] + feedback.deleted = true + this.updateFeedbackAtIndex(feedback, widget, index) }) }) // remove the mc options from the state - // note that his can happen before they are removed in the DB due to async calls + // note that this can happen before they are removed in the DB due to async calls this.setState((prevState) => { return { widgets: update(prevState.widgets, { @@ -294,11 +351,11 @@ class Exams extends React.Component { } /** - * This method creates a widget object and adds it to the corresponding problem + * This method creates a mc option 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) => { + createNewMCWidget = (problemWidget, data) => { this.setState((prevState) => { return { widgets: update(prevState.widgets, { @@ -320,12 +377,12 @@ class Exams extends React.Component { * @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) => { + updateMCWidget = (widget, data) => { let newMCO = widget.problem.mc_options.map((option, i) => { return { 'widget': { 'x': { - $set: data.x + i * 24 + $set: data.x + i * widget.problem.widthMCO }, 'y': { // each mc option needs to be positioned next to the previous option and should not overlap it @@ -359,30 +416,41 @@ class Exams extends React.Component { generateAnswerBoxes = (problemWidget, labels, index, xPos, yPos) => { if (labels.length === index) return + let feedback = { + 'name': labels[index], + 'description': '', + 'score': 0 + } + let data = { 'label': labels[index], 'problem_id': problemWidget.problem.id, 'feedback_id': null, + 'cbOffsetX': 7, // checkbox offset relative to option position on x axis + 'cbOffsetY': 21, // checkbox offset relative to option position on y axis 'widget': { 'name': 'mc_option_' + labels[index], - 'x': xPos + 7, - 'y': yPos + 21, + 'x': xPos, + 'y': yPos, '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('x', data.widget.x + data.cbOffsetX) + formData.append('y', data.widget.y + data.cbOffsetY) formData.append('problem_id', data.problem_id) formData.append('label', data.label) + formData.append('fb_description', feedback.description) + formData.append('fb_score', feedback.score) 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) + data.feedback_id = result.feedback_id + feedback.id = result.feedback_id + this.createNewMCWidget(problemWidget, data) + this.updateFeedback(feedback) + this.generateAnswerBoxes(problemWidget, labels, index + 1, xPos + problemWidget.problem.widthMCO, yPos) }).catch(err => { console.log(err) }) @@ -392,13 +460,14 @@ class Exams extends React.Component { const selectedWidgetId = this.state.selectedWidgetId let selectedWidget = selectedWidgetId && this.state.widgets[selectedWidgetId] let problem = selectedWidget && selectedWidget.problem - let widgetEditDisabled = this.state.previewing || !problem + let containsMCOptions = (problem && problem.mc_options.length > 0) || false + let widgetEditDisabled = (this.state.previewing || !problem) || (this.props.exam.finalized && containsMCOptions) 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 + let showPanelMCQ = isMCQ && !this.state.previewing && !this.props.exam.finalized return ( <React.Fragment> @@ -443,7 +512,7 @@ class Exams extends React.Component { } } /> - { isMCQ ? ( + { showPanelMCQ ? ( <PanelMCQ totalNrAnswers={totalNrAnswers} disabledGenerateBoxes={containsMCOptions} @@ -505,17 +574,29 @@ class Exams extends React.Component { </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> + <label className='label'> Multiple choice question </label> + <input disabled={props.disableIsMCQ} type='checkbox' checked={props.isMCQProblem} onChange={ + (e) => { + props.onMCQChange(e.target.checked) + }} /> </div> </div> </React.Fragment> )} + {this.isProblemWidget(selectedWidgetId) && + <React.Fragment> + <div className='panel-block'> + {!this.state.editActive && <label className='label'>Feedback options</label>} + </div> + {this.state.editActive + ? <EditPanel problemID={props.problem.id} feedback={this.state.feedbackToEdit} + goBack={this.backToFeedback} updateCallback={this.updateFeedback} /> + : <FeedbackPanel examID={this.props.examID} problem={props.problem} + editFeedback={this.editFeedback} showTooltips={this.state.showTooltips} + grading={false} + />} + </React.Fragment> + } <div className='panel-block'> <button disabled={props.disabledDelete} diff --git a/client/views/ExamEditor.jsx b/client/views/ExamEditor.jsx index 311cadafb250703af69f983cae03a8e88438ea5e..4abdf15844b4980279f7450e362e615551ef92f8 100644 --- a/client/views/ExamEditor.jsx +++ b/client/views/ExamEditor.jsx @@ -88,7 +88,10 @@ class ExamEditor extends React.Component { const problemData = { name: 'New problem', // TODO: Name page: this.props.page, + feedback: [], mc_options: [], + widthMCO: 24, + heightMCO: 38, isMCQ: false } const widgetData = { @@ -178,8 +181,8 @@ class ExamEditor extends React.Component { * @param widget the widget that was relocated * @param data the new location */ - updateWidgetPositionDB = (widget, data) => { - api.patch('widgets/' + widget.id, data).then(() => { + updateWidgetDB = (widget, data) => { + return api.patch('widgets/' + widget.id, data).then(() => { // ok }).catch(err => { console.log(err) @@ -188,33 +191,77 @@ class ExamEditor extends React.Component { }) } - updateState = (widget, data) => { - this.props.updateMCWidgetPosition(widget, { + /** + * This function updates the state and the Database with the positions of the mc options. + * @param widget the problem widget the mc options belong to + * @param data the new position of the mc widget + */ + updateMCO = (widget, data) => { + // update state + this.props.updateMCWidget(widget, { x: Math.round(data.x), y: Math.round(data.y) }) - } - - updateMCOPosition = (widget, data) => { - this.updateState(widget, data) + // update DB widget.problem.mc_options.forEach( (option, i) => { let newData = { - x: Math.round(data.x) + i * 24 + 7, - y: Math.round(data.y) + 21 + x: Math.round(data.x) + i * widget.problem.widthMCO + option.cbOffsetX, + y: Math.round(data.y) + option.cbOffsetY } - this.updateWidgetPositionDB(option, newData) + this.updateWidgetDB(option, newData) }) } /** - * This function renders a group of options into one draggable widget - * @returns {*} + * This function updates the position of the mc options inside when the corresponding problem widget changes in + * size or position. Note that the positions in the database are not updated. These should be updated once when the + * action (resizing/dragging/other) is finalized. + * @param widget the problem widget containing mc options + * @param data the new data about the new size/position of the problem widget + */ + repositionMC = (widget, data) => { + if (widget.problem.mc_options.length > 0) { + let oldX = widget.problem.mc_options[0].widget.x + let oldY = widget.problem.mc_options[0].widget.y + let newX = oldX + let newY = oldY + let widthOption = widget.problem.widthMCO * widget.problem.mc_options.length + let heightOption = widget.problem.heightMCO + let widthProblem = data.width ? data.width : widget.width + let heightProblem = data.height ? data.height : widget.height + + if (newX < data.x) { + newX = data.x + } else if (newX + widthOption > data.x + widthProblem) { + newX = data.x + widget.width - widthOption + } + + if (newY < data.y) { + newY = data.y + } else if (newY + heightOption > data.y + heightProblem) { + newY = data.y + widget.height - heightOption + } + + let changed = (oldX !== newX) || (oldY !== newY) // update the state only if the mc options were moved + if (changed) { + this.props.updateMCWidget(widget, { + x: Math.round(newX), + y: Math.round(newY) + }) + } + } + } + + /** + * This function renders a group of options into one draggable widget. + * @param widget the problem widget that contains a mc options + * @return a react component representing the multiple choice widget */ renderMCWidget = (widget) => { - let width = 24 * widget.problem.mc_options.length - let height = 38 + let width = widget.problem.widthMCO * widget.problem.mc_options.length + let height = widget.problem.heightMCO let enableResizing = false const isSelected = widget.id === this.props.selectedWidgetId let xPos = widget.problem.mc_options[0].widget.x @@ -248,7 +295,7 @@ class ExamEditor extends React.Component { this.props.selectWidget(widget.id) }} onDragStop={(e, data) => { - this.updateMCOPosition(widget, data) + this.updateMCO(widget, data) }} > <div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}> @@ -268,9 +315,9 @@ class ExamEditor extends React.Component { } /** - * Render problem widget and the mc options that correspond to the problem + * Render problem widget and the mc options that correspond to the problem. * @param widget the corresponding widget object from the db - * @returns {Array} + * @returns {Array} an array of react components to be displayed */ renderProblemWidget = (widget) => { // Only render when numPage is set @@ -312,79 +359,49 @@ class ExamEditor extends React.Component { x: { $set: Math.round(position.x) }, y: { $set: Math.round(position.y) } }) + this.repositionMC(widget, { + width: ref.offsetWidth, + height: ref.offsetHeight, + x: Math.round(position.x), + y: Math.round(position.y) + }) }} onResizeStop={(e, direction, ref, delta, position) => { - api.patch('widgets/' + widget.id, { + this.updateWidgetDB(widget, { 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() + if (widget.problem.mc_options.length > 0) { + this.updateMCO(widget, { + x: widget.problem.mc_options[0].widget.x, // these are guaranteed to be up to date + y: widget.problem.mc_options[0].widget.y + }) + } }) }} 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 - } - - 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 }) - } - }} + onDrag={(e, data) => this.repositionMC(widget, data)} 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, { + this.updateWidgetDB(widget, { 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 }) + this.updateMCO(widget, { + // react offers the guarantee that setState calls are processed before handling next event + // therefore the data in the state is up to date + x: widget.problem.mc_options[0].widget.x, + y: widget.problem.mc_options[0].widget.y + }) } - }).catch(err => { - console.log(err) - // update to try and get a consistent state - this.props.updateExam() }) }} > @@ -405,7 +422,7 @@ class ExamEditor extends React.Component { /** * Render exam widgets. * @param widget the corresponding widget object from the db - * @returns {Array} + * @returns {Array} an array of react components to be displayed */ renderExamWidget = (widget) => { if (this.props.finalized) return [] @@ -458,15 +475,9 @@ class ExamEditor extends React.Component { x: { $set: Math.round(data.x) }, y: { $set: Math.round(data.y) } }) - api.patch('widgets/' + widget.id, { + this.updateWidgetDB(widget, { 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() }) }} > @@ -484,7 +495,7 @@ class ExamEditor extends React.Component { /** * Render all the widgets by calling the right rendering function for each widget type - * @returns {Array} + * @returns {Array} containing all widgets components to be displayed */ renderWidgets = () => { // Only render when numPage is set diff --git a/client/views/Grade.jsx b/client/views/Grade.jsx index e3bafba95d19e35134a4208cb2528bc885bdf4fc..e6d2496948339f39efc508b33dc3f9a73ef0189f 100644 --- a/client/views/Grade.jsx +++ b/client/views/Grade.jsx @@ -2,9 +2,9 @@ import React from 'react' import Hero from '../components/Hero.jsx' -import FeedbackPanel from './grade/FeedbackPanel.jsx' +import FeedbackPanel from '../components/feedback/FeedbackPanel.jsx' import ProblemSelector from './grade/ProblemSelector.jsx' -import EditPanel from './grade/EditPanel.jsx' +import EditPanel from '../components/feedback/EditPanel.jsx' import SearchBox from '../components/SearchBox.jsx' import ProgressBar from '../components/ProgressBar.jsx' import withShortcuts from '../components/ShortcutBinder.jsx' @@ -30,6 +30,7 @@ class Grade extends React.Component { // update the tooltips for the associated widgets (in render()). this.props.bindShortcut(['left', 'h'], this.prev) this.props.bindShortcut(['right', 'l'], this.next) + this.props.bindShortcut(['a'], this.approve) this.props.bindShortcut(['shift+left', 'shift+h'], (event) => { event.preventDefault() this.prevUngraded() @@ -153,6 +154,20 @@ class Grade extends React.Component { }) } + approve = () => { + const exam = this.props.exam + const problem = exam.problems[this.state.pIndex] + const optionURI = this.state.examID + '/' + + exam.submissions[this.state.sIndex].id + '/' + + problem.id + api.put('solution/approve/' + optionURI, { + graderID: this.props.graderID + }) + .then(result => { + this.props.updateSubmission(this.state.sIndex) + }) + } + toggleFullPage = (event) => { this.setState({ fullPage: event.target.checked @@ -210,17 +225,18 @@ class Grade extends React.Component { <div className='column is-one-quarter-desktop is-one-third-tablet'> <ProblemSelector problems={exam.problems} changeProblem={this.changeProblem} current={this.state.pIndex} showTooltips={this.state.showTooltips} /> - {this.state.editActive - ? <EditPanel problemID={problem.id} feedback={this.state.feedbackToEdit} - goBack={this.backToFeedback} /> - : <FeedbackPanel examID={exam.id} submissionID={submission.id} - problem={problem} solution={solution} graderID={this.props.graderID} - editFeedback={this.editFeedback} showTooltips={this.state.showTooltips} - updateSubmission={() => { - this.props.updateSubmission(this.state.sIndex) - } - } /> - } + <nav className='panel'> + {this.state.editActive + ? <EditPanel problemID={problem.id} feedback={this.state.feedbackToEdit} + goBack={this.backToFeedback} /> + : <FeedbackPanel examID={exam.id} submissionID={submission.id} + problem={problem} solution={solution} graderID={this.props.graderID} + editFeedback={this.editFeedback} showTooltips={this.state.showTooltips} + updateSubmission={() => { + this.props.updateSubmission(this.state.sIndex) + }} grading /> + } + </nav> </div> <div className='column'> @@ -260,9 +276,13 @@ class Grade extends React.Component { renderSuggestion={(submission) => { const stud = submission.student return ( - <div> - <b>{`${stud.firstName} ${stud.lastName}`}</b> - <i style={{float: 'right'}}>({stud.id})</i> + <div className='flex-parent'> + <b className='flex-child truncated'> + {`${stud.firstName} ${stud.lastName}`} + </b> + <i className='flex-child fixed'> + ({stud.id}) + </i> </div> ) }} diff --git a/client/views/grade/Grade.css b/client/views/grade/Grade.css index 90e5dab9e507f7bcb2185c4541ce7632a4564d55..8b88019a647828998811e4a4df4a7a6dec7559c8 100644 --- a/client/views/grade/Grade.css +++ b/client/views/grade/Grade.css @@ -1,3 +1,19 @@ .box.is-graded { box-shadow: 0px 0px 6px #23d160, 0 0 0 1px rgba(10, 10, 10, 0.1); } + +.flex-parent { + display: flex; + align-items: center; +} + +.flex-child.truncated { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.flex-child.fixed { + white-space: nowrap; +} diff --git a/tests/api/test_exams.py b/tests/api/test_exams.py new file mode 100644 index 0000000000000000000000000000000000000000..c115ac5284ea006c30ed66804e9ba8f58c806047 --- /dev/null +++ b/tests/api/test_exams.py @@ -0,0 +1,48 @@ +import pytest + +from flask import json +from zesje.database import db, Exam, Problem, ProblemWidget + + +@pytest.fixture +def add_test_data(app): + with app.app_context(): + exam1 = Exam(id=1, name='exam 1', finalized=False) + db.session.add(exam1) + db.session.commit() + + problem1 = Problem(id=1, name='Problem 1', exam_id=1) + db.session.add(problem1) + db.session.commit() + + problem_widget_1 = ProblemWidget(id=1, name='problem widget', problem_id=1, page=2, + width=100, height=150, x=40, y=200, type='problem_widget') + db.session.add(problem_widget_1) + db.session.commit() + + +def test_get_exams(test_client, add_test_data): + mc_option_1 = { + 'x': 100, + 'y': 40, + 'problem_id': 1, + 'page': 1, + 'label': 'a', + 'name': 'test' + } + test_client.put('/api/mult-choice/', data=mc_option_1) + + mc_option_2 = { + 'x': 100, + 'y': 40, + 'problem_id': 1, + 'page': 1, + 'label': 'a', + 'name': 'test' + } + test_client.put('/api/mult-choice/', data=mc_option_2) + + response = test_client.get('/api/exams/1') + data = json.loads(response.data) + + assert len(data['problems'][0]['mc_options']) == 2 diff --git a/tests/api/test_mc_option.py b/tests/api/test_mc_option.py new file mode 100644 index 0000000000000000000000000000000000000000..4d109bde2c608e9e80db244e8026c9acdccf8220 --- /dev/null +++ b/tests/api/test_mc_option.py @@ -0,0 +1,155 @@ +import pytest + +from flask import json + +from zesje.database import db, Exam, Problem, ProblemWidget + + +@pytest.fixture +def add_test_data(app): + with app.app_context(): + exam1 = Exam(id=1, name='exam 1', finalized=False) + exam2 = Exam(id=2, name='exam 2', finalized=True) + exam3 = Exam(id=3, name='exam 3', finalized=False) + + db.session.add(exam1) + db.session.add(exam2) + db.session.add(exam3) + + problem1 = Problem(id=1, name='Problem 1', exam_id=1) + problem2 = Problem(id=2, name='Problem 2', exam_id=2) + problem3 = Problem(id=3, name='Problem 3', exam_id=3) + + db.session.add(problem1) + db.session.add(problem2) + db.session.add(problem3) + + problem_widget_1 = ProblemWidget(id=1, name='problem widget', problem_id=1, page=2, + width=100, height=150, x=40, y=200, type='problem_widget') + db.session.add(problem_widget_1) + + db.session.commit() + + +def mco_json(): + return { + 'x': 100, + 'y': 40, + 'problem_id': 1, + 'page': 1, + 'label': 'a', + 'name': 'test' + } + + +''' +ACTUAL TESTS +''' + + +def test_not_present(test_client, add_test_data): + result = test_client.get('/api/mult-choice/1') + data = json.loads(result.data) + + assert data['status'] == 404 + + +def test_add(test_client, add_test_data): + req = mco_json() + response = test_client.put('/api/mult-choice/', data=req) + + data = json.loads(response.data) + + assert data['message'] == 'New multiple choice question with id 2 inserted. ' \ + + 'New feedback option with id 1 inserted.' + + assert data['mult_choice_id'] == 2 + assert data['status'] == 200 + + +def test_add_get(test_client, add_test_data): + req = mco_json() + + response = test_client.put('/api/mult-choice/', data=req) + data = json.loads(response.data) + + id = data['mult_choice_id'] + + result = test_client.get(f'/api/mult-choice/{id}') + data = json.loads(result.data) + + exp_resp = { + 'id': 2, + 'name': 'test', + 'x': 100, + 'y': 40, + 'type': 'mcq_widget', + 'feedback_id': 1, + 'label': 'a', + } + + assert exp_resp == data + + +def test_update_put(test_client, add_test_data): + req = mco_json() + + response = test_client.put('/api/mult-choice/', data=req) + data = json.loads(response.data) + id = data['mult_choice_id'] + + req2 = { + 'x': 120, + 'y': 50, + 'problem_id': 4, + 'page': 1, + 'label': 'b', + 'name': 'test' + } + + result = test_client.patch(f'/api/mult-choice/{id}', data=req2) + data = json.loads(result.data) + + assert data['status'] == 200 + + +def test_delete(test_client, add_test_data): + req = mco_json() + + response = test_client.put('/api/mult-choice/', data=req) + data = json.loads(response.data) + id = data['mult_choice_id'] + + response = test_client.delete(f'/api/mult-choice/{id}') + data = json.loads(response.data) + + assert data['status'] == 200 + + +def test_delete_not_present(test_client, add_test_data): + id = 100 + + response = test_client.delete(f'/api/mult-choice/{id}') + data = json.loads(response.data) + + assert data['status'] == 404 + + +def test_delete_finalized_exam(test_client, add_test_data): + mc_option_json = { + 'x': 100, + 'y': 40, + 'problem_id': 2, + 'page': 1, + 'label': 'a', + 'name': 'test' + } + + response = test_client.put('/api/mult-choice/', data=mc_option_json) + data = json.loads(response.data) + mc_id = data['mult_choice_id'] + + response = test_client.delete(f'/api/mult-choice/{mc_id}') + data = json.loads(response.data) + + assert data['status'] == 401 diff --git a/tests/conftest.py b/tests/conftest.py index 0c373c16efa7c85627e2ba0efc09afa8b578987b..789157d6d71df52ade172f0aea9f5824f46adfdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,8 +2,41 @@ import os import pytest +from flask import Flask +from zesje.api import api_bp +from zesje.database import db + # Adapted from https://stackoverflow.com/a/46062148/1062698 @pytest.fixture def datadir(): return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data') + + +@pytest.fixture(scope="module") +def app(): + app = Flask(__name__, static_folder=None) + + app.config.update( + SQLALCHEMY_DATABASE_URI='sqlite:///:memory:', + SQLALCHEMY_TRACK_MODIFICATIONS=False # Suppress future deprecation warning + ) + db.init_app(app) + + with app.app_context(): + db.create_all() + + app.register_blueprint(api_bp, url_prefix='/api') + + return app + + +@pytest.fixture +def test_client(app): + client = app.test_client() + + yield client + + with app.app_context(): + db.drop_all() + db.create_all() diff --git a/tests/test_three_corners.py b/tests/test_three_corners.py index 1e3fb938e911061e2a5e741c619f9d86e7454a00..0994a98a87ef649fc6cb62c3a561b1979cb44eef 100644 --- a/tests/test_three_corners.py +++ b/tests/test_three_corners.py @@ -1,27 +1,20 @@ import cv2 import os import numpy as np +import pytest 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 +@pytest.mark.parametrize( + 'shape,corners,expected', + [((240, 200, 3), [(50, 50), (120, 50), (50, 200)], (120, 200)), + ((240, 200, 3), [(120, 50), (50, 200), (120, 200)], (50, 50))], + ids=["", ""]) +def test_three_straight_corners(shape, corners, expected): + corner_markers = fix_corner_markers(corners, shape) + assert expected in corner_markers def test_pdf(datadir): diff --git a/zesje/api/__init__.py b/zesje/api/__init__.py index 17dfe1554eee0c8752ef1e0f5e71b713d8d150f1..513cef8c67caebaadaeaf2de695136b8e973c947 100644 --- a/zesje/api/__init__.py +++ b/zesje/api/__init__.py @@ -8,7 +8,7 @@ from .students import Students from .submissions import Submissions from .problems import Problems from .feedback import Feedback -from .solutions import Solutions +from .solutions import Solutions, Approve from .widgets import Widgets from .emails import EmailTemplate, RenderedEmailTemplate, Email from .mult_choice import MultipleChoice @@ -50,11 +50,12 @@ api.add_resource(RenderedEmailTemplate, api.add_resource(Email, '/email/<int:exam_id>', '/email/<int:exam_id>/<int:student_id>') +api.add_resource(Approve, + '/solution/approve/<int:exam_id>/<int:submission_id>/<int:problem_id>') api.add_resource(MultipleChoice, '/mult-choice/<int:id>', '/mult-choice/') - # Other resources that don't return JSON # It is possible to get flask_restful to work with these, but not # very idiomatic. diff --git a/zesje/api/exams.py b/zesje/api/exams.py index 83dd18b52229ab84363cd29a877c4cb832c34f31..c82da7a602aca76694a107635f4384ab2019e014 100644 --- a/zesje/api/exams.py +++ b/zesje/api/exams.py @@ -9,7 +9,8 @@ from flask_restful import Resource, reqparse from werkzeug.datastructures import FileStorage from sqlalchemy.orm import selectinload -from ..pdf_generation import PAGE_FORMATS, generate_pdfs, output_pdf_filename_format, join_pdfs, page_is_size +from ..pdf_generation import generate_pdfs, output_pdf_filename_format, join_pdfs, page_is_size, make_pages_even +from ..pdf_generation import PAGE_FORMATS from ..database import db, Exam, ExamWidget, Submission @@ -280,10 +281,9 @@ class Exams(Resource): exam_dir = _get_exam_dir(exam.id) pdf_path = os.path.join(exam_dir, 'exam.pdf') - os.makedirs(exam_dir, exist_ok=True) - pdf_data.save(pdf_path) + make_pages_even(pdf_path, args['pdf']) print(f"Added exam {exam.id} (name: {exam_name}, token: {exam.token}) to database") diff --git a/zesje/api/mult_choice.py b/zesje/api/mult_choice.py index 29eaa61dfa5363f18a0a7afb91d78893da1a678d..fafed9c85b3265c9cab607bf780e762092f1fce0 100644 --- a/zesje/api/mult_choice.py +++ b/zesje/api/mult_choice.py @@ -34,23 +34,17 @@ class MultipleChoice(Resource): 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('fb_description', type=str, required=False) + put_parser.add_argument('fb_score', 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. + def put(self): + """Adds a multiple choice option to the database 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 @@ -58,38 +52,28 @@ class MultipleChoice(Resource): x = args['x'] y = args['y'] label = args['label'] + fb_description = args['fb_description'] + fb_score = args['fb_score'] 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) + # Insert new empty feedback option that links to the same problem + new_feedback_option = FeedbackOption(problem_id=problem_id, text=label, + description=fb_description, score=fb_score) + db.session.add(new_feedback_option) + db.session.commit() - if not mc_entry: - return dict(status=404, message=f"Multiple choice question with id {id} does not exist"), 404 + # Insert new entry into the database + mc_entry = MultipleChoiceOption() + set_mc_data(mc_entry, name, x, y, mc_type, new_feedback_option.id, label) - set_mc_data(mc_entry, name, x, y, mc_type, label) + db.session.add(mc_entry) db.session.commit() - return dict(status=200, message=f'Multiple choice question with id {id} updated'), 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 def get(self, id): """Fetches multiple choice option from the database @@ -122,6 +106,32 @@ class MultipleChoice(Resource): return json + def patch(self, id): + """ + Updates a multiple choice option + + Parameters + ---------- + id: The id of the multiple choice option in the database.s + """ + args = self.put_parser.parse_args() + + name = args['name'] + x = args['x'] + y = args['y'] + label = args['label'] + mc_type = 'mcq_widget' + + 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, mc_entry.feedback_id, label) + db.session.commit() + + return dict(status=200, message=f'Multiple choice question with id {id} updated'), 200 + def delete(self, id): """Deletes a multiple choice option from the database. Also deletes the associated feedback option with this multiple choice option. @@ -157,5 +167,6 @@ class MultipleChoice(Resource): db.session.delete(mult_choice.feedback) db.session.commit() - return dict(status=200, message=f'Multiple choice question with id {id} deleted.' + return dict(status=200, mult_choice_id=id, feedback_id=mult_choice.feedback_id, + 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/solutions.py b/zesje/api/solutions.py index 8001d52994905f0f5b7d5b2504895a6845d35907..2e41beae2297e99e2adabc2c8d8c053cb2d96707 100644 --- a/zesje/api/solutions.py +++ b/zesje/api/solutions.py @@ -147,3 +147,43 @@ class Solutions(Resource): db.session.commit() return {'state': state} + + +class Approve(Resource): + """ add just a grader to a specifc problem on an exam """ + put_parser = reqparse.RequestParser() + put_parser.add_argument('graderID', type=int, required=True) + + def put(self, exam_id, submission_id, problem_id): + """Takes an existing feedback checks if it is valid then gives the current graders id to the solution this is + usefull for approving pre graded solutions + + Parameters + ---------- + graderID: int + + Returns + ------- + state: boolean + """ + args = self.put_parser.parse_args() + + grader = Grader.query.get(args.graderID) + + sub = Submission.query.filter(Submission.exam_id == exam_id, + Submission.copy_number == submission_id).one_or_none() + if sub is None: + return dict(status=404, message='Submission does not exist.'), 404 + + solution = Solution.query.filter(Solution.submission_id == sub.id, + Solution.problem_id == problem_id).one_or_none() + if solution is None: + return dict(status=404, message='Solution does not exist.'), 404 + + graded = len(solution.feedback) + + if graded: + solution.graded_at = datetime.now() + solution.graded_by = grader + + return {'state': graded} diff --git a/zesje/pdf_generation.py b/zesje/pdf_generation.py index e4b3c2ece43bfb237c09cdfc05f2bca60507c336..083d73f7b29fcfe882197714bf157b9795c43e39 100644 --- a/zesje/pdf_generation.py +++ b/zesje/pdf_generation.py @@ -402,3 +402,19 @@ def page_is_size(exam_pdf_file, shape, tolerance=0): pass return not invalid + + +def make_pages_even(output_filename, exam_pdf_file): + exam_pdf = PdfReader(exam_pdf_file) + new = PdfWriter() + new.addpages(exam_pdf.pages) + pagecount = len(exam_pdf.pages) + + if (pagecount % 2 == 1): + blank = PageMerge() + box = exam_pdf.pages[0].MediaBox + blank.mbox = box + blank = blank.render() + new.addpage(blank) + + new.write(output_filename) diff --git a/zesje/statistics.py b/zesje/statistics.py index a2382447d4c1d3faf8820a5d7bcf4571c9348634..ff74c4e6e9ee3a274e0d3cda0c3d29a609af5093 100644 --- a/zesje/statistics.py +++ b/zesje/statistics.py @@ -5,7 +5,7 @@ from sqlalchemy.orm.exc import NoResultFound import numpy as np import pandas -from .database import Exam, Problem, Student +from .database import Exam, Student def solution_data(exam_id, student_id): @@ -68,7 +68,7 @@ def full_exam_data(exam_id): if not data: # No students were assigned. columns = [] - for problem in exam.problems.order_by(Problem.id): + for problem in exam.problems: # Sorted by problem.id if not len(problem.feedback_options): # There is no possible feedback for this problem. continue