From 23044b28dbcfdc82d5f1e934ab18fba034d60e4a Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Fri, 7 Feb 2020 20:57:26 +0100 Subject: [PATCH] feat(exam-correct): general improvement --- .../src/utils/exam-correct/exam-correct.js | 87 ++++++++++++------- .../src/utils/exam-correct/exam-correct.sass | 17 ++-- messages/uniworx/de-de-formal.msg | 1 + messages/uniworx/en-eu.msg | 1 + src/Handler/Exam/Correct.hs | 36 +++++--- 5 files changed, 95 insertions(+), 47 deletions(-) diff --git a/frontend/src/utils/exam-correct/exam-correct.js b/frontend/src/utils/exam-correct/exam-correct.js index 5a7ffc9ca..3c9a1c9f0 100644 --- a/frontend/src/utils/exam-correct/exam-correct.js +++ b/frontend/src/utils/exam-correct/exam-correct.js @@ -47,6 +47,7 @@ export class ExamCorrect { _userInputStatus; _userInputCandidates; _partInputs; + _partDeleteBoxes; _dateFormat; _cIndices; @@ -75,6 +76,7 @@ export class ExamCorrect { this._userInputStatus = document.getElementById(EXAM_CORRECT_USER_INPUT_STATUS_ID); this._userInputCandidates = document.getElementById(EXAM_CORRECT_USER_INPUT_CANDIDATES_ID); this._partInputs = [...this._element.querySelectorAll(`input[${EXAM_CORRECT_PART_INPUT_ATTR}]`)]; + this._partDeleteBoxes = [...this._element.querySelectorAll('input.uw-exam-correct--delete-exam-part')]; if (this._sendBtn) this._sendBtn.addEventListener('click', this._sendCorrectionHandler.bind(this)); @@ -84,6 +86,10 @@ export class ExamCorrect { this._userInput.addEventListener('focusout', this._validateUserInput.bind(this)); else throw new Error('ExamCorrect utility could not detect user input!'); + for (let deleteBox of this._partDeleteBoxes) { + deleteBox.addEventListener('change', (() => { this._updatePartDeleteDisabled(deleteBox); }).bind(this)); + } + if (!this._userInputStatus) { throw new Error('ExamCorrect utility could not detect user input status element!'); } @@ -93,7 +99,7 @@ export class ExamCorrect { } // TODO get date format by post request - this._dateFormat = 'YYYY-MM-DD HH:mm:ss'; + this._dateFormat = 'DD.MM.YYYY HH:mm:ss'; this._cIndices = new Map( [...this._element.querySelectorAll('[uw-exam-correct-header]')] @@ -116,6 +122,14 @@ export class ExamCorrect { // TODO destroy handlers on user input candidate elements } + _updatePartDeleteDisabled(deleteBox) { + const partInput = deleteBox.parentElement.querySelector(`input[${EXAM_CORRECT_PART_INPUT_ATTR}]`); + if (!partInput) + return; + + partInput.disabled = deleteBox.checked; + } + _validateUserInput() { (!this._userInput.value) ? this._userInput.classList.add('no-value') : this._userInput.classList.remove('no-value'); @@ -176,11 +190,17 @@ export class ExamCorrect { const results = {}; for (const input of this._partInputs) { - if (input.reportValidity && !input.reportValidity()) { + if (input.disabled) { + const partKey = input.getAttribute(EXAM_CORRECT_PART_INPUT_ATTR); + if (!partKey) { + console.error('Error while parsing results: Could not detect exam part key attribute'); + return; + } + results[partKey] = null; + } else if (input.reportValidity && !input.reportValidity()) { input.focus(); return; - } - if (input.value) { + } else if (input.value) { const partKey = input.getAttribute(EXAM_CORRECT_PART_INPUT_ATTR); if (!partKey) { console.error('Error while parsing results: Could not detect exam part key attribute'); @@ -189,24 +209,25 @@ export class ExamCorrect { results[partKey] = parseFloat(input.value); } } + console.log('results', results); - const result = this._resultSelect.value !== 'none' && this._resultSelect.value; + const result = this._resultSelect && this._resultSelect.value !== 'none' && this._resultSelect.value; // abort send if there are no results (after validation) - if (Object.keys(results).length <= 0) return; + if (Object.keys(results).length <= 0 && !result) return; const rowInfo = { users: [user], results: results, + result: result === 'delete' ? null : result, status: STATUS.LOADING, }; - if (results) rowInfo.results = results; - if (result) rowInfo.result = result === 'delete' ? null : result; this._addRow(rowInfo); // clear inputs on validation success this._clearUserInput(); this._partInputs.forEach(clearInput); + this._partDeleteBoxes.forEach(box => { box.checked = false; this._updatePartDeleteDisabled(box); }); const body = { user: userId || user, @@ -305,39 +326,38 @@ export class ExamCorrect { case 'success': status = STATUS.SUCCESS; if (response.user) { - userElem.setAttribute(EXAM_CORRECT_USER_ATTR, response.user.id); - userElem.innerHTML = userToHTML(response.user); const timeElem = row.cells.item(0); timeElem.innerHTML = moment(response.time).format(this._dateFormat); timeElem.classList.remove('exam-correct--local-time'); newEntry.users = [response.user]; newEntry.results = response.results; newEntry.result = response.grade; + } else { + console.error('Invalid response'); + return; } // TODO set edit button visibility break; case 'ambiguous': // TODO set edit button visibility status = STATUS.AMBIGUOUS; - if (response.users) { - userElem = this._showUserList(row, response.users, results); - newEntry.users = response.users; - newEntry.results = results.partResults; - newEntry.result = results.result; - } + newEntry.users = response.users; + newEntry.results = typeof results === 'undefined' ? {} : results; + newEntry.result = typeof result === 'undefined' ? undefined : result; // eslint-disable-line no-undef newEntry.message = response.message || null; break; case 'failure': status = STATUS.FAILURE; newEntry.users = (response.user && [response.user]) || null; - newEntry.results = results; + newEntry.results = typeof results === 'undefined' ? {} : results; newEntry.message = response.message || null; - newEntry.result = results.result; + newEntry.result = typeof result === 'undefined' ? undefined : result; // eslint-disable-line no-undef break; default: // TODO show tooltip with 'invalid response' // TODO set edit button visibility console.error('Invalid response'); + return; } row.querySelectorAll('.fa-spin').forEach((elem) => { setStatus(elem, status); @@ -359,19 +379,23 @@ export class ExamCorrect { statusCell.appendChild(messageElem); } - const userCell = row.querySelector('.uw-exam-correct--user-cell'); - if (userCell && newEntry.users && newEntry.users.length === 1) { + if (userElem && newEntry.users && newEntry.users.length === 1) { const user = newEntry.users[0]; - userCell.innerHTML = userToHTML(user); - userCell.setAttribute(EXAM_CORRECT_USER_ATTR, user); - } else if (userCell && newEntry.users) { - row.replaceChild(userCell, this._showUserList(row, newEntry.users, request.results)); + userElem.innerHTML = userToHTML(user); + userElem.setAttribute(EXAM_CORRECT_USER_ATTR, user.id || user); + } else if (userElem && newEntry.users) { + row.replaceChild(userElem, this._showUserList(row, newEntry.users, { partResults: request.results, result: request.grade } )); } for (let [k, v] of Object.entries(newEntry.results)) { const resultCell = row.cells.item(this._cIndices.get(k)); - if (v.result) + if (v === null) { + resultCell.innerHTML = ''; + resultCell.classList.remove('exam-correct--result-unconfirmed'); + } else if (v && v.result) { resultCell.innerHTML = v.result; + resultCell.classList.remove('exam-correct--result-unconfirmed'); + } } savedEntries.push(newEntry); @@ -429,9 +453,9 @@ export class ExamCorrect { const body = { user: listItem.getAttribute(EXAM_CORRECT_USER_ATTR), - results: results.partResults, - grade: results.result, }; + if (results.partResults) body.results = results.partResults; + if (results.result || results.result === null) body.grade = results.result; this._app.httpClient.post({ url: EXAM_CORRECT_URL_POST, @@ -478,8 +502,13 @@ export class ExamCorrect { console.error('Could not determine cell index from part key!'); } else { const partCell = document.createElement('TD'); - partCell.innerHTML = partResult; - partCell.classList.add('uw-exam-correct--part-cell'); + + if (partResult === null) { + partCell.innerHTML = ''; + } else { + partCell.innerHTML = partResult; + } + partCell.classList.add('uw-exam-correct--part-cell', 'exam-correct--result-unconfirmed'); cells.set(cellIndex, partCell); } } diff --git a/frontend/src/utils/exam-correct/exam-correct.sass b/frontend/src/utils/exam-correct/exam-correct.sass index 9b5d3658a..f0554a692 100644 --- a/frontend/src/utils/exam-correct/exam-correct.sass +++ b/frontend/src/utils/exam-correct/exam-correct.sass @@ -12,7 +12,7 @@ table[uw-exam-correct] min-width: 200px th.uw-exam-correct--part-cell, td.uw-exam-correct--part-cell - width: min-content + width: 115px text-align: center white-space: nowrap @@ -24,12 +24,16 @@ table[uw-exam-correct] opacity: .5 cursor: pointer margin-left: 5px - .uw-exam-correct--delete-exam-part ~ .fa-trash:hover - opacity: 1 + + &:hover + opacity: 1 .uw-exam-correct--delete-exam-part:checked ~ .fa-trash opacity: 1 color: var(--color-error) + &:hover + color: var(--color-error-dark) + td#uw-exam-correct__result width: min-content select @@ -63,7 +67,6 @@ table[uw-exam-correct] border: 2px solid var(--color-error) [uw-exam-correct] tbody ul - list-style: none li cursor: pointer text-decoration: underline @@ -73,9 +76,11 @@ table[uw-exam-correct] width: calc(100% - 18em/14 - #{$exam-correct--input-status-margin}) i margin-left: $exam-correct--input-status-margin + ul + margin-top: 7px -.exam-correct--local-time - color: var(--color-fontsec) +.exam-correct--local-time, .exam-correct--result-unconfirmed + opacity: .5 font-style: italic .exam-correct--success diff --git a/messages/uniworx/de-de-formal.msg b/messages/uniworx/de-de-formal.msg index e79cff87f..3fcff0514 100644 --- a/messages/uniworx/de-de-formal.msg +++ b/messages/uniworx/de-de-formal.msg @@ -413,6 +413,7 @@ UnauthorizedAllocationLecturer: Sie sind nicht als Veranstalter für eine Verans UnauthorizedCorrector: Sie sind nicht als Korrektor für diese Veranstaltung eingetragen. UnauthorizedSheetCorrector: Sie sind nicht als Korrektor für dieses Übungsblatt eingetragen. UnauthorizedExamCorrector: Sie sind nicht als Korrektor für diese Prüfung eingetragen. +UnauthorizedExamCorrectorGrade: Sie haben nicht die Berechtigung für diese Prüfung Gesamtergebnisse einzutragen. UnauthorizedCorrectorAny: Sie sind nicht als Korrektor für eine Veranstaltung eingetragen. UnauthorizedRegistered: Sie sind nicht als Teilnehmer für diese Veranstaltung registriert. UnauthorizedAllocationRegistered: Sie sind nicht als Teilnehmer für diese Zentralanmeldung registriert. diff --git a/messages/uniworx/en-eu.msg b/messages/uniworx/en-eu.msg index 8f829c850..e0fa571fd 100644 --- a/messages/uniworx/en-eu.msg +++ b/messages/uniworx/en-eu.msg @@ -411,6 +411,7 @@ UnauthorizedAllocationLecturer: You are no administrator for any of the courses UnauthorizedCorrector: You are no sheet corrector for this course. UnauthorizedSheetCorrector: You are no corrector for this sheet. UnauthorizedExamCorrector: You are no corrector for this exam. +UnauthorizedExamCorrectorGrade: You may not enter overall exam achievements for this exam. UnauthorizedCorrectorAny: You are no corrector for any course. UnauthorizedRegistered: You are no participant in this course. UnauthorizedAllocationRegistered: You are no participant in this central allocation. diff --git a/src/Handler/Exam/Correct.hs b/src/Handler/Exam/Correct.hs index 35736c4c8..34633609b 100644 --- a/src/Handler/Exam/Correct.hs +++ b/src/Handler/Exam/Correct.hs @@ -10,10 +10,13 @@ import qualified Data.Aeson as JSON import qualified Database.Esqueleto as E import qualified Database.Esqueleto.Utils as E +import Database.Persist.Sql (transactionUndo) import Handler.Utils import Handler.Utils.Exam (fetchExam) +import qualified Data.HashMap.Strict as HashMap + data CorrectInterfaceUser = CorrectInterfaceUser @@ -30,7 +33,7 @@ data CorrectInterfaceResponse = CorrectInterfaceResponseSuccess { cirsUser :: CorrectInterfaceUser , cirsResults :: Map ExamPartNumber (Maybe ExamResultPoints) - , cirsGrade :: Maybe ExamResultPassedGrade + , cirsGrade :: Maybe (Maybe ExamResultPassedGrade) , cirsTime :: UTCTime } | CorrectInterfaceResponseAmbiguous @@ -44,17 +47,18 @@ data CorrectInterfaceResponse | CorrectInterfaceResponseNoOp { cirnUsers :: Set CorrectInterfaceUser } -deriveJSON defaultOptions +deriveToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 , fieldLabelModifier = camelToPathPiece' 1 , sumEncoding = TaggedObject "status" "results" + , omitNothingFields = True } ''CorrectInterfaceResponse data CorrectInterfaceRequest = CorrectInterfaceRequest { ciqUser :: Either Text (CryptoID UUID (Key User)) , ciqResults :: Maybe (NonNull (Map ExamPartNumber (Maybe Points))) - , ciqGrade :: Maybe ExamResultPassedGrade + , ciqGrade :: Maybe (Maybe ExamResultPassedGrade) } instance FromJSON CorrectInterfaceRequest where @@ -62,7 +66,11 @@ instance FromJSON CorrectInterfaceRequest where ciqUser <- Right <$> o JSON..: "user" <|> Left <$> o JSON..: "user" results <- o JSON..:? "results" ciqResults <- for results $ maybe (fail "Results may not be nullable") return . fromNullable - ciqGrade <- o JSON..:? "grade" + ciqGrade <- if + | "grade" `HashMap.member` o + -> Just <$> o JSON..: "grade" + | otherwise + -> pure Nothing return CorrectInterfaceRequest{..} @@ -104,7 +112,9 @@ postECorrectR tid ssh csh examn = do CorrectInterfaceRequest{..} <- requireCheckJsonBody - response <- exceptT return return . hoist runDB $ do + let mayEditResults = False + + response <- runDB . exceptT (<$ transactionUndo) return $ do Entity eId Exam{..} <- lift $ fetchExam tid ssh csh examn euid <- traverse decrypt ciqUser @@ -192,20 +202,22 @@ postECorrectR tid ssh csh examn = do | otherwise -> return Nothing | otherwise -> return mempty - newExamResult <- lift $ do + newExamResult <- for ciqGrade $ \ciqGrade' -> lift $ do + unless mayEditResults $ + permissionDeniedI MsgUnauthorizedExamCorrectorGrade mOldResult <- getBy $ UniqueExamResult eId uid if - | Just (Entity oldId _) <- mOldResult, is _Nothing ciqGrade -> do - delete oldId - audit $ TransactionExamResultDeleted eId uid - return Nothing - | Just result <- ciqGrade -> let + | Just (Entity oldId _) <- mOldResult, is _Nothing ciqGrade' -> do + delete oldId + audit $ TransactionExamResultDeleted eId uid + return Nothing + | Just result <- ciqGrade' -> let mOld = view passedGrade . examResultResult . entityVal <$> mOldResult resultGrade = review passedGrade result passedGrade :: Iso' ExamResultGrade ExamResultPassedGrade passedGrade = iso (fmap $ bool (Left . view passingGrade) Right examShowGrades) (fmap $ either (review passingGrade) id) in if - | ciqGrade /= mOld -> do + | ciqGrade' /= mOld -> do newResult <- upsert ExamResult { examResultExam = eId , examResultUser = uid