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
Select Git revision
  • demo-warp-perspective
  • feature/toggle-pregrading
  • develop
  • demo
  • add/find-corner-markers-tests
  • tweak-mc-api
  • fix/simplify-realign-image
  • client-side-statistics
  • size-mc
  • fix-truefalse-mc
  • edit-mc-finalized
  • fix/fix-feedback-mult-choice
  • fix/simplify-pregrader
  • box-loc-db
  • feature/add-question-title
  • feature/identify-blank-solutions
  • master
  • fix/combine-precise-positioning-with-pregrading
  • refactor/global-box-size
  • feature/highlite_pregrade
  • layout-side-panel
  • remove-data-folder
  • approve_shortcut
  • feature/precise-positioning
  • feature/total-time-grading
  • fix/delete-feedback-mco
  • test/pregrading-with-precise-positioning
  • premade-feedback-merge
  • test/add-api-tests
  • highlight-feedback
  • fix/parametrize_corner_test
  • fix-exam-widget
  • feature/pre-grading
  • combinatory-fixes
  • refactoring_frontend
  • fix-cb-snap
  • QRCodes-odd-pages
  • pytest-cov
  • stefan-beta
  • add-boxes-frontend
  • fix/mco-feedback-rel
  • fix/delete-mc-option
  • draw-pdf-box
  • box-fill-detection
  • mc-option-inheritance
  • mcq-fixes
  • mc-checkbox-exam-api
  • feature/upgrade-dependencies
  • feature/additional-tests
  • feature/update-feedback-on-giving-feedback
  • feature/logging
  • Feature/Anonimization-script
  • feature/randomisedGrading
  • feature-anonimization
  • feature/conda-dev
  • feature/no-hero
  • issue/209-exam-always-switches-to-latest-on-page-re-load
  • issue/198-problem-name-is-not-submitted-on-editing-or-navigating-away-from-the-problem
  • client-refactor
  • feature/mobx
  • issue-176-fix
  • bep-demo
  • scan-orientation-v2
  • fuzzy-search-student-number
  • scan-orientation
  • no-latex-pdf-gen-helper-db
  • blank-detection
  • barcode-sample-generation
  • react-temp
  • react-noti
  • legacy
  • dualstack
  • v0.1
  • v0.2
74 results

Target

Select target project
No results found
Select Git revision
  • 694-separate-migrations-app
  • master
  • 672-mc-api-collective
  • 320-copy-feedback
  • 673-delete-copies
  • 670-modals-not-reacting-to-x
  • 481-detach-image-from-page
  • 658-fix-auto-approve-modal-not-showing
  • 590-auto-dockerfile-is-broken
  • v0.3.0a7
  • 568-design-for-multiple-exam-types
  • 561-progress-bar-can-exceed-100
  • 530-improve-ui-for-multi-page-solutions
  • 550-frontend-api-feedback-filter
  • 515-3-cols-layout
  • 481-split-pipelines
  • 501-bulma-0-8
  • 496-create-a-frotend-view-for-courses
  • 487-courses
  • 497-add-course-id-prop
  • 464-create-and-modify-endpoints-to-allow-checking-and-modifying-user-permission-2
  • 467-create-element-for-adding-graders
  • 477-exam-owner-access
  • 430-email-take-home-exams
  • 465_create_roles_table_rest_api
  • test-coverage-report
  • test-coverage-report-2
  • 452-login-page-frontend
  • anonymize-script
  • sqlite_backport
  • 398-explain-meaning-of-boxes-when-exam-is-created
  • 397-autograding-approval-is-counter-intuitive
  • 386-previous-graded
  • 370-rename-id-to-copy
  • 257-randomize-next-ungraded
  • overviewStats
  • feature/logging
  • patch-1
  • filter-submissions
  • feature/upgrade-dependencies
  • feature/additional-tests
  • feature/update-feedback-on-giving-feedback
  • Feature/Anonimization-script
  • feature/randomisedGrading
  • feature-anonimization
  • feature/conda-dev
  • feature/no-hero
  • issue/209-exam-always-switches-to-latest-on-page-re-load
  • issue/198-problem-name-is-not-submitted-on-editing-or-navigating-away-from-the-problem
  • client-refactor
  • issue-176-fix
  • bep-demo
  • scan-orientation-v2
  • fuzzy-search-student-number
  • scan-orientation
  • no-latex-pdf-gen-helper-db
  • blank-detection
  • barcode-sample-generation
  • legacy
  • dualstack
  • sqlite
  • v0.1
  • v0.2
  • v0.3.0a1
  • v0.3.0a2
  • v0.3.0a3
  • v0.3.0a4
  • v0.3.0a5
  • v0.3.0a6
  • v0.4.0
  • v0.4.1
  • v0.4.2
  • v0.4.3
  • v0.4.4
  • v0.4.5
  • v0.4.6
  • v0.4.7
  • v0.4.8
  • v0.4.9
79 results
Show changes

Commits on Source 253

153 additional commits have been omitted to prevent performance issues.
30 files
+ 1847
198
Compare changes
  • Side-by-side
  • Inline

Files

+146 −0
Original line number Original line Diff line number Diff line
import React from 'react'

/**
 * PanelMCQ is a component that allows the user to generate mcq options
 */
class PanelMCQ extends React.Component {
  constructor (props) {
    super(props)
    this.onChangeNPA = this.onChangeNPA.bind(this)
    this.onChangeLabelType = this.onChangeLabelType.bind(this)
    this.generateLabels = this.generateLabels.bind(this)
    this.state = {
      chosenLabelType: 0,
      nrPossibleAnswers: 2,
      labelTypes: ['None', 'True/False', 'A, B, C ...', '1, 2, 3 ...']
    }
  }

  // this function is called when the input is changed for the number of possible answers
  onChangeNPA (e) {
    let value = parseInt(e.target.value)
    if (!isNaN(value)) {
      if (this.state.chosenLabelType === 1) {
        value = 2
      }
      this.setState({
        nrPossibleAnswers: value
      })
    }
  }

  // this function is called when the input is changed for the desired label type
  onChangeLabelType (e) {
    let value = parseInt(e.target.value)
    if (!isNaN(value)) {
      this.setState({
        chosenLabelType: value
      })
      if (parseInt(value) === 1) {
        this.setState({
          nrPossibleAnswers: 2
        })
      }
    }
  }

  /**
   * This function generates an array with the labels for each option
   * @param nrLabels the number of options that need to be generated
   * @returns {any[]|string[]|number[]}
   */
  generateLabels (nrLabels) {
    let type = this.state.chosenLabelType

    switch (type) {
      case 1:
        return ['T', 'F']
      case 2:
        return Array.from(Array(nrLabels).keys()).map((e) => String.fromCharCode(e + 65))
      case 3:
        return Array.from(Array(nrLabels).keys()).map(e => e + 1)
      default:
        return Array(nrLabels).fill(' ')
    }
  }

  /**
   * This function renders the panel with the inputs for generating multiple choice options
   * @returns the react component containing the mcq panel
   */
  render () {
    return (
      <nav className='panel'>
        <p className='panel-heading'>
          Multiple Choice Question
        </p>
        <div className='panel-block'>
          <div className='field'>
            <React.Fragment>
              <label className='label'>Number possible answers</label>
              <div className='control'>
                {(function () {
                  var optionList = []
                  for (var i = 1; i <= this.props.totalNrAnswers; i++) {
                    const optionElement = <option key={i} value={String(i)}>{i}</option>
                    optionList.push(optionElement)
                  }
                  return (<div className='select is-hovered is-fullwidth'>
                    <select value={this.state.nrPossibleAnswers} onChange={this.onChangeNPA}>{optionList}</select>
                  </div>)
                }.bind(this)())}
              </div>
            </React.Fragment>
          </div>
        </div>
        <div className='panel-block'>
          <div className='field'>
            <React.Fragment>
              <label className='label'>Answer boxes labels</label>
              <div className='control'>
                <div className='select is-hovered is-fullwidth'>
                  {(function () {
                    var optionList = this.state.labelTypes.map(
                      (label, i) => <option key={i} value={String(i)}>{label}</option>
                    )
                    return (
                      <div className='select is-hovered is-fullwidth'>
                        <select value={this.state.chosenLabelType} onChange={this.onChangeLabelType}>
                          {optionList}
                        </select>
                      </div>
                    )
                  }.bind(this)())}
                </div>
              </div>
            </React.Fragment>
          </div>
        </div>
        <div className='panel-block field is-grouped'>
          <button
            disabled={this.props.disabledGenerateBoxes}
            className='button is-link is-fullwidth'
            onClick={() => {
              let npa = this.state.nrPossibleAnswers
              let labels = this.generateLabels(npa)
              this.props.onGenerateBoxesClick(labels)
            }}
          >
            Generate
          </button>
          <button
            disabled={this.props.disabledDeleteBoxes}
            className='button is-danger is-fullwidth'
            onClick={() => {
              this.props.onDeleteBoxesClick()
            }}
          >
            Delete
          </button>
        </div>
      </nav>
    )
  }
}

export default PanelMCQ
Original line number Original line Diff line number Diff line
:root {
    --option-width:20px;
    --label-font-size:14px;
}

div.mcq-widget {
    display:inline-flex;
}

div.mcq-option {
    display: block;
    width: var(--option-width);
    padding:2px;
    box-sizing: content-box;
    height: auto;
}

div.mcq-option div.mcq-option-label {
    display:block;
    font-family: Arial, Helvetica, sans-serif;
    font-size: var(--label-font-size);
    text-align: center;
}

div.mcq-option img.mcq-box {
    display: block;
    margin:auto;
}


.editor-content {
.editor-content {
  background-color: #ddd;
  background-color: #ddd;
  border-radius: 10px
  border-radius: 10px;
  height: 100%;
}
}


.selection-area {
.selection-area {
Original line number Original line Diff line number Diff line
@@ -6,6 +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 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'
@@ -28,7 +29,9 @@ class Exams extends React.Component {
    widgets: [],
    widgets: [],
    previewing: false,
    previewing: false,
    deletingExam: false,
    deletingExam: false,
    deletingWidget: false
    deletingWidget: false,
    deletingMCWidget: false,
    showPanelMCQ: false
  }
  }


  static getDerivedStateFromProps = (newProps, prevState) => {
  static getDerivedStateFromProps = (newProps, prevState) => {
@@ -44,7 +47,17 @@ class Exams extends React.Component {
            page: problem.page,
            page: problem.page,
            name: problem.name,
            name: problem.name,
            graded: problem.graded,
            graded: problem.graded,
            feedback: problem.feedback || []
            feedback: problem.feedback || [],
            mc_options: problem.mc_options.map((option) => {
              option.cbOffsetX = 7 // checkbox offset relative to option position on x axis
              option.cbOffsetY = 21 // checkbox offset relative to option position on y axis
              option.widget.x -= option.cbOffsetX
              option.widget.y -= option.cbOffsetY
              return option
            }),
            widthMCO: 24,
            heightMCO: 38,
            isMCQ: problem.mc_options && problem.mc_options.length !== 0 // is the problem a mc question - used to display PanelMCQ
          }
          }
        }
        }
      })
      })
