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