From 4e69f7d72e27e254c96e6f04f1918d078cfeab6e Mon Sep 17 00:00:00 2001 From: Roosted7 <thomasroos@live.nl> Date: Sat, 14 Apr 2018 02:56:05 +0200 Subject: [PATCH] Move exam state up from pages to central app level --- client/components/NavBar.jsx | 29 ++++- client/index.jsx | 76 ++++++----- client/views/AddExam.jsx | 3 +- client/views/Exam.jsx | 38 +++--- client/views/Grade.jsx | 2 +- client/views/Students.jsx | 182 +++++++-------------------- client/views/grade/FeedbackPanel.jsx | 30 ++++- zesje/resources/exams.py | 42 ++++++- zesje/resources/submissions.py | 96 +++++++++----- 9 files changed, 261 insertions(+), 237 deletions(-) diff --git a/client/components/NavBar.jsx b/client/components/NavBar.jsx index 2ac80ca03..6b074ecd5 100644 --- a/client/components/NavBar.jsx +++ b/client/components/NavBar.jsx @@ -1,6 +1,8 @@ import React from 'react'; import { Link } from 'react-router-dom'; +import * as api from '../api.jsx' + const BurgerButton = (props) => ( <button className={"button navbar-burger" + (props.foldOut ? " is-active" : "")} onClick={props.burgerClick}> @@ -12,8 +14,8 @@ const BurgerButton = (props) => ( const ExamDropdown = (props) => ( <div className="navbar-item has-dropdown is-hoverable"> - <Link className="navbar-link" to={'/exams/' + (props.exam ? props.exam.id : "")}> - {props.exam ? <i>{props.exam.name}</i> : "Add exam"} + <Link className="navbar-link" to={'/exams/' + (props.exam.id ? props.exam.id : "")}> + {props.exam.id ? <i>{props.exam.name}</i> : "Add exam"} </Link> <div className="navbar-dropdown"> {props.list.map((exam) => ( @@ -33,7 +35,22 @@ const ExamDropdown = (props) => ( class NavBar extends React.Component { state = { - foldOut: false + foldOut: false, + examList: [] + } + + componentDidMount = () => { + this.updateExamList(); + } + + updateExamList = () => { + api.get('exams') + .then(exams => { + this.setState({ + examList: exams + }) + if (this.props.exam.id == null && exams.length) this.props.updateExam(exams[exams.length - 1].id) + }) } burgerClick = () => { @@ -44,7 +61,7 @@ class NavBar extends React.Component { render() { - const examStyle = this.props.exam && this.props.exam.submissions ? {} : { pointerEvents: 'none', opacity: .65 } + const examStyle = this.props.exam.submissions.length ? {} : { pointerEvents: 'none', opacity: .65 } return ( <nav className="navbar" role="navigation" aria-label="dropdown navigation"> @@ -65,8 +82,8 @@ class NavBar extends React.Component { <div className={"navbar-menu" + (this.state.foldOut ? " is-active" : "")} onClick={this.burgerClick}> <div className="navbar-start"> - {this.props.exam ? - <ExamDropdown exam={this.props.exam} list={this.props.list} /> + {this.state.examList.length ? + <ExamDropdown exam={this.props.exam} list={this.state.examList} /> : <Link className="navbar-item" to='/exams'>Add exam</Link> } diff --git a/client/index.jsx b/client/index.jsx index 3fd4a711d..cb43785d4 100644 --- a/client/index.jsx +++ b/client/index.jsx @@ -51,65 +51,63 @@ const Fail = Loadable({ class App extends React.Component { + menu = React.createRef(); + state = { - examIndex: null, - examList: [] + exam: { + id: null, + name: "", + submissions: [], + problems: [], + yaml: "" + } } - componentDidMount() { - this.updateExamList(); + updateExam = (examID) => { + api.get('exams/' + examID) + .then(ex => this.setState({ + exam: ex + })) } - - updateExamList = (callback, onlyList) => { - api.get('exams') - .then(exams => { - if (exams.length) { - if (onlyList) { - this.setState({ - examList: exams - }) - } else { - this.setState({ - examIndex: exams.length - 1, - examList: exams - }, callback) + updateSubmission = (index, sub) => { + if (index != undefined) { + if (sub) { + let newList = this.state.exam.submissions; + newList[index] = sub; + this.setState({ + exam: { + ...this.state.exam, + submissions: newList } - } - }) - .catch(resp => { - alert('failed to get exams (see javascript console for details)') - console.error('failed to get exams:', resp) - }) - } - - changeExam = (examID) => { - const index = this.state.examList.findIndex(exam => exam.id === examID) - if (index === -1) { - alert('Wrong exam url entered'); - return; + }) + } } else { - this.setState({ - examIndex: index - }) + api.get('submissions/' + this.state.exam.id) + .then(subs => this.setState({ + exam: { + ...this.state.exam, + submissions: subs + } + })) } } render() { - const exam = this.state.examIndex === null ? null : this.state.examList[this.state.examIndex]; + const exam = this.state.exam return ( <Router> <div> - <NavBar exam={exam} list={this.state.examList} changeExam={this.changeExam} /> + <NavBar exam={exam} updateExam={this.updateExam} ref={this.menu} /> <Switch> <Route exact path="/" component={Home} /> <Route path="/exams/:examID" render={({match}) => - <Exam exam={exam} urlID={match.params.examID} changeExam={this.changeExam} updateList={this.updateExamList}/> }/> + <Exam exam={exam} urlID={match.params.examID} changeExam={this.changeExam} updateSubmission={this.updateSubmission}/> }/> <Route path="/exams" render={({history}) => - <AddExam updateExamList={this.updateExamList} changeURL={history.push} /> }/> + <AddExam updateExamList={() => this.menu.current.updateExamList()} changeURL={history.push} /> }/> <Route path="/students" render={() => - <Students exam={exam} /> }/> + <Students exam={exam} updateSubmission={this.updateSubmission}/> }/> <Route path="/grade" render={() => ( exam && exam.submissions ? <Grade exam={exam}/> : <Fail message="No exams uploaded. Please do not bookmark URLs" /> )} /> diff --git a/client/views/AddExam.jsx b/client/views/AddExam.jsx index 29cad59b1..0fc819fa7 100644 --- a/client/views/AddExam.jsx +++ b/client/views/AddExam.jsx @@ -17,7 +17,8 @@ class Exams extends React.Component { data.append('yaml', accepted[0]) api.post('exams', data) .then(exam => { - this.props.updateExamList(() => this.props.changeURL('/exams/' + exam.id)); + this.props.updateExamList() + this.props.changeURL('/exams/' + exam.id); }) .catch(resp => { alert('failed to upload yaml (see javascript console for details)') diff --git a/client/views/Exam.jsx b/client/views/Exam.jsx index a51c0a146..86d6e4f56 100644 --- a/client/views/Exam.jsx +++ b/client/views/Exam.jsx @@ -34,22 +34,8 @@ class Exams extends React.Component { pdfs: [] }; - loadExam = (id) => { - if (this.props.exam.id !== parseInt(id)) { - console.log('Changing exam id to ' + id) - this.props.changeExam(parseInt(id)); - } - - api.get('exams/' + id) - .then(exam => { - this.setState({ - yaml: exam.yaml - }) - }) - } - putYaml = () => { - api.patch('exams/' + this.props.urlID, { yaml: this.state.yaml }) + api.patch('exams/' + this.props.exam.id, { yaml: this.state.yaml }) .then(() => alert('thank you for the update; it was delicious')) .catch(resp => { alert('failed to update the YAML (see javascript console)') @@ -64,13 +50,13 @@ class Exams extends React.Component { } updatePDFs = () => { - api.get('pdfs/' + this.props.urlID) + api.get('pdfs/' + this.props.exam.id) .then(pdfs => { if (JSON.stringify(pdfs) != JSON.stringify(this.state.pdfs)) { - this.props.updateList(null, true) this.setState({ pdfs: pdfs }) + this.props.updateSubmission() } }) } @@ -83,7 +69,7 @@ class Exams extends React.Component { accepted.map(file => { const data = new FormData() data.append('pdf', file) - api.post('pdfs/' + this.props.urlID, data) + api.post('pdfs/' + this.props.exam.id, data) .then(() => { this.updatePDFs(); }) @@ -95,14 +81,22 @@ class Exams extends React.Component { } componentDidMount = () => { - this.loadExam(this.props.urlID); this.pdfUpdater = setInterval(this.updatePDFs, 1000) + if (this.props.exam.id) this.updatePDFs() } - componentWillReceiveProps = (newProps) => { - if (newProps.urlID !== this.props.urlID) { - this.loadExam(newProps.urlID) + static getDerivedStateFromProps = (newProps, prevState) => { + if (newProps.exam.id != prevState.examID) { + return { + yaml: newProps.exam.yaml, + pdfs: [], + examID: newProps.exam.id + } } + return null + } + componentDidUpdate = (prevProps) => { + if (prevProps.exam.id != this.props.exam.id) this.updatePDFs() } componentWillUnmount = () => { diff --git a/client/views/Grade.jsx b/client/views/Grade.jsx index dbf8b8e15..7ae618f1f 100644 --- a/client/views/Grade.jsx +++ b/client/views/Grade.jsx @@ -156,7 +156,7 @@ class Grade extends React.Component { <EditPanel problem={this.state.problem} feedbackID={this.state.editFeedback} toggleEdit={this.toggleEdit}/> : <FeedbackPanel problem={this.state.problem} toggleEdit={this.toggleEdit} - editFeedback={this.editFeedback}/> + editFeedback={this.editFeedback} /> } </div> diff --git a/client/views/Students.jsx b/client/views/Students.jsx index 1c1d39870..fd243dce8 100644 --- a/client/views/Students.jsx +++ b/client/views/Students.jsx @@ -15,15 +15,9 @@ class CheckStudents extends React.Component { state = { editActive: false, editStud: null, - submission: { - id: 0, - input: 0, - index: 0, - student: null, - validated: false, - imagePath: null, - list: [] - } + input: "", + index: 0, + examID: null }; componentWillUnmount = () => { @@ -45,88 +39,73 @@ class CheckStudents extends React.Component { event.preventDefault(); this.prevUnchecked(); }); + } - if (this.props.exam) this.loadSubmissions(); - + static getDerivedStateFromProps = (newProps, prevState) => { + if (newProps.exam.id != prevState.examID && newProps.exam.submissions.length) { + return { + input: newProps.exam.submissions[0].id, + index: 0, + examID: newProps.examID + } + } + return null } prev = () => { - const newIndex = this.state.submission.index - 1; + const newIndex = this.state.index - 1; - if (newIndex >= 0 && newIndex < this.state.submission.list.length) { + if (newIndex >= 0 && newIndex < this.props.exam.submissions.length) { this.setState({ - submission: { - ...this.state.submission, - input: this.state.submission.list[newIndex].id - } + input: this.props.exam.submissions[newIndex].id }, this.setSubmission) } } next = () => { - const newIndex = this.state.submission.index + 1; + const newIndex = this.state.index + 1; - if (newIndex >= 0 && newIndex < this.state.submission.list.length) { + if (newIndex >= 0 && newIndex < this.props.exam.submissions.length) { this.setState({ - submission: { - ...this.state.submission, - input: this.state.submission.list[newIndex].id - } + input: this.props.exam.submissions[newIndex].id }, this.setSubmission) } } prevUnchecked = () => { - const unchecked = this.state.submission.list.filter(sub => sub.validated === false).map(sub => sub.id); - const newInput = getClosest.lowerNumber(this.state.submission.id - 1, unchecked); + const unchecked = this.props.exam.submissions.filter(sub => sub.validated === false).map(sub => sub.id); + const newInput = getClosest.lowerNumber(this.props.exam.submissions[this.state.index].id - 1, unchecked); if (typeof newInput !== 'undefined') { this.setState({ - submission: { - ...this.state.submission, - input: unchecked[newInput] - } + input: unchecked[newInput] }, this.setSubmission) } } nextUnchecked = () => { - const unchecked = this.state.submission.list.filter(sub => sub.validated === false).map(sub => sub.id); - const newInput = getClosest.greaterNumber(this.state.submission.id + 1, unchecked); + const unchecked = this.props.exam.submissions.filter(sub => sub.validated === false).map(sub => sub.id); + const newInput = getClosest.greaterNumber(this.props.exam.submissions[this.state.index].id + 1, unchecked); if (typeof newInput !== 'undefined') { this.setState({ - submission: { - ...this.state.submission, - input: unchecked[newInput] - } + input: unchecked[newInput] }, this.setSubmission) } } setSubmission = () => { - const input = parseInt(this.state.submission.input); - const i = this.state.submission.list.findIndex(sub => sub.id === input); - const sub = this.state.submission.list[i]; + const input = parseInt(this.state.input); + const i = this.props.exam.submissions.findIndex(sub => sub.id === input); if (i >= 0) { this.setState({ - submission: { - ...this.state.submission, - id: input, - student: sub.student, - validated: sub.validated, - index: i, - imagePath: 'api/images/signature/' + this.props.exam.id + '/' + input - } - }, this.getSubmission) + index: i, + }, /* UPDATE SUBMISSION IN TOP COMPONENT */) } else { this.setState({ - submission: { - ...this.state.submission, - input: this.state.submission.id - } + input: this.props.submissions[this.state.index].id }) alert('Could not find that submission number :(\nSorry!'); } @@ -136,91 +115,24 @@ class CheckStudents extends React.Component { const patt = new RegExp(/^([1-9]\d*|0)?$/); if (patt.test(event.target.value)) { - this.setState({ - submission: { - ...this.state.submission, - input: event.target.value - } - }) + this.setState({ input: event.target.value }) } } - getSubmission = () => { - api.get('submissions/' + this.props.exam.id + '/' + this.state.submission.id) - .then(sub => { - let newList = this.state.submission.list; - const index = newList.findIndex(localSub => localSub.id === sub.id) - newList[index] = sub; - this.setState({ - submission: { - ...this.state.submission, - student: sub.student, - validated: sub.validated, - list: newList - } - }) - }) - .catch(err => { - alert('failed to get submission (see javascript console for details)') - console.error('failed to get submission:', err) - throw err - }) - } - - loadSubmissions = () => { - api.get('submissions/' + this.props.exam.id) - .then(subs => { - if (subs.length) { - this.setState({ - submission: { - ...this.state.submission, - id: subs[0].id, - input: subs[0].id, - student: subs[0].student, - validated: subs[0].validated, - imagePath: 'api/images/signature/' + this.props.exam.id + '/' + subs[0].id, - list: subs - } - }) - } - }) - .catch(err => { - alert('failed to get submissions (see javascript console for details)') - console.error('failed to get submissions:', err) - throw err - }) - } - matchStudent = (stud) => { - if(!this.state.submission.list.length) return; - - let newList = this.state.submission.list; - const index = this.state.submission.index; - - this.setState({ - submission: { - ...this.state.submission, - student: stud, - validated: true - } - }, this.nextUnchecked) + if (!this.props.exam.submissions.length) return; - api.put('submissions/' + this.props.exam.id + '/' + this.state.submission.id, { studentID: stud.id }) + api.put('submissions/' + this.props.exam.id + '/' + this.props.exam.submissions[this.state.index].id, { studentID: stud.id }) .then(sub => { - newList[index] = sub; - this.setState({ - submission: { - ...this.state.submission, - list: newList - } - }) + this.props.updateSubmission(this.state.index, sub) + this.nextUnchecked() }) .catch(err => { alert('failed to put submission (see javascript console for details)') console.error('failed to put submission:', err) throw err - }) + }) } toggleEdit = (student) => { @@ -242,7 +154,9 @@ class CheckStudents extends React.Component { width: '5em' }; - const maxSubmission = Math.max(...this.state.submission.list.map(o => o.id)); + const maxSubmission = Math.max(...this.props.exam.submissions.map(o => o.id)); + + const subm = this.props.exam.submissions[this.state.index]; return ( <div> @@ -259,11 +173,11 @@ class CheckStudents extends React.Component { <EditPanel toggleEdit={this.toggleEdit} editStud={this.state.editStud} /> : <SearchPanel matchStudent={this.matchStudent} toggleEdit={this.toggleEdit} - student={this.state.submission.student} validated={this.state.submission.validated} /> + student={subm && subm.student} validated={subm && subm.validated} /> } </div> - {this.state.submission.list.length ? + {this.props.exam.submissions.length ? <div className="column"> <div className="level"> <div className="level-item"> @@ -271,17 +185,17 @@ class CheckStudents extends React.Component { <div className="control"> <button type="submit" className="button is-info is-rounded is-hidden-mobile" onClick={this.prevUnchecked}>unchecked</button> - <button type="submit" className={"button" + (this.state.submission.validated ? " is-success" : " is-link")} + <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" + (this.state.submission.validated ? " is-success" : " is-link")} - value={this.state.submission.input} type="text" + <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} maxLength="4" size="6" style={inputStyle} /> </div> <div className="control"> - <button type="submit" className={"button" + (this.state.submission.validated ? " is-success" : " is-link")} + <button type="submit" className={"button" + (subm.validated ? " is-success" : " is-link")} onClick={this.next}>Next</button> <button type="submit" className="button is-info is-rounded is-hidden-mobile" onClick={this.nextUnchecked}>unchecked</button> @@ -290,14 +204,14 @@ class CheckStudents extends React.Component { </div> </div> - <ProgressBar submissions={this.state.submission.list}/> + <ProgressBar submissions={this.props.exam.submissions} /> <p className="box"> - <img src={this.state.submission.imagePath} alt="" /> + <img src={'api/images/signature/' + this.props.exam.id + '/' + subm.id} alt="" /> </p> </div> - : null } + : null} </div> </div> </section> diff --git a/client/views/grade/FeedbackPanel.jsx b/client/views/grade/FeedbackPanel.jsx index e42123dd7..0346fed5a 100644 --- a/client/views/grade/FeedbackPanel.jsx +++ b/client/views/grade/FeedbackPanel.jsx @@ -7,7 +7,15 @@ import FeedbackBlock from './FeedbackBlock.jsx'; class FeedbackPanel extends React.Component { state = { - feedback: [] + feedback: [], + remark: null, + remarkActive: false + } + + addRemark = () => { + this.setState({ + remarkActive: true + }) } componentDidMount = () => { @@ -33,11 +41,11 @@ class FeedbackPanel extends React.Component { } shouldComponentUpdate = (nextProps, nextState) => { - if (this.props.problem !== nextProps.problem || this.state.feedback != nextState.feedback) { + if (this.props.problem !== nextProps.problem || this.state.feedback != nextState.feedback || this.state.rem) { return true; } else { console.log('halting re-render') - return false; + return true; } } @@ -51,12 +59,24 @@ class FeedbackPanel extends React.Component { {this.state.feedback.map((feedback, i) => <FeedbackBlock key={i} index={i} feedback={feedback} checked={false} onClick={this.props.editFeedback} /> )} - <div className="panel-block is-hidden-mobile"> + {this.state.remarkActive ? + <div className="panel-block"> + <textarea className="textarea" rows="2" placeholder="remark" /> + </div> + : null + } + <div className="panel-block"> <button className="button is-link is-outlined is-fullwidth" onClick={this.props.toggleEdit}> <span className="icon is-small"> <i className="fa fa-plus"></i> </span> - <span>add option</span> + <span>option</span> + </button> + <button className="button is-link is-outlined is-fullwidth" onClick={this.addRemark}> + <span className="icon is-small"> + <i className="fa fa-plus"></i> + </span> + <span>remark</span> </button> </div> </nav> diff --git a/zesje/resources/exams.py b/zesje/resources/exams.py index ae1f6c0b3..080c5bbb6 100644 --- a/zesje/resources/exams.py +++ b/zesje/resources/exams.py @@ -42,7 +42,47 @@ class ExamConfig(Resource): return { 'id': exam_id, 'name': exam.name, - 'submissions': exam.submissions.count(), + 'submissions': + [ + { + 'id': sub.copy_number, + 'student': + { + 'id': sub.student.id, + 'firstName': sub.student.first_name, + 'lastName': sub.student.last_name, + 'email': sub.student.email + } if sub.student else None, + 'validated': sub.signature_validated, + 'solutions': + [ + { + 'problem': sol.problem.id, + 'graded_by': sol.graded_by, + 'graded_at': sol.graded_at.isoformat() if sol.graded_at else None, + 'feedback': [ + fb.id for fb in sol.feedback + ], + 'remarks': sol.remarks + } for sol in sub.solutions.order_by(lambda s: s.problem.id) + ] + } for sub in exam.submissions.order_by(lambda s: s.copy_number) + ], + 'problems': [ + { + 'id': prob.id, + 'name': prob.name, + 'feedback': [ + { + 'id': fb.id, + 'name': fb.text, + 'description': fb.description, + 'score': fb.score, + 'used': fb.solutions.count() + } for fb in prob.feedback_options.order_by(lambda f: f.id) + ] + } for prob in exam.problems.order_by(lambda p: p.id) + ], 'yaml': yml } diff --git a/zesje/resources/submissions.py b/zesje/resources/submissions.py index af9082877..9b44ab137 100644 --- a/zesje/resources/submissions.py +++ b/zesje/resources/submissions.py @@ -31,35 +31,57 @@ class Submissions(Resource): exam = Exam[exam_id] if submission_id is not None: - s = Submission.get(exam=exam, copy_number=submission_id) - if not s: + sub = Submission.get(exam=exam, copy_number=submission_id) + if not sub: raise orm.core.ObjectNotFound(Submission) return { - 'id': s.copy_number, + 'id': sub.copy_number, 'student': { - 'id': s.student.id, - 'firstName': s.student.first_name, - 'lastName': s.student.last_name, - 'email': s.student.email - } if s.student else None, - 'validated': s.signature_validated, + 'id': sub.student.id, + 'firstName': sub.student.first_name, + 'lastName': sub.student.last_name, + 'email': sub.student.email + } if sub.student else None, + 'validated': sub.signature_validated, + 'solutions': + [ + { + 'problem': sol.problem.id, + 'graded_by': sol.graded_by, + 'graded_at': sol.graded_at.isoformat() if sol.graded_at else None, + 'feedback': [ + fb.id for fb in sol.feedback + ], + 'remarks': sol.remarks + } for sol in sub.solutions.order_by(lambda s: s.problem.id) + ] } return [ { - 'id': s.copy_number, + 'id': sub.copy_number, 'student': { - 'id': s.student.id, - 'firstName': s.student.first_name, - 'lastName': s.student.last_name, - 'email': s.student.email - } if s.student else None, - 'validated': s.signature_validated, - } - for s in Submission.select(lambda s: s.exam == exam) - .order_by(Submission.copy_number) + 'id': sub.student.id, + 'firstName': sub.student.first_name, + 'lastName': sub.student.last_name, + 'email': sub.student.email + } if sub.student else None, + 'validated': sub.signature_validated, + 'solutions': + [ + { + 'problem': sol.problem.id, + 'graded_by': sol.graded_by, + 'graded_at': sol.graded_at.isoformat() if sol.graded_at else None, + 'feedback': [ + fb.id for fb in sol.feedback + ], + 'remarks': sol.remarks + } for sol in sub.solutions.order_by(lambda s: s.problem.id) + ] + } for sub in Submission.select(lambda s: s.exam == exam).order_by(lambda s: s.copy_number) ] put_parser = reqparse.RequestParser() @@ -91,8 +113,8 @@ class Submissions(Resource): args = self.put_parser.parse_args() exam = Exam[exam_id] - submission = Submission.get(exam=exam, copy_number=submission_id) - if not submission: + sub = Submission.get(exam=exam, copy_number=submission_id) + if not sub: raise orm.core.ObjectNotFound(Submission) student = Student.get(id=args.studentID) @@ -100,10 +122,28 @@ class Submissions(Resource): msg = f'Student {args.studentID} does not exist' return dict(status=404, message=msg), 404 - submission.student = student - submission.signature_validated = True - return { - 'id': submission.copy_number, - 'studentID': submission.student.id, - 'validated': submission.signature_validated, - } + sub.student = student + sub.signature_validated = True + return { + 'id': sub.copy_number, + 'student': + { + 'id': sub.student.id, + 'firstName': sub.student.first_name, + 'lastName': sub.student.last_name, + 'email': sub.student.email + } if sub.student else None, + 'validated': sub.signature_validated, + 'solutions': + [ + { + 'problem': sol.problem.id, + 'graded_by': sol.graded_by, + 'graded_at': sol.graded_at.isoformat() if sol.graded_at else None, + 'feedback': [ + fb.id for fb in sol.feedback + ], + 'remarks': sol.remarks + } for sol in sub.solutions.order_by(lambda s: s.problem.id) + ] + } -- GitLab