@@ -103,15 +116,20 @@ class Exams extends React.Component {
  }
  }


  updateFeedback = (feedback) => {
  updateFeedback = (feedback) => {
    var widgets = this.state.widgets
    let problemWidget = this.state.widgets[this.state.selectedWidgetId]
    const idx = widgets[this.state.selectedWidgetId].problem.feedback.findIndex(e => { return e.id === feedback.id })
    const index = problemWidget.problem.feedback.findIndex(e => { return e.id === feedback.id })
    if (idx === -1) widgets[this.state.selectedWidgetId].problem.feedback.push(feedback)
    this.updateFeedbackAtIndex(feedback, problemWidget, index)
    else {
  }
      if (feedback.deleted) widgets[this.state.selectedWidgetId].problem.feedback.splice(idx, 1)

      else widgets[this.state.selectedWidgetId].problem.feedback[idx] = feedback
  updateFeedbackAtIndex = (feedback, problemWidget, idx) => {
    if (idx === -1) {
      problemWidget.problem.feedback.push(feedback)
    } else {
      if (feedback.deleted) problemWidget.problem.feedback.splice(idx, 1)
      else problemWidget.problem.feedback[idx] = feedback
    }
    }
    this.setState({
    this.setState({
      widgets: widgets
      widgets: this.state.widgets
    })
    })
  }
  }


@@ -143,6 +161,19 @@ class Exams extends React.Component {
      }))
      }))
  }
  }


  createNewWidget = (widgetData) => {
    this.setState((prevState) => {
      return {
        selectedWidgetId: widgetData.id,
        widgets: update(prevState.widgets, {
          [widgetData.id]: {
            $set: widgetData
          }
        })
      }
    })
  }

  deleteWidget = (widgetId) => {
  deleteWidget = (widgetId) => {
    const widget = this.state.widgets[widgetId]
    const widget = this.state.widgets[widgetId]
    if (widget) {
    if (widget) {
@@ -203,23 +234,16 @@ 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}
          selectedWidgetId={this.state.selectedWidgetId}
          selectedWidgetId={this.state.selectedWidgetId}
          selectWidget={(widgetId) => {
          selectWidget={(widgetId) => {
            this.setState({
            this.setState({
              selectedWidgetId: widgetId
              selectedWidgetId: widgetId
            })
            })
          }}
          }}
          createNewWidget={(widgetData) => {
          createNewWidget={this.createNewWidget}
            this.setState((prevState) => {
          updateExam={() => {
              return {
            this.props.updateExam(this.props.examID)
                selectedWidgetId: widgetData.id,
                widgets: update(prevState.widgets, {
                  [widgetData.id]: {
                    $set: widgetData
                  }
                })
              }
            })
          }}
          }}
        />
        />
      )
      )
@@ -279,18 +303,177 @@ class Exams extends React.Component {
    })
    })
  }
  }


  /**
   * This function deletes the mc options coupled to a problem.
   */
  deleteMCWidget = () => {
    const widget = this.state.widgets[this.state.selectedWidgetId]
    const options = widget.problem.mc_options
    if (options.length > 0) {
      options.forEach((option) => {
        api.del('mult-choice/' + option.id)
          .catch(err => {
            console.log(err)
            err.json().then(res => {
              this.setState({
                deletingMCWidget: false
              })
              Notification.error('Could not delete multiple choice option' +
                (res.message ? ': ' + res.message : ''))
              // update to try and get a consistent state
              this.props.updateExam(this.props.examID)
            })
          }).then(res => {
            let index = widget.problem.feedback.findIndex(e => { return e.id === res.feedback_id })
            let feedback = widget.problem.feedback[index]
            feedback.deleted = true
            this.updateFeedbackAtIndex(feedback, widget, index)
          })
      })

      // remove the mc options from the state
      // note that this can happen before they are removed in the DB due to async calls
      this.setState((prevState) => {
        return {
          widgets: update(prevState.widgets, {
            [widget.id]: {
              problem: {
                mc_options: {
                  $set: []
                }
              }
            }
          }),
          deletingMCWidget: false
        }
      })
    }
  }

  /**
   * This method creates a mc option widget object and adds it to the corresponding problem
   * @param problemWidget The widget the mc option belongs to
   * @param data the mc option
   */
  createNewMCWidget = (problemWidget, data) => {
    this.setState((prevState) => {
      return {
        widgets: update(prevState.widgets, {
          [this.state.selectedWidgetId]: {
            problem: {
              mc_options: {
                $push: [data]
              }
            }
          }
        })
      }
    })
  }

  /**
   * This method is called when the mcq widget is moved. The positions of the options are stored separately and they
  * all need to be updated
   * @param widget the problem widget that includes the mcq widget
   * @param data the new location of the mcq widget (the location of the top-left corner)
   */
  updateMCWidget = (widget, data) => {
    let newMCO = widget.problem.mc_options.map((option, i) => {
      return {
        'widget': {
          'x': {
            $set: data.x + i * widget.problem.widthMCO
          },
          'y': {
            // each mc option needs to be positioned next to the previous option and should not overlap it
            $set: data.y
          }
        }
      }
    })

    // update the state with the new locations
    this.setState(prevState => ({
      widgets: update(prevState.widgets, {
        [widget.id]: {
          'problem': {
            'mc_options': newMCO
          }
        }
      })
    }))
  }

  /**
   * This method generates MC options by making the right calls to the api and creating
   * the widget object in the mc_options array of the corresponding problem.
   * @param problemWidget the problem widget the mc options belong to
   * @param labels the labels for the options
   * @param index the index in the labels array (the function is recusive, this index is increased)
   * @param xPos x position of the current option
   * @param yPos y position of the current option
   */
  generateAnswerBoxes = (problemWidget, labels, index, xPos, yPos) => {
    if (labels.length === index) return

    let feedback = {
      'name': labels[index],
      'description': '',
      'score': 0
    }

    let data = {
      'label': labels[index],
      'problem_id': problemWidget.problem.id,
      'feedback_id': null,
      'cbOffsetX': 7, // checkbox offset relative to option position on x axis
      'cbOffsetY': 21, // checkbox offset relative to option position on y axis
      'widget': {
        'name': 'mc_option_' + labels[index],
        'x': xPos,
        'y': yPos,
        'type': 'mcq_widget'
      }
    }

    const formData = new window.FormData()
    formData.append('name', data.widget.name)
    formData.append('x', data.widget.x + data.cbOffsetX)
    formData.append('y', data.widget.y + data.cbOffsetY)
    formData.append('problem_id', data.problem_id)
    formData.append('label', data.label)
    formData.append('fb_description', feedback.description)
    formData.append('fb_score', feedback.score)
    api.put('mult-choice/', formData).then(result => {
      data.id = result.mult_choice_id
      data.feedback_id = result.feedback_id
      feedback.id = result.feedback_id
      this.createNewMCWidget(problemWidget, data)
      this.updateFeedback(feedback)
      this.generateAnswerBoxes(problemWidget, labels, index + 1, xPos + problemWidget.problem.widthMCO, yPos)
    }).catch(err => {
      console.log(err)
    })
  }

  SidePanel = (props) => {
  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 widgetEditDisabled = this.state.previewing || !problem
    let containsMCOptions = (problem && problem.mc_options.length > 0) || false
    let widgetEditDisabled = (this.state.previewing || !problem) || (this.props.exam.finalized && containsMCOptions)
    let isGraded = problem && problem.graded
    let 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})
@@ -311,7 +494,42 @@ 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>
    )
    )
@@ -325,14 +543,18 @@ class Exams extends React.Component {
        <p className='panel-heading'>
        <p className='panel-heading'>
          Problem details
          Problem details
        </p>
        </p>
        {selectedWidgetId === null ? (
          <div className='panel-block'>
          <div className='panel-block'>
            <div className='field'>
            <div className='field'>
            {selectedWidgetId === null ? (
              <p style={{ margin: '0.625em 0', minHeight: '3em' }}>
              <p style={{ margin: '0.625em 0', minHeight: '3em' }}>
                To create a problem, draw a rectangle on the exam.
                To create a problem, draw a rectangle on the exam.
              </p>
              </p>
            </div>
          </div>
        ) : (
        ) : (
          <React.Fragment>
          <React.Fragment>
            <div className='panel-block'>
              <div className='field'>
                <label className='label'>Name</label>
                <label className='label'>Name</label>
                <div className='control'>
                <div className='control'>
                  <input
                  <input
@@ -345,12 +567,22 @@ class Exams extends React.Component {
                    }}
                    }}
                    onBlur={(e) => {
                    onBlur={(e) => {
                      props.saveProblemName(e.target.value)
                      props.saveProblemName(e.target.value)
                    }}
                  />
                </div>
              </div>
            </div>
            <div className='panel-block'>
              <div className='field'>
                <label className='label'> Multiple choice question </label>
                <input disabled={props.disableIsMCQ} type='checkbox' checked={props.isMCQProblem} onChange={
                  (e) => {
                    props.onMCQChange(e.target.checked)
                  }} />
                  }} />
              </div>
              </div>
            </div>
          </React.Fragment>
          </React.Fragment>
        )}
        )}
          </div>
        </div>
        {this.isProblemWidget(selectedWidgetId) &&
        {this.isProblemWidget(selectedWidgetId) &&
          <React.Fragment>
          <React.Fragment>
            <div className='panel-block'>
            <div className='panel-block'>
@@ -374,7 +606,6 @@ class Exams extends React.Component {
            Delete problem
            Delete problem
          </button>
          </button>
        </div>
        </div>

      </nav>
      </nav>
    )
    )
  }
  }
