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 {
nrPossibleAnswers: 2,
chosenLabelType: value
}, () => {
this.updateNumberOptions()
let labels = this.generateLabels(this.state.nrPossibleAnswers, 0)
this.props.updateLabels(labels)
this.updateNumberOptions().then(() => this.props.updateLabels(labels))
})
} else {
this.setState({
......@@ -116,7 +115,7 @@ class PanelMCQ extends React.Component {
switch (type) {
case 1:
return ['T', 'F']
return ['T', 'F'].slice(startingAt)
case 2:
return Array.from(Array(nrLabels).keys()).map(
(e) => String.fromCharCode(e + 65 + startingAt))
......
:root {
--option-width:20px;
--label-font-size:14px;
}
div.mcq-widget {
display:inline-flex;
box-sizing: content-box;
}
div.mcq-option {
display: block;
width: var(--option-width);
padding:2px;
width: var(--width-mco);
box-sizing: content-box;
height: auto;
height: var(--height-mco);
}
div.mcq-option div.mcq-option-label {
display:block;
font-family: Arial, Helvetica, sans-serif;
font-size: var(--label-font-size);
font-size: 12px;
text-align: center;
}
......
......@@ -6,7 +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 PanelMCQ from '../components/PanelMCQ.jsx'
import ExamEditor from './ExamEditor.jsx'
import update from 'immutability-helper'
import ExamFinalizeMarkdown from './ExamFinalize.md'
......@@ -49,14 +49,16 @@ class Exams extends React.Component {
graded: problem.graded,
feedback: problem.feedback || [],
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.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
widthMCO: 20,
heightMCO: 34
}
}
})
......@@ -243,6 +245,7 @@ class Exams extends React.Component {
updateWidget={this.updateWidget}
updateMCOsInState={this.updateMCOsInState}
selectedWidgetId={this.state.selectedWidgetId}
repositionMCO={this.repositionMCO}
highlightFeedback={(widget, feedbackId) => {
let index = widget.problem.feedback.findIndex(e => { return e.id === feedbackId })
let feedback = widget.problem.feedback[index]
......@@ -255,6 +258,12 @@ class Exams extends React.Component {
feedback.highlight = false
this.updateFeedbackAtIndex(feedback, widget, index)
}}
removeAllHighlight={(widget) => {
widget.problem.feedback.forEach((feedback, index) => {
feedback.highlight = false
this.updateFeedbackAtIndex(feedback, widget, index)
})
}}
selectWidget={(widgetId) => {
this.setState({
selectedWidgetId: widgetId
......@@ -332,7 +341,10 @@ class Exams extends React.Component {
* @param yPos y position of the current option
*/
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 = {
'name': labels[index],
......@@ -362,15 +374,25 @@ class Exams extends React.Component {
formData.append('label', data.label)
formData.append('fb_description', feedback.description)
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.feedback_id = result.feedback_id
feedback.id = result.feedback_id
this.addMCOtoState(problemWidget, data)
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 => {
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 {
*/
deleteMCOs = (widgetId, index, nrMCOs) => {
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]
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 => {
console.log(err)
err.json().then(res => {
......@@ -415,28 +459,53 @@ class Exams extends React.Component {
(res.message ? ': ' + res.message : ''))
// update to try and get a consistent state
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.setState((prevState) => {
return {
widgets: update(prevState.widgets, {
[widget.id]: {
problem: {
mc_options: {
$splice: [[index, 1]]
}
}
}
})
}
}, () => {
this.deleteMCOs(widgetId, index, nrMCOs - 1)
}
/**
* 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
*/
repositionMCO = (widgetId, data) => {
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
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 {
const selectedWidgetId = this.state.selectedWidgetId
let selectedWidget = selectedWidgetId && this.state.widgets[selectedWidgetId]
let problem = selectedWidget && selectedWidget.problem
let widgetEditDisabled = (this.state.previewing || !problem) ||
(this.props.exam.finalized && problem.mc_options.length > 0)
let widgetEditDisabled = (this.state.previewing || !problem)
let isGraded = problem && problem.graded
let widgetDeleteDisabled = widgetEditDisabled || isGraded
......@@ -549,7 +617,7 @@ class Exams extends React.Component {
</div>
</div>
</div>
{props.problem ? (
{props.problem && !this.props.exam.finalized ? (
<PanelMCQ
totalNrAnswers={totalNrAnswers}
problem={props.problem}
......@@ -566,14 +634,16 @@ class Exams extends React.Component {
xPos = problemWidget.x + 2
yPos = problemWidget.y + 2
}
this.generateMCOs(problemWidget, labels, 0, xPos, yPos)
return this.generateMCOs(problemWidget, labels, 0, xPos, yPos)
}}
deleteMCOs={(nrMCOs) => {
let len = props.problem.mc_options.length
if (nrMCOs >= len) {
this.setState({deletingMCWidget: true})
return new Promise((resolve, reject) => {
this.setState({deletingMCWidget: true}, () => { resolve(false) })
})
} else if (nrMCOs > 0) {
this.deleteMCOs(selectedWidgetId, len - nrMCOs, nrMCOs)
return this.deleteMCOs(selectedWidgetId, len - nrMCOs, nrMCOs)
}
}}
updateLabels={(labels) => {
......@@ -617,22 +687,22 @@ class Exams extends React.Component {
})
}}
/>) : 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>
)}
{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'>
<button
disabled={props.disabledDelete}
......@@ -688,7 +758,12 @@ class Exams extends React.Component {
return (
<button
className='button is-link is-fullwidth'
onClick={() => { this.setState({previewing: true}) }}
onClick={() => {
this.setState({
selectedWidgetId: null,
previewing: true
})
}}
>
Finalize
</button>
......
......@@ -23,7 +23,8 @@ class ExamEditor extends React.Component {
mouseDown: false,
selectionStartPoint: null,
selectionEndPoint: null,
selectionBox: null
selectionBox: null,
draggingWidget: false // if a problem widget is being dragged, remove the highlighting of the feedback
}
getPDFUrl = () => {
......@@ -198,6 +199,8 @@ class ExamEditor extends React.Component {
* @param data the new position of the mc widget
*/
updateMCO = (widget, data) => {
if (this.props.finalized) return // do not modify the locations of the mc options after the exam is finalized
// update state
this.props.updateMCOsInState(widget, {
x: Math.round(data.x),
......@@ -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.
* @param widget the problem widget that contains a mc options
......@@ -294,9 +257,17 @@ class ExamEditor extends React.Component {
}}
onDragStart={() => {
this.props.selectWidget(widget.id)
this.setState({
draggingWidget: true
})
this.props.removeAllHighlight(widget)
}}
onDragStop={(e, data) => {
this.updateMCO(widget, data)
this.setState({
draggingWidget: false
})
}}
>
<div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}>
......@@ -304,14 +275,19 @@ class ExamEditor extends React.Component {
return (
<div key={'widget_mco_' + option.id} className='mcq-option'
onMouseEnter={() => {
this.props.highlightFeedback(widget, option.feedback_id)
if (!this.state.draggingWidget) {
this.props.highlightFeedback(widget, option.feedback_id)
}
}}
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'>
{option.label}
{option.label === ' ' ? <span>&nbsp;</span> : option.label}
</div>
<img className='mcq-box' src={answerBoxImage} />
</div>
......@@ -367,7 +343,7 @@ class ExamEditor extends React.Component {
x: { $set: Math.round(position.x) },
y: { $set: Math.round(position.y) }
})
this.repositionMC(widget, {
this.props.repositionMCO(widget.id, {
width: ref.offsetWidth,
height: ref.offsetHeight,
x: Math.round(position.x),
......@@ -391,9 +367,15 @@ class ExamEditor extends React.Component {
}}
onDragStart={() => {
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) => {
this.setState({
draggingWidget: false
})
this.props.updateWidget(widget.id, {
x: { $set: Math.round(data.x) },
y: { $set: Math.round(data.y) }
......
......@@ -2,3 +2,4 @@ You _**will no longer**_ be able to:
+ modify the position of the student ID widget
+ 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
Revises: 4204f4a83863
......@@ -17,7 +17,7 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Create the multiple choice question table
op.create_table('mc_option',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('label', sa.String(), nullable=True),
......@@ -26,10 +26,8 @@ def upgrade():
sa.ForeignKeyConstraint(['id'], ['widget.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Remove the multiple choice question table
op.drop_table('mc_option')
# ### end Alembic commands ###
......@@ -21,6 +21,9 @@ def add_test_data(app):
db.session.commit()
# Actual tests
def test_get_exams(test_client, add_test_data):
mc_option_1 = {
'x': 100,
......
......@@ -32,9 +32,7 @@ def mco_json():
}
'''
ACTUAL TESTS
'''
# Actual tests
def test_delete_with_mc_option(test_client, add_test_data):
......
......@@ -42,9 +42,7 @@ def mco_json():
}
'''
ACTUAL TESTS
'''
# Actual tests
def test_not_present(test_client, add_test_data):
......@@ -130,6 +128,36 @@ def test_update_patch(test_client, add_test_data):
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):
req = mco_json()
......@@ -143,6 +171,43 @@ def test_delete(test_client, add_test_data):
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):
id = 100
......@@ -152,7 +217,7 @@ def test_delete_not_present(test_client, add_test_data):
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 = {
'x': 100,
'y': 40,
......@@ -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)
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']
test_client.put('api/exams/1/finalized', data='true')
response = test_client.delete(f'/api/mult-choice/{mc_id}')
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
\ No newline at end of file
submissions
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
from flask import Flask
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', [
......@@ -128,6 +128,41 @@ def test_cascades_submission(empty_app, exam, problem, submission, solution, pag
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
def exam():
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):
@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-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):
dir_name = "cornermarkers"
......@@ -323,6 +326,7 @@ def test_realign_image(datadir, file_name, markers):
test_image = np.array(PIL.Image.open(test_file))
result_image = scans.realign_image(test_image)
result_corner_markers = scans.find_corner_marker_keypoints(result_image)
assert result_corner_markers is not None
for i in range(len(markers)):
......@@ -337,11 +341,9 @@ def test_incomplete_reference_realign_image(datadir):
test_file = os.path.join(datadir, dir_name, "a4-rotated.png")
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)]
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)
assert result_corner_markers is not None
......@@ -349,3 +351,22 @@ def test_incomplete_reference_realign_image(datadir):
diff = np.absolute(np.subtract(correct_corner_markers[i], result_corner_markers[i]))
assert diff[0] <= 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):
)
def get_cb_data_for_exam(exam):
def checkboxes(exam):
"""
Returns all multiple choice question check boxes for one specific exam
......@@ -122,30 +122,30 @@ class Exams(Resource):
return dict(status=404, message='Exam does not exist.'), 404
submissions = [
{
'id': sub.copy_number,
'student': {
'id': sub.student.id,
'firstName': sub.student.first_name,
'lastName': sub.student.last_name,
'email': sub.student.email
} if sub.student else None,
'validated': sub.signature_validated,
'problems': [
{
'id': sol.problem.id,
'graded_by': {
'id': sol.graded_by.id,
'name': sol.graded_by.name
} if sol.graded_by else None,
'graded_at': sol.graded_at.isoformat() if sol.graded_at else None,
'feedback': [
fb.id for fb in sol.feedback
],
'remark': sol.remarks if sol.remarks else ""
} for sol in sub.solutions # Sorted by sol.problem_id
],
} for sub in exam.submissions
{
'id': sub.copy_number,
'student': {
'id': sub.student.id,
'firstName': sub.student.first_name,
'lastName': sub.student.last_name,
'email': sub.student.email
} if sub.student else None,
'validated': sub.signature_validated,
'problems': [
{
'id': sol.problem.id,
'graded_by': {
'id': sol.graded_by.id,
'name': sol.graded_by.name
} if sol.graded_by else None,
'graded_at': sol.graded_at.isoformat() if sol.graded_at else None,
'feedback': [
fb.id for fb in sol.feedback
],
'remark': sol.remarks if sol.remarks else ""
} for sol in sub.solutions # Sorted by sol.problem_id
],
} for sub in exam.submissions
]
# Sort submissions by selecting those with students assigned, then by
# student number, then by copy number.
......@@ -271,7 +271,9 @@ class Exams(Resource):
pdf_path = os.path.join(exam_dir, 'exam.pdf')
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")
......@@ -357,7 +359,7 @@ class ExamGeneratedPdfs(Resource):
generated_pdfs_dir = self._get_generated_exam_dir(exam_dir)
os.makedirs(generated_pdfs_dir, exist_ok=True)
cb_data = get_cb_data_for_exam(exam)
cb_data = checkboxes(exam)
generate_pdfs(
exam_path,
......@@ -516,7 +518,7 @@ class ExamPreview(Resource):
exam_path = os.path.join(exam_dir, 'exam.pdf')
cb_data = get_cb_data_for_exam(exam)
cb_data = checkboxes(exam)
generate_pdfs(
exam_path,
"A" * token_length,
......
......@@ -128,9 +128,6 @@ class Feedback(Resource):
if fb.mc_option:
return dict(status=401, message='Cannot delete feedback option'
+ ' 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)
......