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 {
background-color: #ddd;
border-radius: 10px
border-radius: 10px;
height: 100%;
}
.selection-area {
......
......@@ -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'
......@@ -28,7 +29,9 @@ class Exams extends React.Component {
widgets: [],
previewing: false,
deletingExam: false,
deletingWidget: false
deletingWidget: false,
deletingMCWidget: false,
showPanelMCQ: false
}
static getDerivedStateFromProps = (newProps, prevState) => {
......@@ -44,7 +47,17 @@ class Exams extends React.Component {
page: problem.page,
name: problem.name,
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 {
}
updateFeedback = (feedback) => {
var widgets = this.state.widgets
const idx = widgets[this.state.selectedWidgetId].problem.feedback.findIndex(e => { return e.id === feedback.id })
if (idx === -1) widgets[this.state.selectedWidgetId].problem.feedback.push(feedback)
else {
if (feedback.deleted) widgets[this.state.selectedWidgetId].problem.feedback.splice(idx, 1)
else widgets[this.state.selectedWidgetId].problem.feedback[idx] = 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: widgets
widgets: this.state.widgets
})
}
......@@ -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) => {
const widget = this.state.widgets[widgetId]
if (widget) {
......@@ -203,23 +234,16 @@ 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({
selectedWidgetId: widgetId
})
}}
createNewWidget={(widgetData) => {
this.setState((prevState) => {
return {
selectedWidgetId: widgetData.id,
widgets: update(prevState.widgets, {
[widgetData.id]: {
$set: widgetData
}
})
}
})
createNewWidget={this.createNewWidget}
updateExam={() => {
this.props.updateExam(this.props.examID)
}}
/>
)
......@@ -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) => {
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 disabledDeleteBoxes = !containsMCOptions
let isMCQ = (problem && problem.isMCQ) || false
let showPanelMCQ = isMCQ && !this.state.previewing && !this.props.exam.finalized
return (
<React.Fragment>
<this.PanelEdit
disabledEdit={widgetEditDisabled}
disableIsMCQ={widgetEditDisabled || containsMCOptions}
disabledDelete={widgetDeleteDisabled}
onDeleteClick={() => {
this.setState({deletingWidget: true})
......@@ -311,7 +494,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
}
}
}
})
}))
}
}
/>
{ 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 />
</React.Fragment>
)
......@@ -325,14 +543,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
......@@ -345,12 +567,22 @@ 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'> 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'>
......@@ -374,7 +606,6 @@ class Exams extends React.Component {
Delete problem
</button>
</div>
</nav>
)
}
......@@ -510,6 +741,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>
}
}
......
......@@ -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'
......@@ -87,13 +88,18 @@ class ExamEditor extends React.Component {
const problemData = {
name: 'New problem', // TODO: Name
page: this.props.page,
feedback: []
feedback: [],
mc_options: [],
widthMCO: 24,
heightMCO: 38,
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)
......@@ -170,131 +176,342 @@ 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
*/
updateWidgetDB = (widget, data) => {
return api.patch('widgets/' + widget.id, data).then(() => {
// ok
}).catch(err => {
console.log(err)
// 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
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
}
view = (
<div
className={isSelected ? 'widget selected' : 'widget'}
style={{
boxSizing: 'content-box',
backgroundImage: 'url(' + image + ')',
backgroundRepeat: 'no-repeat'
}}
/>
)
enableResizing = false
}
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) }
/**
* 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 = 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
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.updateMCO(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} 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(() => {
// ok
}).catch(err => {
console.log(err)
// update to try and get a consistent state
this.updateExam()
}
})
}}
onDragStart={() => {
this.props.selectWidget(widget.id)
}}
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) }
})
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 {
// 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
......
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
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()
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):
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):
......
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
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
from . import signature
from . import images
from . import summary_plot
......@@ -48,7 +50,11 @@ 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
......