Commit 8b60d031 authored by Richard's avatar Richard

Merge branch 'develop' of ssh://gitlab.kwant-project.org:443/works-on-my-machine/zesje into develop

parents 9203d4d1 d8ae8760
Pipeline #18743 passed with stages
in 4 minutes and 17 seconds
.flex-parent {
display: flex;
align-items: center;
}
.flex-child.truncated {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.flex-child.fixed {
white-space: nowrap;
}
......@@ -2,6 +2,8 @@ import React from 'react'
import Autosuggest from 'react-autosuggest'
import Fuse from 'fuse.js'
import './SearchBox.css'
const theme = {
input: {
width: '100%'
......@@ -32,20 +34,20 @@ class SearchBox extends React.Component {
state = {
value: '',
suggestions: [],
selectedID: null
selected: null
}
static getDerivedStateFromProps (nextProps, prevState) {
if (nextProps.selected === null) {
return {
value: '',
selectedID: null
selected: null
}
}
if (nextProps.selected.id !== prevState.selectedID) {
if (nextProps.selected !== prevState.selected) {
return {
value: nextProps.renderSelected(nextProps.selected),
selectedID: nextProps.selected.id
selected: nextProps.selected
}
}
return null
......
.level-item.make-wider {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.level-item.make-wider > .field {
width: 100%
}
.level-item.make-wider .control.is-wider {
flex-grow: 1;
}
......@@ -14,6 +14,7 @@ import * as api from '../api.jsx'
import 'bulma-tooltip/dist/css/bulma-tooltip.min.css'
import './grade/Grade.css'
import Notification from 'react-bulma-notification'
import '../components/SubmissionNavigation.css'
class Grade extends React.Component {
state = {
......@@ -245,7 +246,7 @@ class Grade extends React.Component {
<div className='column'>
<div className='level'>
<div className='level-item'>
<div className='level-item make-wider'>
<div className='field has-addons is-mobile'>
<div className='control'>
<button type='submit'
......@@ -259,7 +260,7 @@ class Grade extends React.Component {
data-tooltip='←'
onClick={this.prev}>Previous</button>
</div>
<div className='control'>
<div className='control is-wider'>
<SearchBox
placeholder='Search for a submission'
selected={submission}
......
......@@ -7,10 +7,13 @@ import * as api from '../api.jsx'
import Hero from '../components/Hero.jsx'
import ProgressBar from '../components/ProgressBar.jsx'
import SearchBox from '../components/SearchBox.jsx'
import SearchPanel from './students/SearchPanel.jsx'
import EditPanel from './students/EditPanel.jsx'
import '../components/SubmissionNavigation.css'
class CheckStudents extends React.Component {
state = {
editActive: false,
......@@ -99,21 +102,12 @@ class CheckStudents extends React.Component {
}
}
setSubmission = () => {
const input = parseInt(this.state.input)
const i = this.props.exam.submissions.findIndex(sub => sub.id === input)
if (i >= 0) {
this.setState({
index: i
})
this.props.updateSubmission(i)
} else {
this.setState({
input: this.props.submissions[this.state.index].id
})
Notification.error('Could not find that submission number :(\nSorry!')
}
setSubmission = (id) => {
const i = this.props.exam.submissions.findIndex(sub => sub.id === id)
this.setState({
index: i
})
this.props.updateSubmission(i)
}
setSubInput = (event) => {
......@@ -150,15 +144,13 @@ class CheckStudents extends React.Component {
editActive: !this.state.editActive,
editStud: null
})
this.props.updateSubmission(this.state.index)
}
}
render () {
const inputStyle = {
width: '5em'
}
const subm = this.props.exam.submissions[this.state.index]
const exam = this.props.exam
const subm = exam.submissions[this.state.index]
return (
<div>
......@@ -173,7 +165,7 @@ class CheckStudents extends React.Component {
<div className='column is-one-quarter-desktop is-one-third-tablet'>
{this.state.editActive
? <EditPanel toggleEdit={this.toggleEdit} editStud={this.state.editStud} />
: <SearchPanel matchStudent={this.matchStudent} toggleEdit={this.toggleEdit}
: <SearchPanel matchStudent={this.matchStudent} toggleEdit={this.toggleEdit} submission={subm}
student={subm && subm.student} validated={subm && subm.validated} subIndex={this.state.index} />
}
</div>
......@@ -181,7 +173,7 @@ class CheckStudents extends React.Component {
{this.props.exam.submissions.length
? <div className='column'>
<div className='level'>
<div className='level-item'>
<div className='level-item make-wider'>
<div className='field has-addons is-mobile'>
<div className='control'>
<button type='submit' className='button is-info is-rounded is-hidden-mobile'
......@@ -189,12 +181,44 @@ class CheckStudents extends React.Component {
<button type='submit' className={'button' + (subm.validated ? ' is-success' : ' is-link')}
onClick={this.prev}>Previous</button>
</div>
<div className='control'>
<input className={'input is-rounded has-text-centered' + (subm.validated ? ' is-success' : ' is-link')}
value={this.state.input} type='text'
onChange={this.setSubInput} onSubmit={this.setSubmission}
onBlur={this.setSubmission} onFocus={(event) => { event.target.select() }}
maxLength='4' size='6' style={inputStyle} />
<div className='control is-wider'>
<SearchBox
placeholder='Search for a submission'
selected={subm}
options={exam.submissions}
suggestionKeys={[
'id',
'student.firstName',
'student.lastName',
'student.id'
]}
setSelected={this.setSubmission}
renderSelected={({id, student}) => {
if (student) {
return `#${id}: ${student.firstName} ${student.lastName} (${student.id})`
} else {
return `#${id}`
}
}}
renderSuggestion={(submission) => {
const stud = submission.student
if (stud) {
return (
<div className='flex-parent'>
<span className='flex-child truncated'>
<b>#{submission.id}</b>
{` ${stud.firstName} ${stud.lastName}`}
</span>
<i className='flex-child fixed'>
({stud.id})
</i>
</div>
)
} else {
return `#${submission.id}: No student`
}
}}
/>
</div>
<div className='control'>
<button type='submit' className={'button' + (subm.validated ? ' is-success' : ' is-link')}
......
.box.is-graded {
box-shadow: 0px 0px 6px #23d160, 0 0 0 1px rgba(10, 10, 10, 0.1);
}
.flex-parent {
display: flex;
align-items: center;
}
.flex-child.truncated {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.flex-child.fixed {
white-space: nowrap;
}
......@@ -108,6 +108,11 @@ class EditPanel extends React.Component {
} else {
this.idblock.clear()
}
}).catch(resp => {
resp.json().then(r => Notification.error(r.message, {
duration: 0,
closable: true
}))
})
}
......@@ -120,12 +125,42 @@ class EditPanel extends React.Component {
const data = new window.FormData()
data.append('csv', file)
api.post('students', data)
.then(newStudentCount => {
Notification.success('successfully added ' + newStudentCount + ' students', { 'duration': 5 })
.then(resp => {
let totalSuccess = resp.added + resp.updated + resp.identical
let total = totalSuccess + resp.failed
let sentences = []
if (resp.added) sentences.push(<React.Fragment><b>{resp.added}</b> new students were added </React.Fragment>)
if (resp.updated) sentences.push(<React.Fragment><b>{resp.updated}</b> students were updated</React.Fragment>)
if (resp.identical) sentences.push(<React.Fragment><b>{resp.identical}</b> were already up to date</React.Fragment>)
let sentence = sentences.map((sent, index) => (
<React.Fragment>
{sent}{index <= sentences.length - 3 ? ', ' : ''}{index === sentences.length - 2 ? ' and ' : ''}
</React.Fragment>
))
let message = <p>
Succesfully processed <b>{totalSuccess} / {total}</b> students. A total of {sentence}.
</p>
if (resp.failed === 0) {
Notification.success(message, { 'duration': 10, 'closeable': true })
} else {
message = <div className='content'>
{message}
<p>However, we were not able to process <b>{resp.failed}</b> students:</p>
<ul>
{
resp.errors.map((error, index) => (
<li key={index}>{error}</li>
))
}
</ul>
</div>
Notification.warn(message, { 'duration': 0, 'closeable': true })
}
})
.catch(resp => {
console.error('failed to upload student CSV file')
resp.json().then(r => Notification.error(r.message))
resp.json().then(r => Notification.error(r.message), { 'duration': 0, 'closeable': true })
})
})
}
......
......@@ -40,6 +40,25 @@ class SearchPanel extends React.Component {
componentDidUpdate (prevProps, prevState) {
this.searchInput.current.focus()
// Check if the search input is empty
if (!this.searchInput.current || !this.searchInput.current.value || this.searchInput.current.value.length === 0) {
if (this.props.submission && this.props.submission.student) {
// There is no result yet, always update it
if (this.state.result.length === 0) {
this.setState({
result: [this.props.submission.student]
})
// There is a result already, check if it is outdated
} else if (this.state.result.length === 1) {
const newResult = this.props.submission.student ? [this.props.submission.student] : []
if (this.state.result[0] !== newResult[0]) {
this.setState({
result: newResult
})
}
}
}
}
}
search = (event) => {
......@@ -92,10 +111,10 @@ class SearchPanel extends React.Component {
if (event.target.selected) {
this.props.matchStudent(this.state.result[this.state.selected])
} else {
const index = this.state.result.findIndex(result => result.id === event.target.id)
const clickedId = parseInt(event.target.id)
const newIndex = this.state.result.findIndex(result => result.id === clickedId)
this.setState({
...this.state,
selected: index
selected: newIndex
})
}
}
......
import pytest
@pytest.mark.parametrize('email', ['student@school.edu', ''], ids=['email', 'no email'])
def test_add_student(client, email):
student = new_student(1000000, email)
result = client.put('api/students', json=student)
assert result.status_code == 200
data = result.get_json()
assert data == student
def test_get_students(client):
students = [
new_student(1000000, ''),
new_student(1000001, 'student@school.edu')
]
for student in students:
assert client.put('api/students', json=student).status_code == 200
result = client.get('api/students')
assert result.status_code == 200
data = result.get_json()
assert len(data) == len(students)
# Rename 'id' key to 'studentID' to match the request JSON
for student_data in data:
student_data['studentID'] = student_data.pop('id')
for student in students:
assert student in data
# Data is in the format [id, first, last, email]
@pytest.mark.parametrize('data,code,expected', [
([[1000000, 'a', 'b', 'c'], [1000000, 'a2', 'b2', 'c']], [200, 200], [[1000000, 'a2', 'b2', 'c']]),
([[1000000, 'a', 'b', 'c'], [1000001, 'a2', 'b2', 'c']], [200, 400], [[1000000, 'a', 'b', 'c']]),
([[1000000, 'a', 'b', 'c'], [1000000, 'a2', 'b2', 'c2']], [200, 200], [[1000000, 'a2', 'b2', 'c2']]),
([[1000000, 'a', 'b', 'c'], [1000000, 'a2', 'b2', '']], [200, 200], [[1000000, 'a2', 'b2', None]]),
([[1000000, 'a', 'b', 'c'], [1000001, 'a2', 'b2', 'c2'], [1000001, 'a3', 'b3', 'c']], [200, 200, 400],
[[1000000, 'a', 'b', 'c'], [1000001, 'a2', 'b2', 'c2']]),
], ids=['same id same email', 'new id same email', 'same id new mail', 'same id no mail', 'update id same mail'])
def test_update_students(client, data, code, expected):
for index, student_data in enumerate(data):
student = new_student(student_data[0], student_data[3], student_data[1], student_data[2])
assert client.put('api/students', json=student).status_code == code[index]
result = client.get('api/students')
assert result.status_code == 200
data = result.get_json()
assert len(data) == len(expected)
data = list(map(lambda d: list(d.values()), data))
for student in expected:
assert student in data
def new_student(id, mail, first='First', last='Last'):
return {
'studentID': id,
'firstName': first,
'lastName': last,
'email': mail or None
}
......@@ -48,3 +48,14 @@ def empty_app(app):
db.create_all()
return app
@pytest.fixture
def client(app):
client = app.test_client()
yield client
with app.app_context():
db.drop_all()
db.create_all()
......@@ -16,12 +16,15 @@ app = create_app()
app.register_blueprint(api_bp, url_prefix='/api')
os.makedirs(app.config['DATA_DIRECTORY'], exist_ok=True)
os.makedirs(app.config['SCAN_DIRECTORY'], exist_ok=True)
celery = make_celery(app)
@app.before_first_request
def setup():
os.makedirs(app.config['DATA_DIRECTORY'], exist_ok=True)
os.makedirs(app.config['SCAN_DIRECTORY'], exist_ok=True)
@app.route('/')
@app.route('/<path:path>')
def index(path='index.html'):
......
......@@ -3,6 +3,7 @@ from flask_restful import Resource, reqparse
from werkzeug.datastructures import FileStorage
import pandas as pd
from io import BytesIO
from enum import Enum
from ..database import db, Student
......@@ -84,20 +85,18 @@ class Students(Resource):
args = self.put_parser.parse_args()
student = Student.query.get(args.studentID)
if student is None:
student = Student(id=args.studentID,
first_name=args.firstName,
last_name=args.lastName,
email=args.email or None)
db.session.add(student)
else:
student.id = args.studentID
student.first_name = args.firstName
student.last_name = args.lastName
student.email = args.email or None
student = Student(id=args.studentID,
first_name=args.firstName,
last_name=args.lastName,
email=args.email or None)
db.session.commit()
result, reason = _add_or_update_student(student)
if result == Result.UPDATED or Result.ADDED:
db.session.commit()
if result == Result.ERROR:
return dict(status=400, message=reason), 400
return {
'studentID': student.id,
......@@ -128,50 +127,118 @@ class Students(Resource):
"""
args = self.post_parser.parse_args()
try:
df = pd.read_csv(BytesIO(args['csv'].read()))
# Disable the NaN filter to allow for empty email fields
df = pd.read_csv(BytesIO(args['csv'].read()), na_filter=False)
except Exception:
return dict(message='Uploaded file is not CSV'), 400
try:
added_students = sum(_add_or_update_student(row)
for _, row in df.iterrows())
except Exception as e:
print(e)
message = ('Uploaded CSV is not in the correct format: '
'did you export it from Brightspace? '
'The error was: ' + str(type(e)) + ": " + str(e))
results = []
errors = []
for _, row in df.iterrows():
student = _row_to_student(row)
if student is None:
results.append(Result.ERROR)
full_row = ', '.join([str(c) for c in row.values])
errors.append(f'The following row has an incorrect format: {full_row}')
else:
result, reason = _add_or_update_student(student)
results.append(result)
if result == Result.ERROR:
errors.append(reason)
# All rows failed to process
if len(errors) == len(results):
message = ('All the rows failed to process, '
'CSV is not in the correct format: '
'did you export it from Brightspace?')
return dict(message=message), 400
# At least one student was added to the database
db.session.commit()
return added_students
return {
'added': results.count(Result.ADDED),
'updated': results.count(Result.UPDATED),
'identical': results.count(Result.IDENTICAL),
'failed': results.count(Result.ERROR),
'errors': errors
}
def _add_or_update_student(row):
"""Add or update a student from a CSV row.
Returns whether a new student was added
(False if the student was already present, or
if there was an error processing the row).
"""
content = dict(id=row['OrgDefinedId'].replace('#', ''),
first_name=row['First Name'],
last_name=row['Last Name'],
email=row['Email'] or None)
def _row_to_student(row):
try:
# Brightspace includes instructors in the course list,
# and these might not have student numbers. (If they
# do then they will be added to the student list).
content['id'] = int(str(content['id']).replace('#', ''))
student = Student.query.get(content['id'])
content = dict(id=int(str(row['OrgDefinedId']).replace('#', '')),
first_name=row['First Name'],
last_name=row['Last Name'],
email=row['Email'] or None)
except ValueError:
return False
if student is None:
db.session.add(Student(**content))
return True
return None
return Student(**content)
class Result(Enum):
ERROR = -1
IDENTICAL = 0
UPDATED = 1
ADDED = 2
def _add_or_update_student(student):
"""Add or update a student from a Student instance
Returns
-------
result : Result
Whether a new student was added, updated, an identical
student was present or an error happened
reason : str
A description of what happened when there was
an error, else an empty string
"""
student_in_db = None
if student.email:
student_same_mail = Student.query.filter(Student.email == student.email).one_or_none()
if student_same_mail:
if student_same_mail.id == student.id:
# The student with the same email is the one we are updating
student_in_db = student_same_mail
else:
# Another student is already present with the same email
return Result.ERROR, (
'Could not add or update student #{student_id}. ' +
'Another student (#{other_id}, {other_first} {other_last}) already has the same email.'
).format(student_id=student.id, other_id=student_same_mail.id,
other_first=student_same_mail.first_name, other_last=student_same_mail.last_name)
if not student_in_db:
student_in_db = Student.query.get(student.id)
if not student_in_db:
db.session.add(student)
return Result.ADDED, ''
elif _student_is_equal(student, student_in_db):
return Result.IDENTICAL, ''
else:
student.id = content['id']
student.first_name = content['first_name']
student.last_name = content['last_name']
student.email = content['email']
student_in_db.first_name = student.first_name
student_in_db.last_name = student.last_name
student_in_db.email = student.email
return Result.UPDATED, ''
def _student_is_equal(student1, student2):
if student1.id != student2.id:
return False
if student1.first_name != student2.first_name:
return False
if student1.last_name != student2.last_name:
return False
return student1.email == student2.email
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment