feat(exams): automatic exam occurrence assignment

This commit is contained in:
Gregor Kleen 2020-01-29 20:31:37 +01:00
parent f89545f36e
commit e994fafe28
26 changed files with 480 additions and 93 deletions

View File

@ -20,4 +20,4 @@ if [[ -d .stack-work-ghci ]]; then
trap move-back EXIT
fi
stack ghci --flag uniworx:dev --flag uniworx:library-only ${@:-uniworx:lib}
stack ghci --flag uniworx:dev --flag uniworx:library-only --ghci-options -fobject-code ${@:-uniworx:lib}

View File

@ -1267,6 +1267,7 @@ BreadcrumbExternalExamGrades: Prüfungsleistungen
BreadcrumbExternalExamStaffInvite: Einladung zum Prüfer
BreadcrumbParticipantsList: Kursteilnehmerlisten
BreadcrumbParticipants: Kursteilnehmerliste
BreadcrumbExamAutoOccurrence: Automatische Raumverteilung
ExternalExamEdit coursen@CourseName examn@ExamName: Bearbeiten: #{coursen}, #{examn}
ExternalExamGrades coursen@CourseName examn@ExamName: Prüfungsleistungen: #{coursen}, #{examn}
@ -1574,7 +1575,7 @@ ExamBonusRoundNonPositive: Vielfaches, auf das gerundet werden soll, muss positi
ExamBonusRoundTip: Bonuspunkte werden kaufmännisch auf ein Vielfaches der angegeben Zahl gerundet.
ExamAutomaticOccurrenceAssignment: Termin- bzw. Raumzuteilung
ExamAutomaticOccurrenceAssignmentTip: Sollen Prüfungsteilnehmer automatisch auf die zur Verfügung stehenden Räume bzw. Termine verteilt werden, sich selbstständig einen Raum bzw. Termin aussuchen dürfen oder manuell durch Kursverwalter zugeteilt werden? Manuelle Umverteilung bzw. vorheriges Festlegen von Zuteilungen einzelner Teilnehmer ist trotzdem möglich.
ExamAutomaticOccurrenceAssignmentTip: Sollen Prüfungsteilnehmer automatisch auf die zur Verfügung stehenden Räume bzw. Termine verteilt werden, sich selbstständig einen Raum bzw. Termin aussuchen dürfen oder manuell durch Kursverwalter zugeteilt werden? Die automatische Verteilung muss von einem Kursverwalter ausgelöst werden und geschieht nicht mit Ablauf einer Frist o.Ä.. Manuelle Umverteilung bzw. vorheriges Festlegen von Zuteilungen einzelner Teilnehmer ist somit immer möglich.
ExamOccurrenceRule: Verfahren
ExamOccurrenceRuleParticipant: Termin- bzw. Raumzuteilungsverfahren
ExamRoomManual': Keine automatische bzw. selbstständige Zuteilung
@ -2270,4 +2271,15 @@ ExternalExamUserMustBeStaff: Sie selbst müssen stets assoziierte Person sein, f
ExternalExamCourseExists: Der angegebene Kurs existiert im System. Prüfungen sollten daher direkt beim Kurs (statt extern) hinterlegt werden.
ExternalExamExists coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ existiert bereits.
ExternalExamCreated coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ erfolgreich angelegt.
ExternalExamEdited coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ erfolgreich bearbeitet.
ExternalExamEdited coursen@CourseName examn@ExamName: Prüfung „#{examn}“ für Kurs „#{coursen}“ erfolgreich bearbeitet.
ExamAutoOccurrenceMinimizeRooms: Verwendete Räume minimieren
ExamAutoOccurrenceMinimizeRoomsTip: Soll, für die Aufteilung, die Liste an Räumen zunächst reduziert werden, sodass nur so wenige Räume verwendet werden, wie nötig (größte zuerst)?
ExamAutoOccurrenceOccurrencesChangedInFlight: Raumliste wurde verändert
ExamAutoOccurrenceParticipantsAssigned num@Int64: Verteilungstabelle erfolgreich gespeichert und #{num} Teilnehmer zugewiesen
TitleExamAutoOccurrence tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{tid}-#{ssh}-#{csh} #{examn}: Automatische Raumverteilung
BtnExamAutoOccurrenceCalculate: Verteilung berechnen
BtnExamAutoOccurrenceAccept: Verteilung akzeptieren
ExamRoomMappingSurname: Nachnamen beginnend mit
ExamRoomMappingMatriculation: Matrikelnummern endend in
ExamRoomLoad: Auslastung

View File

@ -1572,7 +1572,7 @@ ExamBonusRoundNonPositive: Rounding multiple must be positive and greater than z
ExamBonusRoundTip: Bonus points are rounded commercially to a multiple of the given number
ExamAutomaticOccurrenceAssignment: Selection of occurrences/rooms for/by participants
ExamAutomaticOccurrenceAssignmentTip: Should exam participants be distributed automatically among the configured occurrences/rooms, should they instead be permitted to autonomously choose an occurrence/a room, or should they be assigned to occurrences/rooms manually by course administrators? Manipulation of the distribution and manually assigning participants remains possible.
ExamAutomaticOccurrenceAssignmentTip: Should exam participants be distributed automatically among the configured occurrences/rooms, should they instead be permitted to autonomously choose an occurrence/room, or should they be assigned to occurrences/rooms manually by course administrators? Automatic distribution needs to be triggered by a course administrator. It is not done automatically at a predefined time. Thus manipulation of the distribution and manually assigning participants remains possible.
ExamOccurrenceRule: Procedure
ExamOccurrenceRuleParticipant: Occurrence/room assignment procedure
ExamRoomManual': No automatic or autonomous assignment

View File

@ -4,6 +4,7 @@ Exam
gradingRule ExamGradingRule Maybe
bonusRule ExamBonusRule Maybe
occurrenceRule ExamOccurrenceRule
examOccurrenceMapping (ExamOccurrenceMapping ExamOccurrenceName) Maybe
visibleFrom UTCTime Maybe
registerFrom UTCTime Maybe
registerTo UTCTime Maybe