@@ -510,6 +741,18 @@ class Exams extends React.Component {
        onCancel={() => this.setState({deletingWidget: false})}
        onCancel={() => this.setState({deletingWidget: false})}
        onConfirm={() => this.deleteWidget(this.state.selectedWidgetId)}
        onConfirm={() => this.deleteWidget(this.state.selectedWidgetId)}
      />
      />
      <ConfirmationModal
        active={this.state.deletingMCWidget && this.state.selectedWidgetId != null}
        color='is-danger'
        headerText={`Are you sure you want to delete the multiple choice options for problem "${
          this.state.selectedWidgetId &&
          this.state.widgets[this.state.selectedWidgetId] &&
          this.state.widgets[this.state.selectedWidgetId].problem &&
          this.state.widgets[this.state.selectedWidgetId].problem.name}"`}
        confirmText='Delete multiple choice options'
        onCancel={() => this.setState({deletingMCWidget: false})}
        onConfirm={() => this.deleteMCWidget(this.state.selectedWidgetId)}
      />
    </div>
    </div>
  }
  }
}
}
Original line number Original line Diff line number Diff line
@@ -8,6 +8,7 @@ import studentIdExampleImage from '../components/student_id_example.png'
// FIXME!
// FIXME!
// eslint-disable-next-line import/no-webpack-loader-syntax
// eslint-disable-next-line import/no-webpack-loader-syntax
import studentIdExampleImageSize from '!image-dimensions-loader!../components/student_id_example.png'
import studentIdExampleImageSize from '!image-dimensions-loader!../components/student_id_example.png'
import answerBoxImage from '../components/answer_box.png'
import EmptyPDF from '../components/EmptyPDF.jsx'
import EmptyPDF from '../components/EmptyPDF.jsx'
import PDFOverlay from '../components/PDFOverlay.jsx'
import PDFOverlay from '../components/PDFOverlay.jsx'


@@ -87,13 +88,18 @@ class ExamEditor extends React.Component {
        const problemData = {
        const problemData = {
          name: 'New problem', // TODO: Name
          name: 'New problem', // TODO: Name
          page: this.props.page,
          page: this.props.page,
          feedback: []
          feedback: [],
          mc_options: [],
          widthMCO: 24,
          heightMCO: 38,
          isMCQ: false
        }
        }
        const widgetData = {
        const widgetData = {
          x: Math.round(selectionBox.left),
          x: Math.round(selectionBox.left),
          y: Math.round(selectionBox.top),
          y: Math.round(selectionBox.top),
          width: Math.round(selectionBox.width),
          width: Math.round(selectionBox.width),
          height: Math.round(selectionBox.height)
          height: Math.round(selectionBox.height),
          type: 'problem_widget'
        }
        }
        const formData = new window.FormData()
        const formData = new window.FormData()
        formData.append('exam_id', this.props.examID)
        formData.append('exam_id', this.props.examID)
@@ -170,64 +176,161 @@ class ExamEditor extends React.Component {
    }
    }
  }
  }


  renderWidgets = () => {
  /**
    // Only render when numPage is set
   * This method is called when the position of a widget has changed. It informs the server about the relocation.
    if (this.props.numPages !== null && this.props.widgets) {
   * @param widget the widget that was relocated
      const widgets = this.props.widgets.filter(widget => {
   * @param data  the new location
        if (widget.name === 'student_id_widget' ||
   */
          widget.name === 'barcode_widget') {
  updateWidgetDB = (widget, data) => {
          return !this.props.finalized
    return api.patch('widgets/' + widget.id, data).then(() => {
        } else if (widget.problem) {
      // ok
          return widget.problem.page === this.props.page
    }).catch(err => {
        } else {
      console.log(err)
          return true
      // update to try and get a consistent state
      this.props.updateExam()
    })
  }
  }

  /**
   * This function updates the state and the Database with the positions of the mc options.
   * @param widget the problem widget the mc options belong to
   * @param data the new position of the mc widget
   */
  updateMCO = (widget, data) => {
    // update state
    this.props.updateMCWidget(widget, {
      x: Math.round(data.x),
      y: Math.round(data.y)
    })
    })


      let minWidth
    // update DB
      let minHeight
    widget.problem.mc_options.forEach(
      let view
      (option, i) => {
      let enableResizing
        let newData = {
      return widgets.map((widget) => {
          x: Math.round(data.x) + i * widget.problem.widthMCO + option.cbOffsetX,
        const isSelected = widget.id === this.props.selectedWidgetId
          y: Math.round(data.y) + option.cbOffsetY
        }
        this.updateWidgetDB(option, newData)
      })
  }


        if (widget.problem) {
  /**
          minWidth = this.props.problemMinWidth
   * This function updates the position of the mc options inside when the corresponding problem widget changes in
          minHeight = this.props.problemMinHeight
   * size or position. Note that the positions in the database are not updated. These should be updated once when the
          view = (
   * action (resizing/dragging/other) is finalized.
            <div
   * @param widget the problem widget containing mc options
              className={isSelected ? 'widget selected' : 'widget'}
   * @param data the new data about the new size/position of the problem widget
            />
   */
          )
  repositionMC = (widget, data) => {
          enableResizing = true
    if (widget.problem.mc_options.length > 0) {
        } else {
      let oldX = widget.problem.mc_options[0].widget.x
          let image
      let oldY = widget.problem.mc_options[0].widget.y
          if (widget.name === 'barcode_widget') {
      let newX = oldX
            minWidth = barcodeExampleImageSize.width
      let newY = oldY
            minHeight = barcodeExampleImageSize.height
      let widthOption = widget.problem.widthMCO * widget.problem.mc_options.length
            image = barcodeExampleImage
      let heightOption = widget.problem.heightMCO
          } else if (this.props.page === 0 && widget.name === 'student_id_widget') {
      let widthProblem = data.width ? data.width : widget.width
            minWidth = studentIdExampleImageSize.width
      let heightProblem = data.height ? data.height : widget.height
            minHeight = studentIdExampleImageSize.height

            image = studentIdExampleImage
      if (newX < data.x) {
          } else {
        newX = data.x
            return null
      } else if (newX + widthOption > data.x + widthProblem) {
        newX = data.x + widget.width - widthOption
      }
      }
          view = (

            <div
      if (newY < data.y) {
              className={isSelected ? 'widget selected' : 'widget'}
        newY = data.y
              style={{
      } else if (newY + heightOption > data.y + heightProblem) {
                boxSizing: 'content-box',
        newY = data.y + widget.height - heightOption
                backgroundImage: 'url(' + image + ')',
      }
                backgroundRepeat: 'no-repeat'

      let changed = (oldX !== newX) || (oldY !== newY) // update the state only if the mc options were moved
      if (changed) {
        this.props.updateMCWidget(widget, {
          x: Math.round(newX),
          y: Math.round(newY)
        })
      }
    }
  }

  /**
   * This function renders a group of options into one draggable widget.
   * @param widget the problem widget that contains a mc options
   * @return a react component representing the multiple choice widget
   */
  renderMCWidget = (widget) => {
    let width = widget.problem.widthMCO * widget.problem.mc_options.length
    let height = widget.problem.heightMCO
    let enableResizing = false
    const isSelected = widget.id === this.props.selectedWidgetId
    let xPos = widget.problem.mc_options[0].widget.x
    let yPos = widget.problem.mc_options[0].widget.y

    return (
      <ResizeAndDrag
        key={'widget_mc_' + widget.id}
        bounds={'[data-key="widget_' + widget.id + '"]'}
        minWidth={width}
        minHeight={height}
        enableResizing={{
          bottom: enableResizing,
          bottomLeft: enableResizing,
          bottomRight: enableResizing,
          left: enableResizing,
          right: enableResizing,
          top: enableResizing,
          topLeft: enableResizing,
          topRight: enableResizing
        }}
        }}
            />
        position={{
          x: xPos,
          y: yPos
        }}
        size={{
          width: width,
          height: height
        }}
        onDragStart={() => {
          this.props.selectWidget(widget.id)
        }}
        onDragStop={(e, data) => {
          this.updateMCO(widget, data)
        }}
      >
        <div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}>
          {widget.problem.mc_options.map((option) => {
            return (
              <div key={'widget_mco_' + option.id} className='mcq-option'>
                <div className='mcq-option-label'>
                  {option.label}
                </div>
                <img className='mcq-box' src={answerBoxImage} />
              </div>
            )
          })}
        </div>
      </ResizeAndDrag>
    )
    )
          enableResizing = false
  }
  }
        return (

  /**
   * Render problem widget and the mc options that correspond to the problem.
   * @param widget the corresponding widget object from the db
   * @returns {Array} an array of react components to be displayed
   */
  renderProblemWidget = (widget) => {
    // Only render when numPage is set
    if (widget.problem.page !== this.props.page) return []

    let enableResizing = true
    const isSelected = widget.id === this.props.selectedWidgetId
    let minWidth = this.props.problemMinWidth
    let minHeight = this.props.problemMinHeight
    let elementList = [(
      <ResizeAndDrag
      <ResizeAndDrag
        key={'widget_' + widget.id}
        key={'widget_' + widget.id}
        data-key={'widget_' + widget.id}
        bounds='parent'
        bounds='parent'
        minWidth={minWidth}
        minWidth={minWidth}
        minHeight={minHeight}
        minHeight={minHeight}
@@ -256,45 +359,159 @@ 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, {
            width: ref.offsetWidth,
            height: ref.offsetHeight,
            x: Math.round(position.x),
            y: Math.round(position.y)
          })
        }}
        }}
        onResizeStop={(e, direction, ref, delta, position) => {
        onResizeStop={(e, direction, ref, delta, position) => {
              api.patch('widgets/' + widget.id, {
          this.updateWidgetDB(widget, {
            x: Math.round(position.x),
            x: Math.round(position.x),
            y: Math.round(position.y),
            y: Math.round(position.y),
            width: ref.offsetWidth,
            width: ref.offsetWidth,
            height: ref.offsetHeight
            height: ref.offsetHeight
          }).then(() => {
          }).then(() => {
                // ok
            if (widget.problem.mc_options.length > 0) {
              }).catch(err => {
              this.updateMCO(widget, {
                console.log(err)
                x: widget.problem.mc_options[0].widget.x, // these are guaranteed to be up to date
                // update to try and get a consistent state
                y: widget.problem.mc_options[0].widget.y
                this.updateExam()
              })
            }
          })
          })
        }}
        }}
        onDragStart={() => {
        onDragStart={() => {
          this.props.selectWidget(widget.id)
          this.props.selectWidget(widget.id)
        }}
        }}
        onDrag={(e, data) => this.repositionMC(widget, data)}
        onDragStop={(e, data) => {
        onDragStop={(e, data) => {
          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) }
          })
          })
              api.patch('widgets/' + widget.id, {
          this.updateWidgetDB(widget, {
            x: Math.round(data.x),
            x: Math.round(data.x),
            y: Math.round(data.y)
            y: Math.round(data.y)
          }).then(() => {
          }).then(() => {
                // ok
            if (widget.problem.mc_options.length > 0) {
              }).catch(err => {
              this.updateMCO(widget, {
                console.log(err)
                // react offers the guarantee that setState calls are processed before handling next event
                // update to try and get a consistent state
                // therefore the data in the state is up to date
                this.updateExam()
                x: widget.problem.mc_options[0].widget.x,
                y: widget.problem.mc_options[0].widget.y
              })
            }
          })
          })
        }}
        }}
      >
      >
            {view}
        <div
          className={isSelected ? 'widget selected' : 'widget'}
        />
      </ResizeAndDrag>
      </ResizeAndDrag>
        )
    )]

    // depending on the rendering option, render the mc_options separately or in a single widget
    if (widget.problem.mc_options.length > 0 && !this.props.finalized) {
      elementList.push(this.renderMCWidget(widget))
    }

    return elementList
  }

  /**
   * Render exam widgets.
   * @param widget the corresponding widget object from the db
   * @returns {Array} an array of react components to be displayed
   */
  renderExamWidget = (widget) => {
    if (this.props.finalized) return []

    let minWidth, minHeight
    let enableResizing = false
    const isSelected = widget.id === this.props.selectedWidgetId
    let image
    if (widget.name === 'barcode_widget') {
      minWidth = barcodeExampleImageSize.width
      minHeight = barcodeExampleImageSize.height
      image = barcodeExampleImage
    } else if (this.props.page === 0 && widget.name === 'student_id_widget') {
      minWidth = studentIdExampleImageSize.width
      minHeight = studentIdExampleImageSize.height
      image = studentIdExampleImage
    } else {
      return []
    }

    return [(
      <ResizeAndDrag
        key={'widget_' + widget.id}
        bounds='parent'
        minWidth={minWidth}
        minHeight={minHeight}
        enableResizing={{
          bottom: enableResizing,
          bottomLeft: enableResizing,
          bottomRight: enableResizing,
          left: enableResizing,
          right: enableResizing,
          top: enableResizing,
          topLeft: enableResizing,
          topRight: enableResizing
        }}
        position={{
          x: widget.x,
          y: widget.y
        }}
        size={{
          width: widget.width,
          height: widget.height
        }}
        onDragStart={() => {
          this.props.selectWidget(widget.id)
        }}
        onDragStop={(e, data) => {
          this.props.updateWidget(widget.id, {
            x: { $set: Math.round(data.x) },
            y: { $set: Math.round(data.y) }
          })
          })
          this.updateWidgetDB(widget, {
            x: Math.round(data.x),
            y: Math.round(data.y)
          })
        }}
      >
        <div
          className={isSelected ? 'widget selected' : 'widget'}
          style={{
            boxSizing: 'content-box',
            backgroundImage: 'url(' + image + ')',
            backgroundRepeat: 'no-repeat'
          }}
        />
      </ResizeAndDrag>
    )]
  }

  /**
   * Render all the widgets by calling the right rendering function for each widget type
   * @returns {Array} containing all widgets components to be displayed
   */
  renderWidgets = () => {
    // Only render when numPage is set
    if (this.props.numPages !== null && this.props.widgets) {
      let widgets = this.props.widgets
      let elementList = []

      widgets.forEach((widget) => {
        if (widget.type === 'exam_widget') {
          elementList = elementList.concat(this.renderExamWidget(widget))
        } else if (widget.type === 'problem_widget') {
          elementList = elementList.concat(this.renderProblemWidget(widget))
        }
      })

      return elementList
    }
    }
  }
  }


