diff --git a/messages/uniworx/misc/de-de-formal.msg b/messages/uniworx/misc/de-de-formal.msg index 3be85654d..f2e4518b1 100644 --- a/messages/uniworx/misc/de-de-formal.msg +++ b/messages/uniworx/misc/de-de-formal.msg @@ -2800,9 +2800,15 @@ BtnExamAutoOccurrenceNudgeUp: + BtnExamAutoOccurrenceNudgeDown: - ExamRoomMappingSurname: Nachnamen beginnend mit ExamRoomMappingMatriculation: Matrikelnummern endend in +ExamRoomMappingRandom: Verteilung +ExamRoomMappingRandomHere: Zufällig ExamRoomLoad: Auslastung ExamRegisteredCount: Anmeldungen ExamRegisteredCountOf num@Int64 count@Int64: #{num}/#{count} +ExamAutoOccurrenceExceptionRuleNoOp: Kein Verfahren zur automatischen Verteilung gewählt +ExamAutoOccurrenceExceptionNotEnoughSpace: Mehr Teilnehmende als verfügbare Plätze +ExamAutoOccurrenceExceptionNoUsers: Nach dem gewähltem Verfahren können keine Teilnehmenden verteilt werden +ExamAutoOccurrenceExceptionRoomTooSmall: Automatische Verteilung gescheitert. Ein anderes Verteil-Verfahren kann erfolgreich sein. Alternativ kann es helfen Räume zu minimieren oder kleine Räume zu entfernen. NoFilter: Keine Einschränkung @@ -3181,4 +3187,4 @@ WGFFileUpload: Dateifeld WorkflowGraphFormUploadIsDirectory: Upload ist Verzeichnis WorkflowGraphFormInvalidNumberOfFiles: Es muss genau eine Datei hochgeladen werden -CourseSortingOnlyLoggedIn: Das Benutzerinterface zur Sortierung dieser Tabelle ist nur für eingeloggte Benutzer aktiv \ No newline at end of file +CourseSortingOnlyLoggedIn: Das Benutzerinterface zur Sortierung dieser Tabelle ist nur für eingeloggte Benutzer aktiv diff --git a/messages/uniworx/misc/en-eu.msg b/messages/uniworx/misc/en-eu.msg index 3f425b064..892b593f9 100644 --- a/messages/uniworx/misc/en-eu.msg +++ b/messages/uniworx/misc/en-eu.msg @@ -2792,7 +2792,7 @@ ExamAutoOccurrenceHeading: Automatic occurrence/room distribution ExamAutoOccurrenceMinimizeRooms: Minimize number of occurrences used ExamAutoOccurrenceMinimizeRoomsTip: Should the list of occurrences/rooms be reduced prior to distribution? Only as many occurrence/rooms as necessary would be used (starting with the biggest). ExamAutoOccurrenceOccurrencesChangedInFlight: Occurrences/rooms changed -ExamAutoOccurrenceParticipantsAssigned num: Occurrence/room assignment rule saved successfully. Also assigned occurence/room to #{num} #{pluralEN num "participant" "participants"} +ExamAutoOccurrenceParticipantsAssigned num: Occurrence/room assignment rule saved successfully. Also assigned occurrence/room to #{num} #{pluralEN num "participant" "participants"} TitleExamAutoOccurrence tid ssh csh examn: #{tid} - #{ssh} - #{csh} #{examn}: Automatic occurrence/room distribution BtnExamAutoOccurrenceCalculate: Calculate assignment rules BtnExamAutoOccurrenceAccept: Accept assignments @@ -2800,9 +2800,15 @@ BtnExamAutoOccurrenceNudgeUp: + BtnExamAutoOccurrenceNudgeDown: - ExamRoomMappingSurname: Surnames starting with ExamRoomMappingMatriculation: Matriculation numbers ending in +ExamRoomMappingRandom: Distribution +ExamRoomMappingRandomHere: Random ExamRoomLoad: Utilisation ExamRegisteredCount: Registrations ExamRegisteredCountOf num count: #{num}/#{count} +ExamAutoOccurrenceExceptionRuleNoOp: Didn't chose an automatic distribution procedure +ExamAutoOccurrenceExceptionNotEnoughSpace: More participants than available space +ExamAutoOccurrenceExceptionNoUsers: No participants can be distributed with the chosen procedure +ExamAutoOccurrenceExceptionRoomTooSmall: Automatic distribution failed. A different distribution procedure might succeed. Alternatively, minimizing rooms or removing small rooms might help. NoFilter: No restriction diff --git a/package.yaml b/package.yaml index bd5247ac1..c9d092443 100644 --- a/package.yaml +++ b/package.yaml @@ -162,6 +162,8 @@ dependencies: - nonce - IntervalMap - haskell-src-meta + - either + other-extensions: - GeneralizedNewtypeDeriving - IncoherentInstances diff --git a/src/Handler/Exam/AutoOccurrence.hs b/src/Handler/Exam/AutoOccurrence.hs index 1d4fe0b26..2715da603 100644 --- a/src/Handler/Exam/AutoOccurrence.hs +++ b/src/Handler/Exam/AutoOccurrence.hs @@ -23,7 +23,7 @@ newtype ExamAutoOccurrenceCalculateForm = ExamAutoOccurrenceCalculateForm makeLenses_ ''ExamAutoOccurrenceCalculateForm data ExamAutoOccurrenceAcceptForm = ExamAutoOccurrenceAcceptForm - { eaofMapping :: Maybe (ExamOccurrenceMapping ExamOccurrenceId) + { eaofMapping :: ExamOccurrenceMapping ExamOccurrenceId , eaofAssignment :: Map UserId (Maybe ExamOccurrenceId) } deriving (Eq, Ord, Read, Show, Generic, Typeable) @@ -114,7 +114,12 @@ postEAutoOccurrenceR tid ssh csh examn = do (Entity uid userRec, Entity _ ExamRegistration{..}) <- participants return (uid, (userRec, examRegistrationOccurrence)) occurrences' = Map.fromList $ map (\(Entity eoId ExamOccurrence{..}) -> (eoId, fromIntegral examOccurrenceCapacity)) occurrences - (eaofMapping, eaofAssignment) = examAutoOccurrence eId examOccurrenceRule eaofConfig occurrences' participants' + autoOccurrenceResult = examAutoOccurrence eId examOccurrenceRule eaofConfig occurrences' participants' + (eaofMapping, eaofAssignment) <- case autoOccurrenceResult of + (Left e) -> do + addMessageI Error e + redirect $ CExamR tid ssh csh examn EUsersR + (Right r) -> pure r return $ Just ExamAutoOccurrenceAcceptForm{..} ((confirmRes, confirmView), confirmEncoding) <- runFormPost $ examAutoOccurrenceAcceptForm calcResult @@ -126,14 +131,13 @@ postEAutoOccurrenceR tid ssh csh examn = do formResult confirmRes $ \ExamAutoOccurrenceAcceptForm{..} -> do Sum assignedCount <- runDB $ do - let eaofMapping'' :: Maybe (Maybe (ExamOccurrenceMapping ExamOccurrenceName)) - eaofMapping'' = (<$> eaofMapping) . traverseExamOccurrenceMapping $ \eoId -> case filter ((== eoId) . entityKey) occurrences of + let eaofMapping'' :: 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 + Nothing -> invalidArgsI [MsgExamAutoOccurrenceOccurrencesChangedInFlight] + 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 ] @@ -158,13 +162,13 @@ postEAutoOccurrenceR tid ssh csh examn = do occLoad = fromMaybe 0 . flip Map.lookup occLoads - occMappingRule = examOccurrenceMappingRule <$> eaofMapping + 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) + occMapping occId = examOccurrenceMappingDescriptionWidget occMappingRule <$> Map.lookup occId (examOccurrenceMappingMapping eaofMapping) in $(widgetFile "widgets/exam-occurrence-mapping") siteLayoutMsg heading $ do diff --git a/src/Handler/Utils/Exam.hs b/src/Handler/Utils/Exam.hs index 67d0b310e..10e4f9b00 100644 --- a/src/Handler/Utils/Exam.hs +++ b/src/Handler/Utils/Exam.hs @@ -9,6 +9,7 @@ module Handler.Utils.Exam , ExamAutoOccurrenceConfig , eaocMinimizeRooms, eaocFinenessCost, eaocNudge, eaocNudgeSize , _eaocMinimizeRooms, _eaocFinenessCost, _eaocNudge, _eaocNudgeSize + , ExamAutoOccurrenceException(..) , examAutoOccurrence , deregisterExamUsersCount, deregisterExamUsers , examAidsPresetWidget, examOnlinePresetWidget, examSynchronicityPresetWidget, examRequiredEquipmentPresetWidget @@ -27,15 +28,15 @@ import Database.Esqueleto.Utils.TH import qualified Data.Conduit.List as C import qualified Data.Map as Map +import qualified Data.Map.Merge.Lazy as Map import qualified Data.Set as Set import qualified Data.Foldable as F import qualified Data.CaseInsensitive as CI -import Control.Monad.Trans.Random.Lazy (evalRand) import System.Random (mkStdGen) -import Control.Monad.Random.Class (weighted) +import System.Random.Shuffle (shuffle') import Control.Monad.ST (ST, runST) import Data.Array (Array) @@ -47,9 +48,9 @@ import qualified Data.Array.ST as ST import Data.List (findIndex, unfoldr) import qualified Data.List as List -import Data.ExtendedReal +import Data.Either.Combinators (maybeToRight) -import qualified Data.Char as Char +import Data.ExtendedReal import qualified Data.RFC5051 as RFC5051 @@ -257,6 +258,16 @@ deriveJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 } ''ExamAutoOccurrenceConfig +data ExamAutoOccurrenceException + = ExamAutoOccurrenceExceptionRuleNoOp + | ExamAutoOccurrenceExceptionNotEnoughSpace + | ExamAutoOccurrenceExceptionNoUsers + | ExamAutoOccurrenceExceptionRoomTooSmall + deriving (Show, Generic, Typeable) + +instance Exception ExamAutoOccurrenceException + +embedRenderMessage ''UniWorX ''ExamAutoOccurrenceException id examAutoOccurrence :: forall seed. Hashable seed @@ -265,28 +276,58 @@ examAutoOccurrence :: forall seed. -> ExamAutoOccurrenceConfig -> Map ExamOccurrenceId Natural -> Map UserId (User, Maybe ExamOccurrenceId) - -> (Maybe (ExamOccurrenceMapping ExamOccurrenceId), Map UserId (Maybe ExamOccurrenceId)) + -> Either ExamAutoOccurrenceException (ExamOccurrenceMapping ExamOccurrenceId, Map UserId (Maybe ExamOccurrenceId)) examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences users - | sum occurrences < usersCount - || sum occurrences <= 0 - || Map.null users - = nullResult + | Map.null users' + = Left ExamAutoOccurrenceExceptionNoUsers + | sum occurrences' < usersCount + || sum occurrences' <= 0 + = Left ExamAutoOccurrenceExceptionNotEnoughSpace | otherwise = case rule of ExamRoomRandom - -> ( Nothing - , flip Map.mapWithKey users $ \uid (_, mOcc) - -> let randomOcc = flip evalRand (mkStdGen $ hashWithSalt seed uid) $ - weighted $ over _2 fromIntegral <$> occurrences' - in Just $ fromMaybe randomOcc mOcc + -> Right ( ExamOccurrenceMapping { + examOccurrenceMappingRule = rule, + examOccurrenceMappingMapping = Map.fromList $ set _2 (Set.singleton ExamOccurrenceMappingRandom) <$> occurrences'' + } + , Map.union (view _2 <$> assignedUsers) randomlyAssignedUsers ) - _ | Just (postprocess -> (resMapping, result)) <- bestOption - -> ( Just $ ExamOccurrenceMapping rule resMapping - , Map.unionWith (<|>) (view _2 <$> users) result - ) - _ -> nullResult + where + assignedUsers,unassignedUsers :: Map UserId (User, Maybe ExamOccurrenceId) + (assignedUsers, unassignedUsers) = Map.partition (has $ _2 . _Just) users + shuffledUsers :: [UserId] + shuffledUsers = shuffle' (Map.keys unassignedUsers) (length unassignedUsers) (mkStdGen seed) + occurrencesMap :: Map ExamOccurrenceId Natural + occurrencesMap = Map.fromList occurrences'' + -- reduce available space until to excess space is left while keeping the filling ratio as equal as possible + decreaseBiggestOutlier :: Natural -> Map ExamOccurrenceId Natural -> Map ExamOccurrenceId Natural + decreaseBiggestOutlier 0 currentOccurrences = currentOccurrences + decreaseBiggestOutlier n currentOccurrences = decreaseBiggestOutlier (pred n) + $ Map.update predToPositive biggestOutlier currentOccurrences + where + currentRatios :: Map ExamOccurrenceId Rational + currentRatios = Map.merge Map.dropMissing Map.dropMissing (Map.zipWithMatched calculateRatio) + currentOccurrences occurrencesMap + calculateRatio :: ExamOccurrenceId -> Natural -> Natural -> Rational + calculateRatio k c m = fromIntegral c % fromIntegral m - eaocNudgeSize * fromIntegral (lineNudges k) + biggestOutlier :: ExamOccurrenceId + biggestOutlier = fst . List.maximumBy (comparing $ view _2) $ Map.toList currentRatios + extraCapacity :: Natural + extraCapacity = sumOf (folded . _2) occurrences'' - fromIntegral (length unassignedUsers) + finalOccurrences :: [(ExamOccurrenceId, Natural)] + finalOccurrences = Map.toList $ decreaseBiggestOutlier extraCapacity occurrencesMap + -- fill in users in a random order + randomlyAssignedUsers :: Map UserId (Maybe ExamOccurrenceId) + randomlyAssignedUsers = Map.fromList $ fst $ foldl' addUsers ([], shuffledUsers) finalOccurrences + addUsers :: ([(UserId, Maybe ExamOccurrenceId)], [UserId]) + -> (ExamOccurrenceId, Natural) + -> ([(UserId, Maybe ExamOccurrenceId)], [UserId]) + addUsers (acc, userList) (roomId, roomSize) = (map (, Just roomId) newUsers ++ acc, remainingUsers) + where + newUsers, remainingUsers :: [UserId] + (newUsers, remainingUsers) = List.genericSplitAt roomSize userList + _ -> bimap (ExamOccurrenceMapping rule) (Map.unionWith (<|>) (view _2 <$> users)) . postprocess <$> bestOption where - nullResult = (Nothing, view _2 <$> users) usersCount :: forall a. Num a => a usersCount = getSum $ foldMap (Sum . fromIntegral . Set.size) users' @@ -306,24 +347,35 @@ examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences | (uid, (User{..}, Nothing)) <- Map.toList users , matriculation' <- userMatrikelnummer ^.. _Just . filtered (not . null) ] - in Map.mapKeysWith Set.union (take . F.minimum . Set.map length $ Map.keysSet matrUsers) matrUsers - _ -> Map.singleton [] $ Map.keysSet users + takeEnd n chars = drop (length chars - n) chars + in Map.mapKeysWith Set.union (takeEnd . F.minimum . Set.map length $ Map.keysSet matrUsers) matrUsers + _ | null users-> Map.empty + | otherwise -> Map.singleton [] $ Map.keysSet users + occurrences' :: Map ExamOccurrenceId Natural + -- ^ reduce room capacity for every pre-assigned user by 1 + -- also remove empty/pre-filled rooms + occurrences' = foldl' (flip $ Map.update predToPositive) (Map.filter (> 0) occurrences) $ Map.mapMaybe snd users - occurrences' :: [(ExamOccurrenceId, Natural)] + predToPositive :: Natural -> Maybe Natural + predToPositive 0 = Nothing + predToPositive 1 = Nothing + predToPositive n = Just $ pred n + + occurrences'' :: [(ExamOccurrenceId, Natural)] -- ^ Minimise number of occurrences used -- -- Prefer occurrences with higher capacity -- - -- If a single occurrence can accomodate all participants, pick the one with + -- If a single occurrence can accommodate all participants, pick the one with -- the least capacity - occurrences' + occurrences'' | not eaocMinimizeRooms - = Map.toList occurrences - | Just largeEnoughs <- fromNullable . filter ((>= usersCount) . view _2) $ Map.toList occurrences + = Map.toList occurrences' + | Just largeEnoughs <- fromNullable . filter ((>= usersCount) . view _2) $ Map.toList occurrences' = pure $ minimumBy (comparing $ view _2) largeEnoughs | otherwise - = view _2 . foldl' accF (0, []) . sortOn (Down . view _2) $ Map.toList occurrences + = view _2 . foldl' accF (0, []) . sortOn (Down . view _2) $ Map.toList occurrences' where accF :: (Natural, [(ExamOccurrenceId, Natural)]) -> (ExamOccurrenceId, Natural) @@ -396,23 +448,31 @@ examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences 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) - forM_ (Array.range (0, Map.size wordMap)) $ \i' -> do - let go i j + -- find current line + let + walkBack 0 = return 0 + walkBack i'' = fmap succ $ walkBack =<< ST.readArray breaks i'' + -- calculate line breaks + forM_ (Array.range (0, Map.size wordMap)) $ \i -> do + let go j | j <= Map.size wordMap = do - let - walkBack 0 = return 0 - walkBack i'' = fmap succ $ walkBack =<< ST.readArray breaks i'' lineIx <- walkBack i + -- identifier and potential width of current line let (l, potWidth) | lineIx >= 0 , lineIx < length lineLengths = over _1 Just $ lineLengths List.!! lineIx | otherwise = (Nothing, 0) + -- cumulative width for words [i,j), no whitespace required w = offsets Array.! j - offsets Array.! i prevMin <- ST.readArray minima i let cost = prevMin + widthCost l potWidth w + breakCost' + remainingWords = offsets Array.! Map.size wordMap - offsets Array.! i + remainingLineSpace = sumOf (folded . _2) $ drop lineIx lineLengths breakCost' + | remainingWords > remainingLineSpace + = PosInf | j < Map.size wordMap , j > 0 = breakCost (wordIx # pred j) (wordIx # j) @@ -431,21 +491,22 @@ examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences when (cost < minCost) $ do ST.writeArray minima j cost ST.writeArray breaks j i - go i' $ succ j + go $ succ j | otherwise = return () - in go i' $ succ i' + in go $ succ i -- traceM . show . map (fmap (fromRational :: Rational -> Centi)) =<< ST.getElems minima -- traceM . show =<< ST.getElems breaks + usedLines <- walkBack $ Map.size wordMap let accumResult lineIx j (accCost, accMap) = do i <- ST.readArray breaks j accCost' <- (+) accCost <$> ST.readArray minima j -- 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 + if i > 0 + then accumResult (succ lineIx) i (accCost', accMap') + else return (accCost', accMap') + lineIxs = reverse $ map (view _1) $ take usedLines lineLengths in accumResult 0 (Map.size wordMap) (0, []) @@ -460,7 +521,7 @@ examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences 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' + longestLine = maximum . mapNonNull (view _2) $ impureNonNull occurrences'' lcp :: Eq a => [a] -> [a] -> [a] @@ -473,18 +534,18 @@ examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences lineNudges = fromMaybe 0 . flip Map.lookup eaocNudge - bestOption :: Maybe [(ExamOccurrenceId, [[CI Char]])] + bestOption :: Either ExamAutoOccurrenceException [(ExamOccurrenceId, [[CI Char]])] bestOption = case rule of - ExamRoomSurname -> do - (_cost, res) <- distribute (sortBy (RFC5051.compareUnicode `on` (pack . toListOf (_1 . folded . to CI.foldedCase))) . Map.toAscList $ fromIntegral . Set.size <$> users') occurrences' lineNudges charCost + ExamRoomSurname -> maybeToRight ExamAutoOccurrenceExceptionRoomTooSmall $ do + (_cost, res) <- distribute (sortBy (RFC5051.compareUnicode `on` (pack . toListOf (_1 . folded . to CI.foldedCase))) . Map.toAscList $ fromIntegral . Set.size <$> users') occurrences'' lineNudges charCost -- traceM $ show cost return res - ExamRoomMatriculation -> do + ExamRoomMatriculation -> maybeToRight ExamAutoOccurrenceExceptionRoomTooSmall $ do let usersFineness n = Map.toAscList $ fromIntegral . Set.size <$> Map.mapKeysWith Set.union (reverse . take (fromIntegral n) . reverse) users' -- finenessCost n = Finite (max 1 $ fromIntegral n * eaocFinenessCost * fromIntegral longestLine) ^ 2 * length occurrences' distributeFine :: Natural -> Maybe (Extended Rational, _) - distributeFine n = distribute (usersFineness n) occurrences' lineNudges charCost + distributeFine n = distribute (usersFineness n) occurrences'' lineNudges charCost maximumFineness = fromIntegral . F.minimum . Set.map length $ Map.keysSet users' @@ -510,7 +571,7 @@ examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences (_cost, res) <- fmap (minimumBy . comparing $ view _1) . fromNullable $ genResults 1 return res - _other -> Nothing + _other -> Left ExamAutoOccurrenceExceptionRuleNoOp postprocess :: [(ExamOccurrenceId, [[CI Char]])] -> ( Map ExamOccurrenceId (Set ExamOccurrenceMappingDescription) @@ -518,63 +579,135 @@ examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences ) postprocess result = (resultAscList, resultUsers) where - resultAscList = pad . 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 - in if - | Just rmaxA <- nsA ^? to (filter . mayRange . succ $ length common) . _last - , Just rminA <- maybe id (:) prevEnd nsA ^? to (filter . mayRange . succ $ length common) . _head - , Just rminB <- nsB ^? to (filter . mayRange . succ $ length common) . _head - , firstA : _ <- CI.foldedCase <$> drop (length common) rmaxA - , firstB : _ <- CI.foldedCase <$> drop (length common) rminB - -> let break' - | occSize occA > 0 || occSize occB > 0 - = (occSize occA * Char.ord firstA + occSize occB * Char.ord firstB) % (occSize occA + occSize occB) - & floor - & Char.chr - & Char.toUpper - & CI.mk - & pure - & (common ++) - | otherwise = common ++ pure (CI.mk firstA) - 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 - commonLength = max 1 . succ . length $ minA `lcp` break' - isBreakSpecialStart c = not (mayRange (length rminA ) c) && length (rminA `lcp` c) >= pred (length rminA ) - isBreakSpecialEnd c = not (mayRange (length break') c) && length (break' `lcp` c) >= pred (length break') - rangeSpecials = Set.map (ExamOccurrenceMappingSpecial . take commonLength) . Set.filter (not . mayRange commonLength) $ Set.fromList nsA - breakSpecialsStart = Set.map (ExamOccurrenceMappingSpecial . take (length rminA)) . Set.filter isBreakSpecialStart $ Set.fromList nsA - breakSpecialsEnd = Set.map (ExamOccurrenceMappingSpecial . take (length break')) . Set.filter isBreakSpecialEnd $ Set.fromList nsA - in (occA, Set.insert (ExamOccurrenceMappingRange rminA break') $ breakSpecialsStart <> breakSpecialsEnd <> rangeSpecials) : accRes succBreak ((occB, nsB) : xs) + maxTagLength :: Int + maxTagLength = maybe 0 maximum $ fromNullable $ concatMap (map length . snd) result + + rangeAlphabet :: [CI Char] + rangeAlphabet = case rule of + ExamRoomSurname -> map CI.mk ['A'..'Z'] + ExamRoomMatriculation-> map CI.mk ['0'..'9'] + _rule -> [] + + resultAscList :: Map ExamOccurrenceId (Set ExamOccurrenceMappingDescription) + resultAscList = case fromNullable rangeAlphabet of + Nothing -> Map.empty + (Just alphabet) -> Map.fromList $ go (singleton $ head alphabet) 1 [] result + where + go :: NonNull [CI Char] + -> Int + -> [(ExamOccurrenceId, Set ExamOccurrenceMappingDescription)] + -> [(ExamOccurrenceId, [[CI Char]])] + -> [(ExamOccurrenceId, Set ExamOccurrenceMappingDescription)] + go _start _borderLength acc [] = acc + -- special case necessary, so ranges always end on last alphabet + go start _borderLength acc [(_occurrenceId, [])] = case acc of + [] -> [] + ((occurrenceId, mappingDescription):t) -> (occurrenceId, Set.map extendEnd mappingDescription) : t + where + extendEnd :: ExamOccurrenceMappingDescription -> ExamOccurrenceMappingDescription + extendEnd ExamOccurrenceMappingRange {eaomrStart} = ExamOccurrenceMappingRange {eaomrStart, eaomrEnd} + extendEnd examOccurrenceMappingSpecial = examOccurrenceMappingSpecial + eaomrEnd :: [CI Char] + eaomrEnd = replicate (length start) $ last alphabet + go start borderLength acc ((_occurrenceId, []):t) = go start borderLength acc t + go start borderLength acc ((occurrenceId, userTags):t) + | matchMappingDescription mappingDescription userTags + && (null t || toNullable nextStart > end) + = go nextStart borderLength ((occurrenceId, mappingDescription) : acc) t + | borderLength < maxTagLength + = go restartStart restartBorderLength [] result | otherwise - -> (occA, Set.map (ExamOccurrenceMappingSpecial . take (max 1 . max (succ $ length common) $ maybe 0 length prevEnd)) $ Set.fromList nsA) : accRes (Just $ take (succ $ length common) minB) ((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 . succ . length $ takeWhile (== maxAlpha) minZ - isBreakSpecial c = not (mayRange (length minZ) c) && length (minZ `lcp` c) >= pred (length minZ) - rangeSpecials = Set.map (ExamOccurrenceMappingSpecial . take commonLength) . Set.filter (not . mayRange commonLength) $ Set.fromList nsZ - breakSpecials = Set.map (ExamOccurrenceMappingSpecial . take (length minZ)) . Set.filter isBreakSpecial $ Set.fromList nsZ - in pure (occZ, Set.insert (ExamOccurrenceMappingRange minZ $ replicate commonLength maxAlpha) $ rangeSpecials <> breakSpecials) - | otherwise - = pure (occZ, Set.map (ExamOccurrenceMappingSpecial . take (max 1 $ maybe 0 length prevEnd)) $ Set.fromList nsZ) + = [] -- shouldn't happen, but ensures termination on invalid input (e.g. non-monotonic) + where + restartBorderLength :: Int + restartBorderLength = succ borderLength + + restartStart :: NonNull [CI Char] + restartStart = case rule of + ExamRoomMatriculation -> impureNonNull $ replicate restartBorderLength $ head alphabet + _rule -> singleton $ head alphabet + + mappingDescription :: Set ExamOccurrenceMappingDescription + mappingDescription + -- if start > end, the room only consists of users with a non-ascii tag directly adjacent to the last room + -- therefore, leave out a potentially confusing range description + | toNullable start > end = Set.fromList specialMapping + | otherwise = Set.fromList $ ExamOccurrenceMappingRange (toNullable start) end : specialMapping + + specialMapping :: [ExamOccurrenceMappingDescription] + specialMapping + = [ExamOccurrenceMappingSpecial {eaomrSpecial=tag} + | (transformTag borderLength -> tag) <- userTags + , not $ all (`elem` alphabet) tag] + + -- | pre/suffix of largest user tag + -- special (i.e. non-ascii) tags use the largest smaller ascii-char according to RFC5051.compareUnicode, + -- ending the tag with ..ZZZ-padding + end :: [CI Char] + end = case t of + [] -> replicate borderLength $ last alphabet + _nonEmpty -> withAlphabetChars + $ transformTag borderLength + $ maximumBy (\a b -> RFC5051.compareUnicode (pack $ map CI.foldedCase a) (pack $ map CI.foldedCase b)) + -- userTags is guaranteed non-null + $ impureNonNull userTags + where + withAlphabetChars :: [CI Char] -> [CI Char] + withAlphabetChars [] = [] + withAlphabetChars (c:cs) + | c `elem` alphabet = c : withAlphabetChars cs + | otherwise= case previousAlphabetChar c of + Nothing -> [] + (Just c') -> c' : replicate (length cs) (last alphabet) + previousAlphabetChar :: CI Char -> Maybe (CI Char) + previousAlphabetChar c = fmap last $ fromNullable $ nfilter ((== GT) . compareChars c) alphabet + compareChars :: CI Char -> CI Char -> Ordering + compareChars a b = RFC5051.compareUnicode (pack [CI.foldedCase a]) (pack [CI.foldedCase b]) + nextStart :: NonNull [CI Char] + -- end is guaranteed nonNull, all empty tags are filtered out in users' + nextStart + | length end < borderLength + = impureNonNull $ end <> [head alphabet] + | otherwise + = impureNonNull $ reverse $ increase $ reverse end + alphabetCycle :: [CI Char] + alphabetCycle = List.cycle $ toNullable alphabet + increase :: [CI Char] -> [CI Char] + increase [] = [] + increase (c:cs) + | nextChar == head alphabet, rule == ExamRoomMatriculation + = nextChar : increase cs + | nextChar == head alphabet + = increase cs + | otherwise + = nextChar : cs + where + nextChar :: CI Char + nextChar + | c `elem` alphabet + = dropWhile (/= c) alphabetCycle List.!! 1 + | otherwise -- shouldn't happen, simply use head alphabet as a fallback + = head alphabet + + transformTag :: Int -> [CI Char] -> [CI Char] + transformTag l tag = case rule of + ExamRoomMatriculation -> drop (max 0 $ length tag - l) tag + _rule -> take l tag + + matchMappingDescription :: Set ExamOccurrenceMappingDescription -> [[CI Char]] -> Bool + matchMappingDescription mappingDescription userTags = flip all userTags $ \tag -> flip any mappingDescription $ \case + ExamOccurrenceMappingRange {eaomrStart, eaomrEnd} + -- non-rangeAlphabet-chars get a special mapping, so <= is fine here + -> (eaomrStart <= transformTag (length eaomrStart) tag) && (transformTag (length eaomrEnd) tag <= eaomrEnd) + ExamOccurrenceMappingSpecial {eaomrSpecial} -> checkSpecial eaomrSpecial tag + where + checkSpecial :: [CI Char] -> [CI Char] -> Bool + checkSpecial = case rule of + ExamRoomMatriculation -> isSuffixOf + _rule -> isPrefixOf + ExamOccurrenceMappingRandom -> False -- Something went wrong, throw an error instead? + + resultUsers :: Map UserId (Maybe ExamOccurrenceId) resultUsers = Map.fromList $ do (occId, buckets) <- result let matchWord b b' = case rule of @@ -585,30 +718,6 @@ examAutoOccurrence (hash -> seed) rule ExamAutoOccurrenceConfig{..} occurrences user <- Set.toList $ foldMap (\b -> foldMap snd . filter (\(b', _) -> matchWord 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 - - pad :: Map ExamOccurrenceId (Set ExamOccurrenceMappingDescription) -> Map ExamOccurrenceId (Set ExamOccurrenceMappingDescription) - pad res - | ExamRoomMatriculation <- rule - , Just minAlpha <- Set.lookupMin rangeAlphabet - = let maxLength' = maybe 0 maximum . fromNullable $ res ^.. folded . folded . (_eaomrStart <> _eaomrEnd <> _eaomrSpecial) . to length - padSuff cs = replicate (maxLength' - length cs) minAlpha ++ cs - in Set.map (appEndo $ foldMap Endo [ over l padSuff | l <- [_eaomrStart, _eaomrEnd, _eaomrSpecial]]) <$> res - | otherwise - = res - deregisterExamUsersCount :: (MonadIO m, HandlerSite m ~ UniWorX, MonadHandler m, MonadCatch m) => ExamId -> [UserId] -> SqlPersistT m Int64 deregisterExamUsersCount eId uids = do diff --git a/src/Model/Types/Changelog.hs b/src/Model/Types/Changelog.hs index bc07b524a..1285782d5 100644 --- a/src/Model/Types/Changelog.hs +++ b/src/Model/Types/Changelog.hs @@ -29,21 +29,22 @@ makePrisms ''ChangelogItemKind classifyChangelogItem :: ChangelogItem -> ChangelogItemKind classifyChangelogItem = \case - ChangelogHaskellCampusLogin -> ChangelogItemBugfix - ChangelogTooltipsWithoutJavascript -> ChangelogItemBugfix - ChangelogButtonsWorkWithoutJavascript -> ChangelogItemBugfix - ChangelogTableFormsWorkAfterAjax -> ChangelogItemBugfix - ChangelogPassingByPointsWorks -> ChangelogItemBugfix - ChangelogErrorMessagesForTableItemVanish -> ChangelogItemBugfix - ChangelogExamAchievementParticipantDuplication -> ChangelogItemBugfix - ChangelogFormsTimesReset -> ChangelogItemBugfix - ChangelogAllocationCourseAcceptSubstitutesFixed -> ChangelogItemBugfix - ChangelogStoredMarkup -> ChangelogItemBugfix - ChangelogFixPersonalisedSheetFilesKeep -> ChangelogItemBugfix - ChangelogHonorRoomHidden -> ChangelogItemBugfix - ChangelogFixSheetBonusRounding -> ChangelogItemBugfix - ChangelogFixExamBonusAllSheetsBonus -> ChangelogItemBugfix - _other -> ChangelogItemFeature + ChangelogHaskellCampusLogin -> ChangelogItemBugfix + ChangelogTooltipsWithoutJavascript -> ChangelogItemBugfix + ChangelogButtonsWorkWithoutJavascript -> ChangelogItemBugfix + ChangelogTableFormsWorkAfterAjax -> ChangelogItemBugfix + ChangelogPassingByPointsWorks -> ChangelogItemBugfix + ChangelogErrorMessagesForTableItemVanish -> ChangelogItemBugfix + ChangelogExamAchievementParticipantDuplication -> ChangelogItemBugfix + ChangelogFormsTimesReset -> ChangelogItemBugfix + ChangelogAllocationCourseAcceptSubstitutesFixed -> ChangelogItemBugfix + ChangelogStoredMarkup -> ChangelogItemBugfix + ChangelogFixPersonalisedSheetFilesKeep -> ChangelogItemBugfix + ChangelogHonorRoomHidden -> ChangelogItemBugfix + ChangelogFixSheetBonusRounding -> ChangelogItemBugfix + ChangelogFixExamBonusAllSheetsBonus -> ChangelogItemBugfix + ChangelogExamAutomaticRoomDistributionRespectSize -> ChangelogItemBugfix + _other -> ChangelogItemFeature changelogItemDays :: Map ChangelogItem Day changelogItemDays = Map.fromListWithKey (\k d1 d2 -> bool (error $ "Duplicate changelog days for " <> show k) d1 $ d1 /= d2) diff --git a/src/Model/Types/Exam.hs b/src/Model/Types/Exam.hs index 1a9cb0ef4..3910f402a 100644 --- a/src/Model/Types/Exam.hs +++ b/src/Model/Types/Exam.hs @@ -191,6 +191,7 @@ examOccurrenceRuleAutomatic x = any ($ x) data ExamOccurrenceMappingDescription = ExamOccurrenceMappingRange { eaomrStart, eaomrEnd :: [CI Char] } | ExamOccurrenceMappingSpecial { eaomrSpecial :: [CI Char] } + | ExamOccurrenceMappingRandom deriving (Eq, Ord, Read, Show, Generic, Typeable) deriveJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 diff --git a/templates/i18n/changelog/exam-automatic-room-distribution-respect-size.de-de-formal.hamlet b/templates/i18n/changelog/exam-automatic-room-distribution-respect-size.de-de-formal.hamlet new file mode 100644 index 000000000..41a2fd613 --- /dev/null +++ b/templates/i18n/changelog/exam-automatic-room-distribution-respect-size.de-de-formal.hamlet @@ -0,0 +1,2 @@ +$newline never +Diverse Verbesserungen an der automatischen Zuteilung von Klausurteilnehmern auf Termine/Räume diff --git a/templates/i18n/changelog/exam-automatic-room-distribution-respect-size.en-eu.hamlet b/templates/i18n/changelog/exam-automatic-room-distribution-respect-size.en-eu.hamlet new file mode 100644 index 000000000..a9b07c71d --- /dev/null +++ b/templates/i18n/changelog/exam-automatic-room-distribution-respect-size.en-eu.hamlet @@ -0,0 +1,2 @@ +$newline never +Several improvements for the automated distribution of exam participants to occurrences/rooms diff --git a/templates/i18n/exam-auto-occurrence-calculate/de-de-formal.hamlet b/templates/i18n/exam-auto-occurrence-calculate/de-de-formal.hamlet index ef8c4e35b..7db981398 100644 --- a/templates/i18n/exam-auto-occurrence-calculate/de-de-formal.hamlet +++ b/templates/i18n/exam-auto-occurrence-calculate/de-de-formal.hamlet @@ -1,12 +1,11 @@ $newline never
- Bei der Berechnung der Verteilung werden stets alle #
- Klausurteilnehmer berücksichtigt, unabhängig davon, ob ihnen bereits #
- ein Raum/Termin zugewiesen ist, oder nicht.
+ Bei der Berechnung der Verteilung werden nur neu zugewiesene #
+ Klausurteilnehmer berücksichtigt.
- Es werden dennoch nur Klausurteilnehmer anhand der neu berechneten #
+ Es werden nur Klausurteilnehmer anhand der neu berechneten #
Verteilung zugewiesen, die aktuell keinen zugewiesenen Raum/Termin #
haben.
diff --git a/templates/i18n/exam-auto-occurrence-calculate/en-eu.hamlet b/templates/i18n/exam-auto-occurrence-calculate/en-eu.hamlet
index a6b938066..8161a3680 100644
--- a/templates/i18n/exam-auto-occurrence-calculate/en-eu.hamlet
+++ b/templates/i18n/exam-auto-occurrence-calculate/en-eu.hamlet
@@ -1,18 +1,17 @@
$newline never
- When assignment rules are calculated all exam participants are #
- considered, regardless of whether they are already assigned to an #
- occurrence/room.
+ When assignment rules are calculated only newly assigned #
+ exam participants are considered.
- Nonetheless only exam participants, who are not already assigned to #
+ Only exam participants, who are not already assigned to #
an occurrence/room, will be assigned according to the newly #
calculated assignment rules.
- Thus calculating new assignment rules multiple times may lead to a #
+ Thus, calculating new assignment rules multiple times may lead to a #
situation in which the occurrence/room assignments of most #
participants do not match the newest assignment rules.
diff --git a/templates/i18n/implementation/de-de-formal.hamlet b/templates/i18n/implementation/de-de-formal.hamlet
index 23876d482..03418198d 100644
--- a/templates/i18n/implementation/de-de-formal.hamlet
+++ b/templates/i18n/implementation/de-de-formal.hamlet
@@ -29,3 +29,4 @@ $newline never