Commit 2576d761 authored by Anton Akhmerov's avatar Anton Akhmerov
Browse files

Merge branch '454-create-unstructured-exam' into 'master'

Allow the creation of unstructured exams

Closes #454

See merge request !297
parents ad4f525b fe1ca8f4
Pipeline #38021 passed with stages
in 4 minutes and 18 seconds
......@@ -7,9 +7,8 @@ import 'bulma/css/bulma.css'
import 'react-bulma-notification/build/css/index.css'
import 'font-awesome/css/font-awesome.css'
import * as api from './api.jsx'
import NavBar from './components/NavBar.jsx'
import ExamRouter from './components/ExamRouter.jsx'
import Footer from './components/Footer.jsx'
import Loading from './views/Loading.jsx'
......@@ -21,64 +20,26 @@ const AddExam = Loadable({
loader: () => import('./views/AddExam.jsx'),
loading: Loading
})
const Exam = Loadable({
loader: () => import('./views/Exam.jsx'),
loading: Loading
})
const Scans = Loadable({
loader: () => import('./views/Scans.jsx'),
loading: Loading
})
const Students = Loadable({
loader: () => import('./views/Students.jsx'),
loading: Loading
})
const Grade = Loadable({
loader: () => import('./views/Grade.jsx'),
loading: Loading
})
const Graders = Loadable({
loader: () => import('./views/Graders.jsx'),
loading: Loading
})
const Overview = Loadable({
loader: () => import('./views/Overview.jsx'),
loading: Loading
})
const Email = Loadable({
loader: () => import('./views/Email.jsx'),
loading: Loading
})
const Fail = Loadable({
loader: () => import('./views/Fail.jsx'),
loading: Loading
})
const nullExam = () => ({
id: null,
name: '',
submissions: [],
problems: [],
widgets: [],
finalized: false
})
class App extends React.Component {
menu = React.createRef();
state = {
exam: nullExam(),
examID: null,
grader: null
}
updateExam = (examID) => {
if (examID === null) {
this.setState({ exam: nullExam() })
} else {
api.get('exams/' + examID)
.catch(resp => this.setState({ exam: nullExam() }))
.then(ex => this.setState({ exam: ex }))
}
selectExam = (id) => {
this.setState({ examID: parseInt(id) })
}
updateExamList = () => {
......@@ -87,28 +48,6 @@ class App extends React.Component {
}
}
deleteExam = (examID) => {
return api
.del('exams/' + examID)
.then(() => {
this.updateExamList()
})
}
updateSubmission = (submissionID) => {
api.get('submissions/' + this.state.exam.id + '/' + submissionID)
.then(sub => {
let newSubmissions = this.state.exam.submissions
const index = newSubmissions.map(sub => sub.id).indexOf(submissionID)
newSubmissions[index] = sub
this.setState({
exam: {
...this.state.exam,
submissions: newSubmissions
}
})
})
}
changeGrader = (grader) => {
this.setState({
grader: grader
......@@ -117,48 +56,30 @@ class App extends React.Component {
}
render () {
const exam = this.state.exam
const grader = this.state.grader
const updateExamList = this.menu.current ? this.menu.current.updateExamList : () => {}
const updateGraderList = this.menu.current ? this.menu.current.updateGraderList : () => {}
const setHelpPage = this.menu.current ? this.menu.current.setHelpPage : (help) => {}
return (
<Router>
<div>
<NavBar exam={exam} updateExam={this.updateExam} grader={grader} changeGrader={this.changeGrader} ref={this.menu} />
<NavBar examID={this.state.examID} grader={grader} changeGrader={this.changeGrader} ref={this.menu} />
<Switch>
<Route exact path='/' component={Home} />
<Route path='/exams/:examID' render={({ match, history }) =>
<Exam
exam={exam}
examID={match.params.examID}
updateExam={this.updateExam}
updateExamList={this.updateExamList}
deleteExam={this.deleteExam}
updateSubmission={this.updateSubmission}
leave={() => history.push('/')}
setHelpPage={this.menu.current ? this.menu.current.setHelpPage : null} />} />
<Route path='/exams' render={({ history }) =>
<AddExam updateExamList={this.menu.current ? this.menu.current.updateExamList : null} changeURL={history.push} />} />
<Route path='/scans/:examID' render={({ match }) =>
<Scans examID={match.params.examID} />}
/>
<Route path='/students/:examID' render={({ match }) =>
<Students examID={match.params.examID} />}
<Route exact path='/exams' render={({ history }) =>
<AddExam updateExamList={updateExamList} changeURL={history.push} />}
/>
<Route exact path='/grade/:examID/:submissionID?/:problemID?' render={({ match, history }) => (
grader
? <Grade examID={match.params.examID} graderID={this.state.grader.id} history={history} submissionID={match.params.submissionID} problemID={match.params.problemID} />
: <Fail message='No grader selected. Please do not bookmark URLs' />
)} />
<Route path='/overview/:examID' render={({ match }) => (
exam.submissions.length
? <Overview examID={match.params.examID} />
: <Fail message='No exams uploaded. Please do not bookmark URLs' />
)} />
<Route path='/email/:examID' render={({ match }) => (
exam.submissions.length ? <Email examID={match.params.examID} /> : <Fail message='No exams uploaded. Please do not bookmark URLs' />
)} />
<Route path='/graders' render={() =>
<Graders updateGraderList={this.menu.current ? this.menu.current.updateGraderList : null} />} />
<Route path='/exams/:examID/' render={({ match }) =>
<ExamRouter
parentMatch={match}
graderID={grader ? grader.id : null}
selectExam={this.selectExam}
updateExamList={updateExamList}
setHelpPage={setHelpPage} />
} />
<Route exact path='/graders' render={() =>
<Graders updateGraderList={updateGraderList} />} />
<Route render={() =>
<Fail message="404. Could not find that page :'(" />} />
</Switch>
......
import React from 'react'
const DropzoneContent = (props) => (
<div className='file has-name is-boxed is-centered'>
<div className={'file has-name is-boxed' + (props.center ? ' is-centered' : '')}>
<label className='file-label'>
<span className='file-cta'>
<span className='file-icon'>
......
import React from 'react'
import { Route, Switch } from 'react-router-dom'
import Loadable from 'react-loadable'
import * as api from '../api.jsx'
import Loading from '../views/Loading.jsx'
const Exam = Loadable({
loader: () => import('../views/Exam.jsx'),
loading: Loading
})
const Scans = Loadable({
loader: () => import('../views/Scans.jsx'),
loading: Loading
})
const Students = Loadable({
loader: () => import('../views/Students.jsx'),
loading: Loading
})
const Grade = Loadable({
loader: () => import('../views/Grade.jsx'),
loading: Loading
})
const Overview = Loadable({
loader: () => import('../views/Overview.jsx'),
loading: Loading
})
const Email = Loadable({
loader: () => import('../views/Email.jsx'),
loading: Loading
})
const Fail = Loadable({
loader: () => import('../views/Fail.jsx'),
loading: Loading
})
class ExamRouter extends React.PureComponent {
componentDidMount = () => {
this.props.selectExam(this.props.parentMatch.params.examID)
}
componentDidUpdate = (prevProps, prevState) => {
const examID = this.props.parentMatch.params.examID
if (prevProps.parentMatch.params.examID !== examID) {
this.props.selectExam(examID)
}
}
deleteExam = (history, examID) => {
return api
.del('exams/' + examID)
.then(() => {
this.props.updateExamList()
history.push('/')
})
}
render = () => {
const examID = this.props.parentMatch.params.examID
const parentURL = this.props.parentMatch.url
return (
<Switch>
<Route path={`${parentURL}/scans`} render={({ match }) =>
<Scans examID={examID} />}
/>
<Route path={`${parentURL}/students`} render={({ match }) =>
<Students examID={examID} />}
/>
<Route path={`${parentURL}/grade/:submissionID?/:problemID?`} render={({ match, history }) => (
this.props.graderID ? (
<Grade
examID={examID}
graderID={this.props.graderID}
history={history}
parentURL={parentURL}
submissionID={match.params.submissionID}
problemID={match.params.problemID} />
) : <Fail message='No grader selected. Please do not bookmark URLs' />
)} />
<Route path={`${parentURL}/overview`} render={({ match }) => (
<Overview examID={examID} />
)} />
<Route path={`${parentURL}/email`} render={({ match }) => (
<Email examID={examID} />
)} />
<Route path={`${parentURL}`} render={({ match, history }) =>
<Exam
examID={examID}
updateExamList={this.props.updateExamList}
deleteExam={(id) => this.deleteExam(history, id)}
setHelpPage={this.props.setHelpPage} />} />
</Switch>
)
}
}
export default ExamRouter
......@@ -2,7 +2,7 @@ import React from 'react'
const Hero = (props) => {
return (
<section className='hero is-primary is-info is-small'>
<section className={'hero is-small ' + (props.colour ? props.colour : 'is-info')}>
<div className='hero-body'>
<div className='container'>
<h1 className='title'>
......
......@@ -28,14 +28,14 @@ const TooltipLink = (props) => {
</div>
}
const ExamDropdown = (props) => (
<div className='navbar-item has-dropdown is-hoverable'>
<Link className='navbar-link' to={'/exams/' + (props.exam.id ? props.exam.id : '')}>
{props.exam.id ? <i>{props.exam.name}</i> : 'Add exam'}
const ExamDropdown = (props) => {
return (<div className='navbar-item has-dropdown is-hoverable'>
<Link className='navbar-link' to={'/exams/' + (props.selectedExam ? props.selectedExam.id : '')}>
{props.selectedExam ? <i>{props.selectedExam.name}</i> : 'Add exam'}
</Link>
<div className='navbar-dropdown'>
{props.list.map((exam) => (
<Link className={'navbar-item' + (props.exam.id === exam.id ? ' is-active' : '')}
<Link className={'navbar-item' + (props.selectedExam && props.selectedExam.id === exam.id ? ' is-active' : '')}
to={'/exams/' + exam.id} key={exam.id} >
<i>{exam.name}</i>
</Link>
......@@ -45,8 +45,8 @@ const ExamDropdown = (props) => (
Add new
</Link>
</div>
</div>
)
</div>)
}
const GraderDropdown = (props) => (
<div className='navbar-item has-dropdown is-hoverable'>
......@@ -76,7 +76,7 @@ const ExportDropdown = (props) => {
{ label: 'Zip of (anonymized) pdf files', format: 'pdf' }
]
const exportUrl = format => `/api/export/${format}/${props.exam.id}`
const exportUrl = format => `/api/export/${format}/${props.examID}`
return (
<div className='navbar-item has-dropdown is-hoverable' >
......@@ -94,7 +94,7 @@ const ExportDropdown = (props) => {
)}
<hr className='navbar-divider' />
<a className='navbar-item'
href={'/api/export/graders/' + props.exam.id}
href={'/api/export/graders/' + props.examID}
disabled={props.disabled}>
Export grader statistics
</a>
......@@ -117,29 +117,31 @@ class NavBar extends React.Component {
foldOut: false,
examList: [],
graderList: [],
helpPage: null
helpPage: null,
examID: null
}
componentDidUpdate = (prevProps, prevState) => {
if (prevProps.examID !== this.props.examID) {
this.setState({examID: this.props.examID})
}
}
componentDidMount = () => {
this.updateExamList()
this.updateGraderList()
this.setState({examID: this.props.examID}, () => this.updateExamList())
}
updateExamList = () => {
api.get('exams')
.then(exams => {
this.setState({
examList: exams
})
const examIDs = exams.map(exam => exam.id)
const examID = this.props.exam.id
if (!examIDs.includes(examID) || examID === null) {
if (!exams.length) {
this.props.updateExam(null)
} else {
this.props.updateExam(exams[exams.length - 1].id)
}
}
let exam = exams.find(exam => exam.id === this.state.examID)
if (!exam && exams.length) exam = exams[exams.length - 1]
this.setState(prevState => ({
examList: exams,
examID: exam ? exam.id : null
}))
})
}
......@@ -169,9 +171,16 @@ class NavBar extends React.Component {
}
render () {
const predicateExamNotFinalized = [!this.props.exam.finalized, 'The exam is not finalized yet.']
const predicateSubmissionsEmpty = [this.props.exam.submissions.length === 0, 'There are no submissions, please upload some.']
const predicateNoGraderSelected = [this.props.grader === null, 'Please select a grader.']
const selectedExam = this.state.examList.find(exam => exam.id === this.state.examID)
const predicateNoExam = [selectedExam === null || selectedExam === undefined,
'No exam selected.']
const predicateExamNotFinalized = [!predicateNoExam[0] && !selectedExam.finalized,
'The exam is not finalized yet.']
const predicateSubmissionsEmpty = [!predicateNoExam[0] && selectedExam.submissions.length === 0,
'There are no submissions, please upload some.']
const predicateNoGraderSelected = [this.props.grader === null,
'Please select a grader.']
return (
<nav className='navbar' role='navigation' aria-label='dropdown navigation'>
......@@ -193,28 +202,28 @@ class NavBar extends React.Component {
<div className='navbar-start'>
{this.state.examList.length
? <ExamDropdown exam={this.props.exam} list={this.state.examList} />
? <ExamDropdown selectedExam={selectedExam} list={this.state.examList} />
: <Link className='navbar-item' to='/exams'>Add exam</Link>
}
<TooltipLink
to={'/scans/' + this.props.exam.id}
to={`/exams/${this.state.examID}/scans`}
text='Scans'
predicate={[predicateExamNotFinalized]} />
<Link className='navbar-item' to={'/students/' + this.props.exam.id}>Students</Link>
predicate={[predicateNoExam, predicateExamNotFinalized]} />
<Link className='navbar-item' to={`/exams/${this.state.examID}/students`}>Students</Link>
<TooltipLink
to={'/grade/' + this.props.exam.id}
to={`/exams/${this.state.examID}/grade`}
text={<strong><i>Grade</i></strong>}
predicate={[predicateExamNotFinalized, predicateSubmissionsEmpty, predicateNoGraderSelected]} />
predicate={[predicateNoExam, predicateExamNotFinalized, predicateSubmissionsEmpty, predicateNoGraderSelected]} />
<TooltipLink
to={'/overview/' + this.props.exam.id}
to={`/exams/${this.state.examID}/overview`}
text='Overview'
predicate={[predicateExamNotFinalized, predicateSubmissionsEmpty]} />
predicate={[predicateNoExam, predicateExamNotFinalized, predicateSubmissionsEmpty]} />
<TooltipLink
to={'/email/' + this.props.exam.id}
to={`/exams/${this.state.examID}/email`}
text='Email'
predicate={[predicateExamNotFinalized, predicateSubmissionsEmpty]} />
<ExportDropdown className='navbar-item' disabled={predicateSubmissionsEmpty[0]} exam={this.props.exam} />
predicate={[predicateNoExam, predicateExamNotFinalized, predicateSubmissionsEmpty]} />
<ExportDropdown className='navbar-item' disabled={predicateSubmissionsEmpty[0]} examID={this.props.examID} />
<a className='navbar-item' onClick={() => this.setHelpPage('shortcuts')}>
{this.pages['shortcuts'].title}
</a>
......
......@@ -7,13 +7,45 @@ import * as api from '../api.jsx'
import Hero from '../components/Hero.jsx'
import DropzoneContent from '../components/DropzoneContent.jsx'
const LAYOUTS = [
{
name: 'Templated',
value: 'templated',
acceptsPDF: true,
description: 'Upload a PDF, add student ID field and page markers, and distribute to students.' +
'Supports automated student identification, blank detection, and multiple choice questions.'
},
{
name: 'Unstructured',
value: 'unstructured',
acceptsPDF: false,
description: 'Upload any PDF or image files from students and grade (no automatic scan processing).'
}
]
class Exams extends React.Component {
state = {
pdf: null,
previewPageCount: 0,
exam_name: ''
examName: '',
selectedLayout: LAYOUTS[0]
};
onChangeLayout = (event) => {
const newLayout = LAYOUTS[event.target.value]
if (!newLayout.acceptsPDF) {
this.setState({
selectedLayout: newLayout,
pdf: null,
previewPageCount: 0
})
} else {
this.setState({
selectedLayout: newLayout
})
}
}
onDropPDF = (accepted, rejected) => {
if (rejected.length > 0) {
Notification.error('Please upload a PDF.')
......@@ -29,26 +61,22 @@ class Exams extends React.Component {
})
}
changeInput = (name, regex) => {
return (event) => {
this.setState({
[name]: event.target.value
})
}
}
onUploadPDF = (event) => {
if (!this.state.exam_name) {
addExam = (event) => {
if (!this.state.examName) {
Notification.error('Please enter exam name.')
return
}
if (!this.state.pdf) {
if (this.state.selectedLayout.acceptsPDF && !this.state.pdf) {
Notification.error('Please upload a PDF.')
return
}
const data = new window.FormData()
data.append('pdf', this.state.pdf)
data.append('exam_name', this.state.exam_name)
data.append('exam_name', this.state.examName)
data.append('layout', this.state.selectedLayout.value)
if (this.state.selectedLayout.acceptsPDF) {
data.append('pdf', this.state.pdf)
}
api.post('exams', data)
.then(exam => {
this.props.updateExamList()
......@@ -62,67 +90,133 @@ class Exams extends React.Component {
render () {
const previewPages = Array.from({ length: this.state.previewPageCount }, (v, k) => k + 1).map(pageNum => {
return <div key={'preview_col_' + pageNum} className='column'>
<Page width={150} height={200} renderAnnotations={false} renderTextLayer={false} pageNumber={pageNum} />
<Page width={150} height={200}
renderAnnotations={false} renderTextLayer={false}
pageNumber={pageNum} />
</div>
})
return (
<div>
<React.Fragment>
<Hero title='Add exam' subtitle='first step' />
<section className='section'>
<div className='container'>
{this.state.pdf != null ? (
<div className='column has-text-centered'>
<h3 className='title'>Preview the PDF</h3>
<h5 className='subtitle'>{previewPages.length > 1 ? 'The first ' + previewPages.length + ' pages are shown' : 'The first page is shown'}</h5>
<Document
file={this.state.pdf}
onLoadSuccess={this.onDocumentLoad}
>
<div className='columns'>
{previewPages}
<div className='field is-horizontal'>
<div className='field-label'>
<label className='label'>Name</label>
</div>
<div className='field-body'>
<div className='field'>
<div className='control'>
<input
className='input'
placeholder='Exam name'
value={this.state.examName}
required
onChange={(e) => this.setState({examName: e.target.value})} />
</div>
</Document>
</div>
</div>
) : (
<div className='column has-text-centered'>
<h3 className='title'>Upload new exam PDF</h3>
<h5 className='subtitle'>a preview will be shown</h5>
<Dropzone accept='.pdf, application/pdf'
style={{}} activeStyle={{ borderStyle: 'dashed', width: 'fit-content', margin: 'auto' }}
onDrop={this.onDropPDF}
disablePreview
multiple={false}
>
<DropzoneContent />
</Dropzone>
</div>
<div className='field is-horizontal'>
<div className='field-label'>