Original line number Original line Diff line number Diff line
@@ -30,6 +30,7 @@ class Grade extends React.Component {
    // update the tooltips for the associated widgets (in render()).
    // update the tooltips for the associated widgets (in render()).
    this.props.bindShortcut(['left', 'h'], this.prev)
    this.props.bindShortcut(['left', 'h'], this.prev)
    this.props.bindShortcut(['right', 'l'], this.next)
    this.props.bindShortcut(['right', 'l'], this.next)
    this.props.bindShortcut(['a'], this.approve)
    this.props.bindShortcut(['shift+left', 'shift+h'], (event) => {
    this.props.bindShortcut(['shift+left', 'shift+h'], (event) => {
      event.preventDefault()
      event.preventDefault()
      this.prevUngraded()
      this.prevUngraded()
@@ -153,6 +154,20 @@ class Grade extends React.Component {
      })
      })
  }
  }


  approve = () => {
    const exam = this.props.exam
    const problem = exam.problems[this.state.pIndex]
    const optionURI = this.state.examID + '/' +
      exam.submissions[this.state.sIndex].id + '/' +
      problem.id
    api.put('solution/approve/' + optionURI, {
      graderID: this.props.graderID
    })
      .then(result => {
        this.props.updateSubmission(this.state.sIndex)
      })
  }

  toggleFullPage = (event) => {
  toggleFullPage = (event) => {
    this.setState({
    this.setState({
      fullPage: event.target.checked
      fullPage: event.target.checked
+35 −0
Original line number Original line Diff line number Diff line
"""empty message

Revision ID: b46a2994605b
Revises: 4204f4a83863
Create Date: 2019-05-15 15:41:56.615076

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'b46a2994605b'
down_revision = '4204f4a83863'
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('mc_option',
                    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
                    sa.Column('label', sa.String(), nullable=True),
                    sa.Column('feedback_id', sa.Integer(), nullable=False),
                    sa.ForeignKeyConstraint(['feedback_id'], ['feedback_option.id'], ),
                    sa.ForeignKeyConstraint(['id'], ['widget.id'], ),
                    sa.PrimaryKeyConstraint('id')
                    )
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_table('mc_option')
    # ### end Alembic commands ###
+48 −0
Original line number Original line Diff line number Diff line
import pytest

from flask import json
from zesje.database import db, Exam, Problem, ProblemWidget


@pytest.fixture
def add_test_data(app):
    with app.app_context():
        exam1 = Exam(id=1, name='exam 1', finalized=False)
        db.session.add(exam1)
        db.session.commit()

        problem1 = Problem(id=1, name='Problem 1', exam_id=1)
        db.session.add(problem1)
        db.session.commit()

        problem_widget_1 = ProblemWidget(id=1, name='problem widget', problem_id=1, page=2,
                                         width=100, height=150, x=40, y=200, type='problem_widget')
        db.session.add(problem_widget_1)
        db.session.commit()


def test_get_exams(test_client, add_test_data):
    mc_option_1 = {
        'x': 100,
        'y': 40,
        'problem_id': 1,
        'page': 1,
        'label': 'a',
        'name': 'test'
    }
    test_client.put('/api/mult-choice/', data=mc_option_1)

    mc_option_2 = {
        'x': 100,
        'y': 40,
        'problem_id': 1,
        'page': 1,
        'label': 'a',
        'name': 'test'
    }
    test_client.put('/api/mult-choice/', data=mc_option_2)

    response = test_client.get('/api/exams/1')
    data = json.loads(response.data)

    assert len(data['problems'][0]['mc_options']) == 2
+155 −0
Original line number Original line Diff line number Diff line
import pytest

from flask import json

from zesje.database import db, Exam, Problem, ProblemWidget


@pytest.fixture
def add_test_data(app):
    with app.app_context():
        exam1 = Exam(id=1, name='exam 1', finalized=False)
        exam2 = Exam(id=2, name='exam 2', finalized=True)
        exam3 = Exam(id=3, name='exam 3', finalized=False)

        db.session.add(exam1)
        db.session.add(exam2)
        db.session.add(exam3)

        problem1 = Problem(id=1, name='Problem 1', exam_id=1)
        problem2 = Problem(id=2, name='Problem 2', exam_id=2)
        problem3 = Problem(id=3, name='Problem 3', exam_id=3)

        db.session.add(problem1)
        db.session.add(problem2)
        db.session.add(problem3)

        problem_widget_1 = ProblemWidget(id=1, name='problem widget', problem_id=1, page=2,
                                         width=100, height=150, x=40, y=200, type='problem_widget')
        db.session.add(problem_widget_1)

        db.session.commit()


def mco_json():
    return {
        'x': 100,
        'y': 40,
        'problem_id': 1,
        'page': 1,
        'label': 'a',
        'name': 'test'
    }


'''
ACTUAL TESTS
'''


def test_not_present(test_client, add_test_data):
    result = test_client.get('/api/mult-choice/1')
    data = json.loads(result.data)

    assert data['status'] == 404


def test_add(test_client, add_test_data):
    req = mco_json()
    response = test_client.put('/api/mult-choice/', data=req)

    data = json.loads(response.data)

    assert data['message'] == 'New multiple choice question with id 2 inserted. ' \
        + 'New feedback option with id 1 inserted.'

    assert data['mult_choice_id'] == 2
    assert data['status'] == 200


def test_add_get(test_client, add_test_data):
    req = mco_json()

    response = test_client.put('/api/mult-choice/', data=req)
    data = json.loads(response.data)

    id = data['mult_choice_id']

    result = test_client.get(f'/api/mult-choice/{id}')
    data = json.loads(result.data)

    exp_resp = {
        'id': 2,
        'name': 'test',
        'x': 100,
        'y': 40,
        'type': 'mcq_widget',
        'feedback_id': 1,
        'label': 'a',
    }

    assert exp_resp == data


def test_update_put(test_client, add_test_data):
    req = mco_json()

    response = test_client.put('/api/mult-choice/', data=req)
    data = json.loads(response.data)
    id = data['mult_choice_id']

    req2 = {
        'x': 120,
        'y': 50,
        'problem_id': 4,
        'page': 1,
        'label': 'b',
        'name': 'test'
    }

    result = test_client.patch(f'/api/mult-choice/{id}', data=req2)
    data = json.loads(result.data)

    assert data['status'] == 200


def test_delete(test_client, add_test_data):
    req = mco_json()

    response = test_client.put('/api/mult-choice/', data=req)
    data = json.loads(response.data)
    id = data['mult_choice_id']

    response = test_client.delete(f'/api/mult-choice/{id}')
    data = json.loads(response.data)

    assert data['status'] == 200


def test_delete_not_present(test_client, add_test_data):
    id = 100

    response = test_client.delete(f'/api/mult-choice/{id}')
    data = json.loads(response.data)

    assert data['status'] == 404


def test_delete_finalized_exam(test_client, add_test_data):
    mc_option_json = {
        'x': 100,
        'y': 40,
        'problem_id': 2,
        'page': 1,
        'label': 'a',
        'name': 'test'
    }

    response = test_client.put('/api/mult-choice/', data=mc_option_json)
    data = json.loads(response.data)
    mc_id = data['mult_choice_id']

    response = test_client.delete(f'/api/mult-choice/{mc_id}')
    data = json.loads(response.data)

    assert data['status'] == 401
Original line number Original line Diff line number Diff line
@@ -2,8 +2,41 @@ import os


import pytest
import pytest


from flask import Flask
from zesje.api import api_bp
from zesje.database import db



# Adapted from https://stackoverflow.com/a/46062148/1062698
# Adapted from https://stackoverflow.com/a/46062148/1062698
@pytest.fixture
@pytest.fixture
def datadir():
def datadir():
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')


@pytest.fixture(scope="module")
def app():
    app = Flask(__name__, static_folder=None)

    app.config.update(
        SQLALCHEMY_DATABASE_URI='sqlite:///:memory:',
        SQLALCHEMY_TRACK_MODIFICATIONS=False  # Suppress future deprecation warning
    )
    db.init_app(app)

    with app.app_context():
        db.create_all()

    app.register_blueprint(api_bp, url_prefix='/api')

    return app


@pytest.fixture
def test_client(app):
    client = app.test_client()

    yield client

    with app.app_context():
        db.drop_all()
        db.create_all()

tests/data/.gitignore

0 → 100644
+1 −0
Original line number Original line Diff line number Diff line
submissions
 No newline at end of file
Original line number Original line Diff line number Diff line
@@ -106,6 +106,19 @@ def test_generate_pdfs_num_files(datadir, tmpdir):
    assert len(tmpdir.listdir()) == num_copies
    assert len(tmpdir.listdir()) == num_copies




@pytest.mark.parametrize('checkboxes', [[(300, 100, 1, 'c'), (500, 50, 0, 'd'), (500, 500, 0, 'a'), (250, 200, 1, 'b')],
                         [], [(250, 100, 0, None)]])
def test_generate_checkboxes(datadir, tmpdir, checkboxes):
    blank_pdf = os.path.join(datadir, 'blank-a4-2pages.pdf')

    num_copies = 1
    copy_nums = range(num_copies)
    paths = map(lambda copy_num: os.path.join(tmpdir, f'{copy_num}.pdf'), copy_nums)
    pdf_generation.generate_pdfs(blank_pdf, 'ABCDEFGHIJKL', copy_nums, paths, 25, 270, 150, 270, checkboxes)

    assert len(tmpdir.listdir()) == num_copies


@pytest.mark.parametrize('name', ['a4', 'square'], ids=['a4', 'square'])
@pytest.mark.parametrize('name', ['a4', 'square'], ids=['a4', 'square'])
def test_join_pdfs(mock_generate_datamatrix, mock_generate_id_grid,
def test_join_pdfs(mock_generate_datamatrix, mock_generate_id_grid,
                   datadir, tmpdir, name):
                   datadir, tmpdir, name):
+50 −0
Original line number Original line Diff line number Diff line
import os
import pytest
from PIL import Image
import numpy as np
from zesje import pregrader
from zesje import scans
from zesje import images

directory_name = "checkboxes"


@pytest.fixture
def scanned_image(datadir):
    image_filename = os.path.join(datadir, directory_name, "scanned_page.jpg")
    image = Image.open(image_filename)
    image = np.array(image)
    return image


@pytest.fixture
def scanned_image_keypoints(scanned_image):
    corner_markers = scans.find_corner_marker_keypoints(scanned_image)
    fixed_corner_keypoints = images.fix_corner_markers(corner_markers, scanned_image.shape)
    return fixed_corner_keypoints


@pytest.mark.parametrize('box_coords, result', [((346, 479), True), ((370, 479), False), ((393, 479), True),
                                                ((416, 479), True), ((439, 479), True), ((155, 562), True)],
                         ids=["1 filled", "2 empty", "3 marked with line", "4 completely filled",
                              "5 marked with an x", "e marked with a cirle inside"])
def test_ideal_crops(box_coords, result, scanned_image_keypoints, scanned_image):
    assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result


@pytest.mark.parametrize('box_coords, result', [((341, 471), True), ((352, 482), True), ((448, 482), True),
                                                ((423, 474), True), ((460, 475), False), ((477, 474), True),
                                                ((87, 548), False)],
                         ids=["1 filled bottom right", "1 filled top left", "5 filled with a bit of 6",
                              "4 fully filled with the label", "6 empty with label",
                              "7 partially  cropped, filled and a part of 6", "B empty with cb at the bottom"])
def test_shifted_crops(box_coords, result, scanned_image_keypoints, scanned_image):
    assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result


@pytest.mark.parametrize('box_coords, result', [((60, 562), True), ((107, 562), True),
                                                ((131, 562), False)],
                         ids=["A filled with trailing letter", "C filled with letters close",
                              "D blank with trailing letter"])
def test_trailing_text(box_coords, result, scanned_image_keypoints, scanned_image):
    assert pregrader.box_is_filled(box_coords, scanned_image, scanned_image_keypoints[0]) == result
+48 −0
Original line number Original line Diff line number Diff line
import cv2
import os
import numpy as np
import pytest

from zesje.images import fix_corner_markers
from zesje.scans import find_corner_marker_keypoints


@pytest.mark.parametrize(
    'shape,corners,expected',
    [((240, 200, 3), [(50, 50), (120, 50), (50, 200)], (120, 200)),
        ((240, 200, 3), [(120, 50), (50, 200), (120, 200)], (50, 50))],
    ids=["", ""])
def test_three_straight_corners(shape, corners, expected):
    corner_markers = fix_corner_markers(corners, shape)
    assert expected in corner_markers


def test_pdf(datadir):
    # Max deviation of inferred corner marker and actual location
    epsilon = 2

    # Scan rotated image with 4 corner markers
    image_filename1 = 'a4-rotated.png'
    image_path = os.path.join(datadir, 'cornermarkers', image_filename1)
    page_img = cv2.imread(image_path)

    corners1 = find_corner_marker_keypoints(page_img)

    # Scan the same image with 3 corner markers
    image_filename2 = 'a4-rotated-3-markers.png'
    image_path = os.path.join(datadir, 'cornermarkers', image_filename2)
    page_img = cv2.imread(image_path)

    corners2 = find_corner_marker_keypoints(page_img)

    # Get marker that was removed
    diff = [corner for corner in corners1 if corner not in corners2]
    diff_marker = min(diff)

    fixed_corners2 = fix_corner_markers(corners2, page_img.shape)
    added_marker = [corner for corner in fixed_corners2 if corner not in corners1][0]

    # Check if 'inferred' corner marker is not too far away
    dist = np.linalg.norm(np.subtract(added_marker, diff_marker))

    assert dist < epsilon
Original line number Original line Diff line number Diff line
@@ -8,9 +8,11 @@ from .students import Students
from .submissions import Submissions
from .submissions import Submissions
from .problems import Problems
from .problems import Problems
from .feedback import Feedback
from .feedback import Feedback
from .solutions import Solutions
from .solutions import Solutions, Approve
from .widgets import Widgets
from .widgets import Widgets
from .emails import EmailTemplate, RenderedEmailTemplate, Email
from .emails import EmailTemplate, RenderedEmailTemplate, Email
from .mult_choice import MultipleChoice

from . import signature
from . import signature
from . import images
from . import images
from . import summary_plot
from . import summary_plot
@@ -48,7 +50,11 @@ api.add_resource(RenderedEmailTemplate,
api.add_resource(Email,
api.add_resource(Email,
                 '/email/<int:exam_id>',
                 '/email/<int:exam_id>',
                 '/email/<int:exam_id>/<int:student_id>')
                 '/email/<int:exam_id>/<int:student_id>')

api.add_resource(Approve,
                 '/solution/approve/<int:exam_id>/<int:submission_id>/<int:problem_id>')
api.add_resource(MultipleChoice,
                 '/mult-choice/<int:id>',
                 '/mult-choice/')


# Other resources that don't return JSON
# Other resources that don't return JSON
# It is possible to get flask_restful to work with these, but not
# It is possible to get flask_restful to work with these, but not
+78 −30
Original line number Original line Diff line number Diff line
@@ -9,9 +9,11 @@ from flask_restful import Resource, reqparse
from werkzeug.datastructures import FileStorage
from werkzeug.datastructures import FileStorage
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import selectinload


from ..pdf_generation import generate_pdfs, output_pdf_filename_format, join_pdfs, page_is_size

from ..pdf_generation import generate_pdfs, output_pdf_filename_format, join_pdfs, page_is_size, make_pages_even
from ..database import db, Exam, ExamWidget, Submission
from ..database import db, Exam, ExamWidget, Submission



PAGE_FORMATS = {
PAGE_FORMATS = {
    "A4": (595.276, 841.89),
    "A4": (595.276, 841.89),
    "US letter": (612, 792),
    "US letter": (612, 792),
@@ -25,6 +27,33 @@ def _get_exam_dir(exam_id):
    )
    )




def get_cb_data_for_exam(exam):
    """
    Returns all multiple choice question check boxes for one specific exam

    Parameters
    ----------
        exam: the exam

    Returns
    -------
        A list of tuples with checkbox data.
        Each tuple is represented as (x, y, page, label)

        Where
        x: x position
        y: y position
        page: page number
        label: checkbox label
    """
    cb_data = []
    for problem in exam.problems:
        page = problem.widget.page
        cb_data += [(cb.x, cb.y, page, cb.label) for cb in problem.mc_options]

    return cb_data


class Exams(Resource):
class Exams(Resource):


    def get(self, exam_id=None):
    def get(self, exam_id=None):
@@ -171,8 +200,22 @@ class Exams(Resource):
                        'y': prob.widget.y,
                        'y': prob.widget.y,
                        'width': prob.widget.width,
                        'width': prob.widget.width,
                        'height': prob.widget.height,
                        'height': prob.widget.height,
                        'type': prob.widget.type
                    },
                    },
                    'graded': any([sol.graded_by is not None for sol in prob.solutions])
                    'graded': any([sol.graded_by is not None for sol in prob.solutions]),
                    'mc_options': [
                        {
                            'id': mc_option.id,
                            'label': mc_option.label,
                            'feedback_id': mc_option.feedback_id,
                            'widget': {
                                'name': mc_option.name,
                                'x': mc_option.x,
                                'y': mc_option.y,
                                'type': mc_option.type
                            }
                        } for mc_option in prob.mc_options
                    ]
                } for prob in exam.problems  # Sorted by prob.id
                } for prob in exam.problems  # Sorted by prob.id
            ],
            ],
            'widgets': [
            'widgets': [
@@ -181,6 +224,7 @@ class Exams(Resource):
                    'name': widget.name,
                    'name': widget.name,
                    'x': widget.x,
                    'x': widget.x,
                    'y': widget.y,
                    'y': widget.y,
                    'type': widget.type
                } for widget in exam.widgets  # Sorted by widget.id
                } for widget in exam.widgets  # Sorted by widget.id
            ],
            ],
            'finalized': exam.finalized,
            'finalized': exam.finalized,
@@ -243,10 +287,9 @@ class Exams(Resource):


        exam_dir = _get_exam_dir(exam.id)
        exam_dir = _get_exam_dir(exam.id)
        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)


        pdf_data.save(pdf_path)
        make_pages_even(pdf_path, args['pdf'])


        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")


@@ -332,13 +375,16 @@ 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)

        generate_pdfs(
        generate_pdfs(
            exam_path,
            exam_path,
            exam.token,
            exam.token,
            copy_nums,
            copy_nums,
            pdf_paths,
            pdf_paths,
            student_id_widget.x, student_id_widget.y,
            student_id_widget.x, student_id_widget.y,
            barcode_widget.x, barcode_widget.y
            barcode_widget.x, barcode_widget.y,
            cb_data
        )
        )


    post_parser = reqparse.RequestParser()
    post_parser = reqparse.RequestParser()
@@ -488,13 +534,15 @@ 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)
        generate_pdfs(
        generate_pdfs(
            exam_path,
            exam_path,
            exam.token[:5] + 'PREVIEW',
            exam.token[:5] + 'PREVIEW',
            [1519],
            [1519],
            [output_file],
            [output_file],
            student_id_widget.x, student_id_widget.y,
            student_id_widget.x, student_id_widget.y,
            barcode_widget.x, barcode_widget.y
            barcode_widget.x, barcode_widget.y,
            cb_data
        )
        )


        output_file.seek(0)
        output_file.seek(0)
Original line number Original line Diff line number Diff line
@@ -125,6 +125,9 @@ class Feedback(Resource):
        problem = fb.problem
        problem = fb.problem
        if problem.id != problem_id:
        if problem.id != problem_id:
            return dict(status=409, message="Feedback does not match the problem."), 409
            return dict(status=409, message="Feedback does not match the problem."), 409
        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)


@@ -137,4 +140,10 @@ class Feedback(Resource):
                solution.grader_id = None
                solution.grader_id = None
                solution.graded_at = None
                solution.graded_at = None


        # Delete mc_options associated with this feedback option
        if fb.mc_option:
            db.session.delete(fb.mc_option)

        db.session.commit()
        db.session.commit()

        return dict(status=200, message=f"Feedback option with id {feedback_id} deleted."), 200
+172 −0
Original line number Original line Diff line number Diff line
from flask_restful import Resource, reqparse

from ..database import db, MultipleChoiceOption, FeedbackOption


def set_mc_data(mc_entry, name, x, y, mc_type, feedback_id, label):
    """Sets the data of a MultipleChoiceOption ORM object.

    Parameters:
    -----------
    mc_entry: The MultipleChoiceOption object
    name: The name of the MultipleChoiceOption widget
    x: the x-position of the MultipleChoiceOption object.
    y: the y-position of the MultipleChoiceOption object.
    type: the polymorphic type used to distinguish the MultipleChoiceOption widget
        from other widgets
    feedback_id: the feedback the MultipleChoiceOption refers to
    label: label for the checkbox that this MultipleChoiceOption represents
    """
    mc_entry.name = name
    mc_entry.x = x
    mc_entry.y = y
    mc_entry.type = mc_type
    mc_entry.feedback_id = feedback_id
    mc_entry.label = label


class MultipleChoice(Resource):

    put_parser = reqparse.RequestParser()

    # Arguments that have to be supplied in the request body
    put_parser.add_argument('name', type=str, required=True)
    put_parser.add_argument('x', type=int, required=True)
    put_parser.add_argument('y', type=int, required=True)
    put_parser.add_argument('label', type=str, required=False)
    put_parser.add_argument('fb_description', type=str, required=False)
    put_parser.add_argument('fb_score', type=str, required=False)
    put_parser.add_argument('problem_id', type=int, required=True)  # Used for FeedbackOption

    def put(self):
        """Adds a multiple choice option to the database

        For each new multiple choice option, a feedback option that links to
        the multiple choice option is inserted into the database. The new
        feedback option also refers to same problem as the MultipleChoiceOption
        """
        args = self.put_parser.parse_args()

        # Get request arguments
        name = args['name']
        x = args['x']
        y = args['y']
        label = args['label']
        fb_description = args['fb_description']
        fb_score = args['fb_score']
        problem_id = args['problem_id']

        mc_type = 'mcq_widget'

        # Insert new empty feedback option that links to the same problem
        new_feedback_option = FeedbackOption(problem_id=problem_id, text=label,
                                             description=fb_description, score=fb_score)
        db.session.add(new_feedback_option)
        db.session.commit()

        # Insert new entry into the database
        mc_entry = MultipleChoiceOption()
        set_mc_data(mc_entry, name, x, y, mc_type, new_feedback_option.id, label)

        db.session.add(mc_entry)
        db.session.commit()

        return dict(status=200, mult_choice_id=mc_entry.id, feedback_id=new_feedback_option.id,
                    message=f'New multiple choice question with id {mc_entry.id} inserted. '
                            + f'New feedback option with id {new_feedback_option.id} inserted.'), 200

    def get(self, id):
        """Fetches multiple choice option from the database

        Parameters
        ----------
            id: The ID of the multiple choice option in the database

        Returns
        -------
            A JSON object with the multiple choice option data
        """
        mult_choice = MultipleChoiceOption.query.get(id)

        if not mult_choice:
            return dict(status=404, message=f'Multiple choice question with id {id} does not exist.'), 404

        json = {
            'id': mult_choice.id,
            'name': mult_choice.name,
            'x': mult_choice.x,
            'y': mult_choice.y,
            'type': mult_choice.type,
            'feedback_id': mult_choice.feedback_id
        }

        # Nullable database fields
        if mult_choice.label:
            json['label'] = mult_choice.label

        return json

    def patch(self, id):
        """
        Updates a multiple choice option

        Parameters
        ----------
            id: The id of the multiple choice option in the database.s
        """
        args = self.put_parser.parse_args()

        name = args['name']
        x = args['x']
        y = args['y']
        label = args['label']
        mc_type = 'mcq_widget'

        mc_entry = MultipleChoiceOption.query.get(id)

        if not mc_entry:
            return dict(status=404, message=f"Multiple choice question with id {id} does not exist"), 404

        set_mc_data(mc_entry, name, x, y, mc_type, mc_entry.feedback_id, label)
        db.session.commit()

        return dict(status=200, message=f'Multiple choice question with id {id} updated'), 200

    def delete(self, id):
        """Deletes a multiple choice option from the database.
        Also deletes the associated feedback option with this multiple choice option.

        An error will be thrown if the user tries to delete a feedback option
        associated with a multiple choice option in a finalized exam.

        Parameters
        ----------
            id: The ID of the multiple choice option in the database

        Returns
        -------
            A message indicating success or failure
        """
        mult_choice = MultipleChoiceOption.query.get(id)

        if not mult_choice:
            return dict(status=404, message=f'Multiple choice question with id {id} does not exist.'), 404

        if not mult_choice.feedback:
            return dict(status=404, message=f'Multiple choice question with id {id}'
                        + ' is not associated with a feedback option.'), 404

        # Check if the exam is finalized, do not delete the multiple choice option otherwise
        exam = mult_choice.feedback.problem.exam

        if 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(mult_choice)
        db.session.delete(mult_choice.feedback)
        db.session.commit()

        return dict(status=200, mult_choice_id=id, feedback_id=mult_choice.feedback_id,
                    message=f'Multiple choice question with id {id} deleted.'
                    + f'Feedback option with id {mult_choice.feedback_id} deleted.'), 200
Original line number Original line Diff line number Diff line
@@ -108,6 +108,9 @@ class Problems(Resource):
            # Delete all solutions associated with this problem
            # Delete all solutions associated with this problem
            for sol in problem.solutions:
            for sol in problem.solutions:
                db.session.delete(sol)
                db.session.delete(sol)
            # Delete all multiple choice options associated with this problem
            for mc_option in problem.mc_options:
                db.session.delete(mc_option)
            db.session.delete(problem.widget)
            db.session.delete(problem.widget)
            db.session.delete(problem)
            db.session.delete(problem)
            db.session.commit()
            db.session.commit()
Original line number Original line Diff line number Diff line
@@ -147,3 +147,43 @@ class Solutions(Resource):
        db.session.commit()
        db.session.commit()


        return {'state': state}
        return {'state': state}


class Approve(Resource):
    """ add just a grader to a specifc problem on an exam """
    put_parser = reqparse.RequestParser()
    put_parser.add_argument('graderID', type=int, required=True)

    def put(self, exam_id, submission_id, problem_id):
        """Takes an existing feedback checks if it is valid then gives the current graders id to the solution this is
        usefull for approving pre graded solutions

        Parameters
        ----------
            graderID: int

        Returns
        -------
            state: boolean
        """
        args = self.put_parser.parse_args()

        grader = Grader.query.get(args.graderID)

        sub = Submission.query.filter(Submission.exam_id == exam_id,
                                      Submission.copy_number == submission_id).one_or_none()
        if sub is None:
            return dict(status=404, message='Submission does not exist.'), 404

        solution = Solution.query.filter(Solution.submission_id == sub.id,
                                         Solution.problem_id == problem_id).one_or_none()
        if solution is None:
            return dict(status=404, message='Solution does not exist.'), 404

        graded = len(solution.feedback)

        if graded:
            solution.graded_at = datetime.now()
            solution.graded_by = grader

        return {'state': graded}
Original line number Original line Diff line number Diff line
@@ -8,6 +8,7 @@ from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Foreign
from flask_sqlalchemy.model import BindMetaMixin, Model
from flask_sqlalchemy.model import BindMetaMixin, Model
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
from sqlalchemy.orm.session import object_session
from sqlalchemy.orm.session import object_session
from sqlalchemy.ext.hybrid import hybrid_property




# Class for NOT automatically determining table names
# Class for NOT automatically determining table names
@@ -100,6 +101,10 @@ class Problem(db.Model):
    solutions = db.relationship('Solution', backref='problem', lazy=True)
    solutions = db.relationship('Solution', backref='problem', lazy=True)
    widget = db.relationship('ProblemWidget', backref='problem', uselist=False, lazy=True)
    widget = db.relationship('ProblemWidget', backref='problem', uselist=False, lazy=True)


    @hybrid_property
    def mc_options(self):
        return [feedback_option.mc_option for feedback_option in self.feedback_options if feedback_option.mc_option]



class FeedbackOption(db.Model):
class FeedbackOption(db.Model):
    """feedback option"""
    """feedback option"""
@@ -109,6 +114,7 @@ class FeedbackOption(db.Model):
    text = Column(Text, nullable=False)
    text = Column(Text, nullable=False)
    description = Column(Text, nullable=True)
    description = Column(Text, nullable=True)
    score = Column(Integer, nullable=True)
    score = Column(Integer, nullable=True)
    mc_option = db.relationship('MultipleChoiceOption', backref='feedback', cascade='delete', uselist=False, lazy=True)




# Table for many to many relationship of FeedbackOption and Solution
# Table for many to many relationship of FeedbackOption and Solution
@@ -160,6 +166,18 @@ class Widget(db.Model):
    }
    }




class MultipleChoiceOption(Widget):
    __tablename__ = 'mc_option'
    id = Column(Integer, ForeignKey('widget.id'), primary_key=True, autoincrement=True)

    label = Column(String, nullable=True)
    feedback_id = Column(Integer, ForeignKey('feedback_option.id'), nullable=False)

    __mapper_args__ = {
        'polymorphic_identity': 'mcq_widget'
    }


class ExamWidget(Widget):
class ExamWidget(Widget):
    __tablename__ = 'exam_widget'
    __tablename__ = 'exam_widget'
    id = Column(Integer, ForeignKey('widget.id'), primary_key=True, nullable=False)
    id = Column(Integer, ForeignKey('widget.id'), primary_key=True, nullable=False)
+96 −0
Original line number Original line Diff line number Diff line
@@ -2,6 +2,8 @@


import numpy as np
import numpy as np


from operator import sub, add



def guess_dpi(image_array):
def guess_dpi(image_array):
    h, *_ = image_array.shape
    h, *_ = image_array.shape
@@ -34,3 +36,97 @@ def get_box(image_array, box, padding=0.3):
    top, bottom = max(0, min(box[0], h)), max(1, min(box[1], h))
    top, bottom = max(0, min(box[0], h)), max(1, min(box[1], h))
    left, right = max(0, min(box[2], w)), max(1, min(box[3], w))
    left, right = max(0, min(box[2], w)), max(1, min(box[3], w))
    return image_array[top:bottom, left:right]
    return image_array[top:bottom, left:right]


def fix_corner_markers(corner_keypoints, shape):
    """
    Corrects the list of corner markers if only three corner markers are found.
    This function raises if less than three corner markers are detected.

    Parameters
    ----------
    corner_keypoints :
        List of corner marker locations as tuples
    shape :
        Shape of the image in (x, y, dim)

    Returns
    -------
    corner_keypoints :
        A list of four corner markers.
    """

    if len(corner_keypoints) == 4:
        return corner_keypoints

    if len(corner_keypoints) < 3:
        raise RuntimeError("Fewer then 3 corner markers found")

    x_sep = shape[1] / 2
    y_sep = shape[0] / 2

    top_left = [(x, y) for x, y in corner_keypoints if x < x_sep and y < y_sep]
    bottom_left = [(x, y) for x, y in corner_keypoints if x < x_sep and y > y_sep]
    top_right = [(x, y) for x, y in corner_keypoints if x > x_sep and y < y_sep]
    bottom_right = [(x, y) for x, y in corner_keypoints if x > x_sep and y > y_sep]

    missing_point = ()

    if not top_left:
        # Top left point is missing
        (dx, dy) = tuple(map(sub, top_right[0], bottom_right[0]))
        missing_point = tuple(map(add, bottom_left[0], (dx, dy)))

    elif not bottom_left:
        # Bottom left point is missing
        (dx, dy) = tuple(map(sub, top_right[0], bottom_right[0]))
        missing_point = tuple(map(sub, top_left[0], (dx, dy)))

    elif not top_right:
        # Top right point is missing
        (dx, dy) = tuple(map(sub, top_left[0], bottom_left[0]))
        missing_point = tuple(map(add, bottom_right[0], (dx, dy)))

    elif not bottom_right:
        # bottom right
        (dx, dy) = tuple(map(sub, top_left[0], bottom_left[0]))
        missing_point = tuple(map(sub, top_right[0], (dx, dy)))

    corner_keypoints.append(missing_point)
    return corner_keypoints


def box_is_filled(image_array, box_coords, padding=0.3, threshold=150, pixels=False):
    """
    Determines if a box is filled

    Parameters:
    -----------
    image_array : 2D or 3D array
        The image source.
    box_coords : 4 floats (top, bottom, left, right)
        Coordinates of the bounding box in inches or pixels. By due to differing
        traditions, box coordinates are counted from the bottom left of the
        image, while image array coordinates are from the top left.
    padding : float
        Padding around box borders in inches.
    threshold : int
        Optional threshold value to determine minimal 'darkness'
        to consider a box to be filled in
    pixels : boolean
        Whether the box coordinates are entered as pixels instead of inches.
    """

    # Divide by DPI if pixel coordinates are used
    if pixels:
        box_coords /= guess_dpi(image_array)

    box_img = get_box(image_array, box_coords, padding)

    # Check if the coordinates are outside of the image
    if box_img.size == 0:
        raise RuntimeError("Box coordinates are outside of image")

    avg = np.average(box_img)

    return avg < threshold
Original line number Original line Diff line number Diff line
@@ -12,7 +12,7 @@ output_pdf_filename_format = '{0:05d}.pdf'




def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
                  id_grid_y, datamatrix_x, datamatrix_y):
                  id_grid_y, datamatrix_x, datamatrix_y, cb_data=None):
    """
    """
    Generate the final PDFs from the original exam PDF.
    Generate the final PDFs from the original exam PDF.


@@ -24,7 +24,6 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
    If maximum interchangeability with version 1 QR codes is desired (error
    If maximum interchangeability with version 1 QR codes is desired (error
    correction level M), use exam IDs composed of only uppercase letters, and
    correction level M), use exam IDs composed of only uppercase letters, and
    composed of at most 12 letters.
    composed of at most 12 letters.

    Parameters
    Parameters
    ----------
    ----------
    exam_pdf_file : file object or str
    exam_pdf_file : file object or str
@@ -43,6 +42,9 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
        The x coordinate where the DataMatrix code should be placed
        The x coordinate where the DataMatrix code should be placed
    datamatrix_y : int
    datamatrix_y : int
        The y coordinate where the DataMatrix code should be placed
        The y coordinate where the DataMatrix code should be placed
    cb_data : list[ (int, int, int, str)]
        The data needed for drawing a checkbox, namely: the x coordinate; y coordinate; page number and label

    """
    """
    exam_pdf = PdfReader(exam_pdf_file)
    exam_pdf = PdfReader(exam_pdf_file)
    mediabox = exam_pdf.pages[0].MediaBox
    mediabox = exam_pdf.pages[0].MediaBox
@@ -56,7 +58,7 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
            overlay_canv = canvas.Canvas(overlay_file.name, pagesize=pagesize)
            overlay_canv = canvas.Canvas(overlay_file.name, pagesize=pagesize)
            _generate_overlay(overlay_canv, pagesize, exam_id, copy_num,
            _generate_overlay(overlay_canv, pagesize, exam_id, copy_num,
                              len(exam_pdf.pages), id_grid_x, id_grid_y,
                              len(exam_pdf.pages), id_grid_x, id_grid_y,
                              datamatrix_x, datamatrix_y)
                              datamatrix_x, datamatrix_y, cb_data)
            overlay_canv.save()
            overlay_canv.save()


            # Merge overlay and exam
            # Merge overlay and exam
@@ -151,6 +153,38 @@ def generate_id_grid(canv, x, y):
              textboxwidth, textboxheight)
              textboxwidth, textboxheight)




def generate_checkbox(canvas, x, y, label):
    """
    draw a checkbox and draw a  singel character label ontop of the checkbox

    Parameters
    ----------
    canvas : reportlab canvas object

    x : int
        the x coordinate of the top left corner of the box in points (pt)
    y : int
        the y coordinate of the top left corner of the box in points (pt)
    label: str
        A string representing the label that is drawn on top of the box, will only take the first character

    """
    fontsize = 11  # Size of font
    margin = 5  # Margin between elements and sides
    markboxsize = fontsize - 2  # Size of checkboxes boxes
    x_label = x + 1  # location of the label
    y_label = y + margin  # remove fontsize from the y label since we draw from the bottom left up
    box_y = y - markboxsize  # remove the markboxsize because the y is the coord of the top
    # and reportlab prints from the bottom

    # check that there is a label to print
    if (label and not (len(label) == 0)):
        canvas.setFont('Helvetica', fontsize)
        canvas.drawString(x_label, y_label, label[0])

    canvas.rect(x, box_y, markboxsize, markboxsize)


def generate_datamatrix(exam_id, page_num, copy_num):
def generate_datamatrix(exam_id, page_num, copy_num):
    """
    """
    Generates a DataMatrix code to be used on a page.
    Generates a DataMatrix code to be used on a page.
@@ -187,7 +221,7 @@ def generate_datamatrix(exam_id, page_num, copy_num):




def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
                      id_grid_y, datamatrix_x, datamatrix_y):
                      id_grid_y, datamatrix_x, datamatrix_y, cb_data=None):
    """
    """
    Generates an overlay ('watermark') PDF, which can then be overlaid onto
    Generates an overlay ('watermark') PDF, which can then be overlaid onto
    the exam PDF.
    the exam PDF.
@@ -221,6 +255,9 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
        The x coordinate where the DataMatrix codes should be placed
        The x coordinate where the DataMatrix codes should be placed
    datamatrix_y : int
    datamatrix_y : int
        The y coordinate where the DataMatrix codes should be placed
        The y coordinate where the DataMatrix codes should be placed
    cb_data : list[ (int, int, int, str)]
        The data needed for drawing a checkbox, namely: the x coordinate; y coordinate; page number and label

    """
    """


    # Font settings for the copy number (printed under the datamatrix)
    # Font settings for the copy number (printed under the datamatrix)
@@ -233,6 +270,17 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
    # ID grid on first page only
    # ID grid on first page only
    generate_id_grid(canv, id_grid_x, id_grid_y)
    generate_id_grid(canv, id_grid_x, id_grid_y)


    # create index for list of checkbox data and sort the data on page
    if cb_data:
        index = 0
        max_index = len(cb_data)
        cb_data = sorted(cb_data, key=lambda tup: tup[2])
        # invert the y axis
        cb_data = [(cb[0], pagesize[1] - cb[1], cb[2], cb[3]) for cb in cb_data]
    else:
        index = 0
        max_index = 0

    for page_num in range(num_pages):
    for page_num in range(num_pages):
        _add_corner_markers_and_bottom_bar(canv, pagesize)
        _add_corner_markers_and_bottom_bar(canv, pagesize)


@@ -246,6 +294,13 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
            datamatrix_x, datamatrix_y_adjusted - fontsize,
            datamatrix_x, datamatrix_y_adjusted - fontsize,
            f" # {copy_num}"
            f" # {copy_num}"
        )
        )

        # call generate for all checkboxes that belong to the current page
        while index < max_index and cb_data[index][2] <= page_num:
            x, y, _, label = cb_data[index]
            generate_checkbox(canv, x, y, label)
            index += 1

        canv.showPage()
        canv.showPage()




@@ -335,3 +390,19 @@ def page_is_size(exam_pdf_file, shape, tolerance=0):
        pass
        pass


    return not invalid
    return not invalid


def make_pages_even(output_filename, exam_pdf_file):
    exam_pdf = PdfReader(exam_pdf_file)
    new = PdfWriter()
    new.addpages(exam_pdf.pages)
    pagecount = len(exam_pdf.pages)

    if (pagecount % 2 == 1):
        blank = PageMerge()
        box = exam_pdf.pages[0].MediaBox
        blank.mbox = box
        blank = blank.render()
        new.addpage(blank)

    new.write(output_filename)

zesje/pregrader.py

0 → 100644
+139 −0
Original line number Original line Diff line number Diff line
import cv2
import numpy as np

from .database import db, Solution
from .images import guess_dpi, get_box, fix_corner_markers


def add_feedback_to_solution(sub, exam, page, page_img, corner_keypoints):
    """
    Adds the multiple choice options that are identified as marked as a feedback option to a solution

    Parameters
    ------
    sub : Submission
        the current submission
    exam : Exam
        the current exam
    page_img : Image
        image of the page
    corner_keypoints : array
        locations of the corner keypoints as (x, y) tuples
    """
    problems_on_page = [problem for problem in exam.problems if problem.widget.page == page]

    fixed_corner_keypoints = fix_corner_markers(corner_keypoints, page_img.shape)

    x_min = min(point[0] for point in fixed_corner_keypoints)
    y_min = min(point[1] for point in fixed_corner_keypoints)
    top_left_point = (x_min, y_min)

    for problem in problems_on_page:
        sol = Solution.query.filter(Solution.problem_id == problem.id, Solution.submission_id == sub.id).one_or_none()

        for mc_option in problem.mc_options:
            box = (mc_option.x, mc_option.y)

            if box_is_filled(box, page_img, top_left_point):
                feedback = mc_option.feedback
                sol.feedback.append(feedback)
                db.session.commit()


def box_is_filled(box, page_img, corner_keypoints, marker_margin=72/2.54, threshold=225, cut_padding=0.1, box_size=11):
    """
    A function that finds the checkbox in a general area and then checks if it is filled in.

    Params
    ------
    box: (int, int)
        The coordinates of the top left (x,y) of the checkbox in points.
    page_img: np.array
        A numpy array of the image scan
    corner_keypoints: (float,float)
        The x coordinate of the left markers and the y coordinate of the top markers,
        used as point of reference since scans can deviate from the original.
        (x,y) are both in pixels.
    marker_margin: float
        The margin between the corner markers and the edge of a page when generated.
    threshold: int
        the threshold needed for a checkbox to be considered marked range is between 0 (fully black)
        and 255 (absolutely white).
    cut_padding: float
        The extra padding when retrieving an area where the checkbox is in inches.
    box_size: int
        the size of the checkbox in points.

    Output
    ------
    True if the box is marked, else False.
    """

    # shouldn't be needed, but some images are drawn a bit weirdly
    y_shift = 5
    # create an array with y top, y bottom, x left and x right. use the marker margin to allign to the page.
    coords = np.asarray([box[1] - marker_margin + y_shift, box[1] + box_size - marker_margin + y_shift,
                        box[0] - marker_margin, box[0] + box_size - marker_margin])/72

    # add the actually margin from the scan to corner markers to the coords in inches
    dpi = guess_dpi(page_img)
    coords[0] = coords[0] + corner_keypoints[1]/dpi
    coords[1] = coords[1] + corner_keypoints[1]/dpi
    coords[2] = coords[2] + corner_keypoints[0]/dpi
    coords[3] = coords[3] + corner_keypoints[0]/dpi

    # get the box where we think the box is
    cut_im = get_box(page_img, coords, padding=cut_padding)

    # convert to grayscale
    gray_im = cv2.cvtColor(cut_im, cv2.COLOR_BGR2GRAY)
    # apply threshold to only have black or white
    _, bin_im = cv2.threshold(gray_im, 150, 255, cv2.THRESH_BINARY)

    h_bin, w_bin, *_ = bin_im.shape
    # create a mask that gets applied when floodfill the white
    mask = np.zeros((h_bin+2, w_bin+2), np.uint8)
    flood_im = bin_im.copy()
    # fill the image from the top left
    cv2.floodFill(flood_im, mask, (0, 0),  0)
    # fill it from the bottom right just in case the top left doesn't cover all the white
    cv2.floodFill(flood_im, mask, (h_bin-2, w_bin-2), 0)

    # find white parts
    coords = cv2.findNonZero(flood_im)
    # Find a bounding box of the white parts
    x, y, w, h = cv2.boundingRect(coords)
    # cut the image to this box
    res_rect = bin_im[y:y+h, x:x+w]

    # the size in pixels we expect the drawn box to
    box_size_px = box_size*dpi / 72

    # if the rectangle is bigger (higher) than expected, cut the image up a bit
    if h > 1.5 * box_size_px:
        print("in h resize")
        y_partition = 0.333
        # try getting another bounding box on bottom 2/3 of the screen
        coords2 = cv2.findNonZero(flood_im[y + int(y_partition * h): y + h, x: x+w])
        x2, y2, w2, h2 = cv2.boundingRect(coords2)
        # add these coords to create a new bounding box we are looking at
        new_y = y+y2 + int(y_partition * h)
        new_x = x + x2
        res_rect = bin_im[new_y:new_y + h2, new_x:new_x + w2]

    else:
        new_x, new_y, w2, h2 = x, y, w, h

    # do the same for width
    if w2 > 1.5 * box_size_px:
        # usually the checkbox is somewhere in the bottom left of the bounding box
        coords3 = cv2.findNonZero(flood_im[new_y: new_y + h2, new_x: new_x + int(0.66 * w2)])
        x3, y3, w3, h3 = cv2.boundingRect(coords3)
        res_rect = bin_im[new_y + y3: new_y + y3 + h3, new_x + x3: new_x + x3 + w3]

    # if the found box is smaller than a certain threshold
    # it means that we only found a little bit of white and the box is filled
    res_x, res_y, *_ = res_rect.shape
    if res_x < 0.333 * box_size_px or res_y < 0.333 * box_size_px:
        return True
    return np.average(res_rect) < threshold
+19 −7
Original line number Original line Diff line number Diff line
@@ -17,7 +17,7 @@ from .database import db, Scan, Exam, Page, Student, Submission, Solution, ExamW
from .datamatrix import decode_raw_datamatrix
from .datamatrix import decode_raw_datamatrix
from .images import guess_dpi, get_box
from .images import guess_dpi, get_box
from .factory import make_celery
from .factory import make_celery

from .pregrader import add_feedback_to_solution


ExtractedBarcode = namedtuple('ExtractedBarcode', ['token', 'copy', 'page'])
ExtractedBarcode = namedtuple('ExtractedBarcode', ['token', 'copy', 'page'])


@@ -54,7 +54,7 @@ def process_pdf(scan_id):
        # TODO: When #182 is implemented, properly separate user-facing
        # TODO: When #182 is implemented, properly separate user-facing
        #       messages (written to DB) from developer-facing messages,
        #       messages (written to DB) from developer-facing messages,
        #       which should be written into the log.
        #       which should be written into the log.
        write_pdf_status(scan_id, 'error', "Unexpected error: " + str(error))
        write_pdf_status(scan_id, 'error', f"Unexpected error: {error}")




def _process_pdf(scan_id, app_config):
def _process_pdf(scan_id, app_config):
@@ -91,8 +91,8 @@ def _process_pdf(scan_id, app_config):
                    print(description)
                    print(description)
                    failures.append(page)
                    failures.append(page)
            except Exception as e:
            except Exception as e:
                report_error(f'Error processing page {page}: {e}')
                report_error(f'Error processing page {e}')
                return
                raise
    except Exception as e:
    except Exception as e:
        report_error(f"Failed to read pdf: {e}")
        report_error(f"Failed to read pdf: {e}")
        raise
        raise
@@ -337,7 +337,13 @@ def process_page(image_data, exam_config, output_dir=None, strict=False):
    else:
    else:
        return True, "Testing, image not saved and database not updated."
        return True, "Testing, image not saved and database not updated."


    update_database(image_path, barcode)
    sub, exam = update_database(image_path, barcode)

    try:
        add_feedback_to_solution(sub, exam, barcode.page, image_array, corner_keypoints)
    except RuntimeError as e:
        if strict:
            return False, str(e)


    if barcode.page == 0:
    if barcode.page == 0:
        description = guess_student(
        description = guess_student(
@@ -385,8 +391,12 @@ def update_database(image_path, barcode):


    Returns
    Returns
    -------
    -------
    signature_validated : bool
    sub, exam where
        If the corresponding submission has a validated signature.

    sub : Submission
        the current submission
    exam : Exam
        the current exam
    """
    """
    exam = Exam.query.filter(Exam.token == barcode.token).first()
    exam = Exam.query.filter(Exam.token == barcode.token).first()
    if exam is None:
    if exam is None:
@@ -406,6 +416,8 @@ def update_database(image_path, barcode):


    db.session.commit()
    db.session.commit()


    return sub, exam



def decode_barcode(image, exam_config):
def decode_barcode(image, exam_config):
    """Extract a barcode from a PIL Image."""
    """Extract a barcode from a PIL Image."""