View File

@ -138,6 +138,7 @@ dependencies:
- prometheus-metrics-ghc
- wai-middleware-prometheus
- extended-reals
- rfc5051
other-extensions:
- GeneralizedNewtypeDeriving
@ -182,6 +183,8 @@ default-extensions:
- DeriveGeneric
- DeriveLift
- DeriveFunctor
- DeriveFoldable
- DeriveTraversable
- DerivingStrategies
- DerivingVia
- DataKinds

1
routes
View File

@ -188,6 +188,7 @@
/register ERegisterR POST !timeANDcourse-registeredAND¬exam-registered !timeANDexam-registeredAND¬exam-result
/register/#ExamOccurrenceName ERegisterOccR POST !exam-occurrence-registrationANDtimeANDcapacityANDcourse-registeredAND¬exam-occurrence-registered !exam-occurrence-registrationANDtimeANDexam-occurrence-registeredAND¬exam-result
/grades EGradesR GET POST !exam-office
/assign-occurrences EAutoOccurrenceR POST
/apps CApplicationsR GET POST
!/apps/files CAppsFilesR GET
/apps/#CryptoFileNameCourseApplication CourseApplicationR:

View File

@ -1,5 +1,5 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE UndecidableInstances, GeneralizedNewtypeDeriving #-}
module Database.Persist.Class.Instances
(
@ -10,12 +10,15 @@ import ClassyPrelude
import Database.Persist.Class
import Database.Persist.Types (HaskellName, DBName, PersistValue)
import Database.Persist.Types.Instances ()
import Database.Persist.Sql
import Data.Binary (Binary)
import qualified Data.Binary as Binary
import qualified Data.Map as Map
import Data.Aeson (ToJSONKey, FromJSONKey)
instance PersistEntity record => Hashable (Key record) where
hashWithSalt s = hashWithSalt s . toPersistValue
@ -34,3 +37,7 @@ uniqueToMap = fmap Map.fromList $ zip <$> persistUniqueToFieldNames <*> persistU
instance PersistEntity record => Eq (Unique record) where
(==) = (==) `on` uniqueToMap
deriving newtype instance ToJSONKey (BackendKey SqlBackend)
deriving newtype instance FromJSONKey (BackendKey SqlBackend)

View File

@ -1923,6 +1923,7 @@ instance YesodBreadcrumbs UniWorX where
EInviteR -> i18nCrumb MsgBreadcrumbExamParticipantInvite . Just $ CExamR tid ssh csh examn EShowR
ERegisterR -> i18nCrumb MsgBreadcrumbExamRegister . Just $ CExamR tid ssh csh examn EShowR
ERegisterOccR _occn -> i18nCrumb MsgBreadcrumbExamRegister . Just $ CExamR tid ssh csh examn EShowR
EAutoOccurrenceR -> i18nCrumb MsgBreadcrumbExamAutoOccurrence . Just $ CExamR tid ssh csh examn EUsersR
breadcrumb (CourseR tid ssh csh (TutorialR tutn sRoute)) = case sRoute of
TUsersR -> maybeT (i18nCrumb MsgBreadcrumbTutorial . Just $ CourseR tid ssh csh CTutorialListR) $ do

View File

@ -11,3 +11,4 @@ import Handler.Exam.Edit as Handler.Exam
import Handler.Exam.Show as Handler.Exam
import Handler.Exam.Users as Handler.Exam
import Handler.Exam.AddUser as Handler.Exam
import Handler.Exam.AutoOccurrence as Handler.Exam

View File

@ -0,0 +1,137 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Handler.Exam.AutoOccurrence
( examAutoOccurrenceCalculateWidget
, postEAutoOccurrenceR
) where
import Import
import Handler.Utils
import Handler.Utils.Exam
import qualified Data.Map as Map
import qualified Database.Esqueleto as E
import Database.Persist.Sql (updateWhereCount)
newtype ExamAutoOccurrenceCalculateForm = ExamAutoOccurrenceCalculateForm
{ eaofConfig :: ExamAutoOccurrenceConfig
} deriving stock (Eq, Ord, Read, Show, Generic, Typeable)
deriving newtype (Default)
data ExamAutoOccurrenceAcceptForm = ExamAutoOccurrenceAcceptForm
{ eaofMapping :: Maybe (ExamOccurrenceMapping ExamOccurrenceId)
, eaofAssignment :: Map UserId (Maybe ExamOccurrenceId)
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveJSON defaultOptions
{ fieldLabelModifier = camelToPathPiece' 1
} ''ExamAutoOccurrenceAcceptForm
data ExamAutoOccurrenceButton
= BtnExamAutoOccurrenceCalculate
| BtnExamAutoOccurrenceAccept
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
instance Universe ExamAutoOccurrenceButton
instance Finite ExamAutoOccurrenceButton
nullaryPathPiece ''ExamAutoOccurrenceButton $ camelToPathPiece' 4
embedRenderMessage ''UniWorX ''ExamAutoOccurrenceButton id
instance Button UniWorX ExamAutoOccurrenceButton where
btnClasses _ = [BCIsButton, BCPrimary]
examAutoOccurrenceCalculateForm :: ExamAutoOccurrenceCalculateForm -> Form ExamAutoOccurrenceCalculateForm
examAutoOccurrenceCalculateForm ExamAutoOccurrenceCalculateForm{ eaofConfig }
= identifyForm FIDExamAutoOccurrenceCalculate . renderAForm FormStandard $ ExamAutoOccurrenceCalculateForm <$> eaocForm
where
eaocForm =
(set _eaocMinimizeRooms <$> apopt checkBoxField (fslI MsgExamAutoOccurrenceMinimizeRooms & setTooltip MsgExamAutoOccurrenceMinimizeRoomsTip) (Just $ eaofConfig ^. _eaocMinimizeRooms))
<*> pure def
examAutoOccurrenceAcceptForm :: Maybe ExamAutoOccurrenceAcceptForm -> Form ExamAutoOccurrenceAcceptForm
examAutoOccurrenceAcceptForm confirmData = identifyForm FIDExamAutoOccurrenceConfirm $ \html -> do
(confirmDataRes, confirmDataView) <- mreq secretJsonField "" confirmData
(acceptRes, acceptView) <- buttonForm' [BtnExamAutoOccurrenceAccept] mempty
return (acceptRes *> confirmDataRes, toWidget html <> fvInput confirmDataView <> acceptView)
examAutoOccurrenceCalculateWidget :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Widget
examAutoOccurrenceCalculateWidget tid ssh csh examn = do
(formView, formEncoding) <- liftHandler . generateFormPost $ examAutoOccurrenceCalculateForm def
wrapForm' BtnExamAutoOccurrenceCalculate formView def
{ formAction = Just . SomeRoute $ CExamR tid ssh csh examn EAutoOccurrenceR
, formEncoding
}
postEAutoOccurrenceR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html
postEAutoOccurrenceR tid ssh csh examn = do
(Entity eId Exam{ examOccurrenceRule }, occurrences) <- runDB $ do
exam@(Entity eId _) <- fetchExam tid ssh csh examn
occurrences <- selectList [ ExamOccurrenceExam ==. eId ] [ Asc ExamOccurrenceName ]
return (exam, occurrences)
((calculateRes, _), _) <- runFormPost $ examAutoOccurrenceCalculateForm def
calcResult <- formResultMaybe calculateRes $ \ExamAutoOccurrenceCalculateForm{..} -> runDB $ do
participants <- E.select . E.from $ \(registration `E.InnerJoin` user) -> do
E.on $ registration E.^. ExamRegistrationUser E.==. user E.^. UserId
E.where_ $ registration E.^. ExamRegistrationExam E.==. E.val eId
return (user, registration)
let participants' = Map.fromList $ do
(Entity uid userRec, Entity _ ExamRegistration{..}) <- participants
return (uid, (userRec, examRegistrationOccurrence))
occurrences' = Map.fromList $ map (\(Entity eoId ExamOccurrence{..}) -> (eoId, examOccurrenceCapacity)) occurrences
(eaofMapping, eaofAssignment) = examAutoOccurrence eId examOccurrenceRule eaofConfig occurrences' participants'
return $ Just ExamAutoOccurrenceAcceptForm{..}
((confirmRes, confirmView), confirmEncoding) <- runFormPost $ examAutoOccurrenceAcceptForm calcResult
let confirmWidget = wrapForm confirmView def
{ formAction = Just . SomeRoute $ CExamR tid ssh csh examn EAutoOccurrenceR
, formEncoding = confirmEncoding
, formSubmit = FormNoSubmit
}
formResult confirmRes $ \ExamAutoOccurrenceAcceptForm{..} -> do
Sum assignedCount <- runDB $ do
let eaofMapping'' :: Maybe (Maybe (ExamOccurrenceMapping ExamOccurrenceName))
eaofMapping'' = (<$> eaofMapping) . traverseExamOccurrenceMapping $ \eoId -> case filter ((== eoId) . entityKey) occurrences of
[Entity _ ExamOccurrence{..}] -> Just examOccurrenceName
_other -> Nothing
eaofMapping' <- case eaofMapping'' of
Nothing -> return Nothing
Just Nothing -> invalidArgsI [MsgExamAutoOccurrenceOccurrencesChangedInFlight]
Just (Just x ) -> return $ Just x
update eId [ ExamExamOccurrenceMapping =. eaofMapping' ]
fmap fold . iforM eaofAssignment $ \pid occ -> case occ of
Just _ -> Sum <$> updateWhereCount [ ExamRegistrationExam ==. eId, ExamRegistrationUser ==. pid, ExamRegistrationOccurrence ==. Nothing ] [ ExamRegistrationOccurrence =. occ ]
Nothing -> return mempty
addMessageI Success $ MsgExamAutoOccurrenceParticipantsAssigned assignedCount
redirect $ CExamR tid ssh csh examn EUsersR
ExamAutoOccurrenceAcceptForm{..} <- maybe (redirect $ CExamR tid ssh csh examn EUsersR) return calcResult
let heading = MsgTitleExamAutoOccurrence tid ssh csh examn
mappingWgt
= let occLoads :: Map ExamOccurrenceId Natural
occLoads = Map.fromListWith (+) . mapMaybe (\(_, mOcc) -> (, 1) <$> mOcc) $ Map.toList eaofAssignment
occLoad = fromMaybe 0 . flip Map.lookup occLoads
occMappingRule = examOccurrenceMappingRule <$> eaofMapping
loadProp curr max'
| max' /= 0 = MsgProportion (toMessage curr) (toMessage max') (toRational curr / toRational max')
| otherwise = MsgProportionNoRatio (toMessage curr) (toMessage max')
occMapping occId = examOccurrenceMappingDescriptionWidget <$> occMappingRule <*> (Map.lookup occId . examOccurrenceMappingMapping =<< eaofMapping)
in $(widgetFile "widgets/exam-occurrence-mapping")
siteLayoutMsg heading $ do
setTitleI heading
$(widgetFile "exam/auto-occurrence-confirm")

View File

@ -35,6 +35,7 @@ postEEditR tid ssh csh examn = do
, examGradingRule = efGradingRule
, examBonusRule = efBonusRule
, examOccurrenceRule = efOccurrenceRule
, examExamOccurrenceMapping = examExamOccurrenceMapping oldExam
, examVisibleFrom = efVisibleFrom
, examRegisterFrom = efRegisterFrom
, examRegisterTo = efRegisterTo

View File

@ -32,6 +32,7 @@ postCExamNewR tid ssh csh = do
, examGradingRule = efGradingRule
, examBonusRule = efBonusRule
, examOccurrenceRule = efOccurrenceRule
, examExamOccurrenceMapping = Nothing
, examVisibleFrom = efVisibleFrom
, examRegisterFrom = efRegisterFrom
, examRegisterTo = efRegisterTo

View File

@ -145,6 +145,7 @@ getEShowR tid ssh csh examn = do
showAchievedPoints = not $ null results
showOccurrenceRegisterColumn = occurrenceAssignmentsShown || (mayRegister && examOccurrenceRule == ExamRoomFifo)
markUnregisteredOccurrences mOcc = occurrenceAssignmentsShown && hasRegistration && isn't _Just (registerWidget mOcc)
showOccurrenceMappingColumn = examOccurrenceRuleAutomatic examOccurrenceRule && occurrenceAssignmentsShown && is _Just examExamOccurrenceMapping
let heading = prependCourseTitle tid ssh csh $ CI.original examName
@ -161,4 +162,7 @@ getEShowR tid ssh csh examn = do
examBonusW :: ExamBonusRule -> Widget
examBonusW bonusRule = $(widgetFile "widgets/bonusRule")
occurrenceMapping :: ExamOccurrenceName -> Maybe Widget
occurrenceMapping occName = examOccurrenceMappingDescriptionWidget <$> fmap examOccurrenceMappingRule examExamOccurrenceMapping <*> (fmap examOccurrenceMappingMapping examExamOccurrenceMapping >>= Map.lookup occName)
$(widgetFile "exam-show")

View File

@ -11,6 +11,8 @@ import Handler.Utils.Exam
import Handler.Utils.Users
import Handler.Utils.Csv
import Handler.Exam.AutoOccurrence (examAutoOccurrenceCalculateWidget)
import Handler.ExamOffice.Exam (examCloseWidget)
import qualified Database.Esqueleto as E
@ -390,7 +392,7 @@ embedRenderMessage ''UniWorX ''ExamUserCsvException id
getEUsersR, postEUsersR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html
getEUsersR = postEUsersR
postEUsersR tid ssh csh examn = do
(((Any computedValues, registrationResult), examUsersTable), Entity eId examVal, bonus) <- runDB $ do
(((Any computedValues, registrationResult), examUsersTable), Entity eId examVal@Exam{..}, bonus) <- runDB $ do
exam@(Entity eid examVal@Exam{..}) <- fetchExam tid ssh csh examn
examParts <- selectList [ExamPartExam ==. eid] [Asc ExamPartName]
bonus <- examBonus exam

View File

@ -1,12 +1,17 @@
{-# OPTIONS_GHC -fno-warn-deprecations #-}
module Handler.Utils.Exam
( fetchExamAux
, fetchExam, fetchExamId, fetchCourseIdExamId, fetchCourseIdExam
, examBonus, examBonusPossible, examBonusAchieved
, examResultBonus, examGrade
, ExamAutoOccurrenceConfig
, eaocMinimizeRooms, eaocFinenessCost
, _eaocMinimizeRooms, _eaocFinenessCost
, examAutoOccurrence
) where
import Import.NoFoundation hiding (distribute)
import Import.NoFoundation
import Database.Persist.Sql (SqlBackendCanRead)
import qualified Database.Esqueleto as E
@ -26,7 +31,7 @@ import qualified Data.CaseInsensitive as CI
import Control.Monad.Trans.Random.Lazy (evalRand)
import System.Random (mkStdGen)
import Control.Monad.Random.Class (weightedMay)
import Control.Monad.Random.Class (weighted)
import Control.Monad.ST (ST, runST)
import Data.Array (Array)
@ -40,9 +45,10 @@ import qualified Data.List as List
import Data.ExtendedReal
import qualified Data.Text as Text
import qualified Data.Char as Char
import qualified Data.RFC5051 as RFC5051
fetchExamAux :: ( SqlBackendCanRead backend
, E.SqlSelect b a
@ -184,17 +190,32 @@ examGrade Exam{..} mBonus (otoList -> results)
where
lowerBounds :: [(ExamGrade, Points)]
lowerBounds = zip [Grade40, Grade37 ..] examGradingKey'
data ExamAutoOccurrenceConfig = ExamAutoOccurrenceConfig
{ eaocMinimizeRooms :: Bool
, eaocFinenessCost :: Rational -- ^ Cost factor incentivising shorter common prefixes on breaks between rooms
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
instance Default ExamAutoOccurrenceConfig where
def = ExamAutoOccurrenceConfig
{ eaocMinimizeRooms = False
, eaocFinenessCost = 0.2
}
makeLenses_ ''ExamAutoOccurrenceConfig
examAutoOccurrence :: forall seed.
Hashable seed
=> seed
-> ExamOccurrenceRule
-> ExamAutoOccurrenceConfig
-> Map ExamOccurrenceId Natural
-> Map UserId (User, Maybe ExamOccurrenceId)
-> (Maybe (ExamOccurrenceMapping ExamOccurrenceId), Map UserId (Maybe ExamOccurrenceId))
examAutoOccurrence (hash -> seed) rule occurrences users
examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences users
| sum occurrences < usersCount
|| sum occurrences <= 0
|| Map.null users
= nullResult
| otherwise
@ -203,8 +224,8 @@ examAutoOccurrence (hash -> seed) rule occurrences users
-> ( Nothing
, flip Map.mapWithKey users $ \uid (_, mOcc)
-> let randomOcc = flip evalRand (mkStdGen $ hashWithSalt seed uid) $
weightedMay $ over _2 fromIntegral <$> occurrences'
in mOcc <|> randomOcc
weighted $ over _2 fromIntegral <$> occurrences'
in Just $ fromMaybe randomOcc mOcc
)
_ | Just (postprocess -> (resMapping, result)) <- bestOption
-> ( Just $ ExamOccurrenceMapping rule resMapping
@ -221,38 +242,21 @@ examAutoOccurrence (hash -> seed) rule occurrences users
users' = case rule of
ExamRoomSurname
-> Map.fromListWith Set.union
[ (map CI.mk $ unpack userSurname', Set.singleton uid)
[ (map CI.mk $ unpack userSurname, Set.singleton uid)
| (uid, (User{..}, Nothing)) <- Map.toList users
, let userSurname' = Text.filter Char.isLetter userSurname
, not $ null userSurname'
, not $ null userSurname
]
ExamRoomMatriculation
-> let matrUsers
= Map.fromListWith Set.union
[ (map CI.mk $ unpack matriculation', Set.singleton uid)
| (uid, (User{..}, Nothing)) <- Map.toList users
, let Just matriculation' = Text.filter Char.isDigit <$> userMatrikelnummer
, let Just matriculation' = userMatrikelnummer
, not $ null matriculation'
]
in Map.mapKeysWith Set.union (take . F.minimum . Set.map length $ Map.keysSet matrUsers) matrUsers
_ -> Map.singleton [] $ Map.keysSet users
usersGroups :: Natural -- ^ fineness
-> Map [CI Char] (Set UserId)
-- ^ Partition users into monotonously finer
usersGroups (fromIntegral -> c) = Map.mapKeysWith Set.union restr users'
where
restr = case rule of
ExamRoomSurname
-> take c
ExamRoomMatriculation
-> reverse . take c . reverse
_other
-> id
maximumFineness :: Natural
-- ^ Fineness at which `usersGroups` becomes constant
maximumFineness = fromIntegral . F.maximum . Set.map length $ Map.keysSet users'
occurrences' :: [(ExamOccurrenceId, Natural)]
-- ^ Minimise number of occurrences used
@ -262,6 +266,8 @@ examAutoOccurrence (hash -> seed) rule occurrences users
-- If a single occurrence can accomodate all participants, pick the one with
-- the least capacity
occurrences'
| not eaocMinimizeRooms
= Map.toList occurrences
| Just largeEnoughs <- fromNullable . filter ((>= usersCount) . view _2) $ Map.toList occurrences
= pure $ minimumBy (comparing $ view _2) largeEnoughs
| otherwise
@ -278,24 +284,12 @@ examAutoOccurrence (hash -> seed) rule occurrences users
, occ : accOccs
)
largestOccurrence :: Num a => a
largestOccurrence = fromIntegral . maximum . mapNonNull (view _2) $ impureNonNull occurrences'
finenessCost :: Natural -> Natural
finenessCost x = round (finenessConst * largestOccurrence) * fromIntegral (length occurrences') * x * x
where
finenessConst :: Rational
-- ^ Cost (scaled to proportion of occurrence) of having higher fineness
finenessConst = 1 % 5 -- TODO: tweak
distribute :: forall wordId lineId cost.
( Num cost
, Ord wordId, Ord lineId
)
_
=> [(wordId, Natural)]
-> [(lineId, Natural)]
-> Maybe (cost, Map lineId (Set wordId))
-> (wordId -> wordId -> Extended Rational)
-> Maybe (cost, [(lineId, [wordId])])
-- ^ Distribute the given items (@wordId@s) with associated size in
-- contiguous blocks into the given buckets (@lineId@s) such that they are
-- filled as evenly as possible (proportionally)
@ -303,8 +297,8 @@ examAutoOccurrence (hash -> seed) rule occurrences users
-- Return a cost scaled to item-size squared
--
-- See <https://xxyxyz.org/line-breaking/> under \"Shortest Path\"
distribute wordLengths lineLengths
| null wordLengths = Just (0, Map.empty)
distribute wordLengths lineLengths breakCost
| null wordLengths = Just (0, [ (l, []) | (l, _) <- lineLengths ])
| null lineLengths = Nothing
| otherwise = let (cost, result) = distribute'
in case cost of
@ -344,7 +338,7 @@ examAutoOccurrence (hash -> seed) rule occurrences users
bounds = (0, Map.size wordMap)
distribute' :: (Extended Rational, Map lineId (Set wordId))
distribute' :: (Extended Rational, [(lineId, [wordId])])
distribute' = runST $ do
minima <- ST.newListArray (0, Map.size wordMap) $ 0 : repeat PosInf :: forall s. ST s (STArray s Int (Extended Rational))
breaks <- ST.newArray (0, Map.size wordMap) 0 :: forall s. ST s (STUArray s Int Int)
@ -363,7 +357,22 @@ examAutoOccurrence (hash -> seed) rule occurrences users
| otherwise
= 0
w = offsets Array.! j - offsets Array.! i
cost <- (+) (widthCost potWidth w) <$> ST.readArray minima i
prevMin <- ST.readArray minima i
let cost = prevMin + widthCost potWidth w + breakCost'
breakCost'
| j < Map.size wordMap
, j > 0
= breakCost (wordIx # pred j) (wordIx # j)
| otherwise
= 0
traceM $ show ( i
, j
, potWidth
, w
, (fromRational :: Rational -> Centi) <$> prevMin
, (fromRational :: Rational -> Centi) <$> widthCost potWidth w
, (fromRational :: Rational -> Centi) <$> breakCost'
)
when (isFinite cost) $ do
minCost <- ST.readArray minima j
when (cost < minCost) $ do
@ -372,66 +381,119 @@ examAutoOccurrence (hash -> seed) rule occurrences users
go i' $ succ j
| otherwise = return ()
in go i' $ succ i'
traceM . show . map (fmap (fromRational :: Rational -> Centi)) =<< ST.getElems minima
traceM . show =<< ST.getElems breaks
let accumResult lineIx j (accCost, accMap) = do
i <- ST.readArray breaks j
accCost' <- (+) accCost <$> ST.readArray minima j
traceM $ show (accCost', lineIx, [i .. pred j])
let accMap' = Map.insertWith Set.union (lineIxs List.!! lineIx) (Set.fromList $ map (review wordIx) [i .. pred j]) accMap
traceM $ show ((fromRational :: Rational -> Centi) <$> accCost', lineIx, (i, pred j))
let accMap' = (lineIxs List.!! lineIx, map (review wordIx) [i .. pred j]) : accMap
if
| i > 0 -> accumResult (succ lineIx) i (accCost', accMap')
| otherwise -> return (accCost', accMap')
lineIxs = reverse $ map (view _1) lineLengths
in accumResult 0 (Map.size wordMap) (0, Map.empty)
in accumResult 0 (Map.size wordMap) (0, [])
widthCost :: Natural -> Natural -> Extended Rational
widthCost lineWidth w
| lineWidth < w = PosInf
| otherwise = Finite (((fromIntegral lineWidth % fromIntegral w) - optimumRatio) * fromIntegral longestLine) ^ 2
| otherwise = Finite (max 1 . abs $ ((fromIntegral w % fromIntegral lineWidth) - optimumRatio) * fromIntegral longestLine) ^ 2
where
optimumRatio = ((%) `on` fromIntegral . sum) (map (view _2) lineLengths) (map (view _2) wordLengths)
optimumRatio = ((%) `on` fromIntegral . sum) (map (view _2) wordLengths) (map (view _2) lineLengths)
charCost :: [CI Char] -> [CI Char] -> Extended Rational
charCost pA pB = Finite (max 1 $ List.genericLength (pA `lcp` pB) * eaocFinenessCost * fromIntegral longestLine) ^ 2
where
longestLine = maximum . mapNonNull (view _2) $ impureNonNull occurrences'
options :: [(Natural, (Natural, Map ExamOccurrenceId (Set [CI Char])))]
options = do
fineness <- [0..maximumFineness]
let usersGroups' = fromIntegral . Set.size <$> usersGroups fineness
traceM $ show usersGroups'
traceM . show $ map snd occurrences'
-- The algorithm used in `distribute` produces no usable result if the
-- situation occurs, that a single item does not fit within a bucket.
-- In a naive attempt to prevent this we ensure that all items fit into
-- all buckets.
guard . (\(fromIntegral -> maxSize) -> all ((>= maxSize) . view _2) occurrences') . maybe 0 maximum $ fromNullable usersGroups'
lcp :: Eq a => [a] -> [a] -> [a]
-- ^ Longest common prefix
lcp [] _ = []
lcp _ [] = []
lcp (a:as) (b:bs)
| a == b = a:lcp as bs
| otherwise = []
let
packets :: [([CI Char], Natural)]
packets = Map.toAscList usersGroups'
(resultCost, result) <- hoistMaybe $ distribute packets occurrences'
bestOption :: Maybe [(ExamOccurrenceId, [[CI Char]])]
bestOption = do
(_cost, res) <- distribute (sortBy (RFC5051.compareUnicode `on` toListOf (_1 . folded . to CI.foldedCase)) . Map.toAscList $ fromIntegral . Set.size <$> users') occurrences' charCost
traceM $ show (fineness, finenessCost fineness, resultCost)
traceM . show . map (foldMap $ \prefix -> Sum $ usersGroups' Map.! prefix) $ Map.elems result
return (fineness, (resultCost, result))
bestOption :: Maybe (Map ExamOccurrenceId (Set [CI Char]))
bestOption = options
& over _tail (takeWhile $ \(fineness, (resCost, _)) -> finenessCost fineness <= resCost)
& map (view $ _2 . _2)
& fmap last . fromNullable
-- traceM $ show cost
postprocess :: Map ExamOccurrenceId (Set [CI Char])
-> ( [(ExamOccurrenceId, [CI Char])]
return res
postprocess :: [(ExamOccurrenceId, [[CI Char]])]
-> ( Map ExamOccurrenceId (Set ExamOccurrenceMappingDescription)
, Map UserId (Maybe ExamOccurrenceId)
)
postprocess result = (resultAscList, resultUsers)
where
resultAscList = sortOn (view _2) . map (over _2 Set.findMax) $ Map.toList result
resultAscList = Map.fromListWith Set.union $ accRes (pure <$> Set.lookupMin rangeAlphabet) result
where
accRes _ [] = []
accRes prevEnd ((occA, nsA) : (occB, nsB) : xs)
| Just minA <- prevEnd <|> preview _head nsA
, Just maxA <- nsA ^? _last
, Just minB <- nsB ^? _head
= let common = maxA `lcp` minB
mayRange' = mayRange . max 1 . succ $ length common
suffA = CI.foldedCase <$> drop (length common) maxA
suffB = CI.foldedCase <$> drop (length common) minB
in if
| mayRange (succ $ length common) maxA
, mayRange (succ $ length common) minA
, mayRange (succ $ length common) minB
, firstA : _ <- suffA
, firstB : _ <- suffB
-> let break' = (occSize occA * Char.ord firstA + occSize occB * Char.ord firstB) % (occSize occA + occSize occB)
& floor
& Char.chr
& Char.toUpper
& CI.mk
& pure
& (common ++)
succBreak = fmap reverse . go $ reverse break'
where
go [] = Nothing
go (c:cs)
| c' <- CI.map succ c
, c' `Set.member` rangeAlphabet
= Just $ c' : cs
| otherwise
= go cs
in (occA, Set.insert (ExamOccurrenceMappingRange minA break') . Set.map (ExamOccurrenceMappingSpecial . take (max 1 $ length common)) . Set.filter (not . mayRange') $ Set.fromList nsA) : accRes succBreak ((occB, nsB) : xs)
| otherwise
-> (occA, Set.map (ExamOccurrenceMappingSpecial . take (max 1 $ length common)) $ Set.fromList nsA) : accRes prevEnd ((occB, nsB) : xs)
| null nsA
= accRes prevEnd $ (occB, nsB) : xs
| otherwise -- null nsB
= accRes prevEnd $ (occA, nsA) : xs
accRes prevEnd [(occZ, nsZ)]
| Just minAlpha <- Set.lookupMin rangeAlphabet
, Just maxAlpha <- Set.lookupMax rangeAlphabet
, minZ <- fromMaybe (pure minAlpha) prevEnd
= let commonLength = max 1 $ length minZ
in pure (occZ, Set.insert (ExamOccurrenceMappingRange minZ $ replicate commonLength maxAlpha) . Set.map (ExamOccurrenceMappingSpecial . take commonLength) . Set.filter (not . mayRange commonLength) $ Set.fromList nsZ)
| otherwise
= pure (occZ, Set.map (ExamOccurrenceMappingSpecial . take (max 1 $ maybe 0 length prevEnd)) $ Set.fromList nsZ)
resultUsers = Map.fromList $ do
(occId, buckets) <- Map.toList result
user <- Set.toList $ foldMap (\b -> foldMap snd . filter (\(b', _) -> b `List.isPrefixOf` b') $ Map.toList users') buckets
(occId, buckets) <- result
user <- Set.toList $ foldMap (\b -> foldMap snd . filter (\(b', _) -> b == b') $ Map.toList users') buckets
return (user, Just occId)
occSize :: Num a => ExamOccurrenceId -> a
occSize occId = fromIntegral . length $ Map.filter (== Just occId) resultUsers
rangeAlphabet :: Set (CI Char)
rangeAlphabet
| ExamRoomSurname <- rule
= Set.fromList $ map CI.mk ['A'..'Z']
| ExamRoomMatriculation <- rule
= Set.fromList $ map CI.mk ['0'..'9']
| otherwise
= mempty
mayRange :: Int -> [CI Char] -> Bool
mayRange l = all (`Set.member` rangeAlphabet) . take l

View File

@ -8,6 +8,8 @@ import Text.Hamlet (shamletFile)
import Handler.Utils.DateTime
import qualified Data.Char as Char
---------
-- Simple utilities for consistent display
@ -102,3 +104,14 @@ i18n :: forall m msg.
, RenderMessage (HandlerSite m) msg
) => msg -> m ()
i18n = toWidget . (SomeMessage :: msg -> SomeMessage (HandlerSite m))
examOccurrenceMappingDescriptionWidget :: ExamOccurrenceRule -> Set ExamOccurrenceMappingDescription -> Widget
examOccurrenceMappingDescriptionWidget rule descriptions = $(widgetFile "widgets/exam-occurrence-mapping-description")
where
titleCase = over _head Char.toUpper . map CI.foldedCase
doPrefix
| ExamRoomMatriculation <- rule
= False
| otherwise
= True

View File

@ -37,6 +37,12 @@ share [mkPersist sqlSettings, mkDeleteCascade sqlSettings, mkMigrate "migrateAll
submissionRatingDone :: Submission -> Bool
submissionRatingDone Submission{..} = isJust submissionRatingTime
deriving newtype instance ToJSONKey UserId
deriving newtype instance FromJSONKey UserId
deriving newtype instance ToJSONKey ExamOccurrenceId
deriving newtype instance FromJSONKey ExamOccurrenceId
-- ToMarkup and ToMessage instances for displaying selected database primary keys
instance ToMarkup (Key School) where

View File

@ -11,7 +11,14 @@ module Model.Types.Exam
, _examResult
, ExamBonusRule(..)
, ExamOccurrenceRule(..)
, examOccurrenceRuleAutomatic
, ExamOccurrenceMappingDescription(..)
, _eaomrStart, _eaomrEnd, _eaomrSpecial
, _ExamOccurrenceMappingRange, _ExamOccurrenceMappingSpecial
, ExamOccurrenceMapping(..)
, _examOccurrenceMappingRule
, _examOccurrenceMappingMapping
, traverseExamOccurrenceMapping
, ExamGrade(..)
, numberGrade
, ExamGradeDefCenter(..)
@ -28,6 +35,8 @@ import Import.NoModel
import Model.Types.Common
import qualified Data.Text as Text
import qualified Data.Map as Map
import qualified Data.Set as Set
import Utils.Lens.TH
@ -44,6 +53,8 @@ import Text.Blaze (ToMarkup(..))
import qualified Data.Foldable
import Data.Aeson (genericToJSON, genericParseJSON)
data ExamResult' res = ExamAttended { examResult :: res }
| ExamNoShow
@ -152,18 +163,51 @@ deriveJSON defaultOptions
, tagSingleConstructors = True
} ''ExamOccurrenceRule
derivePersistFieldJSON ''ExamOccurrenceRule
makePrisms ''ExamOccurrenceRule
examOccurrenceRuleAutomatic :: ExamOccurrenceRule -> Bool
examOccurrenceRuleAutomatic x = or $ map ($ x)
[ is _ExamRoomSurname
, is _ExamRoomMatriculation
, is _ExamRoomRandom
]
data ExamOccurrenceMappingDescription
= ExamOccurrenceMappingRange { eaomrStart, eaomrEnd :: [CI Char] }
| ExamOccurrenceMappingSpecial { eaomrSpecial :: [CI Char] }
deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveJSON defaultOptions
{ fieldLabelModifier = camelToPathPiece' 1
, constructorTagModifier = camelToPathPiece' 3
} ''ExamOccurrenceMappingDescription
makeLenses_ ''ExamOccurrenceMappingDescription
makePrisms ''ExamOccurrenceMappingDescription
data ExamOccurrenceMapping roomId = ExamOccurrenceMapping
{ examOccurrenceMappingRule :: ExamOccurrenceRule
, examOccurrenceMappingMapping :: [(roomId, [CI Char])]
, examOccurrenceMappingMapping :: Map roomId (Set ExamOccurrenceMappingDescription)
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveJSON defaultOptions
{ fieldLabelModifier = camelToPathPiece' 3
, constructorTagModifier = camelToPathPiece' 1
, tagSingleConstructors = False
} ''ExamOccurrenceMapping
instance ToJSONKey roomId => ToJSON (ExamOccurrenceMapping roomId) where
toJSON = genericToJSON defaultOptions
{ fieldLabelModifier = camelToPathPiece' 3
, constructorTagModifier = camelToPathPiece' 1
, tagSingleConstructors = False
}
instance (FromJSONKey roomId, Ord roomId) => FromJSON (ExamOccurrenceMapping roomId) where
parseJSON = genericParseJSON defaultOptions
{ fieldLabelModifier = camelToPathPiece' 3
, constructorTagModifier = camelToPathPiece' 1
, tagSingleConstructors = False
}
derivePersistFieldJSON ''ExamOccurrenceMapping
makeLenses_ ''ExamOccurrenceMapping
traverseExamOccurrenceMapping :: Ord roomId'
=> Traversal (ExamOccurrenceMapping roomId) (ExamOccurrenceMapping roomId') roomId roomId'
traverseExamOccurrenceMapping = _examOccurrenceMappingMapping . iso Map.toList (Map.fromListWith Set.union) . traverse . _1
data ExamGrade
= Grade50

View File

@ -229,6 +229,7 @@ data FormIdentifier
| FIDUserAuthMode
| FIDAllUsersAction
| FIDLanguage
| FIDExamAutoOccurrenceCalculate | FIDExamAutoOccurrenceConfirm
deriving (Eq, Ord, Read, Show)
instance PathPiece FormIdentifier where

View File

@ -277,6 +277,13 @@ packages:
sha256: 6d64803c639ed4c7204ea6fab0536b97d3ee16cdecb9b4a883cd8e56d3c61402
original:
hackage: wai-middleware-prometheus-1.0.0
- completed:
hackage: extended-reals-0.2.3.0@sha256:78a498d703fffcecfba8e66cfb3e64c4307b2c126a442f6d28cfdd997829f1bf,1563
pantry-tree:
size: 398
sha256: 29629bb0ac41c49671b7f792e540165ee091eb24ffd0eaff229a2f40cc03f3af
original:
hackage: extended-reals-0.2.3.0
snapshots:
- completed:
size: 498180

View File

@ -114,6 +114,22 @@ $if not (null occurrences)
_{MsgExamRoomAssigned}
$if not occurrenceAssignmentsVisible
\ ^{isVisible False}
$if showOccurrenceMappingColumn
$case fmap examOccurrenceMappingRule examExamOccurrenceMapping
$of Just ExamRoomSurname
<th .table__th>
_{MsgExamRoomMappingSurname}
$if not occurrenceAssignmentsVisible
\ ^{isVisible False}
$of Just ExamRoomMatriculation
<th .table__th>
_{MsgExamRoomMappingMatriculation}
$if not occurrenceAssignmentsVisible
\ ^{isVisible False}
$of _
<th .table__td>
$if not occurrenceAssignmentsVisible
^{isVisible False}
<th .table__th>_{MsgExamRoomDescription}
<tbody>
$forall (occurrence, registered) <- occurrences
@ -133,6 +149,10 @@ $if not (null occurrences)
$nothing
$if registered
#{iconOK}
$if showOccurrenceMappingColumn
<td .table__td>
$maybe mappingWgt <- occurrenceMapping examOccurrenceName
^{mappingWgt}
<td .table__td>
$maybe desc <- examOccurrenceDescription
#{desc}

View File

@ -1,6 +1,9 @@
$newline never
<section>
^{closeWgt}
$if examOccurrenceRuleAutomatic examOccurrenceRule
<section>
^{examAutoOccurrenceCalculateWidget tid ssh csh examn}
<section>
$if computedValues
^{computedValuesTip}

View File

@ -0,0 +1,3 @@
$newline never
^{mappingWgt}
^{confirmWidget}

View File

@ -0,0 +1,15 @@
$newline never
<ul .list--inline .list--comma-separated>
$forall desc <- descriptions
<li>
$case desc
$of ExamOccurrenceMappingRange minChars maxChars
$if doPrefix
#{titleCase minChars}… #{titleCase maxChars}…
$else
…#{titleCase minChars} …#{titleCase maxChars}
$of ExamOccurrenceMappingSpecial special
$if doPrefix
#{titleCase special}…
$else
…#{titleCase special}

View File

@ -0,0 +1,41 @@
$newline never
<table .table .table--striped .table--hover>
<thead>
<tr .table__row .table__row--head>
<th .table__th>
_{MsgExamRoomName}
<th .table__th>
_{MsgExamRoomLoad}
$maybe rule <- occMappingRule
$case rule
$of ExamRoomSurname
<th .table__th>
_{MsgExamRoomMappingSurname}
$of ExamRoomMatriculation
<th .table__th>
_{MsgExamRoomMappingMatriculation}
$of _
<th .table__td>
<th .table__th>
_{MsgExamRoom}
<th .table__th>
_{MsgExamRoomTime}
<th .table__th>
_{MsgExamRoomDescription}
<tbody>
$forall Entity occId ExamOccurrence{ examOccurrenceName, examOccurrenceRoom, examOccurrenceStart, examOccurrenceEnd, examOccurrenceDescription, examOccurrenceCapacity } <- occurrences
<tr .table__row>
<td .table__td>
_{examOccurrenceName}
<td .table__td>
_{loadProp (occLoad occId) examOccurrenceCapacity}
$maybe mappingWgt <- occMapping occId
<td .table__td>
^{mappingWgt}
<td .table__td>
#{examOccurrenceRoom}
<td .table__td>
^{formatTimeRangeW SelFormatDateTime examOccurrenceStart examOccurrenceEnd}
<td .table__td>
$maybe desc <- examOccurrenceDescription
#{desc}

View File

@ -505,6 +505,7 @@ fillDb = do
, examGradingRule = Nothing
, examBonusRule = Nothing
, examOccurrenceRule = ExamRoomManual
, examExamOccurrenceMapping = Nothing
, examVisibleFrom = Just now
, examRegisterFrom = Just now
, examRegisterTo = Just $ addUTCTime (14 * nominalDay) now