Commit d0c49b13 authored by Richard's avatar Richard

Merge branch 'feature/toggle-pregrading' into demo

parents 0d8af8f5 b0b304dd
Pipeline #18940 failed with stages
in 3 minutes and 40 seconds
......@@ -47,6 +47,7 @@ class Exams extends React.Component {
page: problem.page,
name: problem.name,
graded: problem.graded,
grading_policy: problem.grading_policy,
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
......@@ -195,13 +196,40 @@ class Exams extends React.Component {
const problem = changedWidget.problem
if (!problem) return
api.put('problems/' + problem.id + '/name', { name: problem.name })
api.put('problems/' + problem.id, { name: problem.name })
.catch(e => Notification.error('Could not save new problem name: ' + e))
.then(this.setState({
changedWidgetId: null
}))
}
onChangeAutoApproveType (e) {
const selectedWidgetId = this.state.selectedWidgetId
if (!selectedWidgetId) return
const selectedWidget = this.state.widgets[selectedWidgetId]
if (!selectedWidget) return
const problem = selectedWidget.problem
if (!problem) return
const newPolicy = e.target.value
api.put('problems/' + problem.id, { grading_policy: newPolicy })
.catch(e => Notification.error('Could not change grading policy: ' + e))
.then(this.setState(prevState => ({
widgets: update(prevState.widgets, {
[selectedWidgetId]: {
problem: {
grading_policy: {
$set: newPolicy
}
}
}
})
})))
}
createNewWidget = (widgetData) => {
this.setState((prevState) => {
return {
......@@ -738,6 +766,20 @@ class Exams extends React.Component {
}
</React.Fragment>
)}
{props.problem &&
<React.Fragment>
<div className='panel-block mcq-block'>
<b>Auto-approve</b>
<div className='select is-hovered is-fullwidth'>
<select value={props.problem.grading_policy} onChange={this.onChangeAutoApproveType.bind(this)}>
<option value='0'>Nothing</option>
<option value='1'>Blanks</option>
<option value='2'>One answer/blanks</option>
</select>
</div>
</div>
</React.Fragment>
}
<div className='panel-block'>
<button
disabled={props.disabledDelete}
......
......@@ -93,7 +93,8 @@ class ExamEditor extends React.Component {
mc_options: [],
widthMCO: 20,
heightMCO: 34,
isMCQ: false
isMCQ: false,
grading_policy: 0
}
const widgetData = {
x: Math.round(selectionBox.left),
......@@ -110,6 +111,7 @@ class ExamEditor extends React.Component {
formData.append('y', widgetData.y)
formData.append('width', widgetData.width)
formData.append('height', widgetData.height)
formData.append('grading_policy', problemData.grading_policy)
api.post('problems', formData).then(result => {
widgetData.id = result.widget_id
problemData.id = result.id
......
......@@ -26,7 +26,9 @@ class Graders extends React.Component {
this.props.updateGraderList()
})
.catch(resp => {
Notification.error('Could not save grader (see Javascript console for details)')
resp.json().then(e => {
Notification.error(e.message)
})
console.error('Error saving grader:', resp)
})
......
""" Adds column to problem to specify the grading policy
Revision ID: 02f99246020f
Revises: b46a2994605b
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '02f99246020f'
down_revision = 'b46a2994605b'
branch_labels = None
depends_on = None
def upgrade():
# Create copy of problem table
op.create_table(
'problem_copy',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('exam_id', sa.Integer(), nullable=False),
sa.Column('grading_policy', sa.Enum('set_nothing', 'set_blank', 'set_blank_single', name='gradingpolicy'),
default='set_blank', nullable=False),
sa.ForeignKeyConstraint(['exam_id'], ['exam.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.execute('INSERT INTO problem_copy (id, name, exam_id, grading_policy)' +
'SELECT id, name, exam_id, \'set_blank\' FROM problem')
op.drop_table('problem')
op.rename_table('problem_copy', 'problem')
def downgrade():
# Create copy of old problem table (without grading policy)
op.create_table(
'problem_copy',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('exam_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['exam_id'], ['exam.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Move data from new problem table into old problem table
op.execute('INSERT INTO problem_copy (id, name, exam_id)' +
'SELECT id, name, exam_id FROM problem')
op.drop_table('problem')
op.rename_table('problem_copy', 'problem')
""" Ensures each grader is uniquely identified by name
Revision ID: d33733e11085
Revises: 02f99246020f
"""
from alembic import op
from flask import current_app
import shutil
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd33733e11085'
down_revision = '02f99246020f'
branch_labels = None
depends_on = None
def backup_db():
"""
Creates a backup of the current database by making a copy
of the SQLite file.
"""
db_url = current_app.config.get('SQLALCHEMY_DATABASE_URI')
db_path = db_url.replace('sqlite:///', '')
shutil.copy2(db_path, db_path + '.old')
def upgrade():
backup_db()
conn = op.get_bind()
graders = conn.execute('SELECT id, name FROM grader').fetchall()
for grader in graders:
# Check if grader is not deleted already
if not conn.execute(f'SELECT * FROM grader WHERE grader.id = {grader.id}').fetchall():
continue
# Get other graders with same name
other_graders = list(filter(lambda x: x[1] == grader[1] and x != grader, graders))
for other_grader in other_graders:
conn.execute(f'UPDATE solution SET grader_id = {grader.id} WHERE solution.grader_id = {other_grader.id}')
conn.execute(f'DELETE FROM grader WHERE grader.id = {other_grader.id}')
# Create copy table and remove data
op.create_table(
'grader_copy',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.Text(), nullable=False, unique=True),
sa.PrimaryKeyConstraint('id')
)
op.execute('INSERT INTO grader_copy (id, name)' +
'SELECT id, name FROM grader')
op.drop_table('grader')
op.rename_table('grader_copy', 'grader')
def downgrade():
backup_db()
# Add Grader table without unique constraint
op.create_table(
'grader_copy',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.Text(), nullable=False, unique=False),
sa.PrimaryKeyConstraint('id')
)
# Move data from old Grader table
op.execute('INSERT INTO grader_copy (id, name)' +
'SELECT id, name FROM grader')
op.drop_table('grader')
op.rename_table('grader_copy', 'grader')
......@@ -11,7 +11,7 @@ def add_test_data(app):
db.session.add(exam1)
db.session.commit()
problem1 = Problem(id=1, name='Problem 1', exam_id=1)
problem1 = Problem(id=1, name='Problem 1', exam_id=1, grading_policy=1)
db.session.add(problem1)
db.session.commit()
......
......@@ -11,7 +11,7 @@ def add_test_data(app):
exam1 = Exam(id=1, name='exam 1', finalized=False)
db.session.add(exam1)
problem1 = Problem(id=1, name='Problem 1', exam_id=1)
problem1 = Problem(id=1, name='Problem 1', exam_id=1, grading_policy=1)
db.session.add(problem1)
problem_widget_1 = ProblemWidget(id=1, name='problem widget', problem_id=1, page=2,
......
import pytest
from zesje.database import db, Grader
@pytest.fixture
def add_test_data(app):
with app.app_context():
grader1 = Grader(name='grader')
db.session.add(grader1)
db.session.commit()
# Actual tests
@pytest.mark.parametrize('grader_name, expected_status_code', [
('grader', 409),
('grader2', 200)],
ids=['Duplicate grader name', 'Unused grader name'])
def test_add_grader(test_client, add_test_data, grader_name, expected_status_code):
body = {'name': grader_name}
result = test_client.post('/api/graders', data=body)
assert result.status_code == expected_status_code
......@@ -16,9 +16,9 @@ def add_test_data(app):
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)
problem1 = Problem(id=1, name='Problem 1', exam_id=1, grading_policy=1)
problem2 = Problem(id=2, name='Problem 2', exam_id=2, grading_policy=1)
problem3 = Problem(id=3, name='Problem 3', exam_id=3, grading_policy=1)
db.session.add(problem1)
db.session.add(problem2)
......
......@@ -11,7 +11,7 @@ def add_test_data(app):
exam1 = Exam(id=1, name='exam 1', finalized=True)
db.session.add(exam1)
problem1 = Problem(id=1, name='Problem 1', exam_id=1)
problem1 = Problem(id=1, name='Problem 1', exam_id=1, grading_policy=1)
db.session.add(problem1)
problem_widget_1 = ProblemWidget(id=1, name='problem widget', problem_id=1, page=2,
......
......@@ -170,7 +170,7 @@ def exam():
@pytest.fixture
def problem():
return Problem(name='')
return Problem(name='', grading_policy=1)
@pytest.fixture
......
......@@ -11,6 +11,7 @@ import wand.image
from pikepdf import Pdf
from zesje.scans import decode_barcode, ExamMetadata, ExtractedBarcode
from zesje.image_extraction import extract_image_pikepdf
from zesje.database import db, _generate_exam_token
from zesje.database import Exam, ExamWidget, Submission
from zesje import scans
......@@ -290,9 +291,9 @@ def test_image_extraction_pike(datadir, filename, expected):
for pagenr in range(len(pdf_reader.pages)):
if expected is not None:
with pytest.raises(expected):
scans.extract_image_pikepdf(pagenr, pdf_reader)
extract_image_pikepdf(pagenr, pdf_reader)
else:
img = scans.extract_image_pikepdf(pagenr, pdf_reader)
img = extract_image_pikepdf(pagenr, pdf_reader)
assert img is not None
......
......@@ -34,8 +34,7 @@ api.add_resource(Submissions,
'/submissions/<int:exam_id>/<int:submission_id>')
api.add_resource(Problems,
'/problems',
'/problems/<int:problem_id>',
'/problems/<int:problem_id>/<string:attr>')
'/problems/<int:problem_id>')
api.add_resource(Feedback,
'/feedback/<int:problem_id>',
'/feedback/<int:problem_id>/<int:feedback_id>')
......
......@@ -185,6 +185,7 @@ class Exams(Resource):
'type': prob.widget.type
},
'graded': any([sol.graded_by is not None for sol in prob.solutions]),
'grading_policy': prob.grading_policy,
'mc_options': [
{
'id': mc_option.id,
......
......@@ -50,8 +50,15 @@ class Graders(Resource):
"""
args = self.post_parser.parse_args()
name = args['name']
grader = Grader.query.filter(Grader.name == name).one_or_none()
if grader:
return dict(status=409, message=f'Grader with name {name} already exists.'), 409
try:
db.session.add(Grader(name=args['name']))
db.session.add(Grader(name=name))
db.session.commit()
except KeyError as error:
abort(400, error)
......
......@@ -3,8 +3,7 @@
import os
from flask_restful import Resource, reqparse, current_app
from zesje.database import db, Exam, Problem, ProblemWidget, Solution
from ..database import db, Exam, Problem, ProblemWidget, Solution, FeedbackOption, GradingPolicy
from zesje.pdf_reader import guess_problem_title, get_problem_page
......@@ -19,6 +18,7 @@ class Problems(Resource):
post_parser.add_argument('y', type=int, required=True, location='form')
post_parser.add_argument('width', type=int, required=True, location='form')
post_parser.add_argument('height', type=int, required=True, location='form')
post_parser.add_argument('grading_policy', type=int, required=True, location='form')
def post(self):
"""Add a new problem.
......@@ -48,6 +48,7 @@ class Problems(Resource):
exam=exam,
name=args['name'],
widget=widget,
grading_policy=GradingPolicy(args['grading_policy'])
)
# Widget is also added because it is used in problem
......@@ -72,6 +73,10 @@ class Problems(Resource):
db.session.commit()
new_feedback_option = FeedbackOption(problem_id=problem.id, text='blank', score=0)
db.session.add(new_feedback_option)
db.session.commit()
return {
'id': problem.id,
'widget_id': widget.id,
......@@ -79,31 +84,34 @@ class Problems(Resource):
}
put_parser = reqparse.RequestParser()
put_parser.add_argument('name', type=str, required=True)
put_parser.add_argument('name', type=str)
put_parser.add_argument('grading_policy', type=int)
def put(self, problem_id, attr):
def put(self, problem_id):
"""PUT to a problem
As of writing this method only supports putting the name property
This method accepts both the problem name and the grading policy.
problem_id: int
the problem id to put to
attr: str
the attribute (or property) to put to (only supports 'name' now)
the attribute (or property) to put to
Returns
HTTP 200 on success
HTTP 200 on success, 404 if the problem does not exist
"""
args = self.put_parser.parse_args()
name = args['name']
problem = Problem.query.get(problem_id)
if problem is None:
msg = f"Problem with id {problem_id} doesn't exist"
return dict(status=404, message=msg), 404
return dict(status=404, message=f"Problem with id {problem_id} doesn't exist"), 404
for attr, value in args.items():
if value is not None:
setattr(problem, attr, value)
problem.name = name
db.session.commit()
return dict(status=200, message="ok"), 200
......
import numpy as np
import os
from .image_extraction import extract_images
from .images import get_box
from PIL import Image
from flask import current_app
def get_blank(problem, dpi, widget_area_in, sub):
page = problem.widget.page
app_config = current_app.config
data_directory = app_config.get('DATA_DIRECTORY', 'data')
output_directory = os.path.join(data_directory, f'{problem.exam_id}_data')
generated_path = os.path.join(output_directory, 'blanks', f'{dpi}')
if not os.path.exists(generated_path):
set_blank(sub.copy_number, problem.exam_id, dpi, output_directory)
image_path = os.path.join(generated_path, f'page{page:02d}.jpg')
blank_page = Image.open(image_path)
return get_box(np.array(blank_page), widget_area_in, padding=0)
def set_blank(copy_number, exam_id, dpi, output_directory):
pdf_path = os.path.join(output_directory, 'generated_pdfs', f'{copy_number:05d}.pdf')
pages = extract_images(pdf_path, dpi)
for image, page in pages:
save_image(np.array(image), page, dpi, output_directory)
def save_image(image, page, dpi, output_directory):
"""Save an image at an appropriate location.
Parameters
----------
image : numpy array
Image data.
barcode : ExtractedBarcode
The barcode identifying the page.
base_path : string
The folder corresponding to a correct exam.
Returns
-------
image_path : string
Location of the image.
"""
submission_path = os.path.join(output_directory, 'blanks', f'{dpi}')
os.makedirs(submission_path, exist_ok=True)
image_path = os.path.join(submission_path, f'page{page-1:02d}.jpg')
Image.fromarray(image).save(image_path)
return image_path
""" db.Models used in the db """
import enum
import random
import string
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Enum
from flask_sqlalchemy.model import BindMetaMixin, Model
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
from sqlalchemy.orm import backref
......@@ -54,7 +55,7 @@ class Grader(db.Model):
"""Graders can be created by any user at any time, but are immutable once they are created"""
__tablename__ = 'grader'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Text, nullable=False)
name = Column(Text, nullable=False, unique=True)
graded_solutions = db.relationship('Solution', backref='graded_by', lazy=True)
......@@ -94,12 +95,27 @@ class Page(db.Model):
number = Column(Integer, nullable=False)
class GradingPolicy(enum.IntEnum):
"""
Enum for the grading policy of a problem
The grading policy of a problem means:
0: Manually grade everything
1: Manually grade blank solutions only
2: Manually grade blank solutions or multiple choice solutions with one option
"""
set_nothing = 0
set_blank = 1
set_blank_single = 2
class Problem(db.Model):
"""this will be initialized @ app initialization and immutable from then on."""
__tablename__ = 'problem'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Text, nullable=False)
exam_id = Column(Integer, ForeignKey('exam.id'), nullable=False)
grading_policy = Column('grading_policy', Enum(GradingPolicy), default=GradingPolicy.set_blank, nullable=False)
feedback_options = db.relationship('FeedbackOption', backref='problem', cascade='all',
order_by='FeedbackOption.id', lazy=True)
solutions = db.relationship('Solution', backref='problem', cascade='all', lazy=True)
......
from io import BytesIO
import numpy as np
from PIL import Image
from pikepdf import Pdf, PdfImage
from tempfile import SpooledTemporaryFile
from wand.image import Image as WandImage
def extract_images(filename):
"""Yield all images from a PDF file.
Tries to use PikePDF to extract the images from the given PDF.
If PikePDF is not able to extract the image from a page,
it continues to use Wand to flatten the rest of the pages.
"""
with Pdf.open(filename) as pdf_reader:
use_wand = False
total = len(pdf_reader.pages)
for pagenr in range(total):
if not use_wand:
try:
# Try to use PikePDF, but catch any error it raises
img = extract_image_pikepdf(pagenr, pdf_reader)
except Exception:
# Fallback to Wand if extracting with PikePDF failed
use_wand = True
if use_wand:
img = extract_image_wand(pagenr, pdf_reader)
if img.mode == 'L':
img = img.convert('RGB')
yield img, pagenr+1
def extract_image_pikepdf(pagenr, reader):
"""Extracts an image as an array from the designated page
This method uses PikePDF to extract the image and only works
when there is a single image present on the page with the
same aspect ratio as the page.
We do not check for the actual size of the image on the page,
since this size depends on the draw instruction rather than
the embedded image object available to pikepdf.
Raises an error if not exactly image is present or the image
does not have the same aspect ratio as the page.
Parameters
----------
pagenr : int
Page number to extract
reader : pikepdf.Pdf instance
The pdf reader to read the page from
Returns
-------
img_array : PIL Image
The extracted image data
Raises
------
ValueError
if not exactly one image is found on the page or the image
does not have the same aspect ratio as the page
AttributeError
if no XObject or MediaBox is present on the page
"""
page = reader.pages[pagenr]
xObject = page.Resources.XObject
if sum((xObject[obj].Subtype == '/Image')
for obj in xObject) != 1:
raise ValueError('Not exactly 1 image present on the page')
for obj in xObject:
if xObject[obj].Subtype == '/Image':
pdfimage = PdfImage(xObject[obj])
pdf_width = float(page.MediaBox[2] - page.MediaBox[0])
pdf_height = float(page.MediaBox[3] - page.MediaBox[1])
ratio_width = pdfimage.width / pdf_width
ratio_height = pdfimage.height / pdf_height
# Check if the aspect ratio of the image is the same as the
# aspect ratio of the page up to a 3% relative error
if abs(ratio_width - ratio_height) > 0.03 * ratio_width:
raise ValueError('Image has incorrect dimensions')
return pdfimage.as_pil_image()
def extract_image_wand(pagenr, reader):
"""Flattens a page from a PDF to an image array
This method uses Wand to flatten the page and creates an image.
Parameters
----------
pagenr : int
Page number to extract, starting at 0
reader : pikepdf.Pdf instance
The pdf reader to read the page from
Returns
-------
img_array : PIL Image
The extracted image data
"""
page = reader.pages[pagenr]
page_pdf = Pdf.new()
page_pdf.pages.append(page)
with SpooledTemporaryFile() as page_file:
page_pdf.save(page_file)
with WandImage(blob=page_file._file.getvalue(), format='pdf', resolution=300) as page_image:
page_image.format = 'jpg'
img_array = np.asarray(bytearray(page_image.make_blob(format="jpg")), dtype=np.uint8)
img = Image.open(BytesIO(img_array))
img.load() # Load the data into the PIL image from the Wand image
return img
import cv2
import numpy as np
from datetime import datetime
from .database import db
from .blanks import get_blank
from .database import db, Grader, FeedbackOption, GradingPolicy
from .images import guess_dpi, get_box
from .pdf_generation import CHECKBOX_FORMAT
def grade_mcq(sub, page, page_img):
def grade_problem(sub, page, page_img):
"""
Adds the multiple choice options that are identified as marked as a feedback option to a solution
Automatically checks if a problem is blank, and adds a feedback option
'blank' if so.
For multiple choice problems, a feedback option is added for each checkbox
that is identified as filled in is created.