This repository has been archived on 2026-07-27. You can view files and clone it, but cannot push or open issues or pull requests.
fradrive-old/src/Handler/Tutorial/Users.hs
Jost, Steffen 92ff99a36e chore(tutorial): granting qualification automatically picks better expiry date
Previously, the form for granting tutorial users a qualification suggested the minimum of all expiry dates, if there where several course qualficiations. This lead to some users being granted driving licences being valid for only one month.

The expiry date can now be left blank, using the validDuration of the selected qualification instead.

The default is blank, if there are more than one course qualification having disagreeing qualification dates.
2025-03-26 16:58:07 +01:00

375 lines
20 KiB
Haskell

-- SPDX-FileCopyrightText: 2022-2025 Gregor Kleen <gregor.kleen@ifi.lmu.de>,Sarah Vaupel <sarah.vaupel@ifi.lmu.de>,Steffen Jost <s.jost@fraport.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later
{-# LANGUAGE TypeApplications, BlockArguments #-}
module Handler.Tutorial.Users
( getTUsersR, postTUsersR
, getTExamR, postTExamR
) where
import Import
import Control.Monad.Zip (munzip)
import Utils.Form
import Utils.Print
import Handler.Utils
import Handler.Utils.Course
import Handler.Utils.Course.Cache
import Handler.Utils.Tutorial
import Handler.Exam.Form (ExamOccurrenceForm(..), examOccurrenceMultiForm, upsertExamOccurrences, copyExamOccurrences)
import Database.Persist.Sql (deleteWhereCount)
import qualified Data.CaseInsensitive as CI
-- import qualified Data.Text as Text
import qualified Data.Set as Set
import qualified Data.Map as Map
import qualified Data.ByteString.Lazy as LBS
-- import qualified Data.Time.Zones as TZ
import Database.Esqueleto.Experimental ((:&)(..))
import qualified Database.Esqueleto.Experimental as E -- needs TypeApplications Lang-Pragma
import qualified Database.Esqueleto.Utils as E
import Handler.Course.Users
data TutorialUserAction
= TutorialUserAssignExam
| TutorialUserPrintQualification
| TutorialUserRenewQualification
| TutorialUserGrantQualification
| TutorialUserSendMail
| TutorialUserDeregister
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
instance Universe TutorialUserAction
instance Finite TutorialUserAction
nullaryPathPiece ''TutorialUserAction $ camelToPathPiece' 2
embedRenderMessage ''UniWorX ''TutorialUserAction id
data TutorialUserActionData
= TutorialUserPrintQualificationData
| TutorialUserRenewQualificationData
{ tuQualification :: QualificationId }
| TutorialUserGrantQualificationData
{ tuQualification :: QualificationId
, tuValidUntil :: Maybe Day
}
| TutorialUserSendMailData
| TutorialUserDeregisterData
| TutorialUserAssignExamData
{ tuOccurrenceId :: ExamOccurrenceId
, tuExaminerAgain :: Bool
, tuReassign :: Bool
}
deriving (Eq, Ord, Read, Show, Generic)
-- non-table form for general tutorial actions
data GenTutAction
= GenTutActShowExam
| GenTutActOccCopyWeek
| GenTutActOccCopyLast
| GenTutActOccEdit
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
deriving anyclass (Universe, Finite)
nullaryPathPiece ''GenTutAction $ camelToPathPiece' 1
embedRenderMessage ''UniWorX ''GenTutAction id
data GenTutActionData = GenTutActionData { gtaAct :: GenTutAction, gtaExam :: ExamId }
deriving (Eq, Ord, Show, Generic)
-- mkGenTutForm :: [Filter Exam] -> Form GenTutActionData
-- mkGenTutForm fltr = renderAForm FormStandard maa
-- where
-- maa = multiActionA acts (fslI MsgCourseExam) Nothing
-- acts :: Map GenTutAction (AForm Handler GenTutActionData)
-- acts = Map.fromList
-- [ (GenTutActOccCopy, GenTutActOccCopyData <$> areq (examFieldFilter (Just $ SomeMessage MsgMenuExamNew) fltr) (fslI MsgCourseExam) Nothing)
-- , (GenTutActOccEdit, GenTutActOccEditData <$> aopt (examFieldFilter (Just $ SomeMessage MsgMenuExamNew) fltr) (fslI MsgCourseExam) Nothing)
-- ]
mkGenTutForm :: [Filter Exam] -> Form GenTutActionData
mkGenTutForm fltr html = do
(actRes, actView) <- mreq (selectFieldList ((\a->(a,a)) <$> universeF)) (fslI MsgCourseExam) Nothing
(exmRes, exmView) <- mreq (examFieldFilter (Just $ SomeMessage MsgMenuExamNew) fltr) (fslI MsgCourseExam) Nothing
let res :: FormResult GenTutAction -> FormResult ExamId -> FormResult GenTutActionData
res (FormSuccess gta) (FormSuccess eid) = FormSuccess $ GenTutActionData{gtaAct=gta, gtaExam=eid}
res (FormFailure e1) (FormFailure e2) = FormFailure $ e1 <> e2
res (FormFailure e) _ = FormFailure e
res _ (FormFailure e) = FormFailure e
res _ _ = FormMissing
viw = [whamlet|
<p>
#{html}^{fvInput actView} _{MsgFor} ^{fvInput exmView}
|]
return (res actRes exmRes, viw)
getTUsersR, postTUsersR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler TypedContent
getTUsersR = postTUsersR
postTUsersR tid ssh csh tutn = do
let heading = prependCourseTitle tid ssh csh $ CI.original tutn
croute = CTutorialR tid ssh csh tutn TUsersR
now <- liftIO getCurrentTime
let nowaday = utctDay now
isAdmin <- hasReadAccessTo AdminR
(Entity tutid tut@Tutorial{..}, (participantRes, participantTable), qualifications, dbegin, hasExams, exmFltr, exOccs) <- runDB do
trm <- get404 tid
-- cid <- getKeyBy404 $ TermSchoolCourseShort tid ssh csh
-- tutEnt@(Entity tutid _) <- fetchTutorial tid ssh csh tutn
(cid, tutEnt@(Entity tutid _)) <- fetchCourseIdTutorial tid ssh csh tutn
qualifications <- getCourseQualifications cid
let dayExpiry = case nubOrd (mapMaybe qualificationValidDuration qualifications) of
[oneDuration] -> Just $ Just $ computeNewValidDate qvd nowaday -- suggest end day only if it is unique for all course qualifications
_ -> Nothing -- using the minimum here causes confusion, better leave blank!
colChoices = mconcat $
[ dbSelect (applying _2) id (return . view (hasEntity . _entityKey))
, colUserNameModalHdr MsgTableCourseMembers ForProfileDataR
, colUserEmail
, colUserMatriclenr isAdmin
] <>
[ colUserQualificationBlocked isAdmin nowaday q | q <- qualifications] <>
[ colUserExamOccurrencesCheck tid ssh csh
, colUserExams tid ssh csh
]
psValidator = def
& defaultSortingByName
& restrictSorting (\name _ -> none (== name) ["note", "registration", "tutorials", "exams", "submission-group", "state"]) -- We need to be careful to restrict allowed sorting/filter to not expose sensitive information
& restrictFilter (\name _ -> none (== name) ["tutorial", "exam", "submission-group", "active", "has-personalised-sheet-files"])
isInTut q = E.exists do
tutorialParticipant <- E.from $ E.table @TutorialParticipant
E.where_ $ tutorialParticipant E.^. TutorialParticipantUser E.==. queryUser q E.^. UserId
E.&&. tutorialParticipant E.^. TutorialParticipantTutorial E.==. E.val tutid
csvColChoices = flip elem ["name", "matriculation", "email", "qualifications"]
qualOptions = qualificationsOptionList qualifications
lessons = occurringLessons trm $ tutEnt ^. _entityVal . _tutorialTime . _Wrapped'
timespan = lessonTimesSpan lessons
(dbegin, dend) = munzip timespan
tbegin = toMidnight . succ <$> dbegin
tend = toMidnight <$> dend
exmFltr = ([ExamEnd >=. tbegin] ||. [ExamEnd ==. Nothing]) ++ [ExamCourse ==. cid, ExamStart <=. tend]
-- $logInfoS "ExamOccurrenceForm" [st|Exams from #{tshow tbegin} until #{tshow tend}.|]
exOccs <- flip foldMapM timespan $ getDayExamOccurrences False ssh $ Just cid -- :: ExamOccurrenceMap
hasExams <- if null exOccs then exists exmFltr else pure True
let
acts :: Map TutorialUserAction (AForm Handler TutorialUserActionData)
acts = Map.fromList $
bcons (not $ null exOccs)
( TutorialUserAssignExam
, TutorialUserAssignExamData
<$> apopt (selectField $ pure $ mkExamOccurrenceOptions exOccs) (fslI MsgCourseUserExamOccurrences) Nothing
<*> apopt checkBoxField (fslI MsgCourseUserExamOccurrenceAgainExaminer) (Just False)
<*> apopt checkBoxField (fslI MsgCourseUserExamOccurrenceOverride) (Just False)
) $
(if null qualifications then mempty else
[ ( TutorialUserRenewQualification
, TutorialUserRenewQualificationData
<$> apopt (selectField $ pure qualOptions) (fslI MsgQualificationName) Nothing
)
, ( TutorialUserGrantQualification
, TutorialUserGrantQualificationData
<$> apopt (selectField $ pure qualOptions) (fslI MsgQualificationName) Nothing
<*> aopt dayField (fslI MsgLmsQualificationValidUntil & setTooltip MsgTutorialUserGrantQualificationDateTooltip) dayExpiry
)
]
) ++
[ ( TutorialUserSendMail , pure TutorialUserSendMailData )
, ( TutorialUserDeregister , pure TutorialUserDeregisterData )
, ( TutorialUserPrintQualification, pure TutorialUserPrintQualificationData )
]
table <- makeCourseUserTable cid acts isInTut colChoices psValidator (Just csvColChoices)
return (tutEnt, table, qualifications, dbegin, hasExams, exmFltr, exOccs)
let courseQids = entities2map qualifications
tcontent <- formResultMaybe participantRes $ \case
(TutorialUserPrintQualificationData, selectedUsers) -> do
rcvr <- requireAuth
encRcvr <- encrypt $ entityKey rcvr
letters <- runDB $ makeCourseCertificates tut Nothing $ toList selectedUsers
let mbAletter = anyone letters
case mbAletter of
Nothing -> addMessageI Error MsgErrorUnknownFormAction >> return Nothing -- cannot really happen
Just aletter -> do
apcIdent <- letterApcIdent aletter encRcvr now
let fName = letterFileName aletter
renderLetters rcvr letters apcIdent >>= \case
Left err -> sendResponseStatus internalServerError500 $ "PDF generation failed: \n" <> err
Right pdf -> return $ Just (sendByteStringAsFile fName (LBS.toStrict pdf) now)
-- sendResponseByteStringFile "demoPDF.pdf" (LBS.toStrict pdf)
-- let typePDF :: ContentType
-- typePDF = "application/pdf"
-- sendResponse (typePDF, toContent pdf)
(TutorialUserGrantQualificationData{..}, selectedUsers)
| Just grantQual <- Map.lookup tuQualification courseQids ->
case tuValidUntil <|> (flip computeNewValidDate nowaday <$> qualificationValidDuration grantQual) of
Nothing -> do -- TODO: change QualificationUser to have an optionnal validUntil for idefinitely valid qualifications
addMessageI Error $ MsgTutorialUserGrantQualificationDateError $ qualificationShorthand grantQual
(Just expiryDay) -> do
let qsh = qualificationShorthand grantQual
reason = "Kurs " <> CI.original (unSchoolKey ssh) <> "-" <> CI.original csh <> "-" <> CI.original tutn
selUsrs = Set.toList selectedUsers
expiryDayText <- formatTime SelFormatDate expiryDay
nterm <- runDB $ do
forM_ selUsrs $ upsertQualificationUser tuQualification now expiryDay Nothing reason
terminateLms (LmsOrphanReasonManualGrant [st|bis #{expiryDayText}, #{reason}|]) tuQualification selUsrs
addMessageI (if 0 < Set.size selectedUsers then Success else Warning) . MsgTutorialUserGrantedQualification qsh expiryDayText $ Set.size selectedUsers
when (nterm > 0) $ addMessageI Warning $ MsgLmsActTerminated nterm
reloadKeepGetParams croute
(TutorialUserRenewQualificationData{..}, selectedUsers)
| Just grantQual <- Map.lookup tuQualification courseQids -> do
let qsh = qualificationShorthand grantQual
selUsrs = Set.toList selectedUsers
mr <- getMessageRender
(noks,nterm) <- runDB $ (,)
<$> renewValidQualificationUsers tuQualification Nothing Nothing selUsrs
<*> terminateLms (LmsOrphanReasonManualGrant $ mr heading) tuQualification selUsrs
addMessageI (if noks > 0 && noks == Set.size selectedUsers then Success else Warning) $ MsgTutorialUserRenewedQualification qsh noks
when (nterm > 0) $ addMessageI Warning $ MsgLmsActTerminated nterm
reloadKeepGetParams croute
(TutorialUserSendMailData, selectedUsers) -> do
cids <- traverse encrypt $ Set.toList selectedUsers :: Handler [CryptoUUIDUser]
redirect (CTutorialR tid ssh csh tutn TCommR, [(toPathPiece GetRecipient, toPathPiece cID) | cID <- cids])
(TutorialUserDeregisterData, selectedUsers) -> do
nrDel <- runDB $ deleteWhereCount
[ TutorialParticipantTutorial ==. tutid
, TutorialParticipantUser <-. Set.toList selectedUsers
]
addMessageI Success $ MsgTutorialUsersDeregistered nrDel
reloadKeepGetParams croute
(TutorialUserAssignExamData{..}, setSelectedUsers)
| (Just (ExamOccurrence{..}, _, (eid,_))) <- Map.lookup tuOccurrenceId exOccs -> do
assignRes <- runDB $ do
(Set.toList &&& Set.size -> (selectedUsers, nr_usrs)) <- if -- remove duplicate examiners, if desired
| isJust examOccurrenceExaminer && not tuExaminerAgain -> do
conflictingUsers <- E.select $ do
reg :& occ <- E.from $ E.table @ExamRegistration
`E.innerJoin` E.table @ExamOccurrence
`E.on` (\(reg :& occ) -> occ E.^. ExamOccurrenceId E.=?. reg E.^. ExamRegistrationOccurrence)
E.where_ $ occ E.^. ExamOccurrenceExaminer E.==. E.val examOccurrenceExaminer
E.&&. occ E.^. ExamOccurrenceExam E.!=. E.val examOccurrenceExam
E.&&. (reg E.^. ExamRegistrationUser `E.in_` E.vals setSelectedUsers)
E.orderBy [E.asc $ reg E.^. ExamRegistrationUser]
E.distinct $ pure $ reg E.^. ExamRegistrationUser
return $ setSelectedUsers `Set.difference` Set.fromAscList (E.unValue <$> conflictingUsers)
| otherwise -> return setSelectedUsers
runExceptT $ do
whenIsJust examOccurrenceCapacity $ \(fromIntegral -> totalCap) -> do
usedCap <- lift $ count [ExamRegistrationOccurrence ==. Just tuOccurrenceId, ExamRegistrationUser /<-. selectedUsers]
let remCap = totalCap - usedCap
when (nr_usrs > remCap) $ throwE $ MsgExamRoomCapacityInsufficient remCap
let regTemplate uid = ExamRegistration eid uid (Just tuOccurrenceId) now
lift $ if tuReassign
then putMany [regTemplate uid | uid <- selectedUsers] >> pure nr_usrs
else forM selectedUsers (insertUnique . regTemplate) <&> (length . catMaybes)
case assignRes of
Left errm -> do
addMessageI Error errm
return Nothing
Right nrOk -> do
let total = Set.size setSelectedUsers
allok = bool Warning Success $ nrOk == total
addMessageI allok $ MsgTutorialUserExamAssignedFor nrOk total $ ciOriginal examOccurrenceName
reloadKeepGetParams croute
_other -> addMessageI Error MsgErrorUnknownFormAction >> return Nothing
case tcontent of
Just act -> act -- execute action and return produced content (i.e. pdf)
Nothing -> do -- no table action content to return, continue normally
let mkExamCreateBtn = linkButton mempty (msg2widget MsgMenuExamNew) [BCIsButton, BCPrimary] $ SomeRoute $ CourseR tid ssh csh CExamNewR
((gtaRes, gtaWgt), gtaEnctype) <- runFormPost . identifyForm ("FIDGeneralTutorialAction"::Text) $ mkGenTutForm exmFltr
let gtaAnchor = "general-tutorial-action-form" :: Text
gtaRoute = croute :#: gtaAnchor
gtaForm = wrapForm' BtnPerform gtaWgt FormSettings
{ formMethod = POST
, formAction = Just . SomeRoute $ gtaRoute
, formEncoding = gtaEnctype
, formAttrs = []
, formSubmit = FormSubmit
, formAnchor = Just gtaAnchor
}
copyAction eId step = case dbegin of
Nothing -> addMessageI Error MsgExamOccurrenceCopyNoStartDate
Just dto ->
let cfailure = addMessageI Error MsgExamOccurrenceCopyFail
csuccess n = addMessageI Success (MsgExamOccurrencesCopied n) >> reloadKeepGetParams croute
copyFrom dfrom = copyExamOccurrences eId dfrom dto <&> (toMaybe =<< (> 0))
step_dto = addDays (negate step) dto
in maybeM cfailure csuccess $
runDB $ firstJustM $ map copyFrom $ take 69 $ drop 1 [dto, step_dto..] -- search for up to 2 months / 1 year backwards
formResult gtaRes $ \GenTutActionData{..} -> case gtaAct of
GenTutActOccCopyWeek -> copyAction gtaExam 7
GenTutActOccCopyLast -> copyAction gtaExam 1
GenTutActOccEdit -> do
Exam{examName=ename} <- runDBRead $ get404 gtaExam
redirect $ CTutorialR tid ssh csh tutn $ TExamR ename
GenTutActShowExam -> do
Exam{examName=ename} <- runDBRead $ get404 gtaExam
redirect (CExamR tid ssh csh ename EUsersR, [("exam-users-tutorial", toPathPiece tutn)])
tutors <- runDBRead $ E.select do
(tutor :& user) <- E.from $ E.table @Tutor `E.innerJoin` E.table @User
`E.on` (\(tutor :& user) -> tutor E.^. TutorUser E.==. user E.^. UserId)
E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid
return user
-- $(i18nWidgetFile "exam-missing")
html <- siteLayoutMsg heading do
setTitleI heading
$(widgetFile "tutorial-participants")
return $ toTypedContent html
getTExamR, postTExamR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> ExamName -> Handler Html
getTExamR = postTExamR
postTExamR tid ssh csh tutn exmName = do
let baseroute = CTutorialR tid ssh csh tutn
(Entity{entityKey=eId,entityVal=exm},exOccs) <- runDB do
trm <- get404 tid
(cid, tutEnt) <- fetchCourseIdTutorial tid ssh csh tutn
exm <- getBy404 $ UniqueExam cid exmName
let lessons = occurringLessons trm $ tutEnt ^. _entityVal . _tutorialTime . _Wrapped'
timespan = lessonTimesSpan lessons
-- (fmap (toMidnight . succ) -> tbegin, fmap toMidnight -> tend) = munzip timespan
-- exms <- selectList ([ExamCourse ==. cid, ExamStart <=. tend] ++ ([ExamEnd >=. tbegin] ||. [ExamEnd ==. Nothing])) [Asc ExamName]
exOccs <- flip foldMapM timespan $ getDayExamOccurrences False ssh $ Just cid
return (exm,exOccs)
cueId :: CryptoUUIDExam <- encrypt eId
let eid2eos = convertExamOccurrenceMap exOccs
(cuEoIds, eos) = munzip $ Map.lookup eId eid2eos
exOcForm = (,,)
<$> areq hiddenField "" (Just cueId)
<*> areq (mkSetField hiddenField) "" cuEoIds
<*> examOccurrenceMultiForm eos
((eofRes, eofWgt), eofEnctype) <- runFormPost $ identifyForm FIDTutorialExamOccurrences $ renderAForm FormStandard exOcForm
let eofForm = wrapForm eofWgt def{formEncoding = eofEnctype}
formResult eofRes $ \(edCEId, edCEOIds, edOccs) -> do
let ceoidsDelete = edCEOIds `Set.difference` setMapMaybe eofId edOccs
$logInfoS "ExamOccurrenceEdit" [st|Exam-Edit: #{length edCEOIds} old occurrences, #{length ceoidsDelete} to delete, #{length $ Set.filter (isNothing . eofId) edOccs} to insert, #{length $ Set.filter (isJust . eofId) edOccs} to edit|]
reId <- decrypt edCEId
eoIdsDelete <- mapM decrypt $ Set.toList ceoidsDelete
when (reId == eId) $ do
(fromIntegral -> nrDel, nrUps) <- runDB $ (,)
<$> deleteWhereCount [ExamOccurrenceExam ==. reId, ExamOccurrenceId <-. eoIdsDelete]
<*> upsertExamOccurrences eId (Set.toList edOccs)
let nr = nrUps + nrDel
mstat = if nr > 0 then Success else Warning
addMessageI mstat $ MsgExamOccurrencesEdited nrUps nrDel
reload $ baseroute $ TExamR exmName
let csh_tutn = csh <> "-" <> tutn -- hack to reuse prependCourseTitle
heading = prependCourseTitle tid ssh csh_tutn $ MsgMenuTutorialExam exmName
siteLayoutMsg heading do
-- setTitle $ citext2Html exmName
setTitleI heading
[whamlet|
<section>
<h2>#{CI.original exmName}
<p>#{examDescription exm}
<section>
^{eofForm}
|]