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 (132)
Showing
with 486 additions and 325 deletions
# This base image can be found in 'Dockerfile' # This base image can be found in 'Dockerfile'
image: zesje/base image: gitlab.kwant-project.org:5005/zesje/zesje/test:latest
stages: stages:
- build - build
...@@ -13,11 +13,13 @@ stages: ...@@ -13,11 +13,13 @@ stages:
paths: paths:
- .yarn-cache - .yarn-cache
before_script: before_script:
- source activate zesje-dev
- yarn install --cache-folder .yarn-cache - yarn install --cache-folder .yarn-cache
.python_packages: &python_packages .python_packages: &python_packages
before_script: before_script:
- pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt - source activate zesje-dev
- conda env update
build: build:
<<: *node_modules <<: *node_modules
......
FROM archlinux/base FROM continuumio/miniconda3
## Install packages and clear the cache after installation. Yarn is fixed at 1.6.0 untill 1.8.0 is released due to a critical bug. RUN apt-get update -y && apt-get install -y libdmtx0a libmagickwand-dev
RUN pacman -Sy --noconfirm nodejs python-pip git libdmtx libsm libxrender libxext gcc libmagick6 imagemagick ghostscript; \
pacman -U --noconfirm https://archive.archlinux.org/packages/y/yarn/yarn-1.6.0-1-any.pkg.tar.xz
WORKDIR ~ RUN apt-get update && \
ADD requirements*.txt ./ apt-get install -y \
#ADD package.json . curl \
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt; poppler-utils build-essential libgl1-mesa-glx \
#RUN yarn install; \ imagemagick libsm-dev libdmtx-dev libdmtx0a libmagickwand-dev \
# yarn cache clean; \ && \
# rm package.json apt-get -y --quiet install git supervisor nginx
CMD bash WORKDIR /app
\ No newline at end of file
ADD environment.yml /app/environment.yml
RUN conda env create
# From https://medium.com/@chadlagore/conda-environments-with-docker-82cdc9d25754
RUN echo "source activate $(head -1 /app/environment.yml | cut -d' ' -f2)" > ~/.bashrc
ENV PATH /opt/conda/envs/$(head -1 /app/environment.yml | cut -d' ' -f2)/bin:$PATH
RUN rm /app/environment.yml
CMD bash
...@@ -15,10 +15,11 @@ Install Miniconda by following the instructions on this page: ...@@ -15,10 +15,11 @@ Install Miniconda by following the instructions on this page:
https://conda.io/miniconda.html https://conda.io/miniconda.html
Create a Conda environment that you will use for installing all Make sure you cloned this repository and enter its directory. Then
of zesje's dependencies: create a Conda environment that will automatically install all
of zesje's Python dependencies:
conda create -c conda-forge -n zesje-dev python=3.6 yarn conda env create # Creates an environment from environment.yml
Then, *activate* the conda environment: Then, *activate* the conda environment:
...@@ -31,10 +32,6 @@ Install all of the Javascript dependencies: ...@@ -31,10 +32,6 @@ Install all of the Javascript dependencies:
yarn install yarn install
Install all of the Python dependencies:
pip install -r requirements.txt -r requirements-dev.txt
Unfortunately there is also another dependency that must be installed Unfortunately there is also another dependency that must be installed
manually for now (we are working to bring this dependency into the manually for now (we are working to bring this dependency into the
Conda ecosystem). You can install this dependency in the following way Conda ecosystem). You can install this dependency in the following way
...@@ -145,10 +142,10 @@ If you use Atom, install the [linter-js-standard-engine](https://atom.io/package ...@@ -145,10 +142,10 @@ If you use Atom, install the [linter-js-standard-engine](https://atom.io/package
### Adding dependencies ### Adding dependencies
#### Server-side #### Server-side
If you start using a new Python library, be sure to add it to `requirements.txt`. Python libraries for the testing are in `requirements-dev.txt`. If you start using a new Python library, be sure to add it to `environment.yml`.
The packages can be installed and updated in your environment by `pip` using The packages can be installed and updated in your environment by `conda` using
pip install -r requirements.txt -r requirements-dev.txt conda env update
#### Client-side #### Client-side
......
import sys
import os
from io import BytesIO from io import BytesIO
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
import PIL
from wand.image import Image from wand.image import Image
from wand.color import Color from wand.color import Color
from pystrich.datamatrix import DataMatrixEncoder
sys.path.append(os.getcwd())
def generate_datamatrix(exam_id, page_num, copy_num): from zesje.pdf_generation import generate_datamatrix # noqa: E402
data = f'{exam_id}/{copy_num:04d}/{page_num:02d}' from zesje.database import token_length # noqa: E402
image_bytes = DataMatrixEncoder(data).get_imagedata(cellsize=2)
return PIL.Image.open(BytesIO(image_bytes))
exam_token = "A" * token_length
copy_num = 1559
page_num = 0
datamatrix = generate_datamatrix(0, 0, 0) fontsize = 12
datamatrix_x = datamatrix_y = 0 datamatrix_x = 0
fontsize = 8 datamatrix_y = fontsize
margin = 3
datamatrix = generate_datamatrix(0, 0, 0000) datamatrix = generate_datamatrix(exam_token, page_num, copy_num)
imagesize = (datamatrix.width, 3 + fontsize + datamatrix.height) imagesize = (datamatrix.width, fontsize + datamatrix.height)
result_pdf = BytesIO() result_pdf = BytesIO()
canv = canvas.Canvas(result_pdf, pagesize=imagesize) canv = canvas.Canvas(result_pdf, pagesize=imagesize)
canv.drawInlineImage(datamatrix, 0, 3 + fontsize) canv.drawInlineImage(datamatrix, datamatrix_x, datamatrix_y)
canv.setFont('Helvetica', fontsize) canv.setFont('Helvetica', fontsize)
canv.drawString(0, 3, f" # 1519") canv.drawString(datamatrix_x, datamatrix_y - (fontsize * 0.66),
f" # {copy_num}")
canv.showPage() canv.showPage()
canv.save() canv.save()
...@@ -36,7 +39,7 @@ canv.save() ...@@ -36,7 +39,7 @@ canv.save()
result_pdf.seek(0) result_pdf.seek(0)
# From https://stackoverflow.com/questions/27826854/python-wand-convert-pdf-to-png-disable-transparent-alpha-channel # From https://stackoverflow.com/questions/27826854/python-wand-convert-pdf-to-png-disable-transparent-alpha-channel
with Image(file=result_pdf, resolution=80) as img: with Image(file=result_pdf, resolution=72) as img:
with Image(width=img.width, height=img.height, background=Color("white")) as bg: with Image(width=imagesize[0], height=imagesize[1], background=Color("white")) as bg:
bg.composite(img, 0, 0) bg.composite(img, 0, 0)
bg.save(filename="client/components/barcode_example.png") bg.save(filename="client/components/barcode_example.png")
import React from 'react' import React from 'react'
import Switch from 'react-bulma-switch/full'
import './PanelMCQ.css'
/** /**
* PanelMCQ is a component that allows the user to generate mcq options * PanelMCQ is a component that allows the user to generate mc questions and options
*/ */
class PanelMCQ extends React.Component { class PanelMCQ extends React.Component {
constructor (props) { constructor (props) {
...@@ -9,23 +11,72 @@ class PanelMCQ extends React.Component { ...@@ -9,23 +11,72 @@ class PanelMCQ extends React.Component {
this.onChangeNPA = this.onChangeNPA.bind(this) this.onChangeNPA = this.onChangeNPA.bind(this)
this.onChangeLabelType = this.onChangeLabelType.bind(this) this.onChangeLabelType = this.onChangeLabelType.bind(this)
this.generateLabels = this.generateLabels.bind(this) this.generateLabels = this.generateLabels.bind(this)
this.updateNumberOptions = this.updateNumberOptions.bind(this)
this.state = { this.state = {
chosenLabelType: 0, chosenLabelType: 2,
nrPossibleAnswers: 2, nrPossibleAnswers: 2,
labelTypes: ['None', 'True/False', 'A, B, C ...', '1, 2, 3 ...'] labelTypes: ['None', 'True/False', 'A, B, C ...', '1, 2, 3 ...']
} }
} }
// modify the state if the properties are changed
static getDerivedStateFromProps (newProps, prevState) {
// if another problem is selected, update the state and implicitly the contents of the inputs
if (prevState.problemId !== newProps.problem.id) {
let prob = newProps.problem
return {
problemId: prob.id,
nrPossibleAnswers: prob.mc_options.length || 2,
chosenLabelType: PanelMCQ.deriveLabelType(prob.mc_options)
}
}
return null
}
/**
* Derive the label type given an array of options.
* @param options the options that correspond to a problem
* @returns {number} the index in the labelTypes array representing the label type
*/
static deriveLabelType (options) {
if (options.length === 0) {
return 2
} else if (options.length === 2 && ((options[0].label === 'T' && options[1].label === 'F') ||
(options[0].label === 'F' && options[1].label === 'T'))) {
return 1
} else if (options[0].label.match(/[A-Z]/)) {
return 2
} else if (parseInt(options[0].label)) {
return 3
} else {
return 0
}
}
// this functions calculates
updateNumberOptions () {
let difference = this.state.nrPossibleAnswers - this.props.problem.mc_options.length
if (difference > 0) {
let startingAt = this.props.problem.mc_options.length
let labels = this.generateLabels(difference, startingAt)
return this.props.generateMCOs(labels)
} else if (difference < 0) {
return this.props.deleteMCOs(-difference)
}
}
// this function is called when the input is changed for the number of possible answers // this function is called when the input is changed for the number of possible answers
onChangeNPA (e) { onChangeNPA (e) {
let value = parseInt(e.target.value) let value = parseInt(e.target.value)
if (!isNaN(value)) { if (!isNaN(value) && value <= this.props.totalNrAnswers) {
if (this.state.chosenLabelType === 1) { if (this.state.chosenLabelType === 1) {
value = 2 value = 2
} }
this.setState({ this.setState({
nrPossibleAnswers: value nrPossibleAnswers: value
}) }, this.updateNumberOptions)
} }
} }
...@@ -33,12 +84,22 @@ class PanelMCQ extends React.Component { ...@@ -33,12 +84,22 @@ class PanelMCQ extends React.Component {
onChangeLabelType (e) { onChangeLabelType (e) {
let value = parseInt(e.target.value) let value = parseInt(e.target.value)
if (!isNaN(value)) { if (!isNaN(value)) {
this.setState({ // if the label type is True/False then reduce the number of mc options to 2
chosenLabelType: value
})
if (parseInt(value) === 1) { if (parseInt(value) === 1) {
this.setState({ this.setState({
nrPossibleAnswers: 2 nrPossibleAnswers: 2,
chosenLabelType: value
}, () => {
this.updateNumberOptions()
let labels = this.generateLabels(this.state.nrPossibleAnswers, 0)
this.props.updateLabels(labels)
})
} else {
this.setState({
chosenLabelType: value
}, () => {
let labels = this.generateLabels(this.state.nrPossibleAnswers, 0)
this.props.updateLabels(labels)
}) })
} }
} }
...@@ -47,18 +108,20 @@ class PanelMCQ extends React.Component { ...@@ -47,18 +108,20 @@ class PanelMCQ extends React.Component {
/** /**
* This function generates an array with the labels for each option * This function generates an array with the labels for each option
* @param nrLabels the number of options that need to be generated * @param nrLabels the number of options that need to be generated
* @param startingAt at which number/character to start generating labels
* @returns {any[]|string[]|number[]} * @returns {any[]|string[]|number[]}
*/ */
generateLabels (nrLabels) { generateLabels (nrLabels, startingAt) {
let type = this.state.chosenLabelType let type = this.state.chosenLabelType
switch (type) { switch (type) {
case 1: case 1:
return ['T', 'F'] return ['T', 'F']
case 2: case 2:
return Array.from(Array(nrLabels).keys()).map((e) => String.fromCharCode(e + 65)) return Array.from(Array(nrLabels).keys()).map(
(e) => String.fromCharCode(e + 65 + startingAt))
case 3: case 3:
return Array.from(Array(nrLabels).keys()).map(e => e + 1) return Array.from(Array(nrLabels).keys()).map(e => String(e + 1 + startingAt))
default: default:
return Array(nrLabels).fill(' ') return Array(nrLabels).fill(' ')
} }
...@@ -70,34 +133,29 @@ class PanelMCQ extends React.Component { ...@@ -70,34 +133,29 @@ class PanelMCQ extends React.Component {
*/ */
render () { render () {
return ( return (
<nav className='panel'> <React.Fragment>
<p className='panel-heading'> <div className='panel-block mcq-block'>
Multiple Choice Question <label className='label'> Multiple choice </label>
</p> <Switch color='info' outlined value={this.props.problem.mc_options.length > 0} onChange={(e) => {
<div className='panel-block'> if (e.target.checked) {
<div className='field'> let npa = this.state.nrPossibleAnswers
<React.Fragment> let labels = this.generateLabels(npa, 0)
<label className='label'>Number possible answers</label> this.props.generateMCOs(labels)
<div className='control'> } else {
{(function () { this.props.deleteMCOs(this.props.problem.mc_options.length)
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>
<div className='panel-block'> { this.props.problem.mc_options.length > 0 ? (
<div className='field'> <React.Fragment>
<React.Fragment> <div className='panel-block mcq-block'>
<label className='label'>Answer boxes labels</label> <div className='inline-mcq-edit'>
<div className='control'> <label>#</label>
<input type='number' value={this.state.nrPossibleAnswers} min='1'
max={this.props.totalNrAnswers} className='input' onChange={this.onChangeNPA} />
</div>
<div className='inline-mcq-edit'>
<label>Labels</label>
<div className='select is-hovered is-fullwidth'> <div className='select is-hovered is-fullwidth'>
{(function () { {(function () {
var optionList = this.state.labelTypes.map( var optionList = this.state.labelTypes.map(
...@@ -113,32 +171,10 @@ class PanelMCQ extends React.Component { ...@@ -113,32 +171,10 @@ class PanelMCQ extends React.Component {
}.bind(this)())} }.bind(this)())}
</div> </div>
</div> </div>
</React.Fragment> </div>
</div> </React.Fragment>) : null
</div> }
<div className='panel-block field is-grouped'> </React.Fragment>
<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>
) )
} }
} }
......
.panel-block.mcq-block {
justify-content: space-between;
}
.mcq-block .inline-mcq-edit {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: nowrap;
}
.mcq-block .inline-mcq-edit label {
margin-right: 4px;
}
.mcq-block .inline-mcq-edit:first-of-type {
margin-right: 20px;
}
.mcq-block input, .mcq-block .select {
max-width: 130px;
}
\ No newline at end of file
client/components/barcode_example.png

383 B | W: 0px | H: 0px

client/components/barcode_example.png

453 B | W: 0px | H: 0px

client/components/barcode_example.png
client/components/barcode_example.png
client/components/barcode_example.png
client/components/barcode_example.png
  • 2-up
  • Swipe
  • Onion skin
...@@ -2,6 +2,7 @@ import React from 'react' ...@@ -2,6 +2,7 @@ import React from 'react'
import ConfirmationModal from '../../components/ConfirmationModal.jsx' import ConfirmationModal from '../../components/ConfirmationModal.jsx'
import * as api from '../../api.jsx' import * as api from '../../api.jsx'
import Notification from 'react-bulma-notification'
const BackButton = (props) => ( const BackButton = (props) => (
<button className='button is-light is-fullwidth' onClick={props.onClick}> <button className='button is-light is-fullwidth' onClick={props.onClick}>
...@@ -116,6 +117,14 @@ class EditPanel extends React.Component { ...@@ -116,6 +117,14 @@ class EditPanel extends React.Component {
}) })
this.props.goBack() this.props.goBack()
}) })
.catch(err => {
err.json().then(res => {
Notification.error('Could not delete feedback' +
(res.message ? ': ' + res.message : ''))
// update to try and get a consistent state
this.props.goBack()
})
})
} }
} }
......
...@@ -18,18 +18,20 @@ class FeedbackPanel extends React.Component { ...@@ -18,18 +18,20 @@ class FeedbackPanel extends React.Component {
} }
componentDidMount = () => { componentDidMount = () => {
this.props.bindShortcut(['up', 'k'], (event) => { if (this.props.grading) {
event.preventDefault() this.props.bindShortcut(['up', 'k'], (event) => {
this.prevOption() event.preventDefault()
}) this.prevOption()
this.props.bindShortcut(['down', 'j'], (event) => { })
event.preventDefault() this.props.bindShortcut(['down', 'j'], (event) => {
this.nextOption() event.preventDefault()
}) this.nextOption()
this.props.bindShortcut(['space'], (event) => { })
event.preventDefault() this.props.bindShortcut(['space'], (event) => {
this.toggleSelectedOption() event.preventDefault()
}) this.toggleSelectedOption()
})
}
} }
static getDerivedStateFromProps (nextProps, prevState) { static getDerivedStateFromProps (nextProps, prevState) {
...@@ -111,7 +113,7 @@ class FeedbackPanel extends React.Component { ...@@ -111,7 +113,7 @@ class FeedbackPanel extends React.Component {
feedback={feedback} checked={this.props.grading && this.props.solution.feedback.includes(feedback.id)} feedback={feedback} checked={this.props.grading && this.props.solution.feedback.includes(feedback.id)}
editFeedback={() => this.props.editFeedback(feedback)} updateSubmission={this.props.updateSubmission} editFeedback={() => this.props.editFeedback(feedback)} updateSubmission={this.props.updateSubmission}
ref={(selectedFeedbackId === feedback.id) ? this.feedbackBlock : null} grading={this.props.grading} ref={(selectedFeedbackId === feedback.id) ? this.feedbackBlock : null} grading={this.props.grading}
selected={selectedFeedbackId === feedback.id} showIndex={this.props.showTooltips} index={index + 1} /> selected={selectedFeedbackId === feedback.id || feedback.highlight} showIndex={this.props.showTooltips} index={index + 1} />
)} )}
{this.props.grading && {this.props.grading &&
<div className='panel-block'> <div className='panel-block'>
......
...@@ -57,6 +57,10 @@ div.mcq-option img.mcq-box { ...@@ -57,6 +57,10 @@ div.mcq-option img.mcq-box {
background-color: hsla(171, 100%, 41%, 0.2) background-color: hsla(171, 100%, 41%, 0.2)
} }
.widget.selected .mcq-option:hover {
background-color: rgb(25, 149, 216);
}
.editor-side-panel { .editor-side-panel {
background: #fff; background: #fff;
margin: 0.75em; margin: 0.75em;
......
...@@ -56,8 +56,7 @@ class Exams extends React.Component { ...@@ -56,8 +56,7 @@ class Exams extends React.Component {
return option return option
}), }),
widthMCO: 24, widthMCO: 24,
heightMCO: 38, heightMCO: 38
isMCQ: problem.mc_options && problem.mc_options.length !== 0 // is the problem a mc question - used to display PanelMCQ
} }
} }
}) })
...@@ -122,14 +121,26 @@ class Exams extends React.Component { ...@@ -122,14 +121,26 @@ class Exams extends React.Component {
} }
updateFeedbackAtIndex = (feedback, problemWidget, idx) => { updateFeedbackAtIndex = (feedback, problemWidget, idx) => {
let fb = [...problemWidget.problem.feedback]
if (idx === -1) { if (idx === -1) {
problemWidget.problem.feedback.push(feedback) fb.push(feedback)
} else { } else {
if (feedback.deleted) problemWidget.problem.feedback.splice(idx, 1) if (feedback.deleted) fb.splice(idx, 1)
else problemWidget.problem.feedback[idx] = feedback else fb[idx] = feedback
} }
this.setState({
widgets: this.state.widgets this.setState(prevState => {
return {
widgets: update(prevState.widgets, {
[problemWidget.id]: {
'problem': {
'feedback': {
$set: fb
}
}
}
})
}
}) })
} }
...@@ -140,10 +151,6 @@ class Exams extends React.Component { ...@@ -140,10 +151,6 @@ class Exams extends React.Component {
}) })
} }
isProblemWidget = (widget) => {
return widget && this.state.widgets[widget].problem
}
saveProblemName = () => { saveProblemName = () => {
const changedWidgetId = this.state.changedWidgetId const changedWidgetId = this.state.changedWidgetId
if (!changedWidgetId) return if (!changedWidgetId) return
...@@ -234,8 +241,20 @@ class Exams extends React.Component { ...@@ -234,8 +241,20 @@ class Exams extends React.Component {
numPages={this.state.numPages} numPages={this.state.numPages}
onPDFLoad={this.onPDFLoad} onPDFLoad={this.onPDFLoad}
updateWidget={this.updateWidget} updateWidget={this.updateWidget}
updateMCWidget={this.updateMCWidget} updateMCOsInState={this.updateMCOsInState}
selectedWidgetId={this.state.selectedWidgetId} selectedWidgetId={this.state.selectedWidgetId}
highlightFeedback={(widget, feedbackId) => {
let index = widget.problem.feedback.findIndex(e => { return e.id === feedbackId })
let feedback = widget.problem.feedback[index]
feedback.highlight = true
this.updateFeedbackAtIndex(feedback, widget, index)
}}
removeHighlight={(widget, feedbackId) => {
let index = widget.problem.feedback.findIndex(e => { return e.id === feedbackId })
let feedback = widget.problem.feedback[index]
feedback.highlight = false
this.updateFeedbackAtIndex(feedback, widget, index)
}}
selectWidget={(widgetId) => { selectWidget={(widgetId) => {
this.setState({ this.setState({
selectedWidgetId: widgetId selectedWidgetId: widgetId
...@@ -304,58 +323,63 @@ class Exams extends React.Component { ...@@ -304,58 +323,63 @@ class Exams extends React.Component {
} }
/** /**
* This function deletes the mc options coupled to a problem. * 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
*/ */
deleteMCWidget = () => { generateMCOs = (problemWidget, labels, index, xPos, yPos) => {
const widget = this.state.widgets[this.state.selectedWidgetId] if (labels.length === index) return
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 let feedback = {
// note that this can happen before they are removed in the DB due to async calls 'name': labels[index],
this.setState((prevState) => { 'description': '',
return { 'score': 0
widgets: update(prevState.widgets, { }
[widget.id]: {
problem: { let data = {
mc_options: { 'label': labels[index],
$set: [] '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': {
deletingMCWidget: false '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.addMCOtoState(problemWidget, data)
this.updateFeedback(feedback)
this.generateMCOs(problemWidget, labels, index + 1, xPos + problemWidget.problem.widthMCO, yPos)
}).catch(err => {
console.log(err)
})
} }
/** /**
* This method creates a mc option widget object and adds it to the corresponding problem * This method creates a mc option widget object and adds it to the corresponding problem in the state
* @param problemWidget The widget the mc option belongs to * @param problemWidget The widget the mc option belongs to
* @param data the mc option * @param data the mc option
*/ */
createNewMCWidget = (problemWidget, data) => { addMCOtoState = (problemWidget, data) => {
this.setState((prevState) => { this.setState((prevState) => {
return { return {
widgets: update(prevState.widgets, { widgets: update(prevState.widgets, {
...@@ -371,13 +395,57 @@ class Exams extends React.Component { ...@@ -371,13 +395,57 @@ class Exams extends React.Component {
}) })
} }
/**
* This method deletes mc options coupled to a problem in both the state and the database.
* @param widgetId the id of the widget for which the mc options need to be deleted
* @param index the index of the first mc option to be removed
* @param nrMCOs the number of mc options to remove
* @returns {Promise<T | never>}
*/
deleteMCOs = (widgetId, index, nrMCOs) => {
let widget = this.state.widgets[widgetId]
if (nrMCOs <= 0 || !widget.problem.mc_options.length) return
let option = widget.problem.mc_options[index]
return api.del('mult-choice/' + option.id)
.catch(err => {
console.log(err)
err.json().then(res => {
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 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 method is called when the mcq widget is moved. The positions of the options are stored separately and they * 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 * all need to be updated in the state. This method does not update the positions of the mc options in the DB.
* @param widget the problem widget that includes the mcq widget * @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) * @param data the new location of the mcq widget (the location of the top-left corner)
*/ */
updateMCWidget = (widget, data) => { updateMCOsInState = (widget, data) => {
let newMCO = widget.problem.mc_options.map((option, i) => { let newMCO = widget.problem.mc_options.map((option, i) => {
return { return {
'widget': { 'widget': {
...@@ -404,76 +472,19 @@ class Exams extends React.Component { ...@@ -404,76 +472,19 @@ class Exams extends React.Component {
})) }))
} }
/**
* This method generates MC options by making the right calls to the api and creating
* the widget object in the mc_options array of the corresponding problem.
* @param problemWidget the problem widget the mc options belong to
* @param labels the labels for the options
* @param index the index in the labels array (the function is recusive, this index is increased)
* @param xPos x position of the current option
* @param yPos y position of the current option
*/
generateAnswerBoxes = (problemWidget, labels, index, xPos, yPos) => {
if (labels.length === index) return
let feedback = {
'name': labels[index],
'description': '',
'score': 0
}
let data = {
'label': labels[index],
'problem_id': problemWidget.problem.id,
'feedback_id': null,
'cbOffsetX': 7, // checkbox offset relative to option position on x axis
'cbOffsetY': 21, // checkbox offset relative to option position on y axis
'widget': {
'name': 'mc_option_' + labels[index],
'x': xPos,
'y': yPos,
'type': 'mcq_widget'
}
}
const formData = new window.FormData()
formData.append('name', data.widget.name)
formData.append('x', data.widget.x + data.cbOffsetX)
formData.append('y', data.widget.y + data.cbOffsetY)
formData.append('problem_id', data.problem_id)
formData.append('label', data.label)
formData.append('fb_description', feedback.description)
formData.append('fb_score', feedback.score)
api.put('mult-choice/', formData).then(result => {
data.id = result.mult_choice_id
data.feedback_id = result.feedback_id
feedback.id = result.feedback_id
this.createNewMCWidget(problemWidget, data)
this.updateFeedback(feedback)
this.generateAnswerBoxes(problemWidget, labels, index + 1, xPos + problemWidget.problem.widthMCO, yPos)
}).catch(err => {
console.log(err)
})
}
SidePanel = (props) => { SidePanel = (props) => {
const selectedWidgetId = this.state.selectedWidgetId const selectedWidgetId = this.state.selectedWidgetId
let selectedWidget = selectedWidgetId && this.state.widgets[selectedWidgetId] let selectedWidget = selectedWidgetId && this.state.widgets[selectedWidgetId]
let problem = selectedWidget && selectedWidget.problem let problem = selectedWidget && selectedWidget.problem
let containsMCOptions = (problem && problem.mc_options.length > 0) || false let widgetEditDisabled = (this.state.previewing || !problem) ||
let widgetEditDisabled = (this.state.previewing || !problem) || (this.props.exam.finalized && containsMCOptions) (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
let totalNrAnswers = 12 // the upper limit for the nr of possible answer boxes
let disabledDeleteBoxes = !containsMCOptions
let isMCQ = (problem && problem.isMCQ) || false
let showPanelMCQ = isMCQ && !this.state.previewing && !this.props.exam.finalized
return ( return (
<React.Fragment> <React.Fragment>
<this.PanelEdit <this.PanelEdit
disabledEdit={widgetEditDisabled} disabledEdit={widgetEditDisabled}
disableIsMCQ={widgetEditDisabled || containsMCOptions}
disabledDelete={widgetDeleteDisabled} disabledDelete={widgetDeleteDisabled}
onDeleteClick={() => { onDeleteClick={() => {
this.setState({deletingWidget: true}) this.setState({deletingWidget: true})
...@@ -494,42 +505,7 @@ class Exams extends React.Component { ...@@ -494,42 +505,7 @@ class Exams extends React.Component {
})) }))
}} }}
saveProblemName={this.saveProblemName} saveProblemName={this.saveProblemName}
isMCQProblem={isMCQ}
onMCQChange={
(checked) => {
this.setState(prevState => ({
changedWidgetId: selectedWidgetId,
widgets: update(prevState.widgets, {
[selectedWidgetId]: {
problem: {
isMCQ: {
$set: checked
}
}
}
})
}))
}
}
/> />
{ showPanelMCQ ? (
<PanelMCQ
totalNrAnswers={totalNrAnswers}
disabledGenerateBoxes={containsMCOptions}
disabledDeleteBoxes={disabledDeleteBoxes}
problem={problem}
onGenerateBoxesClick={(labels) => {
let problemWidget = this.state.widgets[this.state.selectedWidgetId]
// position the new mc option widget inside the problem widget
let xPos = problemWidget.x + 2
let yPos = problemWidget.y + 2
this.generateAnswerBoxes(problemWidget, labels, 0, xPos, yPos)
}}
onDeleteBoxesClick={() => {
this.setState({deletingMCWidget: true})
}}
/>
) : null }
<this.PanelExamActions /> <this.PanelExamActions />
</React.Fragment> </React.Fragment>
) )
...@@ -537,6 +513,7 @@ class Exams extends React.Component { ...@@ -537,6 +513,7 @@ class Exams extends React.Component {
PanelEdit = (props) => { PanelEdit = (props) => {
const selectedWidgetId = this.state.selectedWidgetId const selectedWidgetId = this.state.selectedWidgetId
let totalNrAnswers = 9 // the upper limit for the nr of possible answer boxes
return ( return (
<nav className='panel'> <nav className='panel'>
...@@ -572,18 +549,77 @@ class Exams extends React.Component { ...@@ -572,18 +549,77 @@ class Exams extends React.Component {
</div> </div>
</div> </div>
</div> </div>
<div className='panel-block'> {props.problem ? (
<div className='field'> <PanelMCQ
<label className='label'> Multiple choice question </label> totalNrAnswers={totalNrAnswers}
<input disabled={props.disableIsMCQ} type='checkbox' checked={props.isMCQProblem} onChange={ problem={props.problem}
(e) => { generateMCOs={(labels) => {
props.onMCQChange(e.target.checked) let problemWidget = this.state.widgets[this.state.selectedWidgetId]
}} /> let xPos, yPos
</div> if (props.problem.mc_options.length > 0) {
</div> // position the new mc options widget next to the last mc options
let last = props.problem.mc_options[props.problem.mc_options.length - 1].widget
xPos = last.x + problemWidget.problem.widthMCO
yPos = last.y
} else {
// position the new mc option widget inside the problem widget
xPos = problemWidget.x + 2
yPos = problemWidget.y + 2
}
this.generateMCOs(problemWidget, labels, 0, xPos, yPos)
}}
deleteMCOs={(nrMCOs) => {
let len = props.problem.mc_options.length
if (nrMCOs >= len) {
this.setState({deletingMCWidget: true})
} else if (nrMCOs > 0) {
this.deleteMCOs(selectedWidgetId, len - nrMCOs, nrMCOs)
}
}}
updateLabels={(labels) => {
labels.map((label, index) => {
let option = props.problem.mc_options[index]
const formData = new window.FormData()
formData.append('name', option.widget.name)
formData.append('x', option.widget.x + option.cbOffsetX)
formData.append('y', option.widget.y + option.cbOffsetY)
formData.append('problem_id', props.problem.id)
formData.append('label', labels[index])
api.patch('mult-choice/' + option.id, formData)
.then(() => {
this.setState((prevState) => {
return {
widgets: update(prevState.widgets, {
[selectedWidgetId]: {
'problem': {
'mc_options': {
[index]: {
label: {
$set: labels[index]
}
}
}
}
}
})
}
})
})
.catch(err => {
console.log(err)
err.json().then(res => {
Notification.error('Could not update feedback' +
(res.message ? ': ' + res.message : ''))
// update to try and get a consistent state
this.props.updateExam(this.props.examID)
})
})
})
}}
/>) : null}
</React.Fragment> </React.Fragment>
)} )}
{this.isProblemWidget(selectedWidgetId) && {props.problem &&
<React.Fragment> <React.Fragment>
<div className='panel-block'> <div className='panel-block'>
{!this.state.editActive && <label className='label'>Feedback options</label>} {!this.state.editActive && <label className='label'>Feedback options</label>}
...@@ -751,7 +787,12 @@ class Exams extends React.Component { ...@@ -751,7 +787,12 @@ class Exams extends React.Component {
this.state.widgets[this.state.selectedWidgetId].problem.name}"`} this.state.widgets[this.state.selectedWidgetId].problem.name}"`}
confirmText='Delete multiple choice options' confirmText='Delete multiple choice options'
onCancel={() => this.setState({deletingMCWidget: false})} onCancel={() => this.setState({deletingMCWidget: false})}
onConfirm={() => this.deleteMCWidget(this.state.selectedWidgetId)} onConfirm={() => {
this.setState({
deletingMCWidget: false
})
this.deleteMCOs(this.state.selectedWidgetId, 0)
}}
/> />
</div> </div>
} }
......
...@@ -112,6 +112,7 @@ class ExamEditor extends React.Component { ...@@ -112,6 +112,7 @@ class ExamEditor extends React.Component {
api.post('problems', formData).then(result => { api.post('problems', formData).then(result => {
widgetData.id = result.widget_id widgetData.id = result.widget_id
problemData.id = result.id problemData.id = result.id
problemData.name = result.problem_name
widgetData.problem = problemData widgetData.problem = problemData
this.props.createNewWidget(widgetData) this.props.createNewWidget(widgetData)
...@@ -198,7 +199,7 @@ class ExamEditor extends React.Component { ...@@ -198,7 +199,7 @@ class ExamEditor extends React.Component {
*/ */
updateMCO = (widget, data) => { updateMCO = (widget, data) => {
// update state // update state
this.props.updateMCWidget(widget, { this.props.updateMCOsInState(widget, {
x: Math.round(data.x), x: Math.round(data.x),
y: Math.round(data.y) y: Math.round(data.y)
}) })
...@@ -246,7 +247,7 @@ class ExamEditor extends React.Component { ...@@ -246,7 +247,7 @@ class ExamEditor extends React.Component {
let changed = (oldX !== newX) || (oldY !== newY) // update the state only if the mc options were moved let changed = (oldX !== newX) || (oldY !== newY) // update the state only if the mc options were moved
if (changed) { if (changed) {
this.props.updateMCWidget(widget, { this.props.updateMCOsInState(widget, {
x: Math.round(newX), x: Math.round(newX),
y: Math.round(newY) y: Math.round(newY)
}) })
...@@ -301,7 +302,14 @@ class ExamEditor extends React.Component { ...@@ -301,7 +302,14 @@ class ExamEditor extends React.Component {
<div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}> <div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}>
{widget.problem.mc_options.map((option) => { {widget.problem.mc_options.map((option) => {
return ( return (
<div key={'widget_mco_' + option.id} className='mcq-option'> <div key={'widget_mco_' + option.id} className='mcq-option'
onMouseEnter={() => {
this.props.highlightFeedback(widget, option.feedback_id)
}}
onMouseLeave={() => {
this.props.removeHighlight(widget, option.feedback_id)
}}
>
<div className='mcq-option-label'> <div className='mcq-option-label'>
{option.label} {option.label}
</div> </div>
......
File deleted
name: zesje-dev
channels:
- conda-forge
- anaconda
dependencies:
- python=3.6
- yarn
- gunicorn
- redis
- pip
- pip:
# Core components
- flask
- flask_restful
- flask_sqlalchemy
- sqlalchemy
- Flask-Migrate
- alembic
- pyyaml
- celery
- redis
# General utilities
- numpy==1.15.4
- scipy==1.3.0
# summary plot generation
- matplotlib
- seaborn
# PDF generation
- pdfrw
- reportlab
- Wand
- Pillow # also scan processing
# Scan processing
- opencv-python
- pikepdf
- pylibdmtx
- pdfminer3
# Exporting
- pandas
- openpyxl # required for writing dataframes as Excel spreadsheets
#
# Development dependencies
#
# Tests
- pytest
- pyssim
- pytest-cov
# Linting
- flake8
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
"main": "index.js", "main": "index.js",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"dev": "concurrently --kill-others --names \"WEBPACK,PYTHON,CELERY\" --prefix-colors \"bgBlue.bold,bgGreen.bold,bgRed.bold\" \"webpack-dev-server --hot --inline --progress --config webpack.dev.js\" \"ZESJE_SETTINGS=$(pwd)/zesje.dev.cfg python3 zesje\" \"ZESJE_SETTINGS=$(pwd)/zesje.dev.cfg celery -A zesje.celery worker -l info --autoscale=4,1 --max-tasks-per-child=16\"", "dev": "concurrently --kill-others --names \"WEBPACK,PYTHON,CELERY,REDIS\" --prefix-colors \"bgBlue.bold,bgGreen.bold,bgRed.bold,bgYellow.bold\" \"webpack-dev-server --hot --inline --progress --config webpack.dev.js\" \"ZESJE_SETTINGS=$(pwd)/zesje.dev.cfg python3 zesje\" \"ZESJE_SETTINGS=$(pwd)/zesje.dev.cfg celery -A zesje.celery worker -l info --autoscale=4,1 --max-tasks-per-child=16\" \"redis-server redis.conf\"",
"build": "webpack --config webpack.prod.js", "build": "webpack --config webpack.prod.js",
"ci": "yarn lint; yarn test", "ci": "yarn lint; yarn test",
"lint": "yarn lint:js; yarn lint:py", "lint": "yarn lint:js; yarn lint:py",
...@@ -33,6 +33,7 @@ ...@@ -33,6 +33,7 @@
"babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-object-rest-spread": "^6.26.0",
"bulma": "^0.7.1", "bulma": "^0.7.1",
"bulma-switch": "^2.0.0",
"bulma-tooltip": "^2.0.1", "bulma-tooltip": "^2.0.1",
"concurrently": "^3.6.0", "concurrently": "^3.6.0",
"css-loader": "^1.0.0", "css-loader": "^1.0.0",
...@@ -52,6 +53,7 @@ ...@@ -52,6 +53,7 @@
"react": "^16.4.0", "react": "^16.4.0",
"react-autosuggest": "^9.3.4", "react-autosuggest": "^9.3.4",
"react-bulma-notification": "^1.1.0", "react-bulma-notification": "^1.1.0",
"react-bulma-switch": "^0.0.3",
"react-dom": "^16.4.0", "react-dom": "^16.4.0",
"react-dropzone": "^4.2.13", "react-dropzone": "^4.2.13",
"react-hot-loader": "^4.3.3", "react-hot-loader": "^4.3.3",
......
port 6479
loglevel notice
\ No newline at end of file
# Tests
pytest
pyssim
pytest-cov
# Linting
flake8
# Core components
flask
flask_restful
flask_sqlalchemy
sqlalchemy
Flask-Migrate
alembic
pyyaml
celery
redis
# General utilities
numpy
scipy
# summary plot generation
matplotlib
seaborn
# PDF generation
pdfrw
reportlab
Wand
Pillow # also scan processing
pyStrich # TODO: can we replace this with stuff from pylibdmtx?
# Scan processing
opencv-python
git+https://github.com/mstamy2/PyPDF2
pylibdmtx
# Exporting
pandas
openpyxl # required for writing dataframes as Excel spreadsheets
import os import os
import pytest import pytest
from flask import Flask from flask import Flask
from zesje.api import api_bp from zesje.api import api_bp
from zesje.database import db from zesje.database import db
...@@ -40,3 +39,12 @@ def test_client(app): ...@@ -40,3 +39,12 @@ def test_client(app):
with app.app_context(): with app.app_context():
db.drop_all() db.drop_all()
db.create_all() db.create_all()
@pytest.fixture
def empty_app(app):
with app.app_context():
db.drop_all()
db.create_all()
return app
File added