Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • zesje/zesje
  • jbweston/grader_app
  • dj2k/zesje
  • MrHug/zesje
  • okaaij/zesje
  • tsoud/zesje
  • pimotte/zesje
  • works-on-my-machine/zesje
  • labay11/zesje
  • reouvenassouly/zesje
  • t.v.aerts/zesje
  • giuseppe.deininger/zesje
12 results
Show changes
Commits on Source (253)
Showing
with 1198 additions and 157 deletions
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
client/components/answer_box.png

205 B

: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 { .editor-content {
background-color: #ddd; background-color: #ddd;
border-radius: 10px border-radius: 10px;
height: 100%;
} }
.selection-area { .selection-area {
......
...@@ -6,6 +6,7 @@ import Hero from '../components/Hero.jsx' ...@@ -6,6 +6,7 @@ import Hero from '../components/Hero.jsx'
import './Exam.css' import './Exam.css'
import GeneratedExamPreview from '../components/GeneratedExamPreview.jsx' import GeneratedExamPreview from '../components/GeneratedExamPreview.jsx'
import PanelGenerate from '../components/PanelGenerate.jsx' import PanelGenerate from '../components/PanelGenerate.jsx'
import PanelMCQ from '../components/PaneMCQ.jsx'
import ExamEditor from './ExamEditor.jsx' import ExamEditor from './ExamEditor.jsx'
import update from 'immutability-helper' import update from 'immutability-helper'
import ExamFinalizeMarkdown from './ExamFinalize.md' import ExamFinalizeMarkdown from './ExamFinalize.md'
...@@ -28,7 +29,9 @@ class Exams extends React.Component { ...@@ -28,7 +29,9 @@ class Exams extends React.Component {
widgets: [], widgets: [],
previewing: false, previewing: false,
deletingExam: false, deletingExam: false,
deletingWidget: false deletingWidget: false,
deletingMCWidget: false,
showPanelMCQ: false
} }
static getDerivedStateFromProps = (newProps, prevState) => { static getDerivedStateFromProps = (newProps, prevState) => {
...@@ -44,7 +47,17 @@ class Exams extends React.Component { ...@@ -44,7 +47,17 @@ class Exams extends React.Component {
page: problem.page, page: problem.page,
name: problem.name, name: problem.name,
graded: problem.graded, graded: problem.graded,
feedback: problem.feedback || [] feedback: problem.feedback || [],
mc_options: problem.mc_options.map((option) => {
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
} }
} }
}) })
...@@ -103,15 +116,20 @@ class Exams extends React.Component { ...@@ -103,15 +116,20 @@ class Exams extends React.Component {
} }
updateFeedback = (feedback) => { updateFeedback = (feedback) => {
var widgets = this.state.widgets let problemWidget = this.state.widgets[this.state.selectedWidgetId]
const idx = widgets[this.state.selectedWidgetId].problem.feedback.findIndex(e => { return e.id === feedback.id }) const index = problemWidget.problem.feedback.findIndex(e => { return e.id === feedback.id })
if (idx === -1) widgets[this.state.selectedWidgetId].problem.feedback.push(feedback) this.updateFeedbackAtIndex(feedback, problemWidget, index)
else { }
if (feedback.deleted) widgets[this.state.selectedWidgetId].problem.feedback.splice(idx, 1)
else widgets[this.state.selectedWidgetId].problem.feedback[idx] = feedback 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({ this.setState({
widgets: widgets widgets: this.state.widgets
}) })
} }
...@@ -143,6 +161,19 @@ class Exams extends React.Component { ...@@ -143,6 +161,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) => { deleteWidget = (widgetId) => {
const widget = this.state.widgets[widgetId] const widget = this.state.widgets[widgetId]
if (widget) { if (widget) {
...@@ -203,23 +234,16 @@ class Exams extends React.Component { ...@@ -203,23 +234,16 @@ class Exams extends React.Component {
numPages={this.state.numPages} numPages={this.state.numPages}
onPDFLoad={this.onPDFLoad} onPDFLoad={this.onPDFLoad}
updateWidget={this.updateWidget} updateWidget={this.updateWidget}
updateMCWidget={this.updateMCWidget}
selectedWidgetId={this.state.selectedWidgetId} selectedWidgetId={this.state.selectedWidgetId}
selectWidget={(widgetId) => { selectWidget={(widgetId) => {
this.setState({ this.setState({
selectedWidgetId: widgetId selectedWidgetId: widgetId
}) })
}} }}
createNewWidget={(widgetData) => { createNewWidget={this.createNewWidget}
this.setState((prevState) => { updateExam={() => {
return { this.props.updateExam(this.props.examID)
selectedWidgetId: widgetData.id,
widgets: update(prevState.widgets, {
[widgetData.id]: {
$set: widgetData
}
})
}
})
}} }}
/> />
) )
...@@ -279,18 +303,177 @@ class Exams extends React.Component { ...@@ -279,18 +303,177 @@ 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)
})
}).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 this 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 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
*/
createNewMCWidget = (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)
*/
updateMCWidget = (widget, data) => {
let newMCO = widget.problem.mc_options.map((option, i) => {
return {
'widget': {
'x': {
$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
$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 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,
'y': yPos,
'type': 'mcq_widget'
}
}
const formData = new window.FormData()
formData.append('name', data.widget.name)
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.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)
})
}
SidePanel = (props) => { SidePanel = (props) => {
const selectedWidgetId = this.state.selectedWidgetId const selectedWidgetId = this.state.selectedWidgetId
let selectedWidget = selectedWidgetId && this.state.widgets[selectedWidgetId] let selectedWidget = selectedWidgetId && this.state.widgets[selectedWidgetId]
let problem = selectedWidget && selectedWidget.problem 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 isGraded = problem && problem.graded
let widgetDeleteDisabled = widgetEditDisabled || isGraded let widgetDeleteDisabled = widgetEditDisabled || isGraded
let totalNrAnswers = 12 // the upper limit for the nr of possible answer boxes
let disabledDeleteBoxes = !containsMCOptions
let isMCQ = (problem && problem.isMCQ) || false
let showPanelMCQ = isMCQ && !this.state.previewing && !this.props.exam.finalized
return ( return (
<React.Fragment> <React.Fragment>
<this.PanelEdit <this.PanelEdit
disabledEdit={widgetEditDisabled} disabledEdit={widgetEditDisabled}
disableIsMCQ={widgetEditDisabled || containsMCOptions}
disabledDelete={widgetDeleteDisabled} disabledDelete={widgetDeleteDisabled}
onDeleteClick={() => { onDeleteClick={() => {
this.setState({deletingWidget: true}) this.setState({deletingWidget: true})
...@@ -311,7 +494,42 @@ class Exams extends React.Component { ...@@ -311,7 +494,42 @@ class Exams extends React.Component {
})) }))
}} }}
saveProblemName={this.saveProblemName} saveProblemName={this.saveProblemName}
isMCQProblem={isMCQ}
onMCQChange={
(checked) => {
this.setState(prevState => ({
changedWidgetId: selectedWidgetId,
widgets: update(prevState.widgets, {
[selectedWidgetId]: {
problem: {
isMCQ: {
$set: checked
}
}
}
})
}))
}
}
/> />
{ showPanelMCQ ? (
<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 /> <this.PanelExamActions />
</React.Fragment> </React.Fragment>
) )
...@@ -325,14 +543,18 @@ class Exams extends React.Component { ...@@ -325,14 +543,18 @@ class Exams extends React.Component {
<p className='panel-heading'> <p className='panel-heading'>
Problem details Problem details
</p> </p>
<div className='panel-block'> {selectedWidgetId === null ? (
<div className='field'> <div className='panel-block'>
{selectedWidgetId === null ? ( <div className='field'>
<p style={{margin: '0.625em 0', minHeight: '3em'}}> <p style={{ margin: '0.625em 0', minHeight: '3em' }}>
To create a problem, draw a rectangle on the exam. To create a problem, draw a rectangle on the exam.
</p> </p>
) : ( </div>
<React.Fragment> </div>
) : (
<React.Fragment>
<div className='panel-block'>
<div className='field'>
<label className='label'>Name</label> <label className='label'>Name</label>
<div className='control'> <div className='control'>
<input <input
...@@ -345,12 +567,22 @@ class Exams extends React.Component { ...@@ -345,12 +567,22 @@ class Exams extends React.Component {
}} }}
onBlur={(e) => { onBlur={(e) => {
props.saveProblemName(e.target.value) props.saveProblemName(e.target.value)
}} /> }}
/>
</div> </div>
</React.Fragment> </div>
)} </div>
</div> <div className='panel-block'>
</div> <div className='field'>
<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) && {this.isProblemWidget(selectedWidgetId) &&
<React.Fragment> <React.Fragment>
<div className='panel-block'> <div className='panel-block'>
...@@ -374,7 +606,6 @@ class Exams extends React.Component { ...@@ -374,7 +606,6 @@ class Exams extends React.Component {
Delete problem Delete problem
</button> </button>
</div> </div>
</nav> </nav>
) )
} }
...@@ -510,6 +741,18 @@ class Exams extends React.Component { ...@@ -510,6 +741,18 @@ class Exams extends React.Component {
onCancel={() => this.setState({deletingWidget: false})} onCancel={() => this.setState({deletingWidget: false})}
onConfirm={() => this.deleteWidget(this.state.selectedWidgetId)} 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> </div>
} }
} }
......
...@@ -8,6 +8,7 @@ import studentIdExampleImage from '../components/student_id_example.png' ...@@ -8,6 +8,7 @@ import studentIdExampleImage from '../components/student_id_example.png'
// FIXME! // FIXME!
// eslint-disable-next-line import/no-webpack-loader-syntax // eslint-disable-next-line import/no-webpack-loader-syntax
import studentIdExampleImageSize from '!image-dimensions-loader!../components/student_id_example.png' 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 EmptyPDF from '../components/EmptyPDF.jsx'
import PDFOverlay from '../components/PDFOverlay.jsx' import PDFOverlay from '../components/PDFOverlay.jsx'
...@@ -87,13 +88,18 @@ class ExamEditor extends React.Component { ...@@ -87,13 +88,18 @@ class ExamEditor extends React.Component {
const problemData = { const problemData = {
name: 'New problem', // TODO: Name name: 'New problem', // TODO: Name
page: this.props.page, page: this.props.page,
feedback: [] feedback: [],
mc_options: [],
widthMCO: 24,
heightMCO: 38,
isMCQ: false
} }
const widgetData = { const widgetData = {
x: Math.round(selectionBox.left), x: Math.round(selectionBox.left),
y: Math.round(selectionBox.top), y: Math.round(selectionBox.top),
width: Math.round(selectionBox.width), width: Math.round(selectionBox.width),
height: Math.round(selectionBox.height) height: Math.round(selectionBox.height),
type: 'problem_widget'
} }
const formData = new window.FormData() const formData = new window.FormData()
formData.append('exam_id', this.props.examID) formData.append('exam_id', this.props.examID)
...@@ -170,131 +176,342 @@ class ExamEditor extends React.Component { ...@@ -170,131 +176,342 @@ class ExamEditor extends React.Component {
} }
} }
renderWidgets = () => { /**
// Only render when numPage is set * This method is called when the position of a widget has changed. It informs the server about the relocation.
if (this.props.numPages !== null && this.props.widgets) { * @param widget the widget that was relocated
const widgets = this.props.widgets.filter(widget => { * @param data the new location
if (widget.name === 'student_id_widget' || */
widget.name === 'barcode_widget') { updateWidgetDB = (widget, data) => {
return !this.props.finalized return api.patch('widgets/' + widget.id, data).then(() => {
} else if (widget.problem) { // ok
return widget.problem.page === this.props.page }).catch(err => {
} else { console.log(err)
return true // update to try and get a consistent state
this.props.updateExam()
})
}
/**
* 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)
})
// update DB
widget.problem.mc_options.forEach(
(option, i) => {
let newData = {
x: Math.round(data.x) + i * widget.problem.widthMCO + option.cbOffsetX,
y: Math.round(data.y) + option.cbOffsetY
} }
this.updateWidgetDB(option, newData)
}) })
}
let minWidth /**
let minHeight * This function updates the position of the mc options inside when the corresponding problem widget changes in
let view * size or position. Note that the positions in the database are not updated. These should be updated once when the
let enableResizing * action (resizing/dragging/other) is finalized.
return widgets.map((widget) => { * @param widget the problem widget containing mc options
const isSelected = widget.id === this.props.selectedWidgetId * @param data the new data about the new size/position of the problem widget
*/
if (widget.problem) { repositionMC = (widget, data) => {
minWidth = this.props.problemMinWidth if (widget.problem.mc_options.length > 0) {
minHeight = this.props.problemMinHeight let oldX = widget.problem.mc_options[0].widget.x
view = ( let oldY = widget.problem.mc_options[0].widget.y
<div let newX = oldX
className={isSelected ? 'widget selected' : 'widget'} let newY = oldY
/> let widthOption = widget.problem.widthMCO * widget.problem.mc_options.length
) let heightOption = widget.problem.heightMCO
enableResizing = true let widthProblem = data.width ? data.width : widget.width
} else { let heightProblem = data.height ? data.height : widget.height
let image
if (widget.name === 'barcode_widget') { if (newX < data.x) {
minWidth = barcodeExampleImageSize.width newX = data.x
minHeight = barcodeExampleImageSize.height } else if (newX + widthOption > data.x + widthProblem) {
image = barcodeExampleImage newX = data.x + widget.width - widthOption
} else if (this.props.page === 0 && widget.name === 'student_id_widget') { }
minWidth = studentIdExampleImageSize.width
minHeight = studentIdExampleImageSize.height if (newY < data.y) {
image = studentIdExampleImage newY = data.y
} else { } else if (newY + heightOption > data.y + heightProblem) {
return null newY = data.y + widget.height - heightOption
} }
view = (
<div let changed = (oldX !== newX) || (oldY !== newY) // update the state only if the mc options were moved
className={isSelected ? 'widget selected' : 'widget'} if (changed) {
style={{ this.props.updateMCWidget(widget, {
boxSizing: 'content-box', x: Math.round(newX),
backgroundImage: 'url(' + image + ')', y: Math.round(newY)
backgroundRepeat: 'no-repeat' })
}} }
/> }
) }
enableResizing = false
} /**
return ( * This function renders a group of options into one draggable widget.
<ResizeAndDrag * @param widget the problem widget that contains a mc options
key={'widget_' + widget.id} * @return a react component representing the multiple choice widget
bounds='parent' */
minWidth={minWidth} renderMCWidget = (widget) => {
minHeight={minHeight} let width = widget.problem.widthMCO * widget.problem.mc_options.length
enableResizing={{ let height = widget.problem.heightMCO
bottom: enableResizing, let enableResizing = false
bottomLeft: enableResizing, const isSelected = widget.id === this.props.selectedWidgetId
bottomRight: enableResizing, let xPos = widget.problem.mc_options[0].widget.x
left: enableResizing, let yPos = widget.problem.mc_options[0].widget.y
right: enableResizing,
top: enableResizing, return (
topLeft: enableResizing, <ResizeAndDrag
topRight: enableResizing key={'widget_mc_' + widget.id}
}} bounds={'[data-key="widget_' + widget.id + '"]'}
position={{ minWidth={width}
x: widget.x, minHeight={height}
y: widget.y enableResizing={{
}} bottom: enableResizing,
size={{ bottomLeft: enableResizing,
width: widget.width, bottomRight: enableResizing,
height: widget.height left: enableResizing,
}} right: enableResizing,
onResize={(e, direction, ref, delta, position) => { top: enableResizing,
this.props.updateWidget(widget.id, { topLeft: enableResizing,
width: { $set: ref.offsetWidth }, topRight: enableResizing
height: { $set: ref.offsetHeight }, }}
x: { $set: Math.round(position.x) }, position={{
y: { $set: Math.round(position.y) } x: xPos,
}) y: yPos
}} }}
onResizeStop={(e, direction, ref, delta, position) => { size={{
api.patch('widgets/' + widget.id, { width: width,
x: Math.round(position.x), height: height
y: Math.round(position.y), }}
width: ref.offsetWidth, onDragStart={() => {
height: ref.offsetHeight this.props.selectWidget(widget.id)
}).then(() => { }}
// ok onDragStop={(e, data) => {
}).catch(err => { this.updateMCO(widget, data)
console.log(err) }}
// update to try and get a consistent state >
this.updateExam() <div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}>
}) {widget.problem.mc_options.map((option) => {
}} return (
onDragStart={() => { <div key={'widget_mco_' + option.id} className='mcq-option'>
this.props.selectWidget(widget.id) <div className='mcq-option-label'>
}} {option.label}
onDragStop={(e, data) => { </div>
this.props.updateWidget(widget.id, { <img className='mcq-box' src={answerBoxImage} />
x: { $set: Math.round(data.x) }, </div>
y: { $set: Math.round(data.y) } )
})}
</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} an array of react components to be displayed
*/
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) }
})
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) => {
this.updateWidgetDB(widget, {
x: Math.round(position.x),
y: Math.round(position.y),
width: ref.offsetWidth,
height: ref.offsetHeight
}).then(() => {
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
}) })
api.patch('widgets/' + widget.id, { }
x: Math.round(data.x), })
y: Math.round(data.y) }}
}).then(() => { onDragStart={() => {
// ok this.props.selectWidget(widget.id)
}).catch(err => { }}
console.log(err) onDrag={(e, data) => this.repositionMC(widget, data)}
// update to try and get a consistent state onDragStop={(e, data) => {
this.updateExam() this.props.updateWidget(widget.id, {
x: { $set: Math.round(data.x) },
y: { $set: Math.round(data.y) }
})
this.updateWidgetDB(widget, {
x: Math.round(data.x),
y: Math.round(data.y)
}).then(() => {
if (widget.problem.mc_options.length > 0) {
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
}) })
}} }
> })
{view} }}
</ResizeAndDrag> >
) <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} an array of react components to be displayed
*/
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) }
})
this.updateWidgetDB(widget, {
x: Math.round(data.x),
y: Math.round(data.y)
})
}}
>
<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} containing all widgets components to be displayed
*/
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 elementList
} }
} }
......
...@@ -30,6 +30,7 @@ class Grade extends React.Component { ...@@ -30,6 +30,7 @@ class Grade extends React.Component {
// update the tooltips for the associated widgets (in render()). // update the tooltips for the associated widgets (in render()).
this.props.bindShortcut(['left', 'h'], this.prev) this.props.bindShortcut(['left', 'h'], this.prev)
this.props.bindShortcut(['right', 'l'], this.next) this.props.bindShortcut(['right', 'l'], this.next)
this.props.bindShortcut(['a'], this.approve)
this.props.bindShortcut(['shift+left', 'shift+h'], (event) => { this.props.bindShortcut(['shift+left', 'shift+h'], (event) => {
event.preventDefault() event.preventDefault()
this.prevUngraded() this.prevUngraded()
...@@ -153,6 +154,20 @@ class Grade extends React.Component { ...@@ -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) => { toggleFullPage = (event) => {
this.setState({ this.setState({
fullPage: event.target.checked fullPage: event.target.checked
......
File added
"""empty message
Revision ID: b46a2994605b
Revises: 4204f4a83863
Create Date: 2019-05-15 15:41:56.615076
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b46a2994605b'
down_revision = '4204f4a83863'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mc_option',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('label', sa.String(), nullable=True),
sa.Column('feedback_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['feedback_id'], ['feedback_option.id'], ),
sa.ForeignKeyConstraint(['id'], ['widget.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('mc_option')
# ### end Alembic commands ###
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
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
...@@ -2,8 +2,41 @@ import os ...@@ -2,8 +2,41 @@ import os
import pytest 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 # Adapted from https://stackoverflow.com/a/46062148/1062698
@pytest.fixture @pytest.fixture
def datadir(): def datadir():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data') 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()
submissions
\ No newline at end of file
tests/data/checkboxes/scanned_page.jpg

165 KiB

tests/data/cornermarkers/a4-3-markers.png

14.9 KiB

tests/data/cornermarkers/a4-rotated-3-markers.png

15.4 KiB

tests/data/cornermarkers/a4-rotated.png

16.1 KiB

...@@ -106,6 +106,19 @@ def test_generate_pdfs_num_files(datadir, tmpdir): ...@@ -106,6 +106,19 @@ def test_generate_pdfs_num_files(datadir, tmpdir):
assert len(tmpdir.listdir()) == num_copies 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']) @pytest.mark.parametrize('name', ['a4', 'square'], ids=['a4', 'square'])
def test_join_pdfs(mock_generate_datamatrix, mock_generate_id_grid, def test_join_pdfs(mock_generate_datamatrix, mock_generate_id_grid,
datadir, tmpdir, name): datadir, tmpdir, name):
......
import os
import pytest
from PIL import Image
import numpy as np
from zesje import pregrader
from zesje import scans
from zesje import images
directory_name = "checkboxes"
@pytest.fixture
def scanned_image(datadir):
image_filename = os.path.join(datadir, directory_name, "scanned_page.jpg")
image = Image.open(image_filename)
image = np.array(image)
return image
@pytest.fixture
def scanned_image_keypoints(scanned_image):
corner_markers = scans.find_corner_marker_keypoints(scanned_image)
fixed_corner_keypoints = images.fix_corner_markers(corner_markers, scanned_image.shape)
return fixed_corner_keypoints
@pytest.mark.parametrize('box_coords, result', [((346, 479), True), ((370, 479), False), ((393, 479), True),
((416, 479), True), ((439, 479), True), ((155, 562), True)],
ids=["1 filled", "2 empty", "3 marked with line", "4 completely filled",
"5 marked with an x", "e marked with a cirle inside"])
def test_ideal_crops(box_coords, result, scanned_image_keypoints, scanned_image):
assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result
@pytest.mark.parametrize('box_coords, result', [((341, 471), True), ((352, 482), True), ((448, 482), True),
((423, 474), True), ((460, 475), False), ((477, 474), True),
((87, 548), False)],
ids=["1 filled bottom right", "1 filled top left", "5 filled with a bit of 6",
"4 fully filled with the label", "6 empty with label",
"7 partially cropped, filled and a part of 6", "B empty with cb at the bottom"])
def test_shifted_crops(box_coords, result, scanned_image_keypoints, scanned_image):
assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result
@pytest.mark.parametrize('box_coords, result', [((60, 562), True), ((107, 562), True),
((131, 562), False)],
ids=["A filled with trailing letter", "C filled with letters close",
"D blank with trailing letter"])
def test_trailing_text(box_coords, result, scanned_image_keypoints, scanned_image):
assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result
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
@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):
# Max deviation of inferred corner marker and actual location
epsilon = 2
# Scan rotated image with 4 corner markers
image_filename1 = 'a4-rotated.png'
image_path = os.path.join(datadir, 'cornermarkers', image_filename1)
page_img = cv2.imread(image_path)
corners1 = find_corner_marker_keypoints(page_img)
# Scan the same image with 3 corner markers
image_filename2 = 'a4-rotated-3-markers.png'
image_path = os.path.join(datadir, 'cornermarkers', image_filename2)
page_img = cv2.imread(image_path)
corners2 = find_corner_marker_keypoints(page_img)
# Get marker that was removed
diff = [corner for corner in corners1 if corner not in corners2]
diff_marker = min(diff)
fixed_corners2 = fix_corner_markers(corners2, page_img.shape)
added_marker = [corner for corner in fixed_corners2 if corner not in corners1][0]
# Check if 'inferred' corner marker is not too far away
dist = np.linalg.norm(np.subtract(added_marker, diff_marker))
assert dist < epsilon
...@@ -8,9 +8,11 @@ from .students import Students ...@@ -8,9 +8,11 @@ from .students import Students
from .submissions import Submissions from .submissions import Submissions
from .problems import Problems from .problems import Problems
from .feedback import Feedback from .feedback import Feedback
from .solutions import Solutions from .solutions import Solutions, Approve
from .widgets import Widgets from .widgets import Widgets
from .emails import EmailTemplate, RenderedEmailTemplate, Email from .emails import EmailTemplate, RenderedEmailTemplate, Email
from .mult_choice import MultipleChoice
from . import signature from . import signature
from . import images from . import images
from . import summary_plot from . import summary_plot
...@@ -48,7 +50,11 @@ api.add_resource(RenderedEmailTemplate, ...@@ -48,7 +50,11 @@ api.add_resource(RenderedEmailTemplate,
api.add_resource(Email, api.add_resource(Email,
'/email/<int:exam_id>', '/email/<int:exam_id>',
'/email/<int:exam_id>/<int:student_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 # Other resources that don't return JSON
# It is possible to get flask_restful to work with these, but not # It is possible to get flask_restful to work with these, but not
......