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 (103)
Showing
with 382 additions and 250 deletions
...@@ -90,9 +90,8 @@ class PanelMCQ extends React.Component { ...@@ -90,9 +90,8 @@ class PanelMCQ extends React.Component {
nrPossibleAnswers: 2, nrPossibleAnswers: 2,
chosenLabelType: value chosenLabelType: value
}, () => { }, () => {
this.updateNumberOptions()
let labels = this.generateLabels(this.state.nrPossibleAnswers, 0) let labels = this.generateLabels(this.state.nrPossibleAnswers, 0)
this.props.updateLabels(labels) this.updateNumberOptions().then(() => this.props.updateLabels(labels))
}) })
} else { } else {
this.setState({ this.setState({
...@@ -116,7 +115,7 @@ class PanelMCQ extends React.Component { ...@@ -116,7 +115,7 @@ class PanelMCQ extends React.Component {
switch (type) { switch (type) {
case 1: case 1:
return ['T', 'F'] return ['T', 'F'].slice(startingAt)
case 2: case 2:
return Array.from(Array(nrLabels).keys()).map( return Array.from(Array(nrLabels).keys()).map(
(e) => String.fromCharCode(e + 65 + startingAt)) (e) => String.fromCharCode(e + 65 + startingAt))
......
:root {
--option-width:20px;
--label-font-size:14px;
}
div.mcq-widget { div.mcq-widget {
display:inline-flex; display:inline-flex;
box-sizing: content-box;
} }
div.mcq-option { div.mcq-option {
display: block; display: block;
width: var(--option-width); width: var(--width-mco);
padding:2px;
box-sizing: content-box; box-sizing: content-box;
height: auto; height: var(--height-mco);
} }
div.mcq-option div.mcq-option-label { div.mcq-option div.mcq-option-label {
display:block; display:block;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
font-size: var(--label-font-size); font-size: 12px;
text-align: center; text-align: center;
} }
......
...@@ -6,7 +6,7 @@ import Hero from '../components/Hero.jsx' ...@@ -6,7 +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 PanelMCQ from '../components/PanelMCQ.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'
...@@ -49,14 +49,16 @@ class Exams extends React.Component { ...@@ -49,14 +49,16 @@ class Exams extends React.Component {
graded: problem.graded, graded: problem.graded,
feedback: problem.feedback || [], feedback: problem.feedback || [],
mc_options: problem.mc_options.map((option) => { mc_options: problem.mc_options.map((option) => {
// the database stores the positions of the checkboxes but the front end uses the top-left position
// of the option; the cbOffsetX and cbOffsetY are used to manually locate the checkbox precisely
option.cbOffsetX = 7 // checkbox offset relative to option position on x axis 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.cbOffsetY = 21 // checkbox offset relative to option position on y axis
option.widget.x -= option.cbOffsetX option.widget.x -= option.cbOffsetX
option.widget.y -= option.cbOffsetY option.widget.y -= option.cbOffsetY
return option return option
}), }),
widthMCO: 24, widthMCO: 20,
heightMCO: 38 heightMCO: 34
} }
} }
}) })
...@@ -243,6 +245,7 @@ class Exams extends React.Component { ...@@ -243,6 +245,7 @@ class Exams extends React.Component {
updateWidget={this.updateWidget} updateWidget={this.updateWidget}
updateMCOsInState={this.updateMCOsInState} updateMCOsInState={this.updateMCOsInState}
selectedWidgetId={this.state.selectedWidgetId} selectedWidgetId={this.state.selectedWidgetId}
repositionMCO={this.repositionMCO}
highlightFeedback={(widget, feedbackId) => { highlightFeedback={(widget, feedbackId) => {
let index = widget.problem.feedback.findIndex(e => { return e.id === feedbackId }) let index = widget.problem.feedback.findIndex(e => { return e.id === feedbackId })
let feedback = widget.problem.feedback[index] let feedback = widget.problem.feedback[index]
...@@ -255,6 +258,12 @@ class Exams extends React.Component { ...@@ -255,6 +258,12 @@ class Exams extends React.Component {
feedback.highlight = false feedback.highlight = false
this.updateFeedbackAtIndex(feedback, widget, index) this.updateFeedbackAtIndex(feedback, widget, index)
}} }}
removeAllHighlight={(widget) => {
widget.problem.feedback.forEach((feedback, index) => {
feedback.highlight = false
this.updateFeedbackAtIndex(feedback, widget, index)
})
}}
selectWidget={(widgetId) => { selectWidget={(widgetId) => {
this.setState({ this.setState({
selectedWidgetId: widgetId selectedWidgetId: widgetId
...@@ -332,7 +341,10 @@ class Exams extends React.Component { ...@@ -332,7 +341,10 @@ class Exams extends React.Component {
* @param yPos y position of the current option * @param yPos y position of the current option
*/ */
generateMCOs = (problemWidget, labels, index, xPos, yPos) => { generateMCOs = (problemWidget, labels, index, xPos, yPos) => {
if (labels.length === index) return if (labels.length === index) {
this.repositionMCO(problemWidget.id, {x: problemWidget.x, y: problemWidget.y})
return true
}
let feedback = { let feedback = {
'name': labels[index], 'name': labels[index],
...@@ -362,15 +374,25 @@ class Exams extends React.Component { ...@@ -362,15 +374,25 @@ class Exams extends React.Component {
formData.append('label', data.label) formData.append('label', data.label)
formData.append('fb_description', feedback.description) formData.append('fb_description', feedback.description)
formData.append('fb_score', feedback.score) formData.append('fb_score', feedback.score)
api.put('mult-choice/', formData).then(result => { return api.put('mult-choice/', formData).then(result => {
data.id = result.mult_choice_id data.id = result.mult_choice_id
data.feedback_id = result.feedback_id data.feedback_id = result.feedback_id
feedback.id = result.feedback_id feedback.id = result.feedback_id
this.addMCOtoState(problemWidget, data) this.addMCOtoState(problemWidget, data)
this.updateFeedback(feedback) this.updateFeedback(feedback)
this.generateMCOs(problemWidget, labels, index + 1, xPos + problemWidget.problem.widthMCO, yPos) return this.generateMCOs(problemWidget, labels, index + 1, xPos + problemWidget.problem.widthMCO, yPos)
}).catch(err => { }).catch(err => {
console.log(err) console.log(err)
err.json().then(res => {
Notification.error('Could not create multiple choice option' +
(res.message ? ': ' + res.message : ''))
// update to try and get a consistent state
this.props.updateExam(this.props.examID)
this.setState({
selectedWidgetId: null
})
return false
})
}) })
} }
...@@ -404,10 +426,32 @@ class Exams extends React.Component { ...@@ -404,10 +426,32 @@ class Exams extends React.Component {
*/ */
deleteMCOs = (widgetId, index, nrMCOs) => { deleteMCOs = (widgetId, index, nrMCOs) => {
let widget = this.state.widgets[widgetId] let widget = this.state.widgets[widgetId]
if (nrMCOs <= 0 || !widget.problem.mc_options.length) return if (nrMCOs <= 0 || !widget.problem.mc_options.length) return true
let option = widget.problem.mc_options[index] let option = widget.problem.mc_options[index]
return api.del('mult-choice/' + option.id) return api.del('mult-choice/' + option.id)
.then(res => {
let feedback = widget.problem.feedback[index]
feedback.deleted = true
this.updateFeedback(feedback)
return new Promise((resolve, reject) => {
this.setState((prevState) => {
return {
widgets: update(prevState.widgets, {
[widget.id]: {
problem: {
mc_options: {
$splice: [[index, 1]]
}
}
}
})
}
}, () => {
resolve(this.deleteMCOs(widgetId, index, nrMCOs - 1))
})
})
})
.catch(err => { .catch(err => {
console.log(err) console.log(err)
err.json().then(res => { err.json().then(res => {
...@@ -415,28 +459,53 @@ class Exams extends React.Component { ...@@ -415,28 +459,53 @@ class Exams extends React.Component {
(res.message ? ': ' + res.message : '')) (res.message ? ': ' + res.message : ''))
// update to try and get a consistent state // update to try and get a consistent state
this.props.updateExam(this.props.examID) this.props.updateExam(this.props.examID)
this.setState({
selectedWidgetId: null
})
return false
}) })
}) })
.then(res => { }
let feedback = widget.problem.feedback[index]
feedback.deleted = true /**
this.updateFeedback(feedback) * This function updates the position of the mc options inside when the corresponding problem widget changes in
this.setState((prevState) => { * size or position. Note that the positions in the database are not updated. These should be updated once when the
return { * action (resizing/dragging/other) is finalized.
widgets: update(prevState.widgets, { * @param widget the problem widget containing mc options
[widget.id]: { * @param data the new data about the new size/position of the problem widget
problem: { */
mc_options: { repositionMCO = (widgetId, data) => {
$splice: [[index, 1]] let widget = this.state.widgets[widgetId]
} 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
this.deleteMCOs(widgetId, index, nrMCOs - 1) 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 || widthOption >= widthProblem) {
newX = data.x
} else if (newX + widthOption > data.x + widthProblem) {
newX = data.x + widget.width - widthOption
}
if (newY < data.y || heightOption >= heightProblem) {
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.updateMCOsInState(widget, {
x: Math.round(newX),
y: Math.round(newY)
}) })
}) }
}
} }
/** /**
...@@ -476,8 +545,7 @@ class Exams extends React.Component { ...@@ -476,8 +545,7 @@ class Exams extends React.Component {
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 widgetEditDisabled = (this.state.previewing || !problem)
(this.props.exam.finalized && problem.mc_options.length > 0)
let isGraded = problem && problem.graded let isGraded = problem && problem.graded
let widgetDeleteDisabled = widgetEditDisabled || isGraded let widgetDeleteDisabled = widgetEditDisabled || isGraded
...@@ -549,7 +617,7 @@ class Exams extends React.Component { ...@@ -549,7 +617,7 @@ class Exams extends React.Component {
</div> </div>
</div> </div>
</div> </div>
{props.problem ? ( {props.problem && !this.props.exam.finalized ? (
<PanelMCQ <PanelMCQ
totalNrAnswers={totalNrAnswers} totalNrAnswers={totalNrAnswers}
problem={props.problem} problem={props.problem}
...@@ -566,14 +634,16 @@ class Exams extends React.Component { ...@@ -566,14 +634,16 @@ class Exams extends React.Component {
xPos = problemWidget.x + 2 xPos = problemWidget.x + 2
yPos = problemWidget.y + 2 yPos = problemWidget.y + 2
} }
this.generateMCOs(problemWidget, labels, 0, xPos, yPos) return this.generateMCOs(problemWidget, labels, 0, xPos, yPos)
}} }}
deleteMCOs={(nrMCOs) => { deleteMCOs={(nrMCOs) => {
let len = props.problem.mc_options.length let len = props.problem.mc_options.length
if (nrMCOs >= len) { if (nrMCOs >= len) {
this.setState({deletingMCWidget: true}) return new Promise((resolve, reject) => {
this.setState({deletingMCWidget: true}, () => { resolve(false) })
})
} else if (nrMCOs > 0) { } else if (nrMCOs > 0) {
this.deleteMCOs(selectedWidgetId, len - nrMCOs, nrMCOs) return this.deleteMCOs(selectedWidgetId, len - nrMCOs, nrMCOs)
} }
}} }}
updateLabels={(labels) => { updateLabels={(labels) => {
...@@ -617,22 +687,22 @@ class Exams extends React.Component { ...@@ -617,22 +687,22 @@ class Exams extends React.Component {
}) })
}} }}
/>) : null} />) : null}
{props.problem &&
<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>
}
</React.Fragment> </React.Fragment>
)} )}
{props.problem &&
<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'> <div className='panel-block'>
<button <button
disabled={props.disabledDelete} disabled={props.disabledDelete}
...@@ -688,7 +758,12 @@ class Exams extends React.Component { ...@@ -688,7 +758,12 @@ class Exams extends React.Component {
return ( return (
<button <button
className='button is-link is-fullwidth' className='button is-link is-fullwidth'
onClick={() => { this.setState({previewing: true}) }} onClick={() => {
this.setState({
selectedWidgetId: null,
previewing: true
})
}}
> >
Finalize Finalize
</button> </button>
......
...@@ -23,7 +23,8 @@ class ExamEditor extends React.Component { ...@@ -23,7 +23,8 @@ class ExamEditor extends React.Component {
mouseDown: false, mouseDown: false,
selectionStartPoint: null, selectionStartPoint: null,
selectionEndPoint: null, selectionEndPoint: null,
selectionBox: null selectionBox: null,
draggingWidget: false // if a problem widget is being dragged, remove the highlighting of the feedback
} }
getPDFUrl = () => { getPDFUrl = () => {
...@@ -198,6 +199,8 @@ class ExamEditor extends React.Component { ...@@ -198,6 +199,8 @@ class ExamEditor extends React.Component {
* @param data the new position of the mc widget * @param data the new position of the mc widget
*/ */
updateMCO = (widget, data) => { updateMCO = (widget, data) => {
if (this.props.finalized) return // do not modify the locations of the mc options after the exam is finalized
// update state // update state
this.props.updateMCOsInState(widget, { this.props.updateMCOsInState(widget, {
x: Math.round(data.x), x: Math.round(data.x),
...@@ -215,46 +218,6 @@ class ExamEditor extends React.Component { ...@@ -215,46 +218,6 @@ class ExamEditor extends React.Component {
}) })
} }
/**
* 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.updateMCOsInState(widget, {
x: Math.round(newX),
y: Math.round(newY)
})
}
}
}
/** /**
* This function renders a group of options into one draggable widget. * This function renders a group of options into one draggable widget.
* @param widget the problem widget that contains a mc options * @param widget the problem widget that contains a mc options
...@@ -294,9 +257,17 @@ class ExamEditor extends React.Component { ...@@ -294,9 +257,17 @@ class ExamEditor extends React.Component {
}} }}
onDragStart={() => { onDragStart={() => {
this.props.selectWidget(widget.id) this.props.selectWidget(widget.id)
this.setState({
draggingWidget: true
})
this.props.removeAllHighlight(widget)
}} }}
onDragStop={(e, data) => { onDragStop={(e, data) => {
this.updateMCO(widget, data) this.updateMCO(widget, data)
this.setState({
draggingWidget: false
})
}} }}
> >
<div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}> <div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}>
...@@ -304,14 +275,19 @@ class ExamEditor extends React.Component { ...@@ -304,14 +275,19 @@ class ExamEditor extends React.Component {
return ( return (
<div key={'widget_mco_' + option.id} className='mcq-option' <div key={'widget_mco_' + option.id} className='mcq-option'
onMouseEnter={() => { onMouseEnter={() => {
this.props.highlightFeedback(widget, option.feedback_id) if (!this.state.draggingWidget) {
this.props.highlightFeedback(widget, option.feedback_id)
}
}} }}
onMouseLeave={() => { onMouseLeave={() => {
this.props.removeHighlight(widget, option.feedback_id) if (!this.state.draggingWidget) {
this.props.removeHighlight(widget, option.feedback_id)
}
}} }}
style={{'--width-mco': widget.problem.widthMCO + 'px', '--height-mco': widget.problem.heightMCO + 'px'}}
> >
<div className='mcq-option-label'> <div className='mcq-option-label'>
{option.label} {option.label === ' ' ? <span>&nbsp;</span> : option.label}
</div> </div>
<img className='mcq-box' src={answerBoxImage} /> <img className='mcq-box' src={answerBoxImage} />
</div> </div>
...@@ -367,7 +343,7 @@ class ExamEditor extends React.Component { ...@@ -367,7 +343,7 @@ class ExamEditor extends React.Component {
x: { $set: Math.round(position.x) }, x: { $set: Math.round(position.x) },
y: { $set: Math.round(position.y) } y: { $set: Math.round(position.y) }
}) })
this.repositionMC(widget, { this.props.repositionMCO(widget.id, {
width: ref.offsetWidth, width: ref.offsetWidth,
height: ref.offsetHeight, height: ref.offsetHeight,
x: Math.round(position.x), x: Math.round(position.x),
...@@ -391,9 +367,15 @@ class ExamEditor extends React.Component { ...@@ -391,9 +367,15 @@ class ExamEditor extends React.Component {
}} }}
onDragStart={() => { onDragStart={() => {
this.props.selectWidget(widget.id) this.props.selectWidget(widget.id)
this.setState({
draggingWidget: true
})
}} }}
onDrag={(e, data) => this.repositionMC(widget, data)} onDrag={(e, data) => this.props.repositionMCO(widget.id, data)}
onDragStop={(e, data) => { onDragStop={(e, data) => {
this.setState({
draggingWidget: false
})
this.props.updateWidget(widget.id, { this.props.updateWidget(widget.id, {
x: { $set: Math.round(data.x) }, x: { $set: Math.round(data.x) },
y: { $set: Math.round(data.y) } y: { $set: Math.round(data.y) }
......
...@@ -2,3 +2,4 @@ You _**will no longer**_ be able to: ...@@ -2,3 +2,4 @@ You _**will no longer**_ be able to:
+ modify the position of the student ID widget + modify the position of the student ID widget
+ modify the position of the page markers + modify the position of the page markers
+ create, delete or modify multiple choice options
\ No newline at end of file
"""empty message """Adds multiple choice question checkboxes
Revision ID: b46a2994605b Revision ID: b46a2994605b
Revises: 4204f4a83863 Revises: 4204f4a83863
...@@ -17,7 +17,7 @@ depends_on = None ...@@ -17,7 +17,7 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # Create the multiple choice question table
op.create_table('mc_option', op.create_table('mc_option',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('label', sa.String(), nullable=True), sa.Column('label', sa.String(), nullable=True),
...@@ -26,10 +26,8 @@ def upgrade(): ...@@ -26,10 +26,8 @@ def upgrade():
sa.ForeignKeyConstraint(['id'], ['widget.id'], ), sa.ForeignKeyConstraint(['id'], ['widget.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
# ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # Remove the multiple choice question table
op.drop_table('mc_option') op.drop_table('mc_option')
# ### end Alembic commands ###
...@@ -21,6 +21,9 @@ def add_test_data(app): ...@@ -21,6 +21,9 @@ def add_test_data(app):
db.session.commit() db.session.commit()
# Actual tests
def test_get_exams(test_client, add_test_data): def test_get_exams(test_client, add_test_data):
mc_option_1 = { mc_option_1 = {
'x': 100, 'x': 100,
......
...@@ -32,9 +32,7 @@ def mco_json(): ...@@ -32,9 +32,7 @@ def mco_json():
} }
''' # Actual tests
ACTUAL TESTS
'''
def test_delete_with_mc_option(test_client, add_test_data): def test_delete_with_mc_option(test_client, add_test_data):
......
...@@ -42,9 +42,7 @@ def mco_json(): ...@@ -42,9 +42,7 @@ def mco_json():
} }
''' # Actual tests
ACTUAL TESTS
'''
def test_not_present(test_client, add_test_data): def test_not_present(test_client, add_test_data):
...@@ -130,6 +128,36 @@ def test_update_patch(test_client, add_test_data): ...@@ -130,6 +128,36 @@ def test_update_patch(test_client, add_test_data):
assert data['status'] == 200 assert data['status'] == 200
def test_update_finalized_exam(test_client, add_test_data):
req = mco_json()
# Link mc_option to finalized exam
req['problem_id'] = '1'
response = test_client.put('/api/mult-choice/', data=req)
data = json.loads(response.data)
assert data['mult_choice_id']
test_client.put('api/exams/1/finalized', data='true')
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'] == 405
def test_delete(test_client, add_test_data): def test_delete(test_client, add_test_data):
req = mco_json() req = mco_json()
...@@ -143,6 +171,43 @@ def test_delete(test_client, add_test_data): ...@@ -143,6 +171,43 @@ def test_delete(test_client, add_test_data):
assert data['status'] == 200 assert data['status'] == 200
def test_delete_problem_check_mco(test_client, add_test_data):
req = mco_json()
problem_id = req['problem_id']
response = test_client.put('/api/mult-choice/', data=req)
data = json.loads(response.data)
mult_choice_id = data['mult_choice_id']
# Delete problem
test_client.delete(f'/api/problems/{problem_id}')
# Get mult choice option
response = test_client.get(f'/api/mult-choice/{mult_choice_id}')
data = json.loads(response.data)
assert data['status'] == 404
def test_delete_mco_check_feedback(test_client, add_test_data):
req = mco_json()
response = test_client.put('/api/mult-choice/', data=req)
data = json.loads(response.data)
mult_choice_id = data['mult_choice_id']
feedback_id = data['feedback_id']
test_client.delete(f'/api/mult-choice/{mult_choice_id}')
# Get feedback
response = test_client.get(f'/api/feedback/{feedback_id}')
data = json.loads(response.data)
ids = [fb['id'] for fb in data]
assert feedback_id not in ids
def test_delete_not_present(test_client, add_test_data): def test_delete_not_present(test_client, add_test_data):
id = 100 id = 100
...@@ -152,7 +217,7 @@ def test_delete_not_present(test_client, add_test_data): ...@@ -152,7 +217,7 @@ def test_delete_not_present(test_client, add_test_data):
assert data['status'] == 404 assert data['status'] == 404
def test_delete_finalized_exam(test_client, add_test_data): def test_add_finalized_exam(test_client, add_test_data):
mc_option_json = { mc_option_json = {
'x': 100, 'x': 100,
'y': 40, 'y': 40,
...@@ -164,9 +229,20 @@ def test_delete_finalized_exam(test_client, add_test_data): ...@@ -164,9 +229,20 @@ def test_delete_finalized_exam(test_client, add_test_data):
response = test_client.put('/api/mult-choice/', data=mc_option_json) response = test_client.put('/api/mult-choice/', data=mc_option_json)
data = json.loads(response.data) data = json.loads(response.data)
assert data['status'] == 405
def test_delete_finalized_exam(test_client, add_test_data):
req = mco_json()
response = test_client.put('/api/mult-choice/', data=req)
data = json.loads(response.data)
mc_id = data['mult_choice_id'] mc_id = data['mult_choice_id']
test_client.put('api/exams/1/finalized', data='true')
response = test_client.delete(f'/api/mult-choice/{mc_id}') response = test_client.delete(f'/api/mult-choice/{mc_id}')
data = json.loads(response.data) data = json.loads(response.data)
assert data['status'] == 401 assert data['status'] == 405
import pytest
from flask import json
from zesje.database import db, Exam, Problem, FeedbackOption, MultipleChoiceOption, ProblemWidget
@pytest.fixture
def add_test_data(app):
with app.app_context():
exam1 = Exam(id=1, name='exam 1', finalized=True)
db.session.add(exam1)
problem1 = Problem(id=1, name='Problem 1', exam_id=1)
db.session.add(problem1)
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()
feedback_option = FeedbackOption(id=1, problem_id=1, text='text', description='desc', score=1)
db.session.add(feedback_option)
db.session.commit()
mc_option = MultipleChoiceOption(id=2, label='a', feedback_id=1, x=10, y=30, name='mco', type='mcq_widget')
db.session.add(mc_option)
db.session.commit()
# Actual tests
def test_update_mco_finalized_exam(test_client, add_test_data):
"""
Attempt to update a ProblemWidget in a finalized exam
"""
widget_id = 2
req_body = {'x': 50}
result = test_client.patch(f'/api/widgets/{widget_id}', data=req_body)
data = json.loads(result.data)
assert data['status'] == 405
submissions submissions
\ No newline at end of file
tests/data/cornermarkers/a4-1-marker.png

14.5 KiB

tests/data/cornermarkers/a4-rotated-2-bottom-markers.png

13.4 KiB

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

13.4 KiB

tests/data/cornermarkers/a4-shifted-1-marker.png

17.8 KiB

...@@ -2,7 +2,7 @@ import pytest ...@@ -2,7 +2,7 @@ import pytest
from flask import Flask from flask import Flask
from zesje.database import db, _generate_exam_token, Exam, Problem, ProblemWidget, Solution from zesje.database import db, _generate_exam_token, Exam, Problem, ProblemWidget, Solution
from zesje.database import Submission, Scan, Page, ExamWidget, FeedbackOption from zesje.database import Submission, Scan, Page, ExamWidget, FeedbackOption, MultipleChoiceOption
@pytest.mark.parametrize('duplicate_count', [ @pytest.mark.parametrize('duplicate_count', [
...@@ -128,6 +128,41 @@ def test_cascades_submission(empty_app, exam, problem, submission, solution, pag ...@@ -128,6 +128,41 @@ def test_cascades_submission(empty_app, exam, problem, submission, solution, pag
assert page not in db.session assert page not in db.session
def test_cascades_fb_mco(empty_app, feedback_option, mc_option):
empty_app.app_context().push()
feedback_option.mc_option = mc_option
db.session.add(feedback_option)
db.session.commit()
assert mc_option in db.session
db.session.delete(feedback_option)
db.session.commit()
assert mc_option not in db.session
def test_cascades_mco_fb(empty_app, feedback_option, mc_option):
empty_app.app_context().push()
feedback_option.mc_option = mc_option
db.session.add(mc_option)
db.session.commit()
assert feedback_option in db.session
db.session.delete(mc_option)
db.session.commit()
assert feedback_option not in db.session
@pytest.fixture
def mc_option():
return MultipleChoiceOption(name='', x=0, y=0)
@pytest.fixture @pytest.fixture
def exam(): def exam():
return Exam(name='') return Exam(name='')
......
import cv2
import os
import numpy as np
import pytest
from zesje.images import get_delta, get_corner_marker_sides, fix_corner_markers, add_tup, sub_tup
from zesje.scans import find_corner_marker_keypoints
@pytest.mark.parametrize(
'shape,corners,expected',
[((240, 200, 3), [(120, 50), (50, 200), (120, 200)], (50, 50)),
((240, 200, 3), [(50, 50), (50, 200), (120, 200)], (120, 50)),
((240, 200, 3), [(50, 50), (120, 50), (120, 200)], (50, 200)),
((240, 200, 3), [(50, 50), (120, 50), (50, 200)], (120, 200))],
ids=["missing top left", "missing top right", "missing bottom left", "missing bottom right"])
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
@pytest.mark.parametrize(
'inputs,expected',
[
(((0, 1), (1, 1), (0, 0), None), (0, 1)),
(((0, 1), None, (0, 0), (0, 1)), (0, 1)),
(((1, 1), (2, 1), None, (2, 2)), (0, -1)),
((None, (1, 1), (1, 2), (2, 2)), (-1, -1))
],
ids=["missing bottom right", "missing top right", "missing bottom left", "missing top left"]
)
def test_get_delta(inputs, expected):
# unpack inputs so that the individual elements are paramaters
delta = get_delta(*inputs)
assert delta == expected
def test_get_corner_marker_sides_all_four():
shape = (100, 100)
corner_markers = [(0, 0), (100, 0), (0, 100), (100, 100)]
assert tuple(corner_markers) == get_corner_marker_sides(corner_markers, shape)
def test_get_corner_markers_three():
shape = (100, 100)
corner_markers = [(0, 0), (0, 100), (100, 0)]
top_left, top_right, bottom_left, bottom_right = get_corner_marker_sides(corner_markers, shape)
assert not bottom_right
def test_add_tup():
tup1 = tup2 = (1, 1)
assert add_tup(tup1, tup2) == (2, 2)
def test_sub_tup():
tup1 = tup2 = (1, 1)
assert sub_tup(tup1, tup2) == (0, 0)
...@@ -313,7 +313,10 @@ def test_image_extraction(datadir, filename): ...@@ -313,7 +313,10 @@ def test_image_extraction(datadir, filename):
@pytest.mark.parametrize('file_name, markers', [("a4-rotated.png", [(59, 59), (1181, 59), (59, 1695), (1181, 1695)]), @pytest.mark.parametrize('file_name, markers', [("a4-rotated.png", [(59, 59), (1181, 59), (59, 1695), (1181, 1695)]),
("a4-3-markers.png", [(1181, 59), (59, 1695), (1181, 1695)]), ("a4-3-markers.png", [(1181, 59), (59, 1695), (1181, 1695)]),
("a4-rotated-3-markers.png", [(1181, 59), (59, 1695), (1181, 1695)]) ("a4-rotated-3-markers.png", [(1181, 59), (59, 1695), (1181, 1695)]),
("a4-rotated-2-markers.png", [(1181, 59), (59, 1695)]),
("a4-rotated-2-bottom-markers.png", [(59, 1695), (1181, 1695)]),
("a4-shifted-1-marker.png", [(59, 1695)])
]) ])
def test_realign_image(datadir, file_name, markers): def test_realign_image(datadir, file_name, markers):
dir_name = "cornermarkers" dir_name = "cornermarkers"
...@@ -323,6 +326,7 @@ def test_realign_image(datadir, file_name, markers): ...@@ -323,6 +326,7 @@ def test_realign_image(datadir, file_name, markers):
test_image = np.array(PIL.Image.open(test_file)) test_image = np.array(PIL.Image.open(test_file))
result_image = scans.realign_image(test_image) result_image = scans.realign_image(test_image)
result_corner_markers = scans.find_corner_marker_keypoints(result_image) result_corner_markers = scans.find_corner_marker_keypoints(result_image)
assert result_corner_markers is not None assert result_corner_markers is not None
for i in range(len(markers)): for i in range(len(markers)):
...@@ -337,11 +341,9 @@ def test_incomplete_reference_realign_image(datadir): ...@@ -337,11 +341,9 @@ def test_incomplete_reference_realign_image(datadir):
test_file = os.path.join(datadir, dir_name, "a4-rotated.png") test_file = os.path.join(datadir, dir_name, "a4-rotated.png")
test_image = cv2.imread(test_file) test_image = cv2.imread(test_file)
reference_markers = [(59, 59), (59, 1695), (1181, 1695)]
correct_corner_markers = [(59, 59), (1181, 59), (59, 1695), (1181, 1695)] correct_corner_markers = [(59, 59), (1181, 59), (59, 1695), (1181, 1695)]
result_image = scans.realign_image(test_image, reference_keypoints=reference_markers) result_image = scans.realign_image(test_image)
result_corner_markers = scans.find_corner_marker_keypoints(result_image) result_corner_markers = scans.find_corner_marker_keypoints(result_image)
assert result_corner_markers is not None assert result_corner_markers is not None
...@@ -349,3 +351,22 @@ def test_incomplete_reference_realign_image(datadir): ...@@ -349,3 +351,22 @@ def test_incomplete_reference_realign_image(datadir):
diff = np.absolute(np.subtract(correct_corner_markers[i], result_corner_markers[i])) diff = np.absolute(np.subtract(correct_corner_markers[i], result_corner_markers[i]))
assert diff[0] <= epsilon assert diff[0] <= epsilon
assert diff[1] <= epsilon assert diff[1] <= epsilon
def test_shift_image(datadir):
dir_name = "cornermarkers"
epsilon = 1
test_file = os.path.join(datadir, dir_name, "a4-1-marker.png")
test_image = cv2.imread(test_file)
bottom_left = (59, 1695)
shift_x, shift_y = 20, -30
shift_keypoint = (bottom_left[0] + shift_x, bottom_left[1] + shift_y)
test_image = scans.shift_image(test_image, shift_x, shift_y)
# test_image = scans.shift_image(test_image, bottom_left, shift_keypoint)
keypoints = scans.find_corner_marker_keypoints(test_image)
diff = np.absolute(np.subtract(shift_keypoint, keypoints[0]))
assert diff[0] <= epsilon
assert diff[1] <= epsilon
...@@ -21,7 +21,7 @@ def _get_exam_dir(exam_id): ...@@ -21,7 +21,7 @@ def _get_exam_dir(exam_id):
) )
def get_cb_data_for_exam(exam): def checkboxes(exam):
""" """
Returns all multiple choice question check boxes for one specific exam Returns all multiple choice question check boxes for one specific exam
...@@ -122,30 +122,30 @@ class Exams(Resource): ...@@ -122,30 +122,30 @@ class Exams(Resource):
return dict(status=404, message='Exam does not exist.'), 404 return dict(status=404, message='Exam does not exist.'), 404
submissions = [ submissions = [
{ {
'id': sub.copy_number, 'id': sub.copy_number,
'student': { 'student': {
'id': sub.student.id, 'id': sub.student.id,
'firstName': sub.student.first_name, 'firstName': sub.student.first_name,
'lastName': sub.student.last_name, 'lastName': sub.student.last_name,
'email': sub.student.email 'email': sub.student.email
} if sub.student else None, } if sub.student else None,
'validated': sub.signature_validated, 'validated': sub.signature_validated,
'problems': [ 'problems': [
{ {
'id': sol.problem.id, 'id': sol.problem.id,
'graded_by': { 'graded_by': {
'id': sol.graded_by.id, 'id': sol.graded_by.id,
'name': sol.graded_by.name 'name': sol.graded_by.name
} if sol.graded_by else None, } if sol.graded_by else None,
'graded_at': sol.graded_at.isoformat() if sol.graded_at else None, 'graded_at': sol.graded_at.isoformat() if sol.graded_at else None,
'feedback': [ 'feedback': [
fb.id for fb in sol.feedback fb.id for fb in sol.feedback
], ],
'remark': sol.remarks if sol.remarks else "" 'remark': sol.remarks if sol.remarks else ""
} for sol in sub.solutions # Sorted by sol.problem_id } for sol in sub.solutions # Sorted by sol.problem_id
], ],
} for sub in exam.submissions } for sub in exam.submissions
] ]
# Sort submissions by selecting those with students assigned, then by # Sort submissions by selecting those with students assigned, then by
# student number, then by copy number. # student number, then by copy number.
...@@ -271,7 +271,9 @@ class Exams(Resource): ...@@ -271,7 +271,9 @@ class Exams(Resource):
pdf_path = os.path.join(exam_dir, 'exam.pdf') pdf_path = os.path.join(exam_dir, 'exam.pdf')
os.makedirs(exam_dir, exist_ok=True) os.makedirs(exam_dir, exist_ok=True)
make_pages_even(pdf_path, args['pdf']) even_pdf = make_pages_even(args['pdf'])
even_pdf.write(pdf_path)
print(f"Added exam {exam.id} (name: {exam_name}, token: {exam.token}) to database") print(f"Added exam {exam.id} (name: {exam_name}, token: {exam.token}) to database")
...@@ -357,7 +359,7 @@ class ExamGeneratedPdfs(Resource): ...@@ -357,7 +359,7 @@ class ExamGeneratedPdfs(Resource):
generated_pdfs_dir = self._get_generated_exam_dir(exam_dir) generated_pdfs_dir = self._get_generated_exam_dir(exam_dir)
os.makedirs(generated_pdfs_dir, exist_ok=True) os.makedirs(generated_pdfs_dir, exist_ok=True)
cb_data = get_cb_data_for_exam(exam) cb_data = checkboxes(exam)
generate_pdfs( generate_pdfs(
exam_path, exam_path,
...@@ -516,7 +518,7 @@ class ExamPreview(Resource): ...@@ -516,7 +518,7 @@ class ExamPreview(Resource):
exam_path = os.path.join(exam_dir, 'exam.pdf') exam_path = os.path.join(exam_dir, 'exam.pdf')
cb_data = get_cb_data_for_exam(exam) cb_data = checkboxes(exam)
generate_pdfs( generate_pdfs(
exam_path, exam_path,
"A" * token_length, "A" * token_length,
......
...@@ -128,9 +128,6 @@ class Feedback(Resource): ...@@ -128,9 +128,6 @@ class Feedback(Resource):
if fb.mc_option: if fb.mc_option:
return dict(status=401, message='Cannot delete feedback option' return dict(status=401, message='Cannot delete feedback option'
+ ' attached to a multiple choice option.'), 401 + ' attached to a multiple choice option.'), 401
if fb.mc_option and problem.exam.finalized:
return dict(status=401, message='Cannot delete feedback option'
+ ' attached to a multiple choice option in a finalized exam.'), 401
db.session.delete(fb) db.session.delete(fb)
......