_{msg}
|]
+-- | Always display widget; maybe a link if user is Authorized.
+-- Also see variant `linkEmptyCell`
anchorCell :: IsDBTable m a => Route UniWorX -> Widget -> DBCell m a
anchorCell = anchorCellM . return
@@ -867,6 +899,25 @@ anchorCellM' xM x2route x2widget = cell $ do
Authorized -> $(widgetFile "table/cell/link") -- show allowed link
_otherwise -> widget -- don't show prohibited link
+-- | Variant of `anchorCell` that displays different widgets depending whether the route is authorized for current user
+linkEitherCell :: IsDBTable m a => Route UniWorX -> (Widget, Widget) -> DBCell m a
+linkEitherCell = linkEitherCellM . return
+
+linkEitherCellM :: IsDBTable m a => WidgetT UniWorX IO (Route UniWorX) -> (Widget, Widget) -> DBCell m a
+linkEitherCellM routeM (widgetAuth,widgetUnauth) = linkEitherCellM' routeM id (const widgetAuth, const widgetUnauth)
+
+linkEitherCellM' :: IsDBTable m a => WidgetT UniWorX IO x -> (x -> Route UniWorX) -> (x -> Widget, x -> Widget) -> DBCell m a
+linkEitherCellM' xM x2route (x2widgetAuth,x2widgetUnauth) = cell $ do
+ x <- xM
+ let route = x2route x
+ widget = x2widgetAuth x
+ widgetUnauth = x2widgetUnauth x
+ authResult <- liftHandlerT $ isAuthorized route False
+ case authResult of
+ Authorized -> $(widgetFile "table/cell/link") -- show allowed link
+ _otherwise -> widgetUnauth -- show alternative widget
+
+
listCell :: (IsDBTable m a, Traversable f) => f r' -> (r' -> DBCell m a) -> DBCell m a
listCell xs mkCell = review dbCell . ([], ) $ do
@@ -879,9 +930,12 @@ newtype DBFormResult i a r = DBFormResult (Map i (r, a -> a))
instance Functor (DBFormResult i a) where
f `fmap` (DBFormResult resMap) = DBFormResult $ fmap (over _1 f) resMap
+instance Ord i => Sem.Semigroup (DBFormResult i a r) where
+ (DBFormResult m1) <> (DBFormResult m2) = DBFormResult $ Map.unionWith (\(r, f1) (_, f2) -> (r, f2 . f1)) m1 m2
+
instance Ord i => Monoid (DBFormResult i a r) where
mempty = DBFormResult Map.empty
- (DBFormResult m1) `mappend` (DBFormResult m2) = DBFormResult $ Map.unionWith (\(r, f1) (_, f2) -> (r, f2 . f1)) m1 m2
+ mappend = (<>)
getDBFormResult :: forall r i a. Ord i => (r -> a) -> DBFormResult i a r -> Map i a
getDBFormResult initial (DBFormResult m) = Map.map (\(r, f) -> f $ initial r) m
@@ -909,16 +963,17 @@ formCell formCellLens genIndex genForm input@(DBRow{dbrKey}) = FormCell
-- Predefined colonnades
---Number column?
+-- | Simple number column, also see Handler.Utils.Table.Columns.dbRowIndicator
dbRow :: forall h r m a. (Headedness h, IsDBTable m a) => Colonnade h (DBRow r) (DBCell m a)
dbRow = Colonnade.singleton (headednessPure $ i18nCell MsgNrColumn) $ \DBRow{ dbrIndex } -> textCell $ tshow dbrIndex
-dbSelect :: forall x h r i a. (Headedness h, Ord i, PathPiece i, Monoid x)
+dbSelect :: forall x h r i a. (Headedness h, Ord i, PathPiece i, Monoid' x)
=> Lens' x (FormResult (DBFormResult i a (DBRow r)))
-> Setter' a Bool
-> (DBRow r -> MForm (HandlerT UniWorX IO) i)
-> Colonnade h (DBRow r) (DBCell (MForm (HandlerT UniWorX IO)) x)
-dbSelect resLens selLens genIndex = Colonnade.singleton (headednessPure $ i18nCell MsgSelectColumn) $ formCell resLens genIndex genForm
+-- dbSelect resLens selLens genIndex = Colonnade.singleton (headednessPure $ i18nCell MsgSelectColumn) $ formCell resLens genIndex genForm
+dbSelect resLens selLens genIndex = Colonnade.singleton (headednessPure $ mempty) $ formCell resLens genIndex genForm
where
genForm _ mkUnique = do
(selResult, selWidget) <- mreq checkBoxField (fsUniq mkUnique "select") (Just False)
diff --git a/src/Handler/Utils/Tokens.hs b/src/Handler/Utils/Tokens.hs
new file mode 100644
index 000000000..736bb929a
--- /dev/null
+++ b/src/Handler/Utils/Tokens.hs
@@ -0,0 +1,34 @@
+module Handler.Utils.Tokens
+ ( maybeBearerToken, requireBearerToken
+ , currentTokenRestrictions
+ ) where
+
+import Import
+
+import Utils.Lens
+
+import Control.Monad.Trans.Maybe (runMaybeT)
+
+
+maybeBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m) => m (Maybe (BearerToken UniWorX))
+maybeBearerToken = runMaybeT $ catchIfMaybeT cPred requireBearerToken
+ where
+ cPred err = any ($ err)
+ [ is $ _HCError . _PermissionDenied
+ , is $ _HCError . _NotAuthenticated
+ ]
+
+requireBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX) => m (BearerToken UniWorX)
+requireBearerToken = liftHandlerT $ do
+ token <- exceptT (guardAuthResult >=> error "askToken should not throw `Authorized`") return askTokenUnsafe
+ mAuthId <- maybeAuthId
+ currentRoute <- maybe (permissionDeniedI MsgUnauthorizedToken404) return =<< getCurrentRoute
+ isWrite <- isWriteRequest currentRoute
+ guardAuthResult <=< runDB $ validateToken mAuthId currentRoute isWrite token
+ return token
+
+currentTokenRestrictions :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m, MonadLogger m, FromJSON a, ToJSON a) => m (Maybe a)
+currentTokenRestrictions = runMaybeT $ do
+ token <- requireBearerToken
+ route <- MaybeT getCurrentRoute
+ hoistMaybe $ preview (_tokenRestrictionIx route) token
diff --git a/src/Handler/Utils/Tutorial.hs b/src/Handler/Utils/Tutorial.hs
new file mode 100644
index 000000000..fc3d992e0
--- /dev/null
+++ b/src/Handler/Utils/Tutorial.hs
@@ -0,0 +1,47 @@
+module Handler.Utils.Tutorial
+ ( fetchTutorialAux
+ , fetchTutorial, fetchTutorialId, fetchCourseIdTutorialId, fetchCourseIdTutorial
+ ) where
+
+import Import
+
+import Database.Persist.Sql (SqlBackendCanRead)
+import qualified Database.Esqueleto as E
+import qualified Database.Esqueleto.Internal.Sql as E
+import Database.Esqueleto.Utils.TH
+
+import Utils.Lens
+
+
+fetchTutorialAux :: ( SqlBackendCanRead backend
+ , E.SqlSelect b a
+ , MonadHandler m
+ , Typeable a
+ )
+ => (E.SqlExpr (Entity Tutorial) -> E.SqlExpr (Entity Course) -> b)
+ -> TermId -> SchoolId -> CourseShorthand -> TutorialName -> ReaderT backend m a
+fetchTutorialAux prj tid ssh csh tutn =
+ let cachId = encodeUtf8 $ tshow (tid, ssh, csh, tutn)
+ in cachedBy cachId $ do
+ tutList <- E.select . E.from $ \(course `E.InnerJoin` tut) -> do
+ E.on $ course E.^. CourseId E.==. tut E.^. TutorialCourse
+ E.where_ $ course E.^. CourseTerm E.==. E.val tid
+ E.&&. course E.^. CourseSchool E.==. E.val ssh
+ E.&&. course E.^. CourseShorthand E.==. E.val csh
+ E.&&. tut E.^. TutorialName E.==. E.val tutn
+ return $ prj tut course
+ case tutList of
+ [tut] -> return tut
+ _other -> notFound
+
+fetchTutorial :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> DB (Entity Tutorial)
+fetchTutorial = fetchTutorialAux const
+
+fetchTutorialId :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> YesodDB UniWorX (Key Tutorial)
+fetchTutorialId tid ssh cid tutn = E.unValue <$> fetchTutorialAux (\tutorial _ -> tutorial E.^. TutorialId) tid ssh cid tutn
+
+fetchCourseIdTutorialId :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> YesodDB UniWorX (Key Course, Key Tutorial)
+fetchCourseIdTutorialId tid ssh cid tutn = $(unValueN 2) <$> fetchTutorialAux (\tutorial course -> (course E.^. CourseId, tutorial E.^. TutorialId)) tid ssh cid tutn
+
+fetchCourseIdTutorial :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> YesodDB UniWorX (Key Course, Entity Tutorial)
+fetchCourseIdTutorial tid ssh cid tutn = over _1 E.unValue <$> fetchTutorialAux (\tutorial course -> (course E.^. CourseId, tutorial)) tid ssh cid tutn
diff --git a/src/Handler/Utils/Zip.hs b/src/Handler/Utils/Zip.hs
index fd98ab67b..c1fd25524 100644
--- a/src/Handler/Utils/Zip.hs
+++ b/src/Handler/Utils/Zip.hs
@@ -23,12 +23,10 @@ import Data.ByteString (ByteString)
import qualified Data.ByteString as ByteString
import System.FilePath
-import Data.Time
+import Data.Time.LocalTime (localTimeToUTC, utcToLocalTime)
import Data.List (dropWhileEnd)
-import Network.Mime
-
instance Default ZipInfo where
def = ZipInfo
@@ -95,12 +93,16 @@ modifyFileTitle :: Monad m => (FilePath -> FilePath) -> Conduit File m File
modifyFileTitle f = mapC $ \x@File{..} -> x{ fileTitle = f fileTitle }
-- Takes FileInfo and if it is a ZIP-Archive, extract files, otherwiese yield fileinfo
-sourceFiles :: (MonadResource m, MonadThrow m, MonadIO m) => FileInfo -> Source m File
+sourceFiles :: (MonadLogger m, MonadResource m, MonadThrow m, MonadIO m) => FileInfo -> Source m File
sourceFiles fInfo
- | mimeType == "application/zip" = fileSource fInfo =$= void consumeZip
- | otherwise = yieldM $ acceptFile fInfo
+ | mimeType == "application/zip" = do
+ $logInfoS "sourceFiles" "Unpacking ZIP"
+ fileSource fInfo =$= void consumeZip
+ | otherwise = do
+ $logDebugS "sourceFiles" [st|Not unpacking file of type #{decodeUtf8 mimeType}|]
+ yieldM $ acceptFile fInfo
where
- mimeType = defaultMimeLookup (fileName fInfo)
+ mimeType = mimeLookup $ fileName fInfo
acceptFile :: (MonadResource m, MonadThrow m, MonadIO m) => FileInfo -> m File
acceptFile fInfo = do
diff --git a/src/Import/NoFoundation.hs b/src/Import/NoFoundation.hs
index 457682087..0577f3915 100644
--- a/src/Import/NoFoundation.hs
+++ b/src/Import/NoFoundation.hs
@@ -1,73 +1,17 @@
module Import.NoFoundation
( module Import
- , MForm
) where
-import ClassyPrelude.Yesod as Import hiding (formatTime, derivePersistFieldJSON, addMessage, addMessageI, (.=), MForm, Proxy, foldlM, static, boolField, identifyForm)
+import Import.NoModel as Import
import Model as Import
-import Model.Types.JSON as Import
import Model.Migration as Import
import Model.Rating as Import
import Model.Submission as Import
+import Model.Tokens as Import
+import Utils.Tokens as Import
+import Utils.Frontend.Modal as Import
+
import Settings as Import
import Settings.StaticFiles as Import
-import Yesod.Auth as Import
-import Yesod.Core.Types as Import (loggerSet)
-import Yesod.Default.Config2 as Import
-import Utils as Import
-import Utils.Modal as Import
-import Yesod.Core.Json as Import (provideJson)
-import Yesod.Core.Types.Instances as Import ()
-
-
-import Data.Fixed as Import
import CryptoID as Import
-import Data.UUID as Import (UUID)
-
-import Text.Lucius as Import
-
-import Text.Shakespeare.Text as Import hiding (text, stext)
-
-import Data.Universe as Import
-import Data.Pool as Import (Pool)
-import Network.HaskellNet.SMTP as Import (SMTPConnection)
-
-import Mail as Import
-
-import Data.Data as Import (Data)
-import Data.Typeable as Import (Typeable)
-import GHC.Generics as Import (Generic)
-import GHC.Exts as Import (IsList)
-
-import Data.Hashable as Import
-import Data.List.NonEmpty as Import (NonEmpty(..))
-import Data.List.NonEmpty.Instances as Import ()
-import Data.Text.Encoding.Error as Import(UnicodeException(..))
-import Data.Semigroup as Import (Semigroup)
-import Data.Monoid as Import (Last(..), First(..))
-import Data.Monoid.Instances as Import ()
-
-import Data.Binary as Import (Binary)
-
-import Control.Monad.Morph as Import (MFunctor(..))
-
-import Control.Monad.Trans.Resource as Import (ReleaseKey)
-
-import Network.Mail.Mime.Instances as Import ()
-import Yesod.Core.Instances as Import ()
-
-import Ldap.Client.Pool as Import
-
-import Database.Esqueleto.Instances as Import ()
-import Database.Persist.Sql.Instances as Import ()
-import Database.Persist.Sql as Import (SqlReadT,SqlWriteT)
-
-import Numeric.Natural.Instances as Import ()
-import System.Random as Import (Random)
-import Control.Monad.Random.Class as Import (MonadRandom(..))
-
-
-import Control.Monad.Trans.RWS (RWST)
-
-type MForm m = RWST (Maybe (Env, FileEnv), HandlerSite m, [Lang]) Enctype Ints m
diff --git a/src/Import/NoModel.hs b/src/Import/NoModel.hs
new file mode 100644
index 000000000..9753d0e75
--- /dev/null
+++ b/src/Import/NoModel.hs
@@ -0,0 +1,109 @@
+module Import.NoModel
+ ( module Import
+ , MForm
+ ) where
+
+import ClassyPrelude.Yesod as Import hiding (formatTime, derivePersistFieldJSON, addMessage, addMessageI, (.=), MForm, Proxy, foldlM, static, boolField, identifyForm, cons)
+
+import Model.Types.TH.JSON as Import
+import Model.Types.TH.Wordlist as Import
+
+import Mail as Import
+
+import Yesod.Auth as Import
+import Yesod.Core.Types as Import (loggerSet)
+import Yesod.Default.Config2 as Import
+import Yesod.Core.Json as Import (provideJson)
+import Yesod.Core.Types.Instances as Import
+
+import Utils as Import
+import Utils.Frontend.I18n as Import
+import Utils.DB as Import
+
+import Data.Fixed as Import
+
+import Data.UUID as Import (UUID)
+
+import Data.CaseInsensitive as Import (CI, FoldCase(..), foldedCase)
+
+import Text.Lucius as Import
+import Text.Shakespeare.Text as Import hiding (text, stext)
+
+import Data.Universe as Import
+import Data.Universe.TH as Import
+import Data.Pool as Import (Pool)
+import Network.HaskellNet.SMTP as Import (SMTPConnection)
+
+import Data.Data as Import (Data)
+import Data.Typeable as Import (Typeable)
+import GHC.Generics as Import (Generic)
+import GHC.Exts as Import (IsList)
+import Data.Ix as Import (Ix)
+
+import Data.Hashable as Import
+import Data.List.NonEmpty as Import (NonEmpty(..), nonEmpty)
+import Data.Text.Encoding.Error as Import(UnicodeException(..))
+import Data.Semigroup as Import (Semigroup)
+import Data.Monoid as Import (Last(..), First(..), Any(..), All(..), Sum(..))
+import Data.Binary as Import (Binary)
+
+import Numeric.Natural as Import (Natural)
+import Data.Ratio as Import ((%))
+
+import Database.Persist.Sql as Import (SqlReadT, SqlWriteT, fromSqlKey, toSqlKey)
+
+import Ldap.Client.Pool as Import
+
+import System.Random as Import (Random(..))
+import Control.Monad.Random.Class as Import (MonadRandom(..))
+
+import Control.Monad.Morph as Import (MFunctor(..))
+import Control.Monad.Trans.Resource as Import (ReleaseKey)
+
+import Jose.Jwt as Import (Jwt)
+
+import Data.Time.Calendar as Import
+import Data.Time.Clock as Import
+import Data.Time.LocalTime as Import hiding (utcToLocalTime, localTimeToUTC)
+import Time.Types as Import (WeekDay(..))
+
+import Network.Mime as Import
+
+import Data.Aeson.TH as Import
+import Data.Aeson.Types as Import (FromJSON(..), ToJSON(..), FromJSONKey(..), ToJSONKey(..), toJSONKeyText, FromJSONKeyFunction(..), ToJSONKeyFunction(..), Value)
+
+import Language.Haskell.TH.Instances as Import ()
+import Data.List.NonEmpty.Instances as Import ()
+import Data.NonNull.Instances as Import ()
+import Data.Monoid.Instances as Import ()
+import Data.Maybe.Instances as Import ()
+import Data.CryptoID.Instances as Import ()
+import Data.Sum.Instances as Import ()
+import Data.Fixed.Instances as Import ()
+import Data.Set.Instances as Import ()
+import Data.HashMap.Strict.Instances as Import ()
+import Data.HashSet.Instances as Import ()
+import Data.Vector.Instances as Import ()
+import Data.Time.Clock.Instances as Import ()
+import Data.Time.LocalTime.Instances as Import ()
+import Data.Time.Calendar.Instances as Import ()
+import Data.Time.Format.Instances as Import ()
+import Time.Types.Instances as Import ()
+import Network.Mail.Mime.Instances as Import ()
+import Yesod.Core.Instances as Import ()
+import Data.Aeson.Types.Instances as Import ()
+import Database.Esqueleto.Instances as Import ()
+import Numeric.Natural.Instances as Import ()
+import Text.Blaze.Instances as Import ()
+import Jose.Jwt.Instances as Import ()
+import Web.PathPieces.Instances as Import ()
+import Data.Universe.Instances.Reverse.MonoTraversable ()
+import Database.Persist.Class.Instances as Import ()
+import Database.Persist.Types.Instances as Import ()
+import Data.UUID.Instances as Import ()
+import System.FilePath.Instances as Import ()
+
+
+import Control.Monad.Trans.RWS (RWST)
+
+type MForm m = RWST (Maybe (Env, FileEnv), HandlerSite m, [Lang]) Enctype Ints m
diff --git a/src/Jobs.hs b/src/Jobs.hs
index 2a9a42556..867718bab 100644
--- a/src/Jobs.hs
+++ b/src/Jobs.hs
@@ -6,6 +6,8 @@ module Jobs
) where
import Import
+import Utils.Lens
+import Handler.Utils
import Jobs.Types as Types hiding (JobCtl(JobCtlQueue))
import Jobs.Types (JobCtl(JobCtlQueue))
@@ -31,6 +33,7 @@ import Control.Monad.Random (evalRand, mkStdGen, getRandomR)
import Cron
import qualified Data.HashMap.Strict as HashMap
import Data.HashMap.Strict (HashMap)
+import qualified Data.Set as Set
import qualified Data.List.NonEmpty as NonEmpty
@@ -46,7 +49,6 @@ import Control.Monad.Trans.Resource (MonadResourceBase, runResourceT, allocate,
import Control.Monad.Trans.Maybe (MaybeT(..))
import Control.Monad.Logger
-import Data.Time.Clock
import Data.Time.Zones
import Control.Concurrent.STM (retry)
@@ -58,6 +60,10 @@ import Jobs.Handler.QueueNotification
import Jobs.Handler.HelpRequest
import Jobs.Handler.SetLogSettings
import Jobs.Handler.DistributeCorrections
+import Jobs.Handler.SendCourseCommunication
+import Jobs.Handler.Invitation
+
+import Jobs.HealthReport
data JobQueueException = JInvalid QueuedJobId QueuedJob
@@ -77,7 +83,7 @@ handleJobs :: ( MonadResource m
-- Uses `unsafeHandler`, as per documentation all HTTP-related fields of state/environment are meaningless placeholders.
-- Handling commands in `HandlerT` provides us with the facilities to render urls, unifies logging, provides a value of the foundation type, ...
handleJobs foundation@UniWorX{..} = do
- let num = appJobWorkers appSettings
+ let num = foundation ^. _appJobWorkers
jobCrontab <- liftIO $ newTMVarIO HashMap.empty
jobConfirm <- liftIO $ newTVarIO HashMap.empty
@@ -88,22 +94,23 @@ handleJobs foundation@UniWorX{..} = do
logStart = $logDebugS ("Jobs #" <> tshow n) "Starting"
logStop = $logDebugS ("Jobs #" <> tshow n) "Stopping"
removeChan = atomically . modifyTVar' appJobCtl . Map.delete =<< myThreadId
- doFork = flip forkFinally (\_ -> removeChan) . unsafeHandler foundation . bracket_ logStart logStop . flip runReaderT JobContext{..} . runConduit $ sourceTMChan chan .| handleJobs' n
+ doFork = flip forkFinally (\_ -> removeChan) . runAppLoggingT foundation . bracket_ logStart logStop . flip runReaderT JobContext{..} . runConduit $ sourceTMChan chan .| handleJobs' foundation n
(_, tId) <- allocate (liftIO doFork) (\_ -> liftIO . atomically $ closeTMChan chan)
atomically . modifyTVar' appJobCtl $ Map.insert tId bChan
-- Start cron operation
- registeredCron <- liftIO newEmptyTMVarIO
- let execCrontab' = whenM (atomically $ readTMVar registeredCron) $
- unsafeHandler foundation $ runReaderT execCrontab JobContext{..}
- unregister = atomically . whenM (fromMaybe False <$> tryReadTMVar registeredCron) . void $ tryTakeTMVar appCronThread
- cData <- allocate (liftIO . forkFinally execCrontab' $ \_ -> unregister) (\_ -> liftIO . atomically . void $ tryTakeTMVar jobCrontab)
- registeredCron' <- atomically $ do
- registeredCron' <- tryPutTMVar appCronThread cData
- registeredCron' <$ putTMVar registeredCron registeredCron'
- when registeredCron' $
- liftIO . unsafeHandler foundation . flip runReaderT JobContext{..} $
- writeJobCtlBlock JobCtlDetermineCrontab
+ when (num > 0) $ do
+ registeredCron <- liftIO newEmptyTMVarIO
+ let execCrontab' = whenM (atomically $ readTMVar registeredCron) $
+ runReaderT (execCrontab foundation) JobContext{..}
+ unregister = atomically . whenM (fromMaybe False <$> tryReadTMVar registeredCron) . void $ tryTakeTMVar appCronThread
+ cData <- allocate (liftIO . forkFinally execCrontab' $ \_ -> unregister) (\_ -> liftIO . atomically . void $ tryTakeTMVar jobCrontab)
+ registeredCron' <- atomically $ do
+ registeredCron' <- tryPutTMVar appCronThread cData
+ registeredCron' <$ putTMVar registeredCron registeredCron'
+ when registeredCron' $
+ liftIO . unsafeHandler foundation . flip runReaderT JobContext{..} $
+ writeJobCtlBlock JobCtlDetermineCrontab
stopJobCtl :: MonadIO m => UniWorX -> m ()
-- ^ Stop all worker threads currently running
@@ -120,73 +127,75 @@ stopJobCtl UniWorX{appJobCtl, appCronThread} = do
guard . none (`Map.member` wMap') $ Map.keysSet wMap
-execCrontab :: ReaderT JobContext (HandlerT UniWorX IO) ()
+execCrontab :: MonadIO m => UniWorX -> ReaderT JobContext m ()
-- ^ Keeping a `HashMap` of the latest execution times of `JobCtl`s we have
-- seen, wait for the time of the next job and fire it
-execCrontab = evalStateT go HashMap.empty
+execCrontab foundation = evalStateT go HashMap.empty
where
go = do
- mapStateT (liftHandlerT . runDB . setSerializable) $ do
- let
- merge (Entity leId CronLastExec{..})
- | Just job <- Aeson.parseMaybe parseJSON cronLastExecJob
- = State.modify $ HashMap.insertWith (<>) (JobCtlQueue job) (Max cronLastExecTime)
- | otherwise = lift $ delete leId
- runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ merge
+ cont <- mapStateT (mapReaderT $ liftIO . unsafeHandler foundation) $ do
+ mapStateT (liftHandlerT . runDB . setSerializable) $ do
+ let
+ merge (Entity leId CronLastExec{..})
+ | Just job <- Aeson.parseMaybe parseJSON cronLastExecJob
+ = State.modify $ HashMap.insertWith (<>) (JobCtlQueue job) (Max cronLastExecTime)
+ | otherwise = lift $ delete leId
+ runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ merge
- refT <- liftIO getCurrentTime
- settings <- getsYesod appSettings
- currentState <- mapStateT (mapReaderT $ liftIO . atomically) $ do
- crontab' <- liftBase . tryReadTMVar =<< asks jobCrontab
- case crontab' of
- Nothing -> return Nothing
- Just crontab -> Just <$> do
- State.modify . HashMap.filterWithKey $ \k _ -> HashMap.member k crontab
- prevExec <- State.get
- case earliestJob settings prevExec crontab refT of
- Nothing -> liftBase retry
- Just (_, MatchNone) -> liftBase retry
- Just x -> return (crontab, x)
+ refT <- liftIO getCurrentTime
+ settings <- getsYesod appSettings'
+ currentState <- mapStateT (mapReaderT $ liftIO . atomically) $ do
+ crontab' <- liftBase . tryReadTMVar =<< asks jobCrontab
+ case crontab' of
+ Nothing -> return Nothing
+ Just crontab -> Just <$> do
+ State.modify . HashMap.filterWithKey $ \k _ -> HashMap.member k crontab
+ prevExec <- State.get
+ case earliestJob settings prevExec crontab refT of
+ Nothing -> liftBase retry
+ Just (_, MatchNone) -> liftBase retry
+ Just x -> return (crontab, x)
- case currentState of
- Nothing -> return ()
- Just (currentCrontab, (jobCtl, nextMatch)) -> do
- let doJob = mapStateT (mapReaderT $ liftHandlerT . runDBJobs . setSerializable) $ do
- newCrontab <- lift . lift . hoist lift $ determineCrontab'
- if
- | ((==) `on` HashMap.lookup jobCtl) newCrontab currentCrontab
- -> do
- now <- liftIO $ getCurrentTime
- instanceID <- getsYesod appInstanceID
- State.modify $ HashMap.alter (Just . ($ Max now) . maybe id (<>)) jobCtl
- case jobCtl of
- JobCtlQueue job -> do
- void . lift . lift $ upsertBy
- (UniqueCronLastExec $ toJSON job)
- CronLastExec
- { cronLastExecJob = toJSON job
- , cronLastExecTime = now
- , cronLastExecInstance = instanceID
- }
- [ CronLastExecTime =. now ]
- lift . lift $ queueDBJob job
- other -> writeJobCtl other
- | otherwise
- -> lift . mapReaderT (liftIO . atomically) $
- lift . void . flip swapTMVar newCrontab =<< asks jobCrontab
+ case currentState of
+ Nothing -> return False
+ Just (currentCrontab, (jobCtl, nextMatch)) -> do
+ let doJob = mapStateT (mapReaderT $ liftHandlerT . runDBJobs . setSerializable) $ do
+ newCrontab <- lift . lift . hoist lift $ determineCrontab'
+ if
+ | ((==) `on` HashMap.lookup jobCtl) newCrontab currentCrontab
+ -> do
+ now <- liftIO $ getCurrentTime
+ instanceID' <- getsYesod appInstanceID
+ State.modify $ HashMap.alter (Just . ($ Max now) . maybe id (<>)) jobCtl
+ case jobCtl of
+ JobCtlQueue job -> do
+ void . lift . lift $ upsertBy
+ (UniqueCronLastExec $ toJSON job)
+ CronLastExec
+ { cronLastExecJob = toJSON job
+ , cronLastExecTime = now
+ , cronLastExecInstance = instanceID'
+ }
+ [ CronLastExecTime =. now ]
+ lift . lift $ queueDBJob job
+ other -> writeJobCtl other
+ | otherwise
+ -> lift . mapReaderT (liftIO . atomically) $
+ lift . void . flip swapTMVar newCrontab =<< asks jobCrontab
- case nextMatch of
- MatchAsap -> doJob
- MatchNone -> return ()
- MatchAt nextTime -> do
- JobContext{jobCrontab} <- ask
- nextTime' <- applyJitter jobCtl nextTime
- $logDebugS "Cron" [st|Waiting until #{tshow (utcToLocalTimeTZ appTZ nextTime')} to execute #{tshow jobCtl}|]
- logFunc <- askLoggerIO
- whenM (liftIO . flip runLoggingT logFunc $ waitUntil jobCrontab currentCrontab nextTime')
- doJob
+ case nextMatch of
+ MatchAsap -> doJob
+ MatchNone -> return ()
+ MatchAt nextTime -> do
+ JobContext{jobCrontab} <- ask
+ nextTime' <- applyJitter jobCtl nextTime
+ $logDebugS "Cron" [st|Waiting until #{tshow (utcToLocalTimeTZ appTZ nextTime')} to execute #{tshow jobCtl}|]
+ logFunc <- askLoggerIO
+ whenM (liftIO . flip runLoggingT logFunc $ waitUntil jobCrontab currentCrontab nextTime')
+ doJob
- go
+ return True
+ when cont go
where
acc :: NominalDiffTime
acc = 1e-3
@@ -238,12 +247,12 @@ execCrontab = evalStateT go HashMap.empty
bool (waitUntil crontabTV crontab nextTime) (return False) crontabChanged
-handleJobs' :: Natural -> Sink JobCtl (ReaderT JobContext Handler) ()
-handleJobs' wNum = C.mapM_ $ \jctl -> do
+handleJobs' :: (MonadIO m, MonadLogger m, MonadCatch m) => UniWorX -> Natural -> Sink JobCtl (ReaderT JobContext m) ()
+handleJobs' foundation wNum = C.mapM_ $ \jctl -> do
$logDebugS logIdent $ tshow jctl
resVars <- mapReaderT (liftIO . atomically) $
HashMap.lookup jctl <$> (lift . readTVar =<< asks jobConfirm)
- res <- fmap (either Just $ const Nothing) . try $ handleCmd jctl
+ res <- fmap (either Just $ const Nothing) . try . (mapReaderT $ liftIO . unsafeHandler foundation) $ handleCmd jctl
sentRes <- liftIO . atomically $ foldrM (\resVar -> bool (tryPutTMVar resVar res) $ return True) False (maybe [] NonEmpty.toList resVars)
case res of
Just err
@@ -277,6 +286,19 @@ handleJobs' wNum = C.mapM_ $ \jctl -> do
-- logDebugS logIdent $ tshow newCTab
mapReaderT (liftIO . atomically) $
lift . void . flip swapTMVar newCTab =<< asks jobCrontab
+ handleCmd (JobCtlGenerateHealthReport kind) = do
+ hrStorage <- getsYesod appHealthReport
+ newReport@(healthReportStatus -> newStatus) <- lift $ generateHealthReport kind
+
+ $logInfoS (tshow kind) $ toPathPiece newStatus
+ unless (newStatus == HealthSuccess) $ do
+ $logErrorS (tshow kind) $ tshow newReport
+
+ liftIO $ do
+ now <- getCurrentTime
+ let updateReports = Set.insert (now, newReport)
+ . Set.filter (((/=) `on` classifyHealthReport) newReport . snd)
+ atomically . modifyTVar' hrStorage $ force . updateReports
jLocked :: QueuedJobId -> (QueuedJob -> Handler a) -> Handler a
jLocked jId act = do
@@ -285,21 +307,21 @@ jLocked jId act = do
let
lock = runDB . setSerializable $ do
qj@QueuedJob{..} <- maybe (throwM $ JNonexistant jId) return =<< get jId
- instanceID <- getsYesod appInstanceID
- threshold <- getsYesod $ appJobStaleThreshold . appSettings
+ instanceID' <- getsYesod $ view instanceID
+ threshold <- getsYesod $ view _appJobStaleThreshold
now <- liftIO getCurrentTime
hadStale <- maybeT (return False) $ do
lockTime <- MaybeT $ return queuedJobLockTime
lockInstance <- MaybeT $ return queuedJobLockInstance
if
- | lockInstance == instanceID
+ | lockInstance == instanceID'
, diffUTCTime now lockTime >= threshold
-> return True
| otherwise
-> throwM $ JLocked jId lockInstance lockTime
when hadStale .
$logWarnS "Jobs" $ "Ignored stale lock: " <> tshow qj
- val <- updateGet jId [ QueuedJobLockInstance =. Just instanceID
+ val <- updateGet jId [ QueuedJobLockInstance =. Just instanceID'
, QueuedJobLockTime =. Just now
]
liftIO . atomically $ writeTVar hasLock True
diff --git a/src/Jobs/Crontab.hs b/src/Jobs/Crontab.hs
index 41b3441c6..aecca927e 100644
--- a/src/Jobs/Crontab.hs
+++ b/src/Jobs/Crontab.hs
@@ -2,19 +2,17 @@ module Jobs.Crontab
( determineCrontab
) where
-import Import
+import Import
import qualified Data.HashMap.Strict as HashMap
import Jobs.Types
-import Data.Maybe (fromJust)
import qualified Data.Map as Map
import Data.Semigroup (Max(..))
-import Data.Time
import Data.Time.Zones
-import Control.Monad.Trans.Writer (execWriterT)
+import Control.Monad.Trans.Writer (WriterT, execWriterT)
import Control.Monad.Writer.Class (MonadWriter(..))
import qualified Data.Conduit.List as C
@@ -23,7 +21,7 @@ import qualified Data.Conduit.List as C
determineCrontab :: DB (Crontab JobCtl)
-- ^ Extract all future jobs from the database (sheet deadlines, ...)
determineCrontab = execWriterT $ do
- AppSettings{..} <- getsYesod appSettings
+ AppSettings{..} <- getsYesod appSettings'
case appJobFlushInterval of
Just interval -> tell $ HashMap.singleton
@@ -45,6 +43,18 @@ determineCrontab = execWriterT $ do
, cronNotAfter = Right CronNotScheduled
}
+ tell . flip foldMap universeF $ \kind ->
+ case appHealthCheckInterval kind of
+ Just int -> HashMap.singleton
+ (JobCtlGenerateHealthReport kind)
+ Cron
+ { cronInitial = CronAsap
+ , cronRepeat = CronRepeatScheduled CronAsap
+ , cronRateLimit = int
+ , cronNotAfter = Right CronNotScheduled
+ }
+ Nothing -> mempty
+
let
sheetJobs (Entity nSheet Sheet{..}) = do
tell $ HashMap.singleton
@@ -80,28 +90,31 @@ determineCrontab = execWriterT $ do
, cronRateLimit = 3600 -- Irrelevant due to `cronRepeat`
, cronNotAfter = Left nominalDay
}
-
- sheetSubmissions <- lift $ collateSubmissions <$>
- selectList [SubmissionRatingBy !=. Nothing, SubmissionSheet ==. nSheet] []
- tell $ flip Map.foldMapWithKey sheetSubmissions $
- \nUser (Max mbTime) -> if
- | Just time <- mbTime -> HashMap.singleton
- (JobCtlQueue $ JobQueueNotification NotificationCorrectionsAssigned { nUser, nSheet } )
- Cron
- { cronInitial = CronTimestamp $ utcToLocalTimeTZ appTZ $ addUTCTime appNotificationCollateDelay time
- , cronRepeat = CronRepeatNever
- , cronRateLimit = appNotificationRateLimit
- , cronNotAfter = Left appNotificationExpiration
- }
- | otherwise -> mempty
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ sheetJobs
--- | Partial function: Submission must not have Nothing at ratingBy
-collateSubmissions :: [Entity Submission] -> Map UserId (Max (Maybe UTCTime))
-collateSubmissions = Map.fromListWith (<>) . fmap procCorrector
- where
- procCorrector :: Entity Submission -> (UserId ,Max (Maybe UTCTime))
- procCorrector = (,) <$> fromJust . submissionRatingBy . entityVal
- <*> Max . submissionRatingAssigned . entityVal
-
+ let
+ correctorNotifications :: Map (UserId, SheetId) (Max UTCTime) -> WriterT (Crontab JobCtl) DB ()
+ correctorNotifications = (tell .) . Map.foldMapWithKey $ \(nUser, nSheet) (Max time) -> HashMap.singleton
+ (JobCtlQueue $ JobQueueNotification NotificationCorrectionsAssigned { nUser, nSheet } )
+ Cron
+ { cronInitial = CronTimestamp . utcToLocalTimeTZ appTZ $ addUTCTime appNotificationCollateDelay time
+ , cronRepeat = CronRepeatNever
+ , cronRateLimit = appNotificationRateLimit
+ , cronNotAfter = Left appNotificationExpiration
+ }
+
+ submissionsByCorrector :: Entity Submission -> Map (UserId, SheetId) (Max UTCTime)
+ submissionsByCorrector (Entity _ sub)
+ | Just ratingBy <- submissionRatingBy sub
+ , Just assigned <- submissionRatingAssigned sub
+ , not $ submissionRatingDone sub
+ = Map.singleton (ratingBy, submissionSheet sub) $ Max assigned
+ | otherwise
+ = Map.empty
+
+ collateSubmissionsByCorrector acc entity = Map.unionWith (<>) acc $ submissionsByCorrector entity
+ correctorNotifications <=< runConduit $
+ transPipe lift ( selectSource [ SubmissionRatingBy !=. Nothing, SubmissionRatingAssigned !=. Nothing ] []
+ )
+ .| C.fold collateSubmissionsByCorrector Map.empty
diff --git a/src/Jobs/Handler/HelpRequest.hs b/src/Jobs/Handler/HelpRequest.hs
index 2b92c0e2b..68d3a2d1e 100644
--- a/src/Jobs/Handler/HelpRequest.hs
+++ b/src/Jobs/Handler/HelpRequest.hs
@@ -21,15 +21,15 @@ dispatchJobHelpRequest :: Either (Maybe Address) UserId
-> Maybe Text -- ^ Referer
-> Handler ()
dispatchJobHelpRequest jSender jRequestTime jHelpSubject jHelpRequest jReferer = do
- supportAddress <- getsYesod $ appMailSupport . appSettings
+ supportAddress <- view _appMailSupport
userInfo <- bitraverse return (runDB . getEntity) jSender
- let userAddress = either
+ let senderAddress = either
id
(fmap $ \(Entity _ User{..}) -> Address (Just userDisplayName) (CI.original userEmail))
userInfo
mailT def $ do
_mailTo .= [supportAddress]
- whenIsJust userAddress (_mailFrom .=)
+ whenIsJust senderAddress (_mailFrom .=)
replaceMailHeader "Auto-Submitted" $ Just "no"
setSubjectI $ maybe MsgMailSubjectSupport MsgMailSubjectSupportCustom jHelpSubject
setDate jRequestTime
diff --git a/src/Jobs/Handler/Invitation.hs b/src/Jobs/Handler/Invitation.hs
new file mode 100644
index 000000000..f86256f33
--- /dev/null
+++ b/src/Jobs/Handler/Invitation.hs
@@ -0,0 +1,27 @@
+module Jobs.Handler.Invitation
+ ( dispatchJobInvitation
+ ) where
+
+import Import
+import Utils.Lens
+import Handler.Utils.Mail
+
+import qualified Data.CaseInsensitive as CI
+import Text.Hamlet
+
+
+dispatchJobInvitation :: UserId
+ -> UserEmail
+ -> Text
+ -> Text
+ -> Html
+ -> Handler ()
+dispatchJobInvitation jInviter jInvitee jInvitationUrl jInvitationSubject jInvitationExplanation = do
+ mInviter <- runDB $ get jInviter
+
+ whenIsJust mInviter $ \jInviter' -> mailT def $ do
+ _mailTo .= [Address Nothing $ CI.original jInvitee]
+ replaceMailHeader "Reply-To" . Just . renderAddress $ userAddress jInviter'
+ replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
+ replaceMailHeader "Subject" $ Just jInvitationSubject
+ addPart ($(ihamletFile "templates/mail/invitation.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
diff --git a/src/Jobs/Handler/SendCourseCommunication.hs b/src/Jobs/Handler/SendCourseCommunication.hs
new file mode 100644
index 000000000..734612c43
--- /dev/null
+++ b/src/Jobs/Handler/SendCourseCommunication.hs
@@ -0,0 +1,37 @@
+module Jobs.Handler.SendCourseCommunication
+ ( dispatchJobSendCourseCommunication
+ ) where
+
+import Import
+
+import Utils.Lens
+import Handler.Utils
+
+import qualified Data.Set as Set
+
+import qualified Data.CaseInsensitive as CI
+
+
+dispatchJobSendCourseCommunication :: Either UserEmail UserId
+ -> Set Address
+ -> CourseId
+ -> UserId
+ -> UUID
+ -> Maybe Text
+ -> Html
+ -> Handler ()
+dispatchJobSendCourseCommunication jRecipientEmail jAllRecipientAddresses jCourse jSender jMailObjectUUID jSubject jMailContent = do
+ (sender, Course{..}) <- runDB $ (,)
+ <$> getJust jSender
+ <*> getJust jCourse
+ either (\email -> mailT def . (assign _mailTo (pure . Address Nothing $ CI.original email) *>)) userMailT jRecipientEmail $ do
+ void $ setMailObjectUUID jMailObjectUUID
+ _mailFrom .= userAddress sender
+ if -- Use `addMailHeader` instead of `_mailCc` to make `mailT` ignore the additional recipients
+ | jRecipientEmail == Right jSender
+ -> addMailHeader "Cc" . intercalate ", " . map renderAddress $ Set.toAscList (Set.delete (userAddress sender) jAllRecipientAddresses)
+ | otherwise
+ -> addMailHeader "Cc" "Undisclosed Recipients:;"
+ addMailHeader "Auto-Submitted" "no"
+ setSubjectI . prependCourseTitle courseTerm courseSchool courseShorthand $ maybe (SomeMessage MsgCommCourseSubject) SomeMessage jSubject
+ void $ addPart jMailContent
diff --git a/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs b/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs
index 6a9e6ace9..9c11a5bb6 100644
--- a/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs
+++ b/src/Jobs/Handler/SendNotification/CorrectionsAssigned.hs
@@ -6,6 +6,7 @@ module Jobs.Handler.SendNotification.CorrectionsAssigned
import Import
+import Jobs.Handler.SendNotification.Utils
import Handler.Utils.Mail
import Text.Hamlet
@@ -28,6 +29,7 @@ dispatchNotificationCorrectionsAssigned nUser nSheet jRecipient = do
MsgRenderer mr <- getMailMsgRenderer
let termDesc = mr . ShortTermIdentifier $ unTermKey courseTerm
- addAlternatives $ do
- let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet")
+ editNotifications <- mkEditNotifications jRecipient
+
+ addAlternatives $
providePreferredAlternative ($(ihamletFile "templates/mail/correctionsAssigned.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
diff --git a/src/Jobs/Handler/SendNotification/SheetActive.hs b/src/Jobs/Handler/SendNotification/SheetActive.hs
index fc2c5a185..3276b9d44 100644
--- a/src/Jobs/Handler/SendNotification/SheetActive.hs
+++ b/src/Jobs/Handler/SendNotification/SheetActive.hs
@@ -7,6 +7,7 @@ module Jobs.Handler.SendNotification.SheetActive
import Import
import Handler.Utils.Mail
+import Jobs.Handler.SendNotification.Utils
import Text.Hamlet
import qualified Data.CaseInsensitive as CI
@@ -27,6 +28,7 @@ dispatchNotificationSheetActive nSheet jRecipient = userMailT jRecipient $ do
csh = courseShorthand
shn = sheetName
- addAlternatives $ do
- let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet")
+ editNotifications <- mkEditNotifications jRecipient
+
+ addAlternatives $
providePreferredAlternative ($(ihamletFile "templates/mail/sheetActive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
diff --git a/src/Jobs/Handler/SendNotification/SheetInactive.hs b/src/Jobs/Handler/SendNotification/SheetInactive.hs
index ed76be1b3..855009743 100644
--- a/src/Jobs/Handler/SendNotification/SheetInactive.hs
+++ b/src/Jobs/Handler/SendNotification/SheetInactive.hs
@@ -8,6 +8,7 @@ module Jobs.Handler.SendNotification.SheetInactive
import Import
import Handler.Utils.Mail
+import Jobs.Handler.SendNotification.Utils
import Text.Hamlet
import qualified Data.CaseInsensitive as CI
@@ -30,8 +31,9 @@ dispatchNotificationSheetSoonInactive nSheet jRecipient = userMailT jRecipient $
csh = courseShorthand
shn = sheetName
- addAlternatives $ do
- let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet")
+ editNotifications <- mkEditNotifications jRecipient
+
+ addAlternatives $
providePreferredAlternative ($(ihamletFile "templates/mail/sheetSoonInactive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
dispatchNotificationSheetInactive :: SheetId -> UserId -> Handler ()
@@ -56,7 +58,8 @@ dispatchNotificationSheetInactive nSheet jRecipient = userMailT jRecipient $ do
csh = courseShorthand
shn = sheetName
- addAlternatives $ do
- let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet")
+ editNotifications <- mkEditNotifications jRecipient
+
+ addAlternatives $
providePreferredAlternative ($(ihamletFile "templates/mail/sheetInactive.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
diff --git a/src/Jobs/Handler/SendNotification/SubmissionRated.hs b/src/Jobs/Handler/SendNotification/SubmissionRated.hs
index 1cb3e1d50..75314e786 100644
--- a/src/Jobs/Handler/SendNotification/SubmissionRated.hs
+++ b/src/Jobs/Handler/SendNotification/SubmissionRated.hs
@@ -9,6 +9,7 @@ import Import
import Utils.Lens
import Handler.Utils.DateTime
import Handler.Utils.Mail
+import Jobs.Handler.SendNotification.Utils
import Text.Hamlet
import qualified Data.Aeson as Aeson
@@ -35,6 +36,8 @@ dispatchNotificationSubmissionRated nSubmission jRecipient = userMailT jRecipien
csh = courseShorthand
shn = sheetName
+ editNotifications <- mkEditNotifications jRecipient
+
-- TODO: provide convienience template-haskell for `addAlternatives`
addAlternatives $ do
provideAlternative $ Aeson.object
@@ -52,5 +55,4 @@ dispatchNotificationSubmissionRated nSubmission jRecipient = userMailT jRecipien
, "course-school" Aeson..= courseSchool
]
-- provideAlternative $ \(MsgRenderer mr) -> ($(textFile "templates/mail/submissionRated.txt") :: TextUrl (Route UniWorX)) -- textFile does not support control statements
- let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet")
providePreferredAlternative ($(ihamletFile "templates/mail/submissionRated.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
diff --git a/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs b/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs
index 3e9d2c4a8..a13c0004a 100644
--- a/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs
+++ b/src/Jobs/Handler/SendNotification/UserRightsUpdate.hs
@@ -8,6 +8,7 @@ import Import
import Handler.Utils.Database
import Handler.Utils.Mail
+import Jobs.Handler.SendNotification.Utils
import Text.Hamlet
-- import qualified Data.CaseInsensitive as CI
@@ -22,7 +23,7 @@ dispatchNotificationUserRightsUpdate nUser _originalRights jRecipient = userMai
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectUserRightsUpdate userDisplayName
-- MsgRenderer mr <- getMailMsgRenderer
- addAlternatives $ do
- let editNotifications = $(ihamletFile "templates/mail/editNotifications.hamlet")
+ editNotifications <- mkEditNotifications jRecipient
+ addAlternatives $
providePreferredAlternative ($(ihamletFile "templates/mail/userRightsUpdate.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
diff --git a/src/Jobs/Handler/SendNotification/Utils.hs b/src/Jobs/Handler/SendNotification/Utils.hs
new file mode 100644
index 000000000..c91199db9
--- /dev/null
+++ b/src/Jobs/Handler/SendNotification/Utils.hs
@@ -0,0 +1,20 @@
+module Jobs.Handler.SendNotification.Utils
+ ( mkEditNotifications
+ ) where
+
+import Import
+
+import Text.Hamlet
+
+import qualified Data.HashSet as HashSet
+
+
+mkEditNotifications :: (MonadHandler m, HandlerSite m ~ UniWorX) => UserId -> m (HtmlUrlI18n UniWorXMessage (Route UniWorX))
+mkEditNotifications uid = liftHandlerT $ do
+ cID <- encrypt uid
+ jwt <- encodeToken =<< bearerToken uid (Just . HashSet.singleton $ UserNotificationR cID) Nothing Nothing Nothing
+ let
+ editNotificationsUrl :: SomeRoute UniWorX
+ editNotificationsUrl = SomeRoute (UserNotificationR cID, [(toPathPiece GetBearer, toPathPiece jwt)])
+ editNotificationsUrl' <- toTextUrl editNotificationsUrl
+ return ($(ihamletFile "templates/mail/editNotifications.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
diff --git a/src/Jobs/HealthReport.hs b/src/Jobs/HealthReport.hs
new file mode 100644
index 000000000..45500a8bb
--- /dev/null
+++ b/src/Jobs/HealthReport.hs
@@ -0,0 +1,137 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+
+module Jobs.HealthReport
+ ( generateHealthReport
+ ) where
+
+import Import
+
+import Data.List (genericLength)
+
+import qualified Data.Aeson as Aeson
+import Data.Proxy (Proxy(..))
+
+import qualified Data.ByteArray as ByteArray
+
+import Utils.Lens
+
+import Network.HTTP.Simple (httpJSON, httpLBS)
+import qualified Network.HTTP.Simple as HTTP
+
+import qualified Database.Esqueleto as E
+
+import Auth.LDAP
+
+import qualified Data.CaseInsensitive as CI
+
+import qualified Network.HaskellNet.SMTP as SMTP
+import Data.Pool (withResource)
+
+
+generateHealthReport :: HealthCheck -> Handler HealthReport
+generateHealthReport = $(dispatchTH ''HealthCheck)
+
+dispatchHealthCheckMatchingClusterConfig :: Handler HealthReport
+-- ^ Can the cluster configuration be read from the database and does it match our configuration?
+dispatchHealthCheckMatchingClusterConfig
+ = fmap HealthMatchingClusterConfig . runDB $ and <$> forM universeF clusterSettingMatches
+ where
+ clusterSettingMatches ClusterCryptoIDKey = do
+ ourSetting <- getsYesod appCryptoIDKey
+ dbSetting <- clusterSetting @'ClusterCryptoIDKey
+ return $ ((==) `on` fmap (ByteArray.convert :: CryptoIDKey -> ByteString)) (Just ourSetting) dbSetting
+ clusterSettingMatches ClusterClientSessionKey = do
+ ourSetting <- getsYesod appSessionKey
+ dbSetting <- clusterSetting @'ClusterClientSessionKey
+ return $ Just ourSetting == dbSetting
+ clusterSettingMatches ClusterSecretBoxKey = do
+ ourSetting <- getsYesod appSecretBoxKey
+ dbSetting <- clusterSetting @'ClusterSecretBoxKey
+ return $ Just ourSetting == dbSetting
+ clusterSettingMatches ClusterJSONWebKeySet = do
+ ourSetting <- getsYesod appJSONWebKeySet
+ dbSetting <- clusterSetting @'ClusterJSONWebKeySet
+ return $ Just ourSetting == dbSetting
+ clusterSettingMatches ClusterId = do
+ ourSetting <- getsYesod appClusterID
+ dbSetting <- clusterSetting @'ClusterId
+ return $ Just ourSetting == dbSetting
+
+
+ clusterSetting :: forall key.
+ ( ClusterSetting key
+ )
+ => DB (Maybe (ClusterSettingValue key))
+ clusterSetting = do
+ current' <- get . ClusterConfigKey $ knownClusterSetting (Proxy @key)
+ case Aeson.fromJSON . clusterConfigValue <$> current' of
+ Just (Aeson.Success c) -> return $ Just c
+ _other -> return Nothing
+
+
+dispatchHealthCheckHTTPReachable :: Handler HealthReport
+dispatchHealthCheckHTTPReachable = HealthHTTPReachable <$> do
+ staticAppRoot <- getsYesod $ view _appRoot
+ doHTTP <- getsYesod $ view _appHealthCheckHTTP
+ for (staticAppRoot <* guard doHTTP) $ \_ -> do
+ url <- getUrlRender <*> pure InstanceR
+ baseRequest <- HTTP.parseRequest $ unpack url
+ httpManager <- getsYesod appHttpManager
+ let httpRequest = baseRequest
+ & HTTP.setRequestManager httpManager
+ (clusterId, _ :: InstanceId) <- responseBody <$> httpJSON httpRequest
+ getsYesod $ (== clusterId) . appClusterID
+
+
+dispatchHealthCheckLDAPAdmins :: Handler HealthReport
+dispatchHealthCheckLDAPAdmins = HealthLDAPAdmins <$> do
+ ldapPool' <- getsYesod appLdapPool
+ ldapConf' <- getsYesod $ view _appLdapConf
+ ldapAdminUsers <- fmap (map E.unValue) . runDB . E.select . E.from $ \(user `E.InnerJoin` lecturer) -> E.distinctOnOrderBy [E.asc $ user E.^. UserId] $ do
+ E.on $ user E.^. UserId E.==. lecturer E.^. LecturerUser
+ E.where_ $ user E.^. UserAuthentication E.==. E.val AuthLDAP
+ return $ user E.^. UserIdent
+ case (,) <$> ldapPool' <*> ldapConf' of
+ Just (ldapPool, ldapConf)
+ | not $ null ldapAdminUsers
+ -> do
+ let numAdmins = genericLength ldapAdminUsers
+ hCampusExc :: CampusUserException -> Handler (Sum Integer)
+ hCampusExc _ = return $ Sum 0
+ Sum numResolved <- fmap fold . forM ldapAdminUsers $
+ \(CI.original -> adminIdent) -> handle hCampusExc $ Sum 1 <$ campusUser ldapConf ldapPool (Creds "LDAP" adminIdent [])
+ return . Just $ numResolved % numAdmins
+ _other -> return Nothing
+
+
+dispatchHealthCheckSMTPConnect :: Handler HealthReport
+dispatchHealthCheckSMTPConnect = HealthSMTPConnect <$> do
+ smtpPool <- getsYesod appSmtpPool
+ for smtpPool . flip withResource $ \smtpConn -> do
+ response@(rCode, _) <- liftIO $ SMTP.sendCommand smtpConn SMTP.NOOP
+ case rCode of
+ 250 -> return True
+ _ -> do
+ $logErrorS "Mail" $ "NOOP failed: " <> tshow response
+ return False
+
+
+dispatchHealthCheckWidgetMemcached :: Handler HealthReport
+dispatchHealthCheckWidgetMemcached = HealthWidgetMemcached <$> do
+ memcachedConn <- getsYesod appWidgetMemcached
+ for memcachedConn $ \_memcachedConn' -> do
+ let ext = "bin"
+ mimeType = "application/octet-stream"
+ content <- pack . take 256 <$> liftIO getRandoms
+ staticLink <- addStaticContent ext mimeType content
+ doHTTP <- getsYesod $ view _appHealthCheckHTTP
+ case staticLink of
+ _ | not doHTTP -> return True
+ Just (Left url) -> do
+ baseRequest <- HTTP.parseRequest $ unpack url
+ httpManager <- getsYesod appHttpManager
+ let httpRequest = baseRequest
+ & HTTP.setRequestManager httpManager
+ (== content) . responseBody <$> httpLBS httpRequest
+ _other -> return False
+
diff --git a/src/Jobs/Queue.hs b/src/Jobs/Queue.hs
index a9d701ec4..8152ffbfb 100644
--- a/src/Jobs/Queue.hs
+++ b/src/Jobs/Queue.hs
@@ -2,11 +2,11 @@ module Jobs.Queue
( writeJobCtl, writeJobCtlBlock
, queueJob, queueJob'
, YesodJobDB
- , runDBJobs, queueDBJob
+ , runDBJobs, queueDBJob, sinkDBJobs
, module Jobs.Types
) where
-import Import
+import Import hiding ((<>))
import Utils.Sql
import Jobs.Types
@@ -21,6 +21,10 @@ import qualified Data.HashMap.Strict as HashMap
import Control.Monad.Random (evalRand, mkStdGen, uniform)
+import qualified Data.Conduit.List as C
+
+import Data.Semigroup ((<>))
+
data JobQueueException = JobQueuePoolEmpty
deriving (Eq, Ord, Enum, Bounded, Show, Read, Generic)
@@ -29,6 +33,10 @@ instance Exception JobQueueException
writeJobCtl :: (MonadHandler m, HandlerSite m ~ UniWorX) => JobCtl -> m ()
+-- | Pass an instruction to the `Job`-Workers
+--
+-- Instructions are assigned deterministically and pseudo-randomly to one specific worker.
+-- While this means that they might be executed later than desireable, rouge threads that queue the same instruction many times do not deny service to others
writeJobCtl cmd = do
tid <- liftIO myThreadId
wMap <- getsYesod appJobCtl >>= liftIO . readTVarIO
@@ -39,6 +47,7 @@ writeJobCtl cmd = do
liftIO . atomically $ writeTMChan chan cmd
writeJobCtlBlock :: (MonadHandler m, HandlerSite m ~ UniWorX) => JobCtl -> ReaderT JobContext m ()
+-- | Pass an instruction to the `Job`-Workers and block until it was acted upon
writeJobCtlBlock cmd = do
getResVar <- asks jobConfirm
resVar <- liftIO . atomically $ do
@@ -67,19 +76,30 @@ queueJobUnsafe job = do
-- return jId
queueJob :: (MonadHandler m, HandlerSite m ~ UniWorX) => Job -> m QueuedJobId
+-- ^ Queue a job for later execution
+--
+-- Makes no guarantees as to when it will be executed (`queueJob'`) and does not interact with any running database transactions (`runDBJobs`)
queueJob = liftHandlerT . runDB . setSerializable . queueJobUnsafe
queueJob' :: (MonadHandler m, HandlerSite m ~ UniWorX) => Job -> m ()
--- ^ `queueJob` followed by `JobCtlPerform`
+-- ^ `queueJob` followed by `writeJobCtl` `JobCtlPerform` to ensure, that it is executed asap
queueJob' job = queueJob job >>= writeJobCtl . JobCtlPerform
+-- | Slightly modified Version of `YesodDB` for `runDBJobs`
type YesodJobDB site = ReaderT (YesodPersistBackend site) (WriterT (Set QueuedJobId) (HandlerT site IO))
-queueDBJob :: Job -> ReaderT (YesodPersistBackend UniWorX) (WriterT (Set QueuedJobId) (HandlerT UniWorX IO)) ()
+queueDBJob :: Job -> YesodJobDB UniWorX ()
+-- | Queue a job as part of a database transaction and execute it after the transaction succeeds
queueDBJob job = mapReaderT lift (queueJobUnsafe job) >>= tell . Set.singleton
-runDBJobs :: (MonadHandler m, HandlerSite m ~ UniWorX)
- => ReaderT (YesodPersistBackend UniWorX) (WriterT (Set QueuedJobId) (HandlerT UniWorX IO)) a -> m a
+sinkDBJobs :: Sink Job (YesodJobDB UniWorX) ()
+-- | Queue many jobs as part of a database transaction and execute them after the transaction passes
+sinkDBJobs = C.mapM_ queueDBJob
+
+runDBJobs :: (MonadHandler m, HandlerSite m ~ UniWorX) => YesodJobDB UniWorX a -> m a
+-- | Replacement for/Wrapper around `runDB` when jobs need to be queued as part of a database transaction
+--
+-- Jobs get immediately executed if the transaction succeeds
runDBJobs act = do
(ret, jIds) <- liftHandlerT . runDB $ mapReaderT runWriterT act
forM_ jIds $ writeJobCtl . JobCtlPerform
diff --git a/src/Jobs/Types.hs b/src/Jobs/Types.hs
index dc29a9e7a..3522ff802 100644
--- a/src/Jobs/Types.hs
+++ b/src/Jobs/Types.hs
@@ -15,14 +15,28 @@ import Data.List.NonEmpty (NonEmpty)
data Job = JobSendNotification { jRecipient :: UserId, jNotification :: Notification }
| JobSendTestEmail { jEmail :: Email, jMailContext :: MailContext }
| JobQueueNotification { jNotification :: Notification }
- | JobHelpRequest { jSender :: Either (Maybe Address) UserId
+ | JobHelpRequest { jHelpSender :: Either (Maybe Address) UserId
, jRequestTime :: UTCTime
- , jHelpSubject :: Maybe Text
+ , jSubject :: Maybe Text
, jHelpRequest :: Text
, jReferer :: Maybe Text
}
| JobSetLogSettings { jInstance :: InstanceId, jLogSettings :: LogSettings }
| JobDistributeCorrections { jSheet :: SheetId }
+ | JobSendCourseCommunication { jRecipientEmail :: Either UserEmail UserId
+ , jAllRecipientAddresses :: Set Address
+ , jCourse :: CourseId
+ , jSender :: UserId
+ , jMailObjectUUID :: UUID
+ , jSubject :: Maybe Text
+ , jMailContent :: Html
+ }
+ | JobInvitation { jInviter :: UserId
+ , jInvitee :: UserEmail
+ , jInvitationUrl :: Text
+ , jInvitationSubject :: Text
+ , jInvitationExplanation :: Html
+ }
deriving (Eq, Ord, Show, Read, Generic, Typeable)
data Notification = NotificationSubmissionRated { nSubmission :: SubmissionId }
| NotificationSheetActive { nSheet :: SheetId }
@@ -37,15 +51,15 @@ instance Hashable Job
instance Hashable Notification
deriveJSON defaultOptions
- { constructorTagModifier = intercalate "-" . map toLower . drop 1 . splitCamel
- , fieldLabelModifier = intercalate "-" . map toLower . drop 1 . splitCamel
+ { constructorTagModifier = camelToPathPiece' 1
+ , fieldLabelModifier = camelToPathPiece' 1
, tagSingleConstructors = True
, sumEncoding = TaggedObject "job" "data"
} ''Job
deriveJSON defaultOptions
- { constructorTagModifier = intercalate "-" . map toLower . drop 1 . splitCamel
- , fieldLabelModifier = intercalate "-" . map toLower . drop 1 . splitCamel
+ { constructorTagModifier = camelToPathPiece' 1
+ , fieldLabelModifier = camelToPathPiece' 1
, tagSingleConstructors = True
, sumEncoding = TaggedObject "notification" "data"
} ''Notification
@@ -55,6 +69,7 @@ data JobCtl = JobCtlFlush
| JobCtlPerform QueuedJobId
| JobCtlDetermineCrontab
| JobCtlQueue Job
+ | JobCtlGenerateHealthReport HealthCheck
deriving (Eq, Ord, Read, Show, Generic, Typeable)
instance Hashable JobCtl
diff --git a/src/Jose/Jwt/Instances.hs b/src/Jose/Jwt/Instances.hs
new file mode 100644
index 000000000..0c0c093ef
--- /dev/null
+++ b/src/Jose/Jwt/Instances.hs
@@ -0,0 +1,27 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Jose.Jwt.Instances
+ (
+ ) where
+
+import ClassyPrelude.Yesod
+
+import Jose.Jwt
+
+
+deriving instance Ord Jwt
+deriving instance Read Jwt
+deriving instance Generic Jwt
+deriving instance Typeable Jwt
+
+instance PathPiece Jwt where
+ toPathPiece (Jwt bytes) = decodeUtf8 bytes
+ fromPathPiece = Just . Jwt . encodeUtf8
+
+instance Hashable Jwt
+
+
+deriving instance Generic JwtError
+deriving instance Typeable JwtError
+
+instance Exception JwtError
diff --git a/src/Language/Haskell/TH/Instances.hs b/src/Language/Haskell/TH/Instances.hs
new file mode 100644
index 000000000..48c419705
--- /dev/null
+++ b/src/Language/Haskell/TH/Instances.hs
@@ -0,0 +1,14 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Language.Haskell.TH.Instances
+ (
+ ) where
+
+import Language.Haskell.TH
+import Language.Haskell.TH.Lift (deriveLift)
+import Data.Binary (Binary)
+
+
+instance Binary Loc
+
+deriveLift ''Loc
diff --git a/src/Ldap/Client/Pool.hs b/src/Ldap/Client/Pool.hs
index 875078b6f..6682d7c98 100644
--- a/src/Ldap/Client/Pool.hs
+++ b/src/Ldap/Client/Pool.hs
@@ -95,7 +95,7 @@ createLdapPool host port stripes timeoutConn (round . (* 1e6) -> timeoutAct) lim
setup <- newEmptyTMVarIO
void . fork . flip runLoggingT logFunc $ do
- $logDebugS "LdapExecutor" "Starting"
+ $logInfoS "LdapExecutor" "Starting"
res <- liftIO . Ldap.with host port $ flip runLoggingT logFunc . go (Just setup)
case res of
Left exc -> do
diff --git a/src/Mail.hs b/src/Mail.hs
index 008af9987..8cfa03200 100644
--- a/src/Mail.hs
+++ b/src/Mail.hs
@@ -7,7 +7,9 @@ module Mail
module Network.Mail.Mime
-- * MailT
, MailT, defMailT
- , MailSmtpData(..), MailContext(..), MailLanguages(..)
+ , MailSmtpData(..)
+ , _MailSmtpDataSet
+ , MailContext(..), MailLanguages(..)
, MonadMail(..)
, getMailMessageRender, getMailMsgRenderer
-- * YesodMail
@@ -24,14 +26,18 @@ module Mail
, MailObjectId
, replaceMailHeader, addMailHeader, removeMailHeader
, replaceMailHeaderI, addMailHeaderI
- , setSubjectI, setMailObjectId, setMailObjectId'
+ , setSubjectI
+ , setMailObjectUUID, setMailObjectIdRandom, setMailObjectIdCrypto, setMailObjectIdPseudorandom
, setDate, setDateCurrent
, setMailSmtpData
+ , _addressName, _addressEmail
, _mailFrom, _mailTo, _mailCc, _mailBcc, _mailHeaders, _mailHeader, _mailParts
, _partType, _partEncoding, _partFilename, _partHeaders, _partContent
) where
-import ClassyPrelude.Yesod hiding (snoc, (.=), getMessageRender)
+import ClassyPrelude.Yesod hiding (snoc, (.=), getMessageRender, derivePersistFieldJSON)
+
+import Model.Types.TH.JSON
import Network.Mail.Mime hiding (addPart, addAttachment)
import qualified Network.Mail.Mime as Mime (addPart)
@@ -60,18 +66,19 @@ import qualified Data.Text.Lazy as LT
import qualified Data.Text.Lazy.Builder as LTB
import qualified Data.ByteString.Lazy as LBS
-import Utils (MsgRendererS(..))
+import Utils (MsgRendererS(..), MonadSecretBox(..))
import Utils.Lens.TH
import Control.Lens hiding (from)
+import Control.Lens.Extras (is)
import Text.Blaze.Renderer.Utf8
import Data.UUID (UUID)
import qualified Data.UUID as UUID
-import qualified Data.UUID.V4 as UUID
import Data.UUID.Cryptographic.ImplicitNamespace
import Data.Binary (Binary)
+import qualified Data.Binary as Binary
import GHC.TypeLits (KnownSymbol)
@@ -104,7 +111,14 @@ import Control.Monad.Trans.Maybe (MaybeT(..))
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
+import Control.Monad.Random (MonadRandom(..), evalRand, mkStdGen)
+import qualified Crypto.Saltine.Class as Saltine (IsEncoding(..))
+import qualified Data.ByteArray as ByteArray (convert)
+import Crypto.MAC.HMAC (hmac, HMAC)
+import Crypto.Hash.Algorithms (SHAKE128)
+
+makeLenses_ ''Address
makeLenses_ ''Mail
makeLenses_ ''Part
@@ -131,6 +145,13 @@ instance Monoid (MailSmtpData) where
mempty = memptydefault
mappend = mappenddefault
+_MailSmtpDataSet :: Getter MailSmtpData Bool
+_MailSmtpDataSet = to $ \MailSmtpData{..} -> none id
+ [ is (_Wrapped . _Nothing) smtpEnvelopeFrom
+ , Set.null smtpRecipients
+ ]
+
+
newtype MailLanguages = MailLanguages { mailLanguages :: [Lang] }
deriving (Eq, Ord, Show, Read, Generic, Typeable)
deriving newtype (FromJSON, ToJSON, IsList)
@@ -140,6 +161,7 @@ instance Default MailLanguages where
instance Hashable MailLanguages
+
data MailContext = MailContext
{ mcLanguages :: MailLanguages
, mcDateTimeFormat :: SelDateTimeFormat -> DateTimeFormat
@@ -424,20 +446,33 @@ setMailObjectUUID uuid = do
replaceMailHeader oidHeader . Just $ "<" <> objectId <> ">"
return objectId
-setMailObjectId :: ( MonadHeader m
- , YesodMail (HandlerSite m)
- ) => m MailObjectId
-setMailObjectId = setMailObjectUUID =<< liftIO UUID.nextRandom
+setMailObjectIdRandom :: ( MonadHeader m
+ , YesodMail (HandlerSite m)
+ ) => m MailObjectId
+setMailObjectIdRandom = setMailObjectUUID =<< liftIO getRandom
-setMailObjectId' :: ( MonadHeader m
- , YesodMail (HandlerSite m)
- , MonadCrypto m
- , HasCryptoUUID plain m
- , MonadCryptoKey m ~ CryptoIDKey
- , KnownSymbol (CryptoIDNamespace UUID plain)
- , Binary plain
- ) => plain -> m MailObjectId
-setMailObjectId' oid = setMailObjectUUID . ciphertext =<< encrypt oid
+setMailObjectIdCrypto :: ( MonadHeader m
+ , YesodMail (HandlerSite m)
+ , MonadCrypto m
+ , HasCryptoUUID plain m
+ , MonadCryptoKey m ~ CryptoIDKey
+ , KnownSymbol (CryptoIDNamespace UUID plain)
+ , Binary plain
+ ) => plain -> m MailObjectId
+setMailObjectIdCrypto oid = setMailObjectUUID . ciphertext =<< encrypt oid
+
+setMailObjectIdPseudorandom :: ( MonadHeader m
+ , YesodMail (HandlerSite m)
+ , Binary obj
+ , MonadSecretBox m
+ ) => obj -> m MailObjectId
+-- | Designed to leak no information about the `secretBoxKey` or the given object
+setMailObjectIdPseudorandom obj = do
+ sbKey <- secretBoxKey
+ let
+ seed :: HMAC (SHAKE128 64)
+ seed = hmac (Saltine.encode sbKey) . toStrict $ Binary.encode obj
+ setMailObjectUUID . evalRand getRandom . mkStdGen $ hash (ByteArray.convert seed :: ByteString)
setDateCurrent :: (MonadHandler m, YesodMail (HandlerSite m)) => MailT m ()
@@ -474,3 +509,6 @@ setMailSmtpData = do
in tell $ mempty { smtpEnvelopeFrom = Last $ Just verp }
| otherwise
-> tell $ mempty { smtpEnvelopeFrom = Last $ Just from }
+
+
+derivePersistFieldJSON ''MailLanguages
diff --git a/src/Model.hs b/src/Model.hs
index 9210edfde..c97b1a68e 100644
--- a/src/Model.hs
+++ b/src/Model.hs
@@ -6,7 +6,7 @@ module Model
, module Cron.Types
) where
-import ClassyPrelude.Yesod
+import Import.NoModel
import Database.Persist.Quasi
import Database.Persist.TH.Directory
-- import Data.Time
@@ -16,14 +16,15 @@ import Cron.Types
import Data.Aeson (Value)
-import Data.CaseInsensitive (CI)
+import Data.CaseInsensitive (CI, original)
import Data.CaseInsensitive.Instances ()
import Utils.Message (MessageStatus)
import Settings.Cluster (ClusterSettingsKey)
-import Data.Binary (Binary)
+import Text.Blaze (ToMarkup(..))
+
-- You can define all of your database entities in the entities file.
-- You can find more information on persistent and how to declare entities
@@ -32,13 +33,35 @@ import Data.Binary (Binary)
share [mkPersist sqlSettings, mkDeleteCascade sqlSettings, mkMigrate "migrateAll'", mkSave "currentModel"]
$(persistDirectoryWith lowerCaseSettings "models")
--- (Eq Course) is impossible so we derive it for the Uniqueness Constraint only
-deriving instance Eq (Unique Course)
-deriving instance Eq (Unique Sheet)
+-- (Eq Course) is impossible so we derive it for the Uniqueness Constraint only; comments helpful for searching in code
+deriving instance Eq (Unique Course) -- instance Eq TermSchoolCourseShort; instance Eq TermSchoolCourseName
+deriving instance Eq (Unique Sheet) -- instance Eq CourseSheet
+deriving instance Eq (Unique Material) -- instance Eq UniqueMaterial
+deriving instance Eq (Unique Tutorial) -- instance Eq Tutorial
+deriving instance Eq (Unique Exam)
+
+instance Ord User where
+ compare User{userSurname=surnameA, userDisplayName=displayNameA, userEmail=emailA}
+ User{userSurname=surnameB, userDisplayName=displayNameB, userEmail=emailB}
+ = compare surnameA surnameB
+ <> compare displayNameA displayNameB
+ <> compare emailA emailB -- userEmail is unique, so this suffices
+
--- Primary keys mentioned in dbtable row-keys must be Binary
--- Automatically generated (i.e. numeric) ids are already taken care of
-deriving instance Binary (Key Term)
submissionRatingDone :: Submission -> Bool
submissionRatingDone Submission{..} = isJust submissionRatingTime
+
+-- ToMarkup and ToMessage instances for displaying selected database primary keys
+
+instance ToMarkup (Key School) where
+ toMarkup = toMarkup . unSchoolKey
+
+instance ToMessage (Key School) where
+ toMessage = original . unSchoolKey
+
+instance ToMarkup (Key Term) where
+ toMarkup = toMarkup . termToText . unTermKey
+
+instance ToMessage (Key Term) where
+ toMessage = termToText . unTermKey
\ No newline at end of file
diff --git a/src/Model/Migration.hs b/src/Model/Migration.hs
index 7b5fcc375..e24c93de3 100644
--- a/src/Model/Migration.hs
+++ b/src/Model/Migration.hs
@@ -1,5 +1,6 @@
module Model.Migration
( migrateAll
+ , requiresMigration
) where
import ClassyPrelude.Yesod
@@ -21,6 +22,12 @@ import Database.Persist.Postgresql
import Text.Read (readMaybe)
import Data.CaseInsensitive (CI)
+import Text.Shakespeare.Text (st)
+
+import Control.Monad.Trans.Reader (mapReaderT)
+import Control.Monad.Except (MonadError(..))
+import Utils (exceptT)
+
-- Database versions must follow https://pvp.haskell.org:
-- - Breaking changes are instances where manual migration is necessary (via customMigrations; i.e. changing a columns format)
-- - Non-breaking changes are instances where the automatic migration done by persistent is sufficient (i.e. adding a column or table)
@@ -52,25 +59,61 @@ share [mkPersist sqlSettings, mkMigrate "migrateDBVersioning"]
migrateAll :: (MonadLogger m, MonadBaseControl IO m, MonadIO m) => ReaderT SqlBackend m ()
migrateAll = do
- mapM_ ($logInfoS "Migration") <=< runMigrationSilent $ do
- -- Manual migrations to go to InitialVersion below:
- migrateEnableExtension "citext"
+ $logDebugS "Migration" "Initial migration"
+ mapM_ ($logInfoS "Migration") =<< runMigrationSilent initialMigration
- migrateDBVersioning
-
- appliedMigrations <- map entityKey <$> selectList [] []
+ missingMigrations <- getMissingMigrations
let
- missingMigrations = customMigrations `Map.withoutKeys` Set.fromList appliedMigrations
doCustomMigration acc desc migration = acc <* do
let AppliedMigrationKey appliedMigrationFrom appliedMigrationTo = desc
+ $logInfoS "Migration" [st|#{tshow appliedMigrationFrom} -> #{tshow appliedMigrationTo}|]
appliedMigrationTime <- liftIO getCurrentTime
_ <- migration
insert AppliedMigration{..}
-- Map.foldlWithKey traverses migrations in ascending order of AppliedMigrationKey
+ $logDebugS "Migration" "Apply missing migrations"
Map.foldlWithKey doCustomMigration (return ()) missingMigrations
+ $logDebugS "Migration" "Persistent automatic migration"
mapM_ ($logInfoS "Migration") =<< runMigrationSilent migrateAll'
+requiresMigration :: forall m. (MonadLogger m, MonadBaseControl IO m, MonadIO m) => ReaderT SqlBackend m Bool
+requiresMigration = mapReaderT (exceptT return return) $ do
+ initial <- either id (map snd) <$> parseMigration initialMigration
+ when (not $ null initial) $ do
+ $logInfoS "Migration" $ intercalate "; " initial
+ throwError True
+
+ customs <- getMissingMigrations @_ @m
+ when (not $ Map.null customs) $ do
+ $logInfoS "Migration" . intercalate ", " . map tshow $ Map.keys customs
+ throwError True
+
+ automatic <- either id (map snd) <$> parseMigration migrateAll'
+ when (not $ null automatic) $ do
+ $logInfoS "Migration" $ intercalate "; " automatic
+ throwError True
+
+ return False
+
+initialMigration :: Migration
+-- ^ Manual migrations to go to InitialVersion below:
+initialMigration = do
+ migrateEnableExtension "citext"
+ migrateDBVersioning
+
+getMissingMigrations :: forall m m'.
+ ( MonadLogger m
+ , MonadBaseControl IO m
+ , MonadIO m
+ , MonadIO m'
+ )
+ => ReaderT SqlBackend m (Map (Key AppliedMigration) (ReaderT SqlBackend m' ()))
+getMissingMigrations = do
+ $logDebugS "Migration" "Retrieve applied migrations"
+ appliedMigrations <- selectKeysList [] []
+ return $ customMigrations `Map.withoutKeys` Set.fromList appliedMigrations
+
{-
Confusion about quotes, from the PostgreSQL Manual:
Single quotes for string constants, double quotes for table/column names.
@@ -223,6 +266,32 @@ customMigrations = Map.fromListWith (>>)
whenM (columnExists "study_terms" "shorthand") $ [executeQQ| UPDATE "study_terms" SET "shorthand" = NULL WHERE "shorthand" = '' |]
whenM (columnExists "study_terms" "name") $ [executeQQ| UPDATE "study_terms" SET "name" = NULL WHERE "shorthand" = '' |]
)
+ , ( AppliedMigrationKey [migrationVersion|10.0.0|] [version|11.0.0|]
+ , whenM ((&&) <$> columnExists "sheet" "upload_mode" <*> columnExists "sheet" "submission_mode") $ do
+ sheetModes <- [sqlQQ| SELECT "id", "upload_mode", "submission_mode" FROM "sheet"; |]
+ [executeQQ|
+ ALTER TABLE "sheet" DROP COLUMN "upload_mode";
+ ALTER TABLE "sheet" ALTER COLUMN "submission_mode" DROP DEFAULT;
+ ALTER TABLE "sheet" ALTER COLUMN "submission_mode" TYPE jsonb USING 'null'::jsonb;
+ |]
+ forM_ sheetModes $ \(shid :: SheetId, unSingle -> uploadMode :: Legacy.UploadMode, unSingle -> submissionMode :: Legacy.SheetSubmissionMode ) -> do
+ let submissionMode' = case (submissionMode, uploadMode) of
+ ( Legacy.NoSubmissions , _ ) -> SubmissionMode False Nothing
+ ( Legacy.CorrectorSubmissions, _ ) -> SubmissionMode True Nothing
+ ( Legacy.UserSubmissions , Legacy.NoUpload ) -> SubmissionMode False (Just NoUpload)
+ ( Legacy.UserSubmissions , Legacy.Upload True ) -> SubmissionMode False (Just $ UploadAny True defaultExtensionRestriction)
+ ( Legacy.UserSubmissions , Legacy.Upload False ) -> SubmissionMode False (Just $ UploadAny False defaultExtensionRestriction)
+ [executeQQ| UPDATE "sheet" SET "submission_mode" = #{submissionMode'} WHERE "id" = #{shid}; |]
+ )
+ , ( AppliedMigrationKey [migrationVersion|11.0.0|] [version|12.0.0|]
+ , whenM ((&&) <$> tableExists "tutorial" <*> tableExists "tutorial_user") $ do -- Tutorials were an unused stub before
+ tableDropEmpty "tutorial"
+ tableDropEmpty "tutorial_user"
+ )
+ , ( AppliedMigrationKey [migrationVersion|12.0.0|] [version|13.0.0|]
+ , whenM (tableExists "exam") $ -- Exams were an unused stub before
+ tableDropEmpty "exam"
+ )
]
@@ -234,6 +303,18 @@ tableExists table = do
[Just _] -> return True
_other -> return False
+tableIsEmpty :: MonadIO m => Text -> ReaderT SqlBackend m Bool
+tableIsEmpty table = do
+ [rows] <- rawSql [st|SELECT COUNT(*) FROM "#{table}"|] []
+ return $ unSingle rows == (0 :: Int64)
+
+tableDropEmpty :: MonadIO m => Text -> ReaderT SqlBackend m ()
+tableDropEmpty table = do
+ isEmpty <- tableIsEmpty table
+ if
+ | isEmpty -> rawExecute [st|DROP TABLE "#{table}" CASCADE|] []
+ | otherwise -> fail $ "Table " <> unpack table <> " is not empty"
+
columnExists :: MonadIO m
=> Text -- ^ Table
-> Text -- ^ Column
diff --git a/src/Model/Migration/Types.hs b/src/Model/Migration/Types.hs
index 0aed744b0..e5ed53362 100644
--- a/src/Model/Migration/Types.hs
+++ b/src/Model/Migration/Types.hs
@@ -1,11 +1,16 @@
module Model.Migration.Types where
import ClassyPrelude.Yesod
+import Data.Aeson
import Data.Aeson.TH (deriveJSON, defaultOptions)
-import Database.Persist.Sql
+
+import Utils.PathPiece
import qualified Model as Current
-import qualified Model.Types.JSON as Current
+import qualified Model.Types.TH.JSON as Current
+
+import Data.Universe
+
data SheetType
= Bonus { maxPoints :: Current.Points } -- Erhöht nicht das Maximum, wird gutgeschrieben
@@ -20,6 +25,40 @@ sheetType Normal {..} = Current.Normal Current.Points {..}
sheetType Pass {..} = Current.Normal Current.PassPoints {..}
sheetType NotGraded = Current.NotGraded
+
+data UploadMode = NoUpload | Upload { unpackZips :: Bool }
+ deriving (Show, Read, Eq, Ord, Generic)
+
+deriveJSON defaultOptions ''UploadMode
+Current.derivePersistFieldJSON ''UploadMode
+
+instance Universe UploadMode where
+ universe = NoUpload : (Upload <$> universe)
+instance Finite UploadMode
+
+instance PathPiece UploadMode where
+ toPathPiece = \case
+ NoUpload -> "no-upload"
+ Upload True -> "unpack"
+ Upload False -> "no-unpack"
+ fromPathPiece = finiteFromPathPiece
+
+data SheetSubmissionMode = NoSubmissions
+ | CorrectorSubmissions
+ | UserSubmissions
+ deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)
+
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece
+ } ''SheetSubmissionMode
+derivePersistField "SheetSubmissionMode"
+
+instance Universe SheetSubmissionMode
+instance Finite SheetSubmissionMode
+
+nullaryPathPiece ''SheetSubmissionMode camelToPathPiece
+
+
{- TODO:
* RenderMessage instance for newtype(SheetType) if needed
-}
diff --git a/src/Model/Rating.hs b/src/Model/Rating.hs
index c7b4e910f..295d275eb 100644
--- a/src/Model/Rating.hs
+++ b/src/Model/Rating.hs
@@ -31,6 +31,7 @@ data RatingException = RatingNotUnicode UnicodeException -- ^ Rating failed to p
| RatingExceedsMax -- ^ Rating point must not exceed maximum points
| RatingNotExpected -- ^ Rating not expected
| RatingBinaryExpected -- ^ Rating must be 0 or 1
+ | RatingPointsRequired -- ^ Rating without points for sheet that requires there to be points
deriving (Show, Eq, Generic, Typeable)
instance Exception RatingException
diff --git a/src/Model/Submission.hs b/src/Model/Submission.hs
index 0f931911b..24ef1bad6 100644
--- a/src/Model/Submission.hs
+++ b/src/Model/Submission.hs
@@ -7,6 +7,7 @@ data SubmissionSinkException = DuplicateFileTitle FilePath
| DuplicateRating
| RatingWithoutUpdate
| ForeignRating CryptoFileNameSubmission
+ | InvalidFileTitleExtension FilePath
deriving (Typeable, Show)
instance Exception SubmissionSinkException
diff --git a/src/Model/Tokens.hs b/src/Model/Tokens.hs
new file mode 100644
index 000000000..2b445eb99
--- /dev/null
+++ b/src/Model/Tokens.hs
@@ -0,0 +1,149 @@
+{-# LANGUAGE UndecidableInstances #-}
+
+module Model.Tokens
+ ( BearerToken(..)
+ , _tokenIdentifier, _tokenAuthority, _tokenRoutes, _tokenAddAuth, _tokenRestrictions, _tokenRestrictionIx, _tokenRestrictionAt, _tokenIssuedAt, _tokenIssuedBy, _tokenExpiresAt, _tokenStartsAt
+ , tokenRestrict
+ , tokenToJSON, tokenParseJSON
+ ) where
+
+import ClassyPrelude.Yesod
+import Yesod.Core.Instances ()
+
+import Model
+import Utils (assertM')
+import Utils.Lens hiding ((.=))
+import Data.Aeson.Lens (AsJSON(..))
+
+import Yesod.Auth (AuthId)
+
+import Jose.Jwt (IntDate(..))
+import qualified Jose.Jwt as Jose
+
+import Jose.Jwt.Instances ()
+import Data.Aeson.Types.Instances ()
+
+import Data.HashSet (HashSet)
+
+import qualified Data.HashMap.Strict as HashMap
+import Data.HashMap.Strict.Instances ()
+import Data.HashSet.Instances ()
+import Data.Time.Clock.Instances ()
+
+import Data.Aeson.Types (Parser, (.:?), (.:), (.!=), (.=))
+import qualified Data.Aeson as JSON
+import qualified Data.Aeson.Types as JSON
+
+import CryptoID
+
+import Data.Time.Clock.POSIX
+
+import Data.Binary (Binary)
+
+
+
+-- | Presenting a `BearerToken` transfers some authorisation from `tokenAuthority` to /whoever/ presents the token
+data BearerToken site = BearerToken
+ { tokenIdentifier :: TokenId
+ -- ^ Unique identifier for each token; maybe useful for tracing usage of tokens
+ , tokenAuthority :: AuthId site
+ -- ^ Tokens only grant rights the `tokenAuthority` has (i.e. `AuthTag`s are evaluated with the user set to `tokenAuthority`)
+ , tokenRoutes :: Maybe (HashSet (Route site))
+ -- ^ Tokens can optionally be restricted to only be usable on a subset of routes
+ , tokenAddAuth :: Maybe AuthDNF
+ -- ^ Tokens can specify an additional predicate logic formula of `AuthTag`s that needs to evaluate to `Authorized` in order for the token to be valid.
+ , tokenRestrictions :: HashMap (Route site) Value
+ -- ^ Tokens can be restricted to certain actions within the context of a route (i.e. creating an user only with a certain username, resetting a password only if the old matches a hash, ...)
+ --
+ -- In general this is not encrypted; some care is required to not expose sensitive information to the bearer of the token
+ , tokenIssuedAt :: UTCTime
+ , tokenIssuedBy :: InstanceId
+ , tokenExpiresAt
+ , tokenStartsAt :: Maybe UTCTime
+ } deriving (Generic, Typeable)
+
+deriving instance (Eq (AuthId site), Eq (Route site)) => Eq (BearerToken site)
+deriving instance (Read (AuthId site), Eq (Route site), Hashable (Route site), Read (Route site)) => Read (BearerToken site)
+deriving instance (Show (AuthId site), Show (Route site)) => Show (BearerToken site)
+
+instance (Binary (AuthId site), Binary (Route site), Hashable (Route site), Eq (Route site)) => Binary (BearerToken site)
+
+makeLenses_ ''BearerToken
+
+_tokenRestrictionIx :: (FromJSON a, ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> Traversal' (BearerToken site) a
+-- ^ Focus a singular restriction (by route) if it exists
+--
+-- This /cannot/ be used to add restrictions, use `_tokenRestrictionAt` or `tokenRestrict` instead
+_tokenRestrictionIx route = _tokenRestrictions . ix route . _JSON
+
+_tokenRestrictionAt :: (FromJSON a, ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> Traversal' (BearerToken site) (Maybe a)
+-- ^ Focus a singular restriction (by route) whether it exists, or not
+_tokenRestrictionAt route = _tokenRestrictions . at route . maybePrism _JSON
+
+tokenRestrict :: (ToJSON a, Hashable (Route site), Eq (Route site)) => Route site -> a -> BearerToken site -> BearerToken site
+-- ^ Add a restriction to a `BearerToken`
+--
+-- If a restriction already exists for the targeted route, it's silently overwritten
+tokenRestrict route (toJSON -> resVal) = over _tokenRestrictions $ HashMap.insert route resVal
+
+
+
+tokenToJSON :: forall m.
+ ( MonadHandler m
+ , HasCryptoUUID (AuthId (HandlerSite m)) m
+ , RenderRoute (HandlerSite m)
+ ) => BearerToken (HandlerSite m) -> m Value
+-- ^ Encode a `BearerToken` analogously to `toJSON`
+--
+-- Monadic context is needed because `AuthId`s are encrypted during encoding
+tokenToJSON BearerToken{..} = do
+ cID <- encrypt tokenAuthority :: m (CryptoUUID (AuthId (HandlerSite m)))
+ let stdPayload = Jose.JwtClaims
+ { jwtIss = Just $ toPathPiece tokenIssuedBy
+ , jwtSub = Nothing
+ , jwtAud = Nothing
+ , jwtExp = IntDate . utcTimeToPOSIXSeconds <$> tokenExpiresAt
+ , jwtNbf = IntDate . utcTimeToPOSIXSeconds <$> tokenStartsAt
+ , jwtIat = Just . IntDate $ utcTimeToPOSIXSeconds tokenIssuedAt
+ , jwtJti = Just $ toPathPiece tokenIdentifier
+ }
+ return . JSON.object $
+ catMaybes [ Just $ "authority" .= cID
+ , ("routes" .=) <$> tokenRoutes
+ , ("add-auth" .=) <$> tokenAddAuth
+ , ("restrictions" .=) <$> assertM' (not . HashMap.null) tokenRestrictions
+ ]
+ ++ let JSON.Object hm = toJSON stdPayload in HashMap.toList hm
+
+tokenParseJSON :: forall site.
+ ( HasCryptoUUID (AuthId site) (ReaderT CryptoIDKey Parser)
+ , ParseRoute site
+ , Hashable (Route site)
+ )
+ => Value
+ -> ReaderT CryptoIDKey Parser (BearerToken site)
+-- ^ Decode a `Value` to a `BearerToken` analogously to `parseJSON`
+--
+-- Monadic context is needed because `AuthId`s are encrypted during encoding
+--
+-- It's usually easier to use `Utils.Tokens.tokenParseJSON'`
+tokenParseJSON v@(Object o) = do
+ tokenAuthority' <- lift (o .: "authority") :: ReaderT CryptoIDKey Parser (CryptoUUID (AuthId site))
+ tokenAuthority <- decrypt tokenAuthority'
+
+ tokenRoutes <- lift $ o .:? "routes"
+ tokenAddAuth <- lift $ o .:? "add-auth"
+ tokenRestrictions <- lift $ o .:? "restrictions" .!= HashMap.empty
+ Jose.JwtClaims{..} <- lift $ parseJSON v
+
+ let unIntDate (IntDate posix) = posixSecondsToUTCTime posix
+
+ Just tokenIssuedBy <- return $ jwtIss >>= fromPathPiece
+ Just tokenIdentifier <- return $ jwtJti >>= fromPathPiece
+ Just tokenIssuedAt <- return $ unIntDate <$> jwtIat
+ let tokenExpiresAt = unIntDate <$> jwtExp
+ tokenStartsAt = unIntDate <$> jwtNbf
+
+ return BearerToken{..}
+tokenParseJSON v = lift $ JSON.typeMismatch "BearerToken" v
+
diff --git a/src/Model/Types.hs b/src/Model/Types.hs
index 775900850..a8e2fc90c 100644
--- a/src/Model/Types.hs
+++ b/src/Model/Types.hs
@@ -1,802 +1,14 @@
-{-# LANGUAGE GeneralizedNewtypeDeriving
- , UndecidableInstances
- #-}
-{-# OPTIONS_GHC -fno-warn-orphans #-} -- for instance PathPiece (CI Text)
-
module Model.Types
- ( module Model.Types
- , module Numeric.Natural
- , module Mail
- , module Utils.DateTime
- , module Data.UUID.Types
+ ( module Types
) where
-import ClassyPrelude
-import Utils
-import Control.Lens hiding (universe)
-import Utils.Lens.TH
-
-import Data.Set (Set)
-import qualified Data.Set as Set
-import qualified Data.Map as Map
-import Data.Fixed
-import Data.Monoid (Sum(..))
-import Data.Maybe (fromJust)
-import Data.Universe
-import Data.Universe.Helpers
-import Data.UUID.Types (UUID)
-import qualified Data.UUID.Types as UUID
-
-import Data.Default
-
-import Text.Read (readMaybe)
-
-import Database.Persist.TH hiding (derivePersistFieldJSON)
-import Model.Types.JSON
-import Database.Persist.Class
-import Database.Persist.Sql
-
-import Web.HttpApiData
-import Web.PathPieces
-
-import Data.Text (Text)
-import qualified Data.Text as Text
-import qualified Data.Text.Lens as Text
-
-import qualified Data.HashMap.Strict as HashMap
-
-import Data.CaseInsensitive (CI)
-import qualified Data.CaseInsensitive as CI
-import Data.CaseInsensitive.Instances ()
-
-import Yesod.Core.Dispatch (PathPiece(..))
-import qualified Data.Aeson as Aeson
-import qualified Data.Aeson.Types as Aeson
-import Data.Aeson (FromJSON(..), ToJSON(..), FromJSONKey(..), ToJSONKey(..), FromJSONKeyFunction(..), withText, withObject, Value())
-import Data.Aeson.Types (toJSONKeyText)
-import Data.Aeson.TH (deriveJSON, defaultOptions, Options(..), SumEncoding(..))
-
-import GHC.Generics (Generic)
-import Generics.Deriving.Monoid (memptydefault, mappenddefault)
-import Data.Typeable (Typeable)
-
-import Data.Universe.Instances.Reverse ()
-
-import qualified Yesod.Auth.Util.PasswordStore as PWStore
-
-import Mail (MailLanguages(..))
-import Utils.DateTime (DateTimeFormat(..), SelDateTimeFormat(..))
-
-import Numeric.Natural
-import Data.Word.Word24 (Word24)
-import Data.Bits
-import Data.Ix
-import Data.List (genericIndex, elemIndex)
-import System.Random (Random(..))
-import Data.Data (Data)
-
-import Model.Types.Wordlist
-import Data.Text.Metrics (damerauLevenshtein)
-
-import Data.Binary (Binary)
-
-
-instance PathPiece UUID where
- fromPathPiece = UUID.fromString . unpack
- toPathPiece = pack . UUID.toString
-
-instance {-# OVERLAPS #-} PathMultiPiece FilePath where
- fromPathMultiPiece = Just . unpack . intercalate "/"
- toPathMultiPiece = Text.splitOn "/" . pack
-
-
-type Count = Sum Integer
-type Points = Centi
-
-toPoints :: Integral a => a -> Points -- deprecated
-toPoints = fromIntegral
-
-pToI :: Points -> Integer -- deprecated
-pToI = fromPoints
-
-fromPoints :: Integral a => Points -> a -- deprecated
-fromPoints = round
-
-instance DisplayAble Points
-
-instance DisplayAble a => DisplayAble (Sum a) where
- display (Sum x) = display x
-
-data SheetGrading
- = Points { maxPoints :: Points }
- | PassPoints { maxPoints, passingPoints :: Points }
- | PassBinary -- non-zero means passed
- deriving (Eq, Read, Show, Generic)
-
-deriveJSON defaultOptions
- { constructorTagModifier = camelToPathPiece
- , fieldLabelModifier = intercalate "-" . map toLower . dropEnd 1 . splitCamel
- , sumEncoding = TaggedObject "type" "data"
- } ''SheetGrading
-derivePersistFieldJSON ''SheetGrading
-
-makeLenses_ ''SheetGrading
-
-_passingBound :: Fold SheetGrading (Either () Points)
-_passingBound = folding passPts
- where
- passPts :: SheetGrading -> Maybe (Either () Points)
- passPts (Points{}) = Nothing
- passPts (PassPoints{passingPoints}) = Just $ Right passingPoints
- passPts (PassBinary) = Just $ Left ()
-
-gradingPassed :: SheetGrading -> Points -> Maybe Bool
-gradingPassed gr pts = either pBinary pPoints <$> gr ^? _passingBound
- where pBinary _ = pts /= 0
- pPoints b = pts >= b
-
-
-data SheetGradeSummary = SheetGradeSummary
- { numSheets :: Count -- Total number of sheets, includes all
- , numSheetsPasses :: Count -- Number of sheets required to pass FKA: numGradePasses
- , numSheetsPoints :: Count -- Number of sheets having points FKA: sumGradePointsd
- , sumSheetsPoints :: Sum Points -- Total of all points in all sheets
- -- Marking dependend
- , numMarked :: Count -- Number of already marked sheets
- , numMarkedPasses :: Count -- Number of already marked sheets with passes
- , numMarkedPoints :: Count -- Number of already marked sheets with points
- , sumMarkedPoints :: Sum Points -- Achieveable points within marked sheets
- --
- , achievedPasses :: Count -- Achieved passes (within marked sheets)
- , achievedPoints :: Sum Points -- Achieved points (within marked sheets)
- } deriving (Generic, Read, Show, Eq)
-
-instance Monoid SheetGradeSummary where
- mempty = memptydefault
- mappend = mappenddefault
-
-instance Semigroup SheetGradeSummary where
- (<>) = mappend -- TODO: remove for GHC > 8.4.x
-
-makeLenses_ ''SheetGradeSummary
-
-sheetGradeSum :: SheetGrading -> Maybe Points -> SheetGradeSummary
-sheetGradeSum gr Nothing = mempty
- { numSheets = 1
- , numSheetsPasses = bool mempty 1 $ has _passingBound gr
- , numSheetsPoints = bool mempty 1 $ has _maxPoints gr
- , sumSheetsPoints = maybe mempty Sum $ gr ^? _maxPoints
- }
-sheetGradeSum gr (Just p) =
- let unmarked@SheetGradeSummary{..} = sheetGradeSum gr Nothing
- in unmarked
- { numMarked = numSheets
- , numMarkedPasses = numSheetsPasses
- , numMarkedPoints = numSheetsPoints
- , sumMarkedPoints = sumSheetsPoints
- , achievedPasses = fromMaybe mempty $ bool 0 1 <$> gradingPassed gr p
- , achievedPoints = bool mempty (Sum p) $ has _maxPoints gr
- }
-
-
-data SheetType
- = NotGraded
- | Normal { grading :: SheetGrading }
- | Bonus { grading :: SheetGrading }
- | Informational { grading :: SheetGrading }
- deriving (Eq, Read, Show, Generic)
-
-deriveJSON defaultOptions
- { constructorTagModifier = camelToPathPiece
- , fieldLabelModifier = camelToPathPiece
- , sumEncoding = TaggedObject "type" "data"
- } ''SheetType
-derivePersistFieldJSON ''SheetType
-
-data SheetTypeSummary = SheetTypeSummary
- { normalSummary
- , bonusSummary
- , informationalSummary :: SheetGradeSummary
- , numNotGraded :: Count
- } deriving (Generic, Read, Show, Eq)
-
-instance Monoid SheetTypeSummary where
- mempty = memptydefault
- mappend = mappenddefault
-
-instance Semigroup SheetTypeSummary where
- (<>) = mappend -- TODO: remove for GHC > 8.4.x
-
-makeLenses_ ''SheetTypeSummary
-
-sheetTypeSum :: SheetType -> Maybe Points -> SheetTypeSummary
-sheetTypeSum Bonus{..} mps = mempty { bonusSummary = sheetGradeSum grading mps }
-sheetTypeSum Normal{..} mps = mempty { normalSummary = sheetGradeSum grading mps }
-sheetTypeSum Informational{..} mps = mempty { informationalSummary = sheetGradeSum grading mps }
-sheetTypeSum NotGraded _ = mempty { numNotGraded = Sum 1 }
-
-data SheetGroup
- = Arbitrary { maxParticipants :: Natural }
- | RegisteredGroups
- | NoGroups
- deriving (Show, Read, Eq, Generic)
-deriveJSON defaultOptions ''SheetGroup
-derivePersistFieldJSON ''SheetGroup
-
-makeLenses_ ''SheetGroup
-
-data SheetFileType = SheetExercise | SheetHint | SheetSolution | SheetMarking
- deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)
-derivePersistField "SheetFileType"
-
-instance Universe SheetFileType where universe = universeDef
-instance Finite SheetFileType
-
-instance PathPiece SheetFileType where
- toPathPiece SheetExercise = "file"
- toPathPiece SheetHint = "hint"
- toPathPiece SheetSolution = "solution"
- toPathPiece SheetMarking = "marking"
- fromPathPiece = finiteFromPathPiece
-
--- $(deriveSimpleWith ''DisplayAble 'display (drop 17) ''SheetFileType)
-instance DisplayAble SheetFileType where -- deprecated, see RenderMessage instance in Foundation
- display SheetExercise = "Aufgabenstellung"
- display SheetHint = "Hinweise"
- display SheetSolution = "Musterlösung"
- display SheetMarking = "Korrekturhinweise"
-
--- partitionFileType' :: Ord a => [(SheetFileType,a)] -> Map SheetFileType (Set a)
--- partitionFileType' = groupMap
-
-partitionFileType :: Ord a => [(SheetFileType,a)] -> SheetFileType -> Set a
-partitionFileType fs t = Map.findWithDefault Set.empty t . Map.fromListWith Set.union $ map (over _2 Set.singleton) fs
-
-data SubmissionFileType = SubmissionOriginal | SubmissionCorrected
- deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)
-
-instance Universe SubmissionFileType where universe = universeDef
-instance Finite SubmissionFileType
-
-submissionFileTypeIsUpdate :: SubmissionFileType -> Bool
-submissionFileTypeIsUpdate SubmissionOriginal = False
-submissionFileTypeIsUpdate SubmissionCorrected = True
-
-isUpdateSubmissionFileType :: Bool -> SubmissionFileType
-isUpdateSubmissionFileType False = SubmissionOriginal
-isUpdateSubmissionFileType True = SubmissionCorrected
-
-instance PathPiece SubmissionFileType where
- toPathPiece SubmissionOriginal = "original"
- toPathPiece SubmissionCorrected = "corrected"
- fromPathPiece = finiteFromPathPiece
-
-instance DisplayAble SubmissionFileType where
- display SubmissionOriginal = "Abgabe"
- display SubmissionCorrected = "Korrektur"
-
-{-
-data DA = forall a . (DisplayAble a) => DA a
-
-instance DisplayAble DA where
- display (DA x) = display x
--}
-
-
-data UploadMode = NoUpload | Upload { unpackZips :: Bool }
- deriving (Show, Read, Eq, Ord, Generic)
-
-deriveJSON defaultOptions ''UploadMode
-derivePersistFieldJSON ''UploadMode
-
-instance Universe UploadMode where
- universe = NoUpload : (Upload <$> universe)
-instance Finite UploadMode
-
-instance PathPiece UploadMode where
- toPathPiece = \case
- NoUpload -> "no-upload"
- Upload True -> "unpack"
- Upload False -> "no-unpack"
- fromPathPiece = finiteFromPathPiece
-
-data SheetSubmissionMode = NoSubmissions
- | CorrectorSubmissions
- | UserSubmissions
- deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)
-
-deriveJSON defaultOptions
- { constructorTagModifier = camelToPathPiece
- } ''SheetSubmissionMode
-derivePersistField "SheetSubmissionMode"
-
-instance Universe SheetSubmissionMode
-instance Finite SheetSubmissionMode
-
-nullaryPathPiece ''SheetSubmissionMode camelToPathPiece
-
-data ExamStatus = Attended | NoShow | Voided
- deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)
-derivePersistField "ExamStatus"
-
--- | Specify a corrector's workload
-data Load -- = ByTutorial { countsToLoad :: Bool } | ByProportion { load :: Rational }
- = Load { byTutorial :: Maybe Bool -- ^ @Just@ all from Tutorial, @True@ if counting towards overall workload
- , byProportion :: Rational -- ^ workload proportion of all submission not assigned to tutorial leaders
- }
- deriving (Show, Read, Eq, Ord, Generic)
-
-deriveJSON defaultOptions ''Load
-derivePersistFieldJSON ''Load
-
-
-instance Semigroup Load where
- (Load byTut prop) <> (Load byTut' prop') = Load byTut'' (prop + prop')
- where
- byTut''
- | Nothing <- byTut = byTut'
- | Nothing <- byTut' = byTut
- | Just a <- byTut
- , Just b <- byTut' = Just $ a || b
-
-instance Monoid Load where
- mempty = Load Nothing 0
- mappend = (<>)
-
-{- Use (is _ByTutorial) instead of this unneeded definition:
- isByTutorial :: Load -> Bool
- isByTutorial (ByTutorial {}) = True
- isByTutorial _ = False
--}
-
-data Season = Summer | Winter
- deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic, Typeable)
-
-instance Binary Season
-
-seasonToChar :: Season -> Char
-seasonToChar Summer = 'S'
-seasonToChar Winter = 'W'
-
-seasonFromChar :: Char -> Either Text Season
-seasonFromChar c
- | c ~= 'S' = Right Summer
- | c ~= 'W' = Right Winter
- | otherwise = Left $ "Invalid season character: ‘" <> tshow c <> "’"
- where
- (~=) = (==) `on` CI.mk
-
-instance DisplayAble Season
-
-data TermIdentifier = TermIdentifier
- { year :: Integer -- ^ Using 'Integer' to model years is consistent with 'Data.Time.Calendar'
- , season :: Season
- } deriving (Show, Read, Eq, Ord, Generic, Typeable)
-
-instance Binary TermIdentifier
-
-instance Enum TermIdentifier where
- -- ^ Do not use for conversion – Enumeration only
- toEnum int = let (toInteger -> year, toEnum -> season) = int `divMod` 2 in TermIdentifier{..}
- fromEnum TermIdentifier{..} = fromInteger year * 2 + fromEnum season
-
--- Conversion TermId <-> TermIdentifier::
--- from_TermId_to_TermIdentifier = unTermKey
--- from_TermIdentifier_to_TermId = TermKey
-
-shortened :: Iso' Integer Integer
-shortened = iso shorten expand
- where
- century = ($currentYear `div` 100) * 100
- expand year
- | 0 <= year
- , year < 100 = let
- options = [ expanded | offset <- [-1, 0, 1]
- , let century' = century + offset * 100
- expanded = century' + year
- , $currentYear - 50 <= expanded
- , expanded < $currentYear + 50
- ]
- in case options of
- [unique] -> unique
- failed -> error $ "Could not expand year " ++ show year ++ ": " ++ show failed
- | otherwise = year
- shorten year
- | $currentYear - 50 <= year
- , year < $currentYear + 50 = year `mod` 100
- | otherwise = year
-
-termToText :: TermIdentifier -> Text
-termToText TermIdentifier{..} = Text.pack $ seasonToChar season : show (year ^. shortened)
-
--- also see Hander.Utils.tidFromText
-termFromText :: Text -> Either Text TermIdentifier
-termFromText t
- | (s:ys) <- Text.unpack t
- , Just (review shortened -> year) <- readMaybe ys
- , Right season <- seasonFromChar s
- = Right TermIdentifier{..}
- | otherwise = Left $ "Invalid TermIdentifier: “" <> t <> "”" -- TODO: Could be improved, I.e. say "W"/"S" from Number
-
-termToRational :: TermIdentifier -> Rational
-termToRational TermIdentifier{..} = fromInteger year + seasonOffset
- where
- seasonOffset
- | Summer <- season = 0
- | Winter <- season = 0.5
-
-termFromRational :: Rational -> TermIdentifier
-termFromRational n = TermIdentifier{..}
- where
- year = floor n
- remainder = n - (fromInteger $ floor n)
- season
- | remainder == 0 = Summer
- | otherwise = Winter
-
-instance PersistField TermIdentifier where
- toPersistValue = PersistRational . termToRational
- fromPersistValue (PersistRational t) = Right $ termFromRational t
- fromPersistValue x = Left $ "Expected TermIdentifier, received: " <> tshow x
-
-instance PersistFieldSql TermIdentifier where
- sqlType _ = SqlNumeric 5 1
-
-instance ToHttpApiData TermIdentifier where
- toUrlPiece = termToText
-
-instance FromHttpApiData TermIdentifier where
- parseUrlPiece = termFromText
-
-instance PathPiece TermIdentifier where
- fromPathPiece = either (const Nothing) Just . termFromText
- toPathPiece = termToText
-
-instance ToJSON TermIdentifier where
- toJSON = Aeson.String . termToText
-
-instance FromJSON TermIdentifier where
- parseJSON = withText "Term" $ either (fail . Text.unpack) return . termFromText
-
-{- Must be defined in a later module:
- termField :: Field (HandlerT UniWorX IO) TermIdentifier
- termField = checkMMap (return . termFromText) termToText textField
- See Handler.Utils.Form.termsField and termActiveField
--}
-
-
-withinTerm :: Day -> TermIdentifier -> Bool
-time `withinTerm` term = timeYear `mod` 100 == termYear `mod` 100
- where
- timeYear = fst3 $ toGregorian time
- termYear = year term
-
-
-data StudyFieldType = FieldPrimary | FieldSecondary
- deriving (Eq, Ord, Enum, Show, Read, Bounded, Generic)
-derivePersistField "StudyFieldType"
-
-instance PersistField UUID where
- toPersistValue = PersistDbSpecific . UUID.toASCIIBytes
- fromPersistValue (PersistText t) = maybe (Left "Failed to parse UUID") Right $ UUID.fromText t
- fromPersistValue (PersistByteString bs) = maybe (Left "Failed to parse UUID") Right $ UUID.fromASCIIBytes bs
- fromPersistValue (PersistDbSpecific bs) = maybe (Left "Failed to parse UUID") Right $ UUID.fromASCIIBytes bs
- fromPersistValue x = Left $ "Expected UUID, received: " <> tshow x
-
-instance PersistFieldSql UUID where
- sqlType _ = SqlOther "uuid"
-
-instance DisplayAble StudyFieldType
-
-data Theme
- = ThemeDefault
- | ThemeLavender
- | ThemeNeutralBlue
- | ThemeAberdeenReds
- | ThemeMossGreen
- | ThemeSkyLove
- deriving (Eq, Ord, Bounded, Enum, Show, Read, Generic)
-
-deriveJSON defaultOptions
- { constructorTagModifier = fromJust . stripPrefix "Theme"
- } ''Theme
-
-instance Universe Theme where universe = universeDef
-instance Finite Theme
-
-nullaryPathPiece ''Theme (camelToPathPiece' 1)
-
-$(deriveSimpleWith ''DisplayAble 'display (over Text.packed $ Text.intercalate " " . unsafeTail . splitCamel) ''Theme) -- describe theme to user
-
-derivePersistField "Theme"
-
-
-newtype ZIPArchiveName obj = ZIPArchiveName { unZIPArchiveName :: obj }
- deriving (Show, Read, Eq)
-
-instance PathPiece obj => PathPiece (ZIPArchiveName obj) where
- fromPathPiece = fmap ZIPArchiveName . fromPathPiece <=< (stripSuffix `on` CI.foldCase) ".zip"
- toPathPiece = (<> ".zip") . toPathPiece . unZIPArchiveName
-
-
-data CorrectorState = CorrectorNormal | CorrectorMissing | CorrectorExcused
- deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic)
-
-deriveJSON defaultOptions
- { constructorTagModifier = fromJust . stripPrefix "Corrector"
- } ''CorrectorState
-
-instance Universe CorrectorState where universe = universeDef
-instance Finite CorrectorState
-
-nullaryPathPiece ''CorrectorState (camelToPathPiece' 1)
-
-derivePersistField "CorrectorState"
-
-
-data AuthenticationMode = AuthLDAP
- | AuthPWHash { authPWHash :: Text }
- deriving (Eq, Ord, Read, Show, Generic)
-
-deriveJSON defaultOptions
- { constructorTagModifier = intercalate "-" . map toLower . drop 1 . splitCamel
- , fieldLabelModifier = intercalate "-" . map toLower . drop 1 . splitCamel
- , sumEncoding = UntaggedValue
- } ''AuthenticationMode
-
-derivePersistFieldJSON ''AuthenticationMode
-
-
-derivePersistFieldJSON ''Value
-
-
--- ^ `NotificationSettings` is for now a series of boolean checkboxes, i.e. a mapping @NotificationTrigger -> Bool@
---
--- Could maybe be replaced with `Structure Notification` in the long term
-data NotificationTrigger = NTSubmissionRatedGraded
- | NTSubmissionRated
- | NTSheetActive
- | NTSheetSoonInactive
- | NTSheetInactive
- | NTCorrectionsAssigned
- | NTCorrectionsNotDistributed
- | NTUserRightsUpdate
- deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
-
-instance Universe NotificationTrigger
-instance Finite NotificationTrigger
-
-instance Hashable NotificationTrigger
-
-deriveJSON defaultOptions
- { constructorTagModifier = intercalate "-" . map toLower . drop 1 . splitCamel
- } ''NotificationTrigger
-
-instance ToJSONKey NotificationTrigger where
- toJSONKey = toJSONKeyText $ \v -> let Aeson.String t = toJSON v in t
-
-instance FromJSONKey NotificationTrigger where
- fromJSONKey = FromJSONKeyTextParser $ parseJSON . Aeson.String
-
-
-newtype NotificationSettings = NotificationSettings { notificationAllowed :: NotificationTrigger -> Bool }
- deriving (Generic, Typeable)
- deriving newtype (Eq, Ord, Read, Show)
-
-instance Default NotificationSettings where
- def = NotificationSettings $ \case
- NTSubmissionRatedGraded -> True
- NTSubmissionRated -> False
- NTSheetActive -> True
- NTSheetSoonInactive -> False
- NTSheetInactive -> True
- NTCorrectionsAssigned -> True
- NTCorrectionsNotDistributed -> True
- NTUserRightsUpdate -> True
-
-instance ToJSON NotificationSettings where
- toJSON v = toJSON . HashMap.fromList $ map (id &&& notificationAllowed v) universeF
-
-instance FromJSON NotificationSettings where
- parseJSON = withObject "NotificationSettings" $ \o -> do
- o' <- parseJSON $ Aeson.Object o :: Aeson.Parser (HashMap NotificationTrigger Bool)
- return . NotificationSettings $ \n -> case HashMap.lookup n o' of
- Nothing -> notificationAllowed def n
- Just b -> b
-
-derivePersistFieldJSON ''NotificationSettings
-
-
-instance ToBackendKey SqlBackend record => Hashable (Key record) where
- hashWithSalt s key = s `hashWithSalt` fromSqlKey key
-
-derivePersistFieldJSON ''MailLanguages
-
-
-type PseudonymWord = CI Text
-
-newtype Pseudonym = Pseudonym Word24
- deriving (Eq, Ord, Read, Show, Generic, Data)
- deriving newtype (Bounded, Enum, Integral, Num, Real, Ix)
-
-
-instance PersistField Pseudonym where
- toPersistValue p = toPersistValue (fromIntegral p :: Word32)
- fromPersistValue v = do
- w <- fromPersistValue v :: Either Text Word32
- if
- | 0 <= w
- , w <= fromIntegral (maxBound :: Pseudonym)
- -> return $ fromIntegral w
- | otherwise
- -> Left "Pseudonym out of range"
-
-instance PersistFieldSql Pseudonym where
- sqlType _ = SqlInt32
-
-instance Random Pseudonym where
- randomR (max minBound -> lo, min maxBound -> hi) gen = over _1 (fromIntegral :: Word32 -> Pseudonym) $ randomR (fromIntegral lo, fromIntegral hi) gen
- random = randomR (minBound, maxBound)
-
-instance FromJSON Pseudonym where
- parseJSON v@(Aeson.Number _) = do
- w <- parseJSON v :: Aeson.Parser Word32
- if
- | 0 <= w
- , w <= fromIntegral (maxBound :: Pseudonym)
- -> return $ fromIntegral w
- | otherwise
- -> fail "Pseudonym out auf range"
- parseJSON (Aeson.String t)
- = case t ^? _PseudonymText of
- Just p -> return p
- Nothing -> fail "Could not parse pseudonym"
- parseJSON v = flip (Aeson.withArray "Pseudonym") v $ \ws -> do
- ws' <- toList . map CI.mk <$> mapM parseJSON ws
- case ws' ^? _PseudonymWords of
- Just p -> return p
- Nothing -> fail "Could not parse pseudonym words"
-
-instance ToJSON Pseudonym where
- toJSON = toJSON . (review _PseudonymWords :: Pseudonym -> [PseudonymWord])
-
-pseudonymWordlist :: [PseudonymWord]
-pseudonymCharacters :: Set (CI Char)
-(pseudonymWordlist, pseudonymCharacters) = $(wordlist "config/wordlist.txt")
-
-_PseudonymWords :: Prism' [PseudonymWord] Pseudonym
-_PseudonymWords = prism' pToWords pFromWords
- where
- pFromWords :: [PseudonymWord] -> Maybe Pseudonym
- pFromWords [w1, w2]
- | Just i1 <- elemIndex w1 pseudonymWordlist
- , Just i2 <- elemIndex w2 pseudonymWordlist
- , i1 <= maxWord, i2 <= maxWord
- = Just . Pseudonym $ shiftL (fromIntegral i1) 12 .|. fromIntegral i2
- pFromWords _ = Nothing
-
- pToWords :: Pseudonym -> [PseudonymWord]
- pToWords (Pseudonym p)
- = [ genericIndex pseudonymWordlist $ shiftR p 12 .&. maxWord
- , genericIndex pseudonymWordlist $ p .&. maxWord
- ]
-
- maxWord :: Num a => a
- maxWord = 0b111111111111
-
-_PseudonymText :: Prism' Text Pseudonym
-_PseudonymText = prism' tToWords tFromWords . _PseudonymWords
- where
- tFromWords :: Text -> Maybe [PseudonymWord]
- tFromWords input
- | [result] <- input ^.. pseudonymFragments
- = Just result
- | otherwise
- = Nothing
-
- tToWords :: [PseudonymWord] -> Text
- tToWords = Text.unwords . map CI.original
-
-pseudonymWords :: Fold Text PseudonymWord
-pseudonymWords = folding
- $ \(CI.mk -> input) -> map (view _2) . fromMaybe [] . listToMaybe . groupBy ((==) `on` view _1) . sortBy (comparing $ view _1) . filter ((<= distanceCutoff) . view _1) $ map (distance input &&& id) pseudonymWordlist
- where
- distance = damerauLevenshtein `on` CI.foldedCase
- -- | Arbitrary cutoff point, for reference: ispell cuts off at 1
- distanceCutoff = 2
-
-pseudonymFragments :: Fold Text [PseudonymWord]
-pseudonymFragments = folding
- $ mapM (toListOf pseudonymWords) . (\l -> guard (length l == 2) *> l) . filter (not . null) . Text.split (\(CI.mk -> c) -> not $ Set.member c pseudonymCharacters)
-
-
-data AuthTag -- sortiert nach gewünschter Reihenfolge auf /authpreds, d.h. Prädikate sind sortier nach Relevanz für Benutzer
- = AuthAdmin
- | AuthLecturer
- | AuthCorrector
- | AuthRegistered
- | AuthParticipant
- | AuthTime
- | AuthMaterials
- | AuthOwner
- | AuthRated
- | AuthUserSubmissions
- | AuthCorrectorSubmissions
- | AuthCapacity
- | AuthEmpty
- | AuthAuthentication
- | AuthNoEscalation
- | AuthRead
- | AuthWrite
- | AuthDeprecated
- | AuthDevelopment
- | AuthFree
- deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
-
-instance Universe AuthTag
-instance Finite AuthTag
-instance Hashable AuthTag
-
-deriveJSON defaultOptions
- { constructorTagModifier = camelToPathPiece' 1
- } ''AuthTag
-
-nullaryPathPiece ''AuthTag (camelToPathPiece' 1)
-
-instance ToJSONKey AuthTag where
- toJSONKey = toJSONKeyText $ \v -> let Aeson.String t = toJSON v in t
-
-instance FromJSONKey AuthTag where
- fromJSONKey = FromJSONKeyTextParser $ parseJSON . Aeson.String
-
-
-newtype AuthTagActive = AuthTagActive { authTagIsActive :: AuthTag -> Bool }
- deriving (Read, Show, Generic)
- deriving newtype (Eq, Ord)
-
-instance Default AuthTagActive where
- def = AuthTagActive $ \case
- AuthAdmin -> False
- _ -> True
-
-instance ToJSON AuthTagActive where
- toJSON v = toJSON . HashMap.fromList $ map (id &&& authTagIsActive v) universeF
-
-instance FromJSON AuthTagActive where
- parseJSON = withObject "AuthTagActive" $ \o -> do
- o' <- parseJSON $ Aeson.Object o :: Aeson.Parser (HashMap AuthTag Bool)
- return . AuthTagActive $ \n -> case HashMap.lookup n o' of
- Nothing -> authTagIsActive def n
- Just b -> b
-
-derivePersistFieldJSON ''AuthTagActive
-
-
-
-data LecturerType = CourseLecturer | CourseAssistant
- deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
-
-instance Universe LecturerType
-instance Finite LecturerType
-
-nullaryPathPiece ''LecturerType $ camelToPathPiece' 1
-deriveJSON defaultOptions
- { constructorTagModifier = camelToPathPiece' 1
- } ''LecturerType
-derivePersistFieldJSON ''LecturerType
-
-
--- Type synonyms
-
-type Email = Text
-
-type SchoolName = CI Text
-type SchoolShorthand = CI Text
-type CourseName = CI Text
-type CourseShorthand = CI Text
-type SheetName = CI Text
-type UserEmail = CI Email
-
-type PWHashAlgorithm = ByteString -> PWStore.Salt -> Int -> ByteString
-type InstanceId = UUID
-type TermCandidateIncidence = UUID
+import Model.Types.Common as Types
+import Model.Types.Course as Types
+import Model.Types.DateTime as Types
+import Model.Types.Exam as Types
+import Model.Types.Health as Types
+import Model.Types.Mail as Types
+import Model.Types.Security as Types
+import Model.Types.Sheet as Types
+import Model.Types.Submission as Types
+import Model.Types.Misc as Types
diff --git a/src/Model/Types/Common.hs b/src/Model/Types/Common.hs
new file mode 100644
index 000000000..c7d18cd54
--- /dev/null
+++ b/src/Model/Types/Common.hs
@@ -0,0 +1,36 @@
+{-|
+Module: Model.Types.Common
+Description: Common types used by most @Model.Types.*@-Modules
+
+Types used by multiple other @Model.Types.*@-Modules
+-}
+module Model.Types.Common
+ ( module Model.Types.Common
+ ) where
+
+import Import.NoModel
+
+import qualified Yesod.Auth.Util.PasswordStore as PWStore
+
+
+type Count = Sum Integer
+type Points = Centi
+
+type Email = Text
+
+type SchoolName = CI Text
+type SchoolShorthand = CI Text
+type CourseName = CI Text
+type CourseShorthand = CI Text
+type SheetName = CI Text
+type MaterialName = CI Text
+type UserEmail = CI Email
+type TutorialName = CI Text
+type ExamName = CI Text
+type ExamPartName = CI Text
+
+type PWHashAlgorithm = ByteString -> PWStore.Salt -> Int -> ByteString
+type InstanceId = UUID
+type ClusterId = UUID
+type TokenId = UUID
+type TermCandidateIncidence = UUID
\ No newline at end of file
diff --git a/src/Model/Types/Course.hs b/src/Model/Types/Course.hs
new file mode 100644
index 000000000..4a1a08b3c
--- /dev/null
+++ b/src/Model/Types/Course.hs
@@ -0,0 +1,26 @@
+{-|
+Module: Model.Types.Course
+Description: Types for modeling Courses
+
+Also see `Model.Types.Sheet`
+-}
+module Model.Types.Course
+ ( module Model.Types.Course
+ ) where
+
+import Import.NoModel
+
+
+data LecturerType = CourseLecturer | CourseAssistant
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+
+instance Universe LecturerType
+instance Finite LecturerType
+
+nullaryPathPiece ''LecturerType $ camelToPathPiece' 1
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 1
+ } ''LecturerType
+derivePersistFieldJSON ''LecturerType
+
+instance Hashable LecturerType
diff --git a/src/Model/Types/DateTime.hs b/src/Model/Types/DateTime.hs
new file mode 100644
index 000000000..0cbd6fd2b
--- /dev/null
+++ b/src/Model/Types/DateTime.hs
@@ -0,0 +1,192 @@
+{-|
+Module: Model.Types.DateTime
+Description: Time related types
+
+Terms, Seasons, and Occurrence schedules
+-}
+module Model.Types.DateTime
+ ( module Model.Types.DateTime
+ ) where
+
+import Import.NoModel
+import Control.Lens
+
+import qualified Data.Text as Text
+import qualified Data.CaseInsensitive as CI
+import Text.Read (readMaybe)
+
+import Database.Persist.Sql
+
+import Web.HttpApiData
+
+import Data.Aeson.Types as Aeson
+
+import Time.Types (WeekDay(..))
+import Data.Time.LocalTime (LocalTime, TimeOfDay)
+
+
+----
+-- Terms, Seaons, anything loosely related to time
+
+data Season = Summer | Winter
+ deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic, Typeable)
+
+instance Binary Season
+
+seasonToChar :: Season -> Char
+seasonToChar Summer = 'S'
+seasonToChar Winter = 'W'
+
+seasonFromChar :: Char -> Either Text Season
+seasonFromChar c
+ | c ~= 'S' = Right Summer
+ | c ~= 'W' = Right Winter
+ | otherwise = Left $ "Invalid season character: ‘" <> tshow c <> "’"
+ where
+ (~=) = (==) `on` CI.mk
+
+data TermIdentifier = TermIdentifier
+ { year :: Integer -- ^ Using 'Integer' to model years is consistent with 'Data.Time.Calendar'
+ , season :: Season
+ } deriving (Show, Read, Eq, Ord, Generic, Typeable)
+
+instance Binary TermIdentifier
+
+instance Enum TermIdentifier where
+ -- ^ Do not use for conversion – Enumeration only
+ toEnum int = let (toInteger -> year, toEnum -> season) = int `divMod` 2 in TermIdentifier{..}
+ fromEnum TermIdentifier{..} = fromInteger year * 2 + fromEnum season
+
+-- Conversion TermId <-> TermIdentifier::
+-- from_TermId_to_TermIdentifier = unTermKey
+-- from_TermIdentifier_to_TermId = TermKey
+
+shortened :: Iso' Integer Integer
+-- ^ Year numbers shortened to two digits
+shortened = iso shorten expand
+ where
+ century = ($currentYear `div` 100) * 100
+ expand year
+ | 0 <= year
+ , year < 100 = let
+ options = [ expanded | offset <- [-1, 0, 1]
+ , let century' = century + offset * 100
+ expanded = century' + year
+ , $currentYear - 50 <= expanded
+ , expanded < $currentYear + 50
+ ]
+ in case options of
+ [unique] -> unique
+ failed -> error $ "Could not expand year " ++ show year ++ ": " ++ show failed
+ | otherwise = year
+ shorten year
+ | $currentYear - 50 <= year
+ , year < $currentYear + 50 = year `mod` 100
+ | otherwise = year
+
+termToText :: TermIdentifier -> Text
+termToText TermIdentifier{..} = Text.pack $ seasonToChar season : show (year ^. shortened)
+
+-- also see Hander.Utils.tidFromText
+termFromText :: Text -> Either Text TermIdentifier
+termFromText t
+ | (s:ys) <- Text.unpack t
+ , Just (review shortened -> year) <- readMaybe ys
+ , Right season <- seasonFromChar s
+ = Right TermIdentifier{..}
+ | otherwise = Left $ "Invalid TermIdentifier: “" <> t <> "”" -- TODO: Could be improved, I.e. say "W"/"S" from Number
+
+termToRational :: TermIdentifier -> Rational
+termToRational TermIdentifier{..} = fromInteger year + seasonOffset
+ where
+ seasonOffset
+ | Summer <- season = 0
+ | Winter <- season = 0.5
+
+termFromRational :: Rational -> TermIdentifier
+termFromRational n = TermIdentifier{..}
+ where
+ year = floor n
+ remainder = n - fromInteger (floor n)
+ season
+ | remainder == 0 = Summer
+ | otherwise = Winter
+
+instance PersistField TermIdentifier where
+ toPersistValue = PersistRational . termToRational
+ fromPersistValue (PersistRational t) = Right $ termFromRational t
+ fromPersistValue x = Left $ "Expected TermIdentifier, received: " <> tshow x
+
+instance PersistFieldSql TermIdentifier where
+ sqlType _ = SqlNumeric 5 1
+
+instance ToHttpApiData TermIdentifier where
+ toUrlPiece = termToText
+
+instance FromHttpApiData TermIdentifier where
+ parseUrlPiece = termFromText
+
+instance PathPiece TermIdentifier where
+ fromPathPiece = either (const Nothing) Just . termFromText
+ toPathPiece = termToText
+
+instance ToJSON TermIdentifier where
+ toJSON = Aeson.String . termToText
+
+instance FromJSON TermIdentifier where
+ parseJSON = withText "Term" $ either (fail . Text.unpack) return . termFromText
+
+{- Must be defined in a later module:
+ termField :: Field (HandlerT UniWorX IO) TermIdentifier
+ termField = checkMMap (return . termFromText) termToText textField
+ See Handler.Utils.Form.termsField and termActiveField
+-}
+
+
+withinTerm :: Day -> TermIdentifier -> Bool
+time `withinTerm` term = timeYear `mod` 100 == termYear `mod` 100
+ where
+ timeYear = fst3 $ toGregorian time
+ termYear = year term
+
+
+data OccurrenceSchedule = ScheduleWeekly
+ { scheduleDayOfWeek :: WeekDay
+ , scheduleStart :: TimeOfDay
+ , scheduleEnd :: TimeOfDay
+ }
+ deriving (Eq, Ord, Read, Show, Generic, Typeable)
+
+deriveJSON defaultOptions
+ { fieldLabelModifier = camelToPathPiece' 1
+ , constructorTagModifier = camelToPathPiece' 1
+ , tagSingleConstructors = True
+ , sumEncoding = TaggedObject "repeat" "schedule"
+ } ''OccurrenceSchedule
+
+data OccurrenceException = ExceptOccur
+ { exceptDay :: Day
+ , exceptStart :: TimeOfDay
+ , exceptEnd :: TimeOfDay
+ }
+ | ExceptNoOccur
+ { exceptTime :: LocalTime
+ }
+ deriving (Eq, Ord, Read, Show, Generic, Typeable)
+
+deriveJSON defaultOptions
+ { fieldLabelModifier = camelToPathPiece' 1
+ , constructorTagModifier = camelToPathPiece' 1
+ , sumEncoding = TaggedObject "exception" "for"
+ } ''OccurrenceException
+
+data Occurrences = Occurrences
+ { occurrencesScheduled :: Set OccurrenceSchedule
+ , occurrencesExceptions :: Set OccurrenceException
+ } deriving (Eq, Ord, Read, Show, Generic, Typeable)
+
+deriveJSON defaultOptions
+ { fieldLabelModifier = camelToPathPiece' 1
+ } ''Occurrences
+derivePersistFieldJSON ''Occurrences
+
diff --git a/src/Model/Types/Exam.hs b/src/Model/Types/Exam.hs
new file mode 100644
index 000000000..1608c7311
--- /dev/null
+++ b/src/Model/Types/Exam.hs
@@ -0,0 +1,122 @@
+{-# LANGUAGE UndecidableInstances #-}
+
+{-|
+Module: Model.Types.Exam
+Description: Types for modeling Exams
+-}
+module Model.Types.Exam
+ ( module Model.Types.Exam
+ ) where
+
+import Import.NoModel
+import Model.Types.Common
+
+import Control.Lens
+
+data ExamResult' res = ExamAttended { examResult :: res }
+ | ExamNoShow
+ | ExamVoided
+ deriving (Show, Read, Eq, Ord, Generic, Typeable)
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 1
+ , fieldLabelModifier = camelToPathPiece' 1
+ , omitNothingFields = True
+ , sumEncoding = TaggedObject "status" "result"
+ } ''ExamResult'
+derivePersistFieldJSON ''ExamResult'
+
+data ExamBonusRule = ExamNoBonus
+ | ExamBonusPoints
+ { bonusMaxPoints :: Points
+ , bonusOnlyPassed :: Bool
+ }
+ deriving (Show, Read, Eq, Ord, Generic, Typeable)
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 1
+ , fieldLabelModifier = camelToPathPiece' 1
+ , sumEncoding = TaggedObject "rule" "settings"
+ } ''ExamBonusRule
+derivePersistFieldJSON ''ExamBonusRule
+
+data ExamOccurrenceRule = ExamRoomManual
+ | ExamRoomSurname
+ | ExamRoomMatriculation
+ | ExamRoomRandom
+ deriving (Show, Read, Eq, Ord, Generic, Typeable)
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 2
+ , fieldLabelModifier = camelToPathPiece' 1
+ , sumEncoding = TaggedObject "rule" "settings"
+ } ''ExamOccurrenceRule
+derivePersistFieldJSON ''ExamOccurrenceRule
+
+data ExamGrade
+ = Grade50
+ | Grade40
+ | Grade37
+ | Grade33
+ | Grade30
+ | Grade27
+ | Grade23
+ | Grade20
+ | Grade17
+ | Grade13
+ | Grade10
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+instance Universe ExamGrade
+instance Finite ExamGrade
+
+numberGrade :: Prism' Rational ExamGrade
+numberGrade = prism toNumberGrade fromNumberGrade
+ where
+ toNumberGrade = \case
+ Grade50 -> 5.0
+ Grade40 -> 4.0
+ Grade37 -> 3.7
+ Grade33 -> 3.3
+ Grade30 -> 3.0
+ Grade27 -> 2.7
+ Grade23 -> 2.3
+ Grade20 -> 2.0
+ Grade17 -> 1.7
+ Grade13 -> 1.3
+ Grade10 -> 1.0
+ fromNumberGrade = \case
+ 5.0 -> Right Grade50
+ 4.0 -> Right Grade40
+ 3.7 -> Right Grade37
+ 3.3 -> Right Grade33
+ 3.0 -> Right Grade30
+ 2.7 -> Right Grade27
+ 2.3 -> Right Grade23
+ 2.0 -> Right Grade20
+ 1.7 -> Right Grade17
+ 1.3 -> Right Grade13
+ 1.0 -> Right Grade10
+ n -> Left n
+
+instance PathPiece ExamGrade where
+ toPathPiece = tshow . (fromRational :: Rational -> Deci) . review numberGrade
+ fromPathPiece = finiteFromPathPiece
+
+pathPieceJSON ''ExamGrade
+pathPieceJSONKey ''ExamGrade
+
+passingGrade :: ExamGrade -> Bool
+passingGrade = (>= Grade40)
+
+data ExamGradingRule
+ = ExamGradingManual
+ | ExamGradingKey
+ { examGradingKey :: [Points] -- ^ @[n1, n2, n3, ..., n11]@ means @0 <= p < n1 -> p ~= 5@, @n1 <= p < n2 -> p ~ 4.7@, @n2 <= p < n3 -> p ~ 4.3@, ..., @n11 <= p -> p ~ 1.0@
+ }
+ deriving (Eq, Ord, Read, Show, Generic, Typeable)
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 2
+ , fieldLabelModifier = camelToPathPiece' 2
+ , sumEncoding = TaggedObject "rule" "settings"
+ } ''ExamGradingRule
+derivePersistFieldJSON ''ExamGradingRule
+
+type ExamResultPoints = ExamResult' (Maybe Points)
+type ExamResultGrade = ExamResult' ExamGrade
diff --git a/src/Model/Types/Health.hs b/src/Model/Types/Health.hs
new file mode 100644
index 000000000..aea99d735
--- /dev/null
+++ b/src/Model/Types/Health.hs
@@ -0,0 +1,87 @@
+{-|
+Module: Model.Types.Health
+Description: Types for running self-tests
+-}
+module Model.Types.Health
+ ( module Model.Types.Health
+ ) where
+
+import Import.NoModel
+
+
+data HealthCheck
+ = HealthCheckMatchingClusterConfig
+ | HealthCheckHTTPReachable
+ | HealthCheckLDAPAdmins
+ | HealthCheckSMTPConnect
+ | HealthCheckWidgetMemcached
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+instance Universe HealthCheck
+instance Finite HealthCheck
+instance Hashable HealthCheck
+
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 2
+ } ''HealthCheck
+nullaryPathPiece ''HealthCheck $ camelToPathPiece' 2
+pathPieceJSONKey ''HealthCheck
+
+data HealthReport
+ = HealthMatchingClusterConfig { healthMatchingClusterConfig :: Bool }
+ -- ^ Is the database-stored configuration we're running under still up to date?
+ --
+ -- Also tests database connection as a side effect
+ | HealthHTTPReachable { healthHTTPReachable :: Maybe Bool }
+ -- ^ Can we reach a uni2work-instance with the same `ClusterId` under our configured `approot` via HTTP?
+ | HealthLDAPAdmins { healthLDAPAdmins :: Maybe Rational }
+ -- ^ Proportion of school admins that could be found in LDAP
+ | HealthSMTPConnect { healthSMTPConnect :: Maybe Bool }
+ -- ^ Can we connect to the SMTP server and say @NOOP@?
+ | HealthWidgetMemcached { healthWidgetMemcached :: Maybe Bool }
+ -- ^ Can we store values in memcached and retrieve them via HTTP?
+ deriving (Eq, Ord, Read, Show, Data, Generic, Typeable)
+
+instance NFData HealthReport
+
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 1
+ , fieldLabelModifier = camelToPathPiece' 1
+ , omitNothingFields = True
+ , sumEncoding = TaggedObject "test" "result"
+ , tagSingleConstructors = True
+ } ''HealthReport
+
+classifyHealthReport :: HealthReport -> HealthCheck
+classifyHealthReport HealthMatchingClusterConfig{} = HealthCheckMatchingClusterConfig
+classifyHealthReport HealthLDAPAdmins{} = HealthCheckLDAPAdmins
+classifyHealthReport HealthHTTPReachable{} = HealthCheckHTTPReachable
+classifyHealthReport HealthSMTPConnect{} = HealthCheckSMTPConnect
+classifyHealthReport HealthWidgetMemcached{} = HealthCheckWidgetMemcached
+
+-- | `HealthReport` classified (`classifyHealthReport`) by badness
+--
+-- > a < b = a `worseThan` b
+--
+-- Currently all consumers of this type check for @(== HealthSuccess)@; this
+-- needs to be adjusted on a case-by-case basis if new constructors are added
+data HealthStatus = HealthFailure | HealthSuccess
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+
+instance Universe HealthStatus
+instance Finite HealthStatus
+
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 1
+ } ''HealthStatus
+nullaryPathPiece ''HealthStatus $ camelToPathPiece' 1
+
+healthReportStatus :: HealthReport -> HealthStatus
+-- ^ Classify `HealthReport` by badness
+healthReportStatus = \case
+ HealthMatchingClusterConfig False -> HealthFailure
+ HealthHTTPReachable (Just False) -> HealthFailure
+ HealthLDAPAdmins (Just prop )
+ | prop <= 0 -> HealthFailure
+ HealthSMTPConnect (Just False) -> HealthFailure
+ HealthWidgetMemcached (Just False) -> HealthFailure -- TODO: investigate this failure mode; do we just handle it gracefully?
+ _other -> maxBound -- Minimum badness
diff --git a/src/Model/Types/Mail.hs b/src/Model/Types/Mail.hs
new file mode 100644
index 000000000..d2507e6f9
--- /dev/null
+++ b/src/Model/Types/Mail.hs
@@ -0,0 +1,75 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+{-|
+Module: Model.Types.Mail
+Description: Types related to Notifications
+-}
+
+module Model.Types.Mail
+ ( module Model.Types.Mail
+ ) where
+
+import Import.NoModel
+
+import qualified Data.Aeson.Types as Aeson
+
+import qualified Data.HashMap.Strict as HashMap
+
+
+-- ^ `NotificationSettings` is for now a series of boolean checkboxes, i.e. a mapping @NotificationTrigger -> Bool@
+--
+-- Could maybe be replaced with `Structure Notification` in the long term
+data NotificationTrigger
+ = NTSubmissionRatedGraded
+ | NTSubmissionRated
+ | NTSheetActive
+ | NTSheetSoonInactive
+ | NTSheetInactive
+ | NTCorrectionsAssigned
+ | NTCorrectionsNotDistributed
+ | NTUserRightsUpdate
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+
+instance Universe NotificationTrigger
+instance Finite NotificationTrigger
+
+instance Hashable NotificationTrigger
+
+deriveJSON defaultOptions
+ { constructorTagModifier = intercalate "-" . map toLower . drop 1 . splitCamel
+ } ''NotificationTrigger
+
+instance ToJSONKey NotificationTrigger where
+ toJSONKey = toJSONKeyText $ \v -> let Aeson.String t = toJSON v in t
+
+instance FromJSONKey NotificationTrigger where
+ fromJSONKey = FromJSONKeyTextParser $ parseJSON . Aeson.String
+
+
+newtype NotificationSettings = NotificationSettings { notificationAllowed :: NotificationTrigger -> Bool }
+ deriving (Generic, Typeable)
+ deriving newtype (Eq, Ord, Read, Show)
+
+instance Default NotificationSettings where
+ def = NotificationSettings $ \case
+ NTSubmissionRatedGraded -> True
+ NTSubmissionRated -> False
+ NTSheetActive -> True
+ NTSheetSoonInactive -> False
+ NTSheetInactive -> True
+ NTCorrectionsAssigned -> True
+ NTCorrectionsNotDistributed -> True
+ NTUserRightsUpdate -> True
+
+instance ToJSON NotificationSettings where
+ toJSON v = toJSON . HashMap.fromList $ map (id &&& notificationAllowed v) universeF
+
+instance FromJSON NotificationSettings where
+ parseJSON = Aeson.withObject "NotificationSettings" $ \o -> do
+ o' <- parseJSON $ Aeson.Object o :: Aeson.Parser (HashMap NotificationTrigger Bool)
+ return . NotificationSettings $ \n -> case HashMap.lookup n o' of
+ Nothing -> notificationAllowed def n
+ Just b -> b
+
+derivePersistFieldJSON ''NotificationSettings
diff --git a/src/Model/Types/Misc.hs b/src/Model/Types/Misc.hs
new file mode 100644
index 000000000..510b21251
--- /dev/null
+++ b/src/Model/Types/Misc.hs
@@ -0,0 +1,44 @@
+{-|
+Module: Model.Types.Misc
+Description: Additional uncategorized types
+-}
+
+module Model.Types.Misc
+ ( module Model.Types.Misc
+ ) where
+
+import Import.NoModel
+import Control.Lens
+
+import Data.Maybe (fromJust)
+
+import qualified Data.Text as Text
+import qualified Data.Text.Lens as Text
+
+
+data StudyFieldType = FieldPrimary | FieldSecondary
+ deriving (Eq, Ord, Enum, Show, Read, Bounded, Generic)
+derivePersistField "StudyFieldType"
+
+
+data Theme
+ = ThemeDefault
+ | ThemeLavender
+ | ThemeNeutralBlue
+ | ThemeAberdeenReds
+ | ThemeMossGreen
+ | ThemeSkyLove
+ deriving (Eq, Ord, Bounded, Enum, Show, Read, Generic)
+
+deriveJSON defaultOptions
+ { constructorTagModifier = fromJust . stripPrefix "Theme"
+ } ''Theme
+
+instance Universe Theme
+instance Finite Theme
+
+nullaryPathPiece ''Theme $ camelToPathPiece' 1
+
+$(deriveSimpleWith ''ToMessage 'toMessage (over Text.packed $ Text.intercalate " " . unsafeTail . splitCamel) ''Theme) -- describe theme to user
+
+derivePersistField "Theme"
diff --git a/src/Model/Types/Security.hs b/src/Model/Types/Security.hs
new file mode 100644
index 000000000..805e7d96d
--- /dev/null
+++ b/src/Model/Types/Security.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
+{-|
+Module: Model.Types.Security
+Description: Types for authentication and authorisation
+-}
+
+module Model.Types.Security
+ ( module Model.Types.Security
+ ) where
+
+import Import.NoModel
+
+import Data.Set (Set)
+
+import qualified Data.Text as Text
+
+import qualified Data.HashMap.Strict as HashMap
+
+import qualified Data.Aeson.Types as Aeson
+
+import qualified Data.Binary as Binary
+
+
+data AuthenticationMode = AuthLDAP
+ | AuthPWHash { authPWHash :: Text }
+ deriving (Eq, Ord, Read, Show, Generic)
+
+deriveJSON defaultOptions
+ { constructorTagModifier = intercalate "-" . map toLower . drop 1 . splitCamel
+ , fieldLabelModifier = intercalate "-" . map toLower . drop 1 . splitCamel
+ , sumEncoding = UntaggedValue
+ } ''AuthenticationMode
+
+derivePersistFieldJSON ''AuthenticationMode
+
+
+data AuthTag -- sortiert nach gewünschter Reihenfolge auf /authpreds, d.h. Prädikate sind sortier nach Relevanz für Benutzer
+ = AuthAdmin
+ | AuthLecturer
+ | AuthCorrector
+ | AuthTutor
+ | AuthCourseRegistered
+ | AuthTutorialRegistered
+ | AuthExamRegistered
+ | AuthParticipant
+ | AuthTime
+ | AuthMaterials
+ | AuthOwner
+ | AuthRated
+ | AuthUserSubmissions
+ | AuthCorrectorSubmissions
+ | AuthCapacity
+ | AuthRegisterGroup
+ | AuthEmpty
+ | AuthSelf
+ | AuthAuthentication
+ | AuthNoEscalation
+ | AuthRead
+ | AuthWrite
+ | AuthToken
+ | AuthDeprecated
+ | AuthDevelopment
+ | AuthFree
+ deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
+
+instance Universe AuthTag
+instance Finite AuthTag
+instance Hashable AuthTag
+
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 1
+ } ''AuthTag
+
+nullaryPathPiece ''AuthTag (camelToPathPiece' 1)
+
+instance ToJSONKey AuthTag where
+ toJSONKey = toJSONKeyText $ \v -> let Aeson.String t = toJSON v in t
+
+instance FromJSONKey AuthTag where
+ fromJSONKey = FromJSONKeyTextParser $ parseJSON . Aeson.String
+
+instance Binary AuthTag
+
+
+newtype AuthTagActive = AuthTagActive { authTagIsActive :: AuthTag -> Bool }
+ deriving (Read, Show, Generic)
+ deriving newtype (Eq, Ord)
+
+instance Default AuthTagActive where
+ def = AuthTagActive $ \case
+ AuthAdmin -> False
+ _ -> True
+
+instance ToJSON AuthTagActive where
+ toJSON v = toJSON . HashMap.fromList $ map (id &&& authTagIsActive v) universeF
+
+instance FromJSON AuthTagActive where
+ parseJSON = Aeson.withObject "AuthTagActive" $ \o -> do
+ o' <- parseJSON $ Aeson.Object o :: Aeson.Parser (HashMap AuthTag Bool)
+ return . AuthTagActive $ \n -> case HashMap.lookup n o' of
+ Nothing -> authTagIsActive def n
+ Just b -> b
+
+derivePersistFieldJSON ''AuthTagActive
+
+
+data PredLiteral a = PLVariable { plVar :: a } | PLNegated { plVar :: a }
+ deriving (Eq, Ord, Read, Show, Generic, Typeable)
+
+instance Hashable a => Hashable (PredLiteral a)
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece' 1
+ , sumEncoding = TaggedObject "val" "var"
+ } ''PredLiteral
+
+instance PathPiece a => PathPiece (PredLiteral a) where
+ toPathPiece PLVariable{..} = toPathPiece plVar
+ toPathPiece PLNegated{..} = "¬" <> toPathPiece plVar
+
+ fromPathPiece t = PLVariable <$> fromPathPiece t
+ <|> PLNegated <$> (Text.stripPrefix "¬" t >>= fromPathPiece)
+
+instance Binary a => Binary (PredLiteral a)
+
+
+newtype PredDNF a = PredDNF { dnfTerms :: Set (NonNull (Set (PredLiteral a))) }
+ deriving (Eq, Ord, Read, Show, Generic, Typeable)
+ deriving newtype (Semigroup, Monoid)
+
+$(return [])
+
+instance ToJSON a => ToJSON (PredDNF a) where
+ toJSON = $(mkToJSON predNFAesonOptions ''PredDNF)
+instance (Ord a, FromJSON a) => FromJSON (PredDNF a) where
+ parseJSON = $(mkParseJSON predNFAesonOptions ''PredDNF)
+
+instance (Ord a, Binary a) => Binary (PredDNF a) where
+ get = PredDNF <$> Binary.get
+ put = Binary.put . dnfTerms
+
+type AuthLiteral = PredLiteral AuthTag
+
+type AuthDNF = PredDNF AuthTag
diff --git a/src/Model/Types/Sheet.hs b/src/Model/Types/Sheet.hs
new file mode 100644
index 000000000..b4a6b0a90
--- /dev/null
+++ b/src/Model/Types/Sheet.hs
@@ -0,0 +1,312 @@
+{-|
+Module: Model.Types.Sheet
+Description: Types for modeling sheets
+-}
+
+module Model.Types.Sheet
+ ( module Model.Types.Sheet
+ ) where
+
+import Import.NoModel
+import Model.Types.Common
+import Utils.Lens.TH
+
+import Control.Lens
+import Generics.Deriving.Monoid (memptydefault, mappenddefault)
+
+import Data.Set (Set)
+import qualified Data.Set as Set
+import qualified Data.Map as Map
+import Data.Aeson.TH (deriveJSON, defaultOptions, Options(..), SumEncoding(..))
+
+import Text.Blaze (Markup)
+
+import Yesod.Core.Dispatch (PathPiece(..))
+
+import Data.Maybe (fromJust)
+
+
+data SheetGrading
+ = Points { maxPoints :: Points }
+ | PassPoints { maxPoints, passingPoints :: Points }
+ | PassBinary -- non-zero means passed
+ deriving (Eq, Read, Show, Generic)
+
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece
+ , fieldLabelModifier = intercalate "-" . map toLower . dropEnd 1 . splitCamel
+ , sumEncoding = TaggedObject "type" "data"
+ } ''SheetGrading
+derivePersistFieldJSON ''SheetGrading
+
+makeLenses_ ''SheetGrading
+
+_passingBound :: Fold SheetGrading (Either () Points)
+_passingBound = folding passPts
+ where
+ passPts :: SheetGrading -> Maybe (Either () Points)
+ passPts Points{} = Nothing
+ passPts PassPoints{passingPoints} = Just $ Right passingPoints
+ passPts PassBinary = Just $ Left ()
+
+gradingPassed :: SheetGrading -> Points -> Maybe Bool
+gradingPassed gr pts = either pBinary pPoints <$> gr ^? _passingBound
+ where pBinary _ = pts /= 0
+ pPoints b = pts >= b
+
+
+data SheetGradeSummary = SheetGradeSummary
+ { numSheets :: Count -- Total number of sheets, includes all
+ , numSheetsPasses :: Count -- Number of sheets required to pass FKA: numGradePasses
+ , numSheetsPoints :: Count -- Number of sheets having points FKA: sumGradePointsd
+ , sumSheetsPoints :: Sum Points -- Total of all points in all sheets
+ -- Marking dependend
+ , numMarked :: Count -- Number of already marked sheets
+ , numMarkedPasses :: Count -- Number of already marked sheets with passes
+ , numMarkedPoints :: Count -- Number of already marked sheets with points
+ , sumMarkedPoints :: Sum Points -- Achieveable points within marked sheets
+ --
+ , achievedPasses :: Count -- Achieved passes (within marked sheets)
+ , achievedPoints :: Sum Points -- Achieved points (within marked sheets)
+ } deriving (Generic, Read, Show, Eq)
+
+instance Monoid SheetGradeSummary where
+ mempty = memptydefault
+ mappend = mappenddefault
+
+instance Semigroup SheetGradeSummary where
+ (<>) = mappend -- TODO: remove for GHC > 8.4.x
+
+makeLenses_ ''SheetGradeSummary
+
+sheetGradeSum :: SheetGrading -> Maybe Points -> SheetGradeSummary
+sheetGradeSum gr Nothing = mempty
+ { numSheets = 1
+ , numSheetsPasses = bool mempty 1 $ has _passingBound gr
+ , numSheetsPoints = bool mempty 1 $ has _maxPoints gr
+ , sumSheetsPoints = maybe mempty Sum $ gr ^? _maxPoints
+ }
+sheetGradeSum gr (Just p) =
+ let unmarked@SheetGradeSummary{..} = sheetGradeSum gr Nothing
+ in unmarked
+ { numMarked = numSheets
+ , numMarkedPasses = numSheetsPasses
+ , numMarkedPoints = numSheetsPoints
+ , sumMarkedPoints = sumSheetsPoints
+ , achievedPasses = maybe mempty (bool 0 1) (gradingPassed gr p)
+ , achievedPoints = bool mempty (Sum p) $ has _maxPoints gr
+ }
+
+
+data SheetType
+ = NotGraded
+ | Normal { grading :: SheetGrading }
+ | Bonus { grading :: SheetGrading }
+ | Informational { grading :: SheetGrading }
+ deriving (Eq, Read, Show, Generic)
+
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece
+ , fieldLabelModifier = camelToPathPiece
+ , sumEncoding = TaggedObject "type" "data"
+ } ''SheetType
+derivePersistFieldJSON ''SheetType
+
+data SheetTypeSummary = SheetTypeSummary
+ { normalSummary
+ , bonusSummary
+ , informationalSummary :: SheetGradeSummary
+ , numNotGraded :: Count
+ } deriving (Generic, Read, Show, Eq)
+
+instance Monoid SheetTypeSummary where
+ mempty = memptydefault
+ mappend = mappenddefault
+
+instance Semigroup SheetTypeSummary where
+ (<>) = mappend -- TODO: remove for GHC > 8.4.x
+
+makeLenses_ ''SheetTypeSummary
+
+sheetTypeSum :: SheetType -> Maybe Points -> SheetTypeSummary
+sheetTypeSum Bonus{..} mps = mempty { bonusSummary = sheetGradeSum grading mps }
+sheetTypeSum Normal{..} mps = mempty { normalSummary = sheetGradeSum grading mps }
+sheetTypeSum Informational{..} mps = mempty { informationalSummary = sheetGradeSum grading mps }
+sheetTypeSum NotGraded _ = mempty { numNotGraded = Sum 1 }
+
+data SheetGroup
+ = Arbitrary { maxParticipants :: Natural }
+ | RegisteredGroups
+ | NoGroups
+ deriving (Show, Read, Eq, Generic)
+deriveJSON defaultOptions ''SheetGroup
+derivePersistFieldJSON ''SheetGroup
+
+makeLenses_ ''SheetGroup
+
+data SheetFileType = SheetExercise | SheetHint | SheetSolution | SheetMarking
+ deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)
+derivePersistField "SheetFileType"
+
+instance Universe SheetFileType
+instance Finite SheetFileType
+
+instance PathPiece SheetFileType where
+ toPathPiece SheetExercise = "file"
+ toPathPiece SheetHint = "hint"
+ toPathPiece SheetSolution = "solution"
+ toPathPiece SheetMarking = "marking"
+ fromPathPiece = finiteFromPathPiece
+
+sheetFile2markup :: SheetFileType -> Markup
+sheetFile2markup SheetExercise = iconQuestion
+sheetFile2markup SheetHint = iconHint
+sheetFile2markup SheetSolution = iconSolution
+sheetFile2markup SheetMarking = iconMarking
+
+-- partitionFileType' :: Ord a => [(SheetFileType,a)] -> Map SheetFileType (Set a)
+-- partitionFileType' = groupMap
+
+partitionFileType :: Ord a => [(SheetFileType,a)] -> SheetFileType -> Set a
+partitionFileType fs t = Map.findWithDefault Set.empty t . Map.fromListWith Set.union $ map (over _2 Set.singleton) fs
+
+
+data UploadSpecificFile = UploadSpecificFile
+ { specificFileLabel :: Text
+ , specificFileName :: FileName
+ , specificFileRequired :: Bool
+ } deriving (Show, Read, Eq, Ord, Generic)
+
+deriveJSON defaultOptions
+ { fieldLabelModifier = camelToPathPiece' 2
+ } ''UploadSpecificFile
+derivePersistFieldJSON ''UploadSpecificFile
+
+data UploadMode = NoUpload
+ | UploadAny
+ { unpackZips :: Bool
+ , extensionRestriction :: Maybe (NonNull (Set Extension))
+ }
+ | UploadSpecific
+ { specificFiles :: NonNull (Set UploadSpecificFile)
+ }
+ deriving (Show, Read, Eq, Ord, Generic)
+
+defaultExtensionRestriction :: Maybe (NonNull (Set Extension))
+defaultExtensionRestriction = fromNullable $ Set.fromList ["txt", "pdf"]
+
+deriveJSON defaultOptions
+ { constructorTagModifier = \c -> if
+ | c == "UploadAny" -> "upload"
+ | otherwise -> camelToPathPiece c
+ , fieldLabelModifier = camelToPathPiece
+ , sumEncoding = TaggedObject "mode" "settings"
+ , omitNothingFields = True
+ }''UploadMode
+derivePersistFieldJSON ''UploadMode
+
+data UploadModeDescr = UploadModeNone
+ | UploadModeAny
+ | UploadModeSpecific
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+instance Universe UploadModeDescr
+instance Finite UploadModeDescr
+
+nullaryPathPiece ''UploadModeDescr $ camelToPathPiece' 2
+
+classifyUploadMode :: UploadMode -> UploadModeDescr
+classifyUploadMode NoUpload = UploadModeNone
+classifyUploadMode UploadAny{} = UploadModeAny
+classifyUploadMode UploadSpecific{} = UploadModeSpecific
+
+data SubmissionMode = SubmissionMode
+ { submissionModeCorrector :: Bool
+ , submissionModeUser :: Maybe UploadMode
+ }
+ deriving (Show, Read, Eq, Ord, Generic)
+
+deriveJSON defaultOptions
+ { fieldLabelModifier = camelToPathPiece' 2
+ } ''SubmissionMode
+derivePersistFieldJSON ''SubmissionMode
+
+data SubmissionModeDescr = SubmissionModeNone
+ | SubmissionModeCorrector
+ | SubmissionModeUser
+ | SubmissionModeBoth
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+instance Universe SubmissionModeDescr
+instance Finite SubmissionModeDescr
+
+finitePathPiece ''SubmissionModeDescr
+ [ "no-submissions"
+ , "correctors"
+ , "users"
+ , "correctors+users"
+ ]
+
+classifySubmissionMode :: SubmissionMode -> SubmissionModeDescr
+classifySubmissionMode (SubmissionMode False Nothing ) = SubmissionModeNone
+classifySubmissionMode (SubmissionMode True Nothing ) = SubmissionModeCorrector
+classifySubmissionMode (SubmissionMode False (Just _)) = SubmissionModeUser
+classifySubmissionMode (SubmissionMode True (Just _)) = SubmissionModeBoth
+
+
+-- | Specify a corrector's workload
+data Load -- = ByTutorial { countsToLoad :: Bool } | ByProportion { load :: Rational }
+ = Load { byTutorial :: Maybe Bool -- ^ @Just@ all from Tutorial, @True@ if counting towards overall workload
+ , byProportion :: Rational -- ^ workload proportion of all submission not assigned to tutorial leaders
+ }
+ deriving (Show, Read, Eq, Ord, Generic)
+
+deriveJSON defaultOptions ''Load
+derivePersistFieldJSON ''Load
+
+instance Hashable Load
+
+instance Semigroup Load where
+ (Load byTut prop) <> (Load byTut' prop') = Load byTut'' (prop + prop')
+ where
+ byTut''
+ | Nothing <- byTut = byTut'
+ | Nothing <- byTut' = byTut
+ | Just a <- byTut
+ , Just b <- byTut' = Just $ a || b
+
+instance Monoid Load where
+ mempty = Load Nothing 0
+ mappend = (<>)
+
+{- Use (is _ByTutorial) instead of this unneeded definition:
+ isByTutorial :: Load -> Bool
+ isByTutorial (ByTutorial {}) = True
+ isByTutorial _ = False
+-}
+
+data CorrectorState = CorrectorNormal | CorrectorMissing | CorrectorExcused
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic)
+
+deriveJSON defaultOptions
+ { constructorTagModifier = fromJust . stripPrefix "Corrector"
+ } ''CorrectorState
+
+instance Universe CorrectorState
+instance Finite CorrectorState
+
+instance Hashable CorrectorState
+
+nullaryPathPiece ''CorrectorState (camelToPathPiece' 1)
+
+derivePersistField "CorrectorState"
+
+showCompactCorrectorLoad :: Load -> CorrectorState -> Text
+showCompactCorrectorLoad load CorrectorMissing = "[" <> showCompactCorrectorLoad load CorrectorNormal <> "]"
+showCompactCorrectorLoad load CorrectorExcused = "{" <> showCompactCorrectorLoad load CorrectorNormal <> "}"
+showCompactCorrectorLoad Load{..} CorrectorNormal = proportionText <> tutorialText
+ where
+ proportionText = let propDbl :: Double
+ propDbl = fromRational byProportion
+ in tshow $ roundToDigits 2 propDbl
+ tutorialText = case byTutorial of Nothing -> mempty
+ Just True -> " (T)"
+ Just False -> " +T "
diff --git a/src/Model/Types/Submission.hs b/src/Model/Types/Submission.hs
new file mode 100644
index 000000000..c31fa38fc
--- /dev/null
+++ b/src/Model/Types/Submission.hs
@@ -0,0 +1,151 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
+{-|
+Module: Model.Types.Submission
+Description: Types to support sheet submissions
+-}
+
+module Model.Types.Submission
+ ( module Model.Types.Submission
+ ) where
+
+import Import.NoModel
+
+import Data.Aeson.Types (ToJSON(..), FromJSON(..))
+import qualified Data.Aeson.Types as Aeson
+
+import Database.Persist.Sql
+
+import Data.Word.Word24
+
+import qualified Data.CaseInsensitive as CI
+
+import Control.Lens
+
+import qualified Data.Text as Text
+import qualified Data.Set as Set
+
+
+import Data.List (elemIndex, genericIndex)
+import Data.Bits
+import Data.Text.Metrics (damerauLevenshtein)
+
+-------------------------
+-- Submission Download --
+-------------------------
+
+data SubmissionFileType = SubmissionOriginal | SubmissionCorrected
+ deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)
+
+instance Universe SubmissionFileType
+instance Finite SubmissionFileType
+
+nullaryPathPiece ''SubmissionFileType $ camelToPathPiece' 1
+
+submissionFileTypeIsUpdate :: SubmissionFileType -> Bool
+submissionFileTypeIsUpdate SubmissionOriginal = False
+submissionFileTypeIsUpdate SubmissionCorrected = True
+
+isUpdateSubmissionFileType :: Bool -> SubmissionFileType
+isUpdateSubmissionFileType False = SubmissionOriginal
+isUpdateSubmissionFileType True = SubmissionCorrected
+
+---------------------------
+-- Submission Pseudonyms --
+---------------------------
+
+type PseudonymWord = CI Text
+
+newtype Pseudonym = Pseudonym Word24
+ deriving (Eq, Ord, Read, Show, Generic, Data)
+ deriving newtype (Bounded, Enum, Integral, Num, Real, Ix)
+
+
+instance PersistField Pseudonym where
+ toPersistValue p = toPersistValue (fromIntegral p :: Word32)
+ fromPersistValue v = do
+ w <- fromPersistValue v :: Either Text Word32
+ if
+ | 0 <= w
+ , w <= fromIntegral (maxBound :: Pseudonym)
+ -> return $ fromIntegral w
+ | otherwise
+ -> Left "Pseudonym out of range"
+
+instance PersistFieldSql Pseudonym where
+ sqlType _ = SqlInt32
+
+instance Random Pseudonym where
+ randomR (max minBound -> lo, min maxBound -> hi) gen = over _1 (fromIntegral :: Word32 -> Pseudonym) $ randomR (fromIntegral lo, fromIntegral hi) gen
+ random = randomR (minBound, maxBound)
+
+instance FromJSON Pseudonym where
+ parseJSON v@(Aeson.Number _) = do
+ w <- parseJSON v :: Aeson.Parser Word32
+ if
+ | 0 <= w
+ , w <= fromIntegral (maxBound :: Pseudonym)
+ -> return $ fromIntegral w
+ | otherwise
+ -> fail "Pseudonym out auf range"
+ parseJSON (Aeson.String t)
+ = case t ^? _PseudonymText of
+ Just p -> return p
+ Nothing -> fail "Could not parse pseudonym"
+ parseJSON v = flip (Aeson.withArray "Pseudonym") v $ \ws -> do
+ ws' <- toList . map CI.mk <$> mapM parseJSON ws
+ case ws' ^? _PseudonymWords of
+ Just p -> return p
+ Nothing -> fail "Could not parse pseudonym words"
+
+instance ToJSON Pseudonym where
+ toJSON = toJSON . (review _PseudonymWords :: Pseudonym -> [PseudonymWord])
+
+pseudonymWordlist :: [PseudonymWord]
+pseudonymCharacters :: Set (CI Char)
+(pseudonymWordlist, pseudonymCharacters) = $(wordlist "config/wordlist.txt")
+
+_PseudonymWords :: Prism' [PseudonymWord] Pseudonym
+_PseudonymWords = prism' pToWords pFromWords
+ where
+ pFromWords :: [PseudonymWord] -> Maybe Pseudonym
+ pFromWords [w1, w2]
+ | Just i1 <- elemIndex w1 pseudonymWordlist
+ , Just i2 <- elemIndex w2 pseudonymWordlist
+ , i1 <= maxWord, i2 <= maxWord
+ = Just . Pseudonym $ shiftL (fromIntegral i1) 12 .|. fromIntegral i2
+ pFromWords _ = Nothing
+
+ pToWords :: Pseudonym -> [PseudonymWord]
+ pToWords (Pseudonym p)
+ = [ genericIndex pseudonymWordlist $ shiftR p 12 .&. maxWord
+ , genericIndex pseudonymWordlist $ p .&. maxWord
+ ]
+
+ maxWord :: Num a => a
+ maxWord = 0b111111111111
+
+_PseudonymText :: Prism' Text Pseudonym
+_PseudonymText = prism' tToWords tFromWords . _PseudonymWords
+ where
+ tFromWords :: Text -> Maybe [PseudonymWord]
+ tFromWords input
+ | [result] <- input ^.. pseudonymFragments
+ = Just result
+ | otherwise
+ = Nothing
+
+ tToWords :: [PseudonymWord] -> Text
+ tToWords = Text.unwords . map CI.original
+
+pseudonymWords :: Fold Text PseudonymWord
+pseudonymWords = folding
+ $ \(CI.mk -> input) -> map (view _2) . fromMaybe [] . listToMaybe . groupBy ((==) `on` view _1) . sortBy (comparing $ view _1) . filter ((<= distanceCutoff) . view _1) $ map (distance input &&& id) pseudonymWordlist
+ where
+ distance = damerauLevenshtein `on` CI.foldedCase
+ -- | Arbitrary cutoff point, for reference: ispell cuts off at 1
+ distanceCutoff = 2
+
+pseudonymFragments :: Fold Text [PseudonymWord]
+pseudonymFragments = folding
+ $ mapM (toListOf pseudonymWords) . (\l -> guard (length l == 2) *> l) . filter (not . null) . Text.split (\(CI.mk -> c) -> not $ Set.member c pseudonymCharacters)
diff --git a/src/Model/Types/JSON.hs b/src/Model/Types/TH/JSON.hs
similarity index 77%
rename from src/Model/Types/JSON.hs
rename to src/Model/Types/TH/JSON.hs
index e69f8f1b2..34a752350 100644
--- a/src/Model/Types/JSON.hs
+++ b/src/Model/Types/TH/JSON.hs
@@ -1,5 +1,6 @@
-module Model.Types.JSON
+module Model.Types.TH.JSON
( derivePersistFieldJSON
+ , predNFAesonOptions
) where
import ClassyPrelude.Yesod hiding (derivePersistFieldJSON)
@@ -9,11 +10,13 @@ import Database.Persist.Sql
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text.Encoding as Text
-import qualified Data.Aeson as JSON
+import Data.Aeson as JSON
import Language.Haskell.TH
import Language.Haskell.TH.Datatype
+import Utils.PathPiece
+
derivePersistFieldJSON :: Name -> DecsQ
derivePersistFieldJSON tName = do
@@ -28,10 +31,10 @@ derivePersistFieldJSON tName = do
| otherwise = cxt [[t|PersistField|] `appT` t]
sequence
[ instanceD iCxt ([t|PersistField|] `appT` t)
- [ funD (mkName "toPersistValue")
+ [ funD 'toPersistValue
[ clause [] (normalB [e|PersistDbSpecific . LBS.toStrict . JSON.encode|]) []
]
- , funD (mkName "fromPersistValue")
+ , funD 'fromPersistValue
[ do
bs <- newName "bs"
clause [[p|PersistDbSpecific $(varP bs)|]] (normalB [e|first pack $ JSON.eitherDecodeStrict' $(varE bs)|]) []
@@ -45,8 +48,20 @@ derivePersistFieldJSON tName = do
]
]
, instanceD sqlCxt ([t|PersistFieldSql|] `appT` t)
- [ funD (mkName "sqlType")
+ [ funD 'sqlType
[ clause [wildP] (normalB [e|SqlOther "jsonb"|]) []
]
]
]
+
+
+predNFAesonOptions :: Options
+-- ^ Needed for JSON instances of `predCNF` and `predDNF`
+--
+-- Moved to this module due to stage restriction
+predNFAesonOptions = defaultOptions
+ { constructorTagModifier = camelToPathPiece' 1
+ , sumEncoding = ObjectWithSingleField
+ , tagSingleConstructors = True
+ }
+
diff --git a/src/Model/Types/Wordlist.hs b/src/Model/Types/TH/Wordlist.hs
similarity index 95%
rename from src/Model/Types/Wordlist.hs
rename to src/Model/Types/TH/Wordlist.hs
index 5cfecd662..de3d159d8 100644
--- a/src/Model/Types/Wordlist.hs
+++ b/src/Model/Types/TH/Wordlist.hs
@@ -1,4 +1,6 @@
-module Model.Types.Wordlist (wordlist) where
+module Model.Types.TH.Wordlist
+ ( wordlist
+ ) where
import ClassyPrelude hiding (lift)
diff --git a/src/Network/Mime/TH.hs b/src/Network/Mime/TH.hs
new file mode 100644
index 000000000..486eda779
--- /dev/null
+++ b/src/Network/Mime/TH.hs
@@ -0,0 +1,56 @@
+module Network.Mime.TH
+ ( mimeMapFile, mimeSetFile
+ ) where
+
+import ClassyPrelude.Yesod hiding (lift)
+import Language.Haskell.TH hiding (Extension)
+import Language.Haskell.TH.Syntax (qAddDependentFile, Lift(..))
+
+import qualified Data.Set as Set
+import qualified Data.Map as Map
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.IO as Text
+import qualified Data.Text.Encoding as Text
+
+import Network.Mime
+
+import Instances.TH.Lift ()
+
+
+mimeMapFile, mimeSetFile :: FilePath -> ExpQ
+mimeMapFile file = do
+ qAddDependentFile file
+
+ mappings <- runIO $ filter (not . isComment) . Text.lines <$> Text.readFile file
+ let
+ coMappings :: [(Extension, MimeType)]
+ coMappings = do
+ (mimeType : extensions) <- filter (not . Text.null) . Text.words <$> mappings
+ ext <- extensions
+ return (ext, Text.encodeUtf8 mimeType)
+
+ mimeMap = Map.fromListWithKey duplicateError coMappings
+
+ duplicateError ext t1 t2 = error . Text.unpack $ "Duplicate mimeMap-entries for extension " <> ext <> ": " <> Text.decodeUtf8 t1 <> ", " <> Text.decodeUtf8 t2
+
+
+ lift mimeMap
+mimeSetFile file = do
+ qAddDependentFile file
+
+ ls <- runIO $ filter (not . isComment) . Text.lines <$> Text.readFile file
+
+ let mimeSet :: Set MimeType
+ mimeSet = Set.fromList $ map (encodeUtf8 . Text.strip) ls
+
+ lift mimeSet
+
+isComment :: Text -> Bool
+isComment line = or
+ [ commentSymbol `Text.isPrefixOf` Text.stripStart line
+ , Text.null $ Text.strip line
+ ]
+ where
+ commentSymbol = "#"
diff --git a/src/Settings.hs b/src/Settings.hs
index f717ee378..c53e90269 100644
--- a/src/Settings.hs
+++ b/src/Settings.hs
@@ -10,23 +10,28 @@ module Settings
, module Settings.Cluster
) where
-import ClassyPrelude.Yesod
+import Import.NoModel
import Data.UUID (UUID)
import qualified Control.Exception as Exception
-import Data.Aeson (Result (..), fromJSON, withObject
+import Data.Aeson (fromJSON, withObject
,(.!=), (.:?), withScientific
)
import qualified Data.Aeson.Types as Aeson
-import Data.Aeson.TH
import Data.FileEmbed (embedFile)
import Data.Yaml (decodeEither')
import Database.Persist.Postgresql (PostgresConf)
-import Language.Haskell.TH.Syntax (Exp, Name, Q)
import Network.Wai.Handler.Warp (HostPreference)
import Yesod.Default.Config2 (applyEnvValue, configSettingsYml)
-import Yesod.Default.Util (WidgetFileSettings,
- widgetFileNoReload,
- widgetFileReload)
+#ifdef DEVELOPMENT
+import Yesod.Default.Util (WidgetFileSettings, widgetFileReload)
+import Language.Haskell.TH.Syntax (Exp, Q, location, Loc(..))
+
+import Text.Shakespeare.Text (st)
+import Text.Blaze.Html (preEscapedToHtml)
+#else
+import Yesod.Default.Util (WidgetFileSettings, widgetFileNoReload, widgetFileReload)
+import Language.Haskell.TH.Syntax (Exp, Q)
+#endif
import qualified Yesod.Auth.Util.PasswordStore as PWStore
import Data.Time (NominalDiffTime, nominalDay)
@@ -39,12 +44,8 @@ import qualified Data.Text.Encoding as Text
import qualified Ldap.Client as Ldap
-import Utils hiding (MessageStatus(..))
import Control.Lens
-import Data.Maybe (fromJust)
-import qualified Data.Char as Char
-
import qualified Network.HaskellNet.Auth as HaskellNet (UserName, Password, AuthType(..))
import qualified Network.Socket as HaskellNet (PortNumber(..), HostName)
import qualified Network
@@ -63,6 +64,16 @@ import Control.Monad.Trans.Maybe (MaybeT(..))
import qualified System.FilePath as FilePath
+import Jose.Jwt (JwtEncoding(..))
+
+import System.FilePath.Glob
+import Handler.Utils.Submission.TH
+import Network.Mime.TH
+
+import qualified Data.Map as Map
+import qualified Data.Set as Set
+
+
-- | Runtime settings to configure this application. These settings can be
-- loaded from various sources: defaults, environment variables, config files,
-- theoretically even a database.
@@ -71,6 +82,7 @@ data AppSettings = AppSettings
-- ^ Directory from which to serve static files.
, appDatabaseConf :: PostgresConf
-- ^ Configuration settings for accessing the database.
+ , appAutoDbMigrate :: Bool
, appLdapConf :: Maybe LdapConf
-- ^ Configuration settings for accessing the LDAP-directory
, appSmtpConf :: Maybe SmtpConf
@@ -100,6 +112,12 @@ data AppSettings = AppSettings
, appNotificationExpiration :: NominalDiffTime
, appSessionTimeout :: NominalDiffTime
, appMaximumContentLength :: Maybe Word64
+ , appJwtExpiration :: Maybe NominalDiffTime
+ , appJwtEncoding :: JwtEncoding
+
+ , appHealthCheckInterval :: HealthCheck -> Maybe NominalDiffTime
+ , appHealthCheckDelayNotify :: Bool
+ , appHealthCheckHTTP :: Bool
, appInitialLogSettings :: LogSettings
@@ -267,7 +285,7 @@ deriveFromJSON
deriveJSON
defaultOptions
- { constructorTagModifier = over (ix 1) Char.toLower . fromJust . stripPrefix "Level"
+ { constructorTagModifier = camelToPathPiece' 1
, sumEncoding = UntaggedValue
}
''LogLevel
@@ -310,6 +328,18 @@ deriveFromJSON
}
''SmtpAuthConf
+instance FromJSON JwtEncoding where
+ parseJSON v@(String _) = JwsEncoding <$> parseJSON v
+ parseJSON v = flip (withObject "JwtEncoding") v $ \obj -> asum
+ [ do
+ alg <- obj .: "alg"
+ return $ JwsEncoding alg
+ , do
+ alg <- obj .: "alg"
+ enc <- obj .: "enc"
+ return $ JweEncoding alg enc
+ ]
+
instance FromJSON AppSettings where
parseJSON = withObject "AppSettings" $ \o -> do
@@ -321,6 +351,7 @@ instance FromJSON AppSettings where
#endif
appStaticDir <- o .: "static-dir"
appDatabaseConf <- o .: "database"
+ appAutoDbMigrate <- o .: "auto-db-migrate"
let nonEmptyHost LdapConf{..} = case ldapHost of
Ldap.Tls host _ -> not $ null host
Ldap.Plain host -> not $ null host
@@ -352,6 +383,12 @@ instance FromJSON AppSettings where
appNotificationRateLimit <- o .: "notification-rate-limit"
appNotificationCollateDelay <- o .: "notification-collate-delay"
appNotificationExpiration <- o .: "notification-expiration"
+ appJwtExpiration <- o .:? "jwt-expiration"
+ appJwtEncoding <- o .: "jwt-encoding"
+
+ appHealthCheckInterval <- (assertM' (> 0) . ) <$> o .: "health-check-interval"
+ appHealthCheckDelayNotify <- o .: "health-check-delay-notify"
+ appHealthCheckHTTP <- o .: "health-check-http"
appSessionTimeout <- o .: "session-timeout"
@@ -379,6 +416,8 @@ instance FromJSON AppSettings where
return AppSettings {..}
+makeClassy_ ''AppSettings
+
-- | Settings for 'widgetFile', such as which template languages to support and
-- default Hamlet settings.
--
@@ -388,18 +427,45 @@ instance FromJSON AppSettings where
widgetFileSettings :: WidgetFileSettings
widgetFileSettings = def
--- | How static files should be combined.
-combineSettings :: CombineSettings
-combineSettings = def
+
+submissionBlacklist :: [Pattern]
+submissionBlacklist = $(patternFile compDefault "config/submission-blacklist")
+
+mimeMap :: MimeMap
+mimeMap = $(mimeMapFile "config/mimetypes")
+
+mimeLookup :: FileName -> MimeType
+mimeLookup = mimeByExt mimeMap defaultMimeType
+
+mimeExtensions :: MimeType -> Set Extension
+mimeExtensions needle = Set.fromList [ ext | (ext, typ) <- Map.toList mimeMap, typ == needle ]
+
+archiveTypes :: Set MimeType
+archiveTypes = $(mimeSetFile "config/archive-types")
-- The rest of this file contains settings which rarely need changing by a
-- user.
widgetFile :: String -> Q Exp
-widgetFile = (if appReloadTemplates compileTimeAppSettings
- then widgetFileReload
- else widgetFileNoReload)
- widgetFileSettings
+#ifdef DEVELOPMENT
+widgetFile nameBase = do
+ Loc{..} <- location
+ let nameBase' = "templates" > nameBase
+ before, after :: Text
+ before = [st||]
+ after = [st||]
+ [e| do
+ toWidget $ preEscapedToHtml before
+ $(widgetFileReload widgetFileSettings nameBase)
+ toWidget $ preEscapedToHtml after
+ |]
+#else
+widgetFile
+ | appReloadTemplates compileTimeAppSettings
+ = widgetFileReload widgetFileSettings
+ | otherwise
+ = widgetFileNoReload widgetFileSettings
+#endif
-- | Raw bytes at compile time of @config/settings.yml@
configSettingsYmlBS :: ByteString
@@ -414,21 +480,5 @@ configSettingsYmlValue = either Exception.throw id
compileTimeAppSettings :: AppSettings
compileTimeAppSettings =
case fromJSON $ applyEnvValue False mempty configSettingsYmlValue of
- Error e -> error e
- Success settings -> settings
-
--- The following two functions can be used to combine multiple CSS or JS files
--- at compile time to decrease the number of http requests.
--- Sample usage (inside a Widget):
---
--- > $(combineStylesheets 'StaticR [style1_css, style2_css])
-
-combineStylesheets :: Name -> [Route Static] -> Q Exp
-combineStylesheets = combineStylesheets'
- (appSkipCombining compileTimeAppSettings)
- combineSettings
-
-combineScripts :: Name -> [Route Static] -> Q Exp
-combineScripts = combineScripts'
- (appSkipCombining compileTimeAppSettings)
- combineSettings
+ Aeson.Error e -> error e
+ Aeson.Success settings -> settings
diff --git a/src/Settings/Cluster.hs b/src/Settings/Cluster.hs
index a6fb11799..037c9d967 100644
--- a/src/Settings/Cluster.hs
+++ b/src/Settings/Cluster.hs
@@ -32,11 +32,20 @@ import qualified Data.Binary as Binary
import qualified Data.Serialize as Serialize
import qualified Data.ByteString.Base64.URL as Base64
+import qualified Jose.Jwa as Jose
+import qualified Jose.Jwk as Jose
+import qualified Jose.Jwt as Jose
+
+import Data.UUID (UUID)
+import Control.Monad.Random.Class (MonadRandom(..))
+
data ClusterSettingsKey
= ClusterCryptoIDKey
| ClusterClientSessionKey
| ClusterSecretBoxKey
+ | ClusterJSONWebKeySet
+ | ClusterId
deriving (Eq, Ord, Enum, Bounded, Show, Read)
instance Universe ClusterSettingsKey
@@ -120,3 +129,18 @@ instance FromJSON SecretBox.Key where
parseJSON = Aeson.withText "Key" $ \t -> do
bytes <- either fail return . Base64.decode $ encodeUtf8 t
maybe (fail "Could not parse key") return $ Saltine.decode bytes
+
+
+instance ClusterSetting 'ClusterJSONWebKeySet where
+ type ClusterSettingValue 'ClusterJSONWebKeySet = Jose.JwkSet
+ initClusterSetting _ = liftIO $ do
+ now <- getCurrentTime
+ jwkSig <- Jose.generateSymmetricKey 32 (Jose.UTCKeyId now) Jose.Sig (Just $ Jose.Signed Jose.HS256)
+ return $ Jose.JwkSet [jwkSig]
+ knownClusterSetting _ = ClusterJSONWebKeySet
+
+
+instance ClusterSetting 'ClusterId where
+ type ClusterSettingValue 'ClusterId = UUID
+ initClusterSetting _ = liftIO getRandom
+ knownClusterSetting _ = ClusterId
diff --git a/src/System/FilePath/Instances.hs b/src/System/FilePath/Instances.hs
new file mode 100644
index 000000000..b37e2291a
--- /dev/null
+++ b/src/System/FilePath/Instances.hs
@@ -0,0 +1,16 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module System.FilePath.Instances
+ (
+ ) where
+
+import ClassyPrelude
+
+import qualified Data.Text as Text
+
+import Web.PathPieces
+
+
+instance {-# OVERLAPS #-} PathMultiPiece FilePath where
+ fromPathMultiPiece = Just . unpack . intercalate "/"
+ toPathMultiPiece = Text.splitOn "/" . pack
diff --git a/src/Text/Blaze/Instances.hs b/src/Text/Blaze/Instances.hs
new file mode 100644
index 000000000..346b17c60
--- /dev/null
+++ b/src/Text/Blaze/Instances.hs
@@ -0,0 +1,37 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Text.Blaze.Instances
+ (
+ ) where
+
+import ClassyPrelude
+import Text.Blaze
+import qualified Text.Blaze.Renderer.Text as Text
+
+import Text.Read (Read(..))
+
+import Data.Hashable (Hashable(..))
+import Data.Aeson (ToJSON(..), FromJSON(..))
+import qualified Data.Aeson as Aeson
+
+
+instance Eq Markup where
+ (==) = (==) `on` Text.renderMarkup
+
+instance Ord Markup where
+ compare = comparing Text.renderMarkup
+
+instance Read Markup where
+ readPrec = preEscapedLazyText <$> readPrec
+
+instance Show Markup where
+ showsPrec prec = showsPrec prec . Text.renderMarkup
+
+instance Hashable Markup where
+ hashWithSalt s = hashWithSalt s . Text.renderMarkup
+
+instance ToJSON Markup where
+ toJSON = Aeson.String . toStrict . Text.renderMarkup
+
+instance FromJSON Markup where
+ parseJSON = Aeson.withText "Html" $ return . preEscapedText
diff --git a/src/Time/Types/Instances.hs b/src/Time/Types/Instances.hs
new file mode 100644
index 000000000..fa61bca45
--- /dev/null
+++ b/src/Time/Types/Instances.hs
@@ -0,0 +1,25 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Time.Types.Instances
+ (
+ ) where
+
+-- import ClassyPrelude
+
+import Time.Types
+
+import Data.Universe
+
+import Utils.PathPiece
+
+import Data.Aeson.TH
+
+
+instance Universe WeekDay
+instance Finite WeekDay
+
+nullaryPathPiece ''WeekDay camelToPathPiece
+
+deriveJSON defaultOptions
+ { constructorTagModifier = camelToPathPiece
+ } ''WeekDay
diff --git a/src/Utils.hs b/src/Utils.hs
index 961aa288e..f2beb56d2 100644
--- a/src/Utils.hs
+++ b/src/Utils.hs
@@ -1,15 +1,14 @@
-{-# OPTIONS_GHC -fno-warn-orphans #-} -- Monad FormResult
-
module Utils
( module Utils
) where
-import ClassyPrelude.Yesod hiding (foldlM)
+import ClassyPrelude.Yesod hiding (foldlM, Proxy)
-- import Data.Double.Conversion.Text -- faster implementation for textPercent?
import qualified Data.Foldable as Fold
import Data.Foldable as Utils (foldlM, foldrM)
import Data.Monoid (Sum(..))
+import Data.Proxy
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
@@ -17,8 +16,9 @@ import qualified Data.CaseInsensitive as CI
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
-import Utils.DB as Utils
+-- import Utils.DB as Utils
import Utils.TH as Utils
import Utils.DateTime as Utils
import Utils.PathPiece as Utils
@@ -29,9 +29,8 @@ import Utils.Parameters as Utils
import Text.Blaze (Markup, ToMarkup)
-import Data.Char (isDigit, isSpace)
+import Data.Char (isDigit, isSpace, isAscii)
import Data.Text (dropWhileEnd, takeWhileEnd, justifyRight)
-import Numeric (showFFloat)
import Data.Set (Set)
import qualified Data.Set as Set
@@ -45,13 +44,11 @@ import Control.Lens as Utils (none)
import Control.Arrow as Utils ((>>>))
import Control.Monad.Trans.Except (ExceptT(..), throwE, runExceptT)
import Control.Monad.Except (MonadError(..))
-import Control.Monad.Trans.Maybe (MaybeT(..))
+import Control.Monad.Trans.Maybe as Utils (MaybeT(..))
import Control.Monad.Catch hiding (throwM)
-
-import qualified Database.Esqueleto as E (Value, unValue)
-
import Language.Haskell.TH
+import Language.Haskell.TH.Instances ()
import Instances.TH.Lift ()
import Text.Shakespeare.Text (st)
@@ -66,8 +63,19 @@ import qualified Crypto.Saltine.Core.SecretBox as SecretBox
import qualified Crypto.Saltine.Class as Saltine
import qualified Crypto.Data.PKCS7 as PKCS7
-import Data.Fixed (Centi)
-import Data.Ratio ((%))
+import Data.Fixed
+-- import Data.Ratio ((%))
+
+import Data.Binary (Binary)
+import qualified Data.Binary as Binary
+
+import Network.Wai (requestMethod)
+
+import Data.Time.Clock
+
+import Data.List.NonEmpty (NonEmpty, nonEmpty)
+
+import Algebra.Lattice (top, bottom, (/\), (\/), BoundedJoinSemiLattice, BoundedMeetSemiLattice)
{-# ANN choice ("HLint: ignore Use asum" :: String) #-}
@@ -113,10 +121,55 @@ type WidgetSiteless = forall site. forall m. (MonadIO m, MonadThrow m, MonadBase
-- Icons --
-----------
+-- Create an icon from font-awesome without additional space
+fontAwesomeIcon :: Text -> Markup
+fontAwesomeIcon iconName =
+ [shamlet|$newline never
+
|]
+
+-- We collect all used icons here for an overview.
+-- For consistency, some conditional icons are also provided, e.g. `isIvisble`
+
+iconQuestion :: Markup
+iconQuestion = fontAwesomeIcon "question-circle"
+
+iconNew :: Markup
+iconNew = fontAwesomeIcon "seedling"
+
+iconOK :: Markup
+iconOK = fontAwesomeIcon "check"
+
+iconNotOK :: Markup
+iconNotOK = fontAwesomeIcon "times"
+
+iconWarning :: Markup
+iconWarning = fontAwesomeIcon "exclamation"
+
+iconProblem :: Markup
+iconProblem = fontAwesomeIcon "bolt"
+
+iconHint :: Markup
+iconHint = fontAwesomeIcon "life-ring"
+
+-- Icons for SheetFileType
+iconSolution :: Markup
+iconSolution =fontAwesomeIcon "exclamation-circle"
+
+iconMarking :: Markup
+iconMarking = fontAwesomeIcon "check-circle"
+
+fileDownload :: Markup
+fileDownload = fontAwesomeIcon "file-download"
+
+zipDownload :: Markup
+zipDownload = fontAwesomeIcon "file-archive"
+
+-- Conditional icons
+
isVisible :: Bool -> Markup
-- ^ Display an icon that denotes that something™ is visible or invisible
-isVisible True = [shamlet||]
-isVisible False = [shamlet||]
+isVisible True = fontAwesomeIcon "eye"
+isVisible False = fontAwesomeIcon "eye-slash"
--
-- For documentation on how to avoid these unneccessary functions
-- we implement them here just once for the first icon:
@@ -132,23 +185,28 @@ maybeIsVisibleWidget = toWidget . foldMap isVisible
-- Other _frequently_ used icons:
hasComment :: Bool -> Markup
-- ^ Display an icon that denotes that something™ has a comment or not
-hasComment True = [shamlet||]
-hasComment False = [shamlet||] -- comment-alt-slash is not available for free
+hasComment True = fontAwesomeIcon "comment-alt"
+hasComment False = fontAwesomeIcon "comment-slash" -- comment-alt-slash is not available for free
hasTickmark :: Bool -> Markup
-- ^ Display an icon that denotes that something™ is okay
-hasTickmark True = [shamlet||]
+hasTickmark True = iconOK
hasTickmark False = mempty
isBad :: Bool -> Markup
-- ^ Display an icon that denotes that something™ is bad
-isBad True = [shamlet||] -- or times?!
+isBad True = iconProblem
isBad False = mempty
isNew :: Bool -> Markup
-isNew True = [shamlet||] -- was exclamation
+isNew True = iconNew
isNew False = mempty
+boolSymbol :: Bool -> Markup
+boolSymbol True = iconOK
+boolSymbol False = iconNotOK
+
+
---------------------
-- Text and String --
@@ -156,12 +214,22 @@ isNew False = mempty
-- DEPRECATED: use hasTickmark instead;
-- maybe reinstate if needed for @bewertung.txt@ files
-
-- tickmark :: IsString a => a
-- tickmark = fromString "✔"
+-- | remove all Whitespace from Text
+stripAll :: Text -> Text
+stripAll = Text.filter (not . isSpace)
+
+-- | Convert text as it is to Html, may prevent ambiguous types
+-- This function definition is mainly for documentation purposes
text2Html :: Text -> Html
-text2Html = toHtml -- prevents ambiguous types
+text2Html = toHtml
+
+-- | Convert text as it is to Message, may prevent ambiguous types
+-- This function definition is mainly for documentation purposes
+text2message :: Text -> SomeMessage site
+text2message = SomeMessage
toWgt :: (ToMarkup a, MonadBaseControl IO m, MonadThrow m, MonadIO m)
=> a -> WidgetT site m ()
@@ -180,67 +248,53 @@ str2widget :: (MonadBaseControl IO m, MonadThrow m, MonadIO m)
=> String -> WidgetT site m ()
str2widget s = [whamlet|#{s}|]
-display2widget :: (MonadBaseControl IO m, MonadThrow m, MonadIO m, DisplayAble a)
- => a -> WidgetT site m ()
-display2widget = text2widget . display
-
withFragment :: Monad m => MForm m (a, WidgetT site IO ()) -> Markup -> MForm m (a, WidgetT site IO ())
withFragment form html = flip fmap form $ over _2 (toWidget html >>)
+rationalToFixed :: forall a. HasResolution a => Rational -> Fixed a
+rationalToFixed = MkFixed . round . (* (fromIntegral $ resolution (Proxy :: HasResolution a => Proxy a)))
--- Types that can be converted to Text for direct displayed to User! (Show for debugging, Display for Production)
-{- (not so sure we really want to get rid of display?!) DEPRECATED display "Create RenderMessage Instances instead!" -}
-class DisplayAble a where
- display :: a -> Text
- -- Default definitions for types belonging to Show (allows empty instance declarations)
- default display :: Show a => a -> Text
- display = pack . show
+rationalToFixed3 :: Rational -> Fixed E3
+rationalToFixed3 = rationalToFixed
-instance DisplayAble Text where
- display = id
+-- | Convert `part` and `whole` into percentage including symbol
+-- showing trailing zeroes and to decimal digits
+textPercent :: Real a => a -> a -> Text
+textPercent = textPercent' False 2
-instance DisplayAble String where
- display = pack
+-- | Convert `part` and `whole` into percentage including symbol
+-- `trailZero` shows trailing Zeros, `precision` is number of decimal digits
+textPercent' :: Real a => Bool -> Int -> a -> a -> Text
+textPercent' trailZero precision part whole
+ | precision == 0 = showPercent (frac :: Uni)
+ | precision == 1 = showPercent (frac :: Deci)
+ | precision == 2 = showPercent (frac :: Centi)
+ | precision == 3 = showPercent (frac :: Milli)
+ | precision == 4 = showPercent (frac :: Micro)
+ | otherwise = showPercent (frac :: Pico)
+ where
+ frac :: forall a. HasResolution a => Fixed a
+ frac = rationalToFixed $ (100*) $ toRational part / toRational whole
-instance DisplayAble Int
-instance DisplayAble Int64
-instance DisplayAble Integer
+ showPercent :: HasResolution a => Fixed a -> Text
+ showPercent f = pack $ showFixed trailZero f <> "%"
-instance DisplayAble Rational where
- display r = showFFloat (Just 2) (rat2float r) ""
- & pack
- & dropWhileEnd ('0'==)
- & dropWhileEnd ('.'==)
- where
- rat2float :: Rational -> Double
- rat2float = fromRational
-instance DisplayAble a => DisplayAble (Maybe a) where
- display Nothing = ""
- display (Just x) = display x
-
-instance DisplayAble a => DisplayAble (E.Value a) where
- display = display . E.unValue
-
-instance DisplayAble a => DisplayAble (CI a) where
- display = display . CI.original
-
-{- We do not want DisplayAble for every Show-Class:
- We want to explicitly verify that the resulting text can be displayed to the User!
- For example: UTCTime values were shown without proper format rendering!
-instance {-# OVERLAPPABLE #-} Show a => DisplayAble a where -- The easy way out of UndecidableInstances (TypeFamilies would have been proper, but are much more complicated)
- display = pack . show
--}
-
-textPercent :: Real a => a -> Text -- slow, maybe use Data.Double.Conversion.Text.toFixed instead?
-textPercent x = lz <> pack (show rx) <> "%"
+-- | Convert number of bytes to human readable format
+textBytes :: Integral a => a -> Text
+textBytes x
+ | v < kb = rshow v <> "B"
+ | v < mb = rshow (v/kb) <> "KB"
+ | v < gb = rshow (v/mb) <> "MB"
+ | otherwise = rshow (v/gb) <> "GB"
where
- rx :: Centi
- rx = realToFrac (x * 100)
- lz = if rx < 10.0 then "0" else ""
+ v = fromIntegral x
+ kb = 1024
+ mb = 1024 * kb
+ gb = 1024 * mb
+ rshow :: Double -> Text
+ rshow = tshow . floorToDigits 1
-textPercentInt :: Integral a => a -> a -> Text -- slow, maybe use Data.Double.Conversion.Text.toFixed instead?
-textPercentInt part whole = textPercent $ fromIntegral part % fromIntegral whole
stepTextCounterCI :: CI Text -> CI Text -- find and increment rightmost-number, preserving leading zeroes
stepTextCounterCI = CI.map stepTextCounter
@@ -266,6 +320,55 @@ notUsedT = notUsed
+----------
+-- Bool --
+----------
+
+-- | Logical implication, readable synonym for (<=) which appears the wrong way around
+implies :: Bool -> Bool -> Bool
+implies True x = x
+implies _ _ = True
+
+
+
+-------------
+-- Numeric --
+-------------
+
+-- | round n to nearest multiple of m
+roundToNearestMultiple :: Int -> Int -> Int
+roundToNearestMultiple m n = (n `div` m + 1) * m
+
+roundToDigits :: (RealFrac a, Integral b) => b -> a -> a
+roundToDigits d x = fromInteger (round $ x * prec) / prec
+ where prec = 10^d
+
+floorToDigits :: (RealFrac a, Integral b) => b -> a -> a
+floorToDigits d x = fromInteger (floor $ x * prec) / prec
+ where prec = 10^d
+
+-- | Integral division, but rounded upwards.
+ceilingDiv :: Integral a => a -> a -> a
+ceilingDiv d n = (d+n-1) `div` n
+
+-- | Integral division, rounded to custom digit; convenience function for hamlets
+roundDiv :: (Integral a, Integral b, RealFrac c) => Int -> a -> b -> c
+roundDiv digits numerator denominator
+ = roundToDigits digits $ fromIntegral numerator / fromIntegral denominator
+
+-- | A value between 0 and 1, measuring how close `achieved` is to `full`; 0 meaning very and 1 meaning not at all
+-- `offset` specifies minimum result value, unless the goal is already achieved )i.e. full <= max(0,achieved)
+-- Useful for heat maps, with offset giving a visual step between completed and not yet completed
+cutOffPercent :: Double -> Double -> Double -> Double
+cutOffPercent offset full achieved
+ | full <= achieved = 0
+ | full <= 0 = 0
+ | otherwise = offset + (1-offset) * (1 - percent)
+ where
+ percent = achieved / full
+
+
+
------------
-- Monoid --
------------
@@ -307,6 +410,25 @@ lastMaybe' :: [a] -> Maybe a
lastMaybe' l = fmap snd $ l ^? _Snoc
+-- | Merge/Add any attribute-value pair to an existing list of such pairs.
+-- If the attribute exists, the new valu will be prepended, separated by a single empty space
+-- Also see `Utils.mergeAttrs`
+insertAttr :: Text -> Text -> [(Text,Text)] -> [(Text,Text)]
+insertAttr attr valu = aux
+ where
+ aux :: [(Text,Text)] -> [(Text,Text)]
+ aux [] = [(attr,valu)]
+ aux (p@(a,v) : t)
+ | attr==a = (a, Text.append valu $ Text.cons ' ' v) : t
+ | otherwise = p : aux t
+
+-- | Add another class attribute; special function for a frequent case to avoid mistyping "class".
+-- Also see `Utils.insertAttrs`
+insertClass :: Text -> [(Text,Text)] -> [(Text,Text)]
+insertClass = insertAttr "class"
+
+-- | Append two lists of attributes, merging the class attribute only.
+-- Also see `Utils.insertAttr` to merge any attribute
mergeAttrs :: [(Text, Text)] -> [(Text, Text)] -> [(Text, Text)]
mergeAttrs = mergeAttrs' `on` sort
where
@@ -334,6 +456,9 @@ partitionWith f (x:xs) = case f x of
Right c -> (bs, c:cs)
where (bs,cs) = partitionWith f xs
+nonEmpty' :: Alternative f => [a] -> f (NonEmpty a)
+nonEmpty' = maybe empty pure . nonEmpty
+
----------
-- Sets --
----------
@@ -343,7 +468,8 @@ setIntersections :: Ord a => [Set a] -> Set a
setIntersections [] = Set.empty
setIntersections (h:t) = foldl' Set.intersection h t
-
+setMapMaybe :: (Ord a, Ord b) => (a -> Maybe b) -> Set a -> Set b
+setMapMaybe f = Set.fromList . mapMaybe f . Set.toList
----------
-- Maps --
@@ -364,6 +490,11 @@ partMap = Map.fromListWith mappend
invertMap :: (Ord k, Ord v) => Map k v -> Map v (Set k)
invertMap = groupMap . map swap . Map.toList
+-- | Counts how often a value appears in a map (not derived from invertMap for efficiency reasons)
+countMapElems :: (Ord v) => Map k v -> Map v Int
+countMapElems = Map.fromListWith (+) . map (\(_k,v)->(v,1)) . Map.toList
+
+
---------------
-- Functions --
@@ -391,10 +522,12 @@ toNothing = const Nothing
toNothingS :: String -> Maybe b
toNothingS = const Nothing
-maybeAdd :: Num a => Maybe a -> Maybe a -> Maybe a -- treats Nothing as neutral/zero, unlike fmap/ap
-maybeAdd (Just x) (Just y) = Just (x + y)
-maybeAdd Nothing y = y
-maybeAdd x Nothing = x
+-- | Swap 'Nothing' for 'Just' and vice versa
+-- This belongs into Module 'Utils' but we have a weird cyclic
+-- dependency
+flipMaybe :: b -> Maybe a -> Maybe b
+flipMaybe x Nothing = Just x
+flipMaybe _ (Just _) = Nothing
-- | Deep alternative to avoid any occurrence of Nothing at all costs, left-biased
deepAlt :: Maybe (Maybe a) -> Maybe (Maybe a) -> Maybe (Maybe a)
@@ -441,6 +574,12 @@ mcons :: Maybe a -> [a] -> [a]
mcons Nothing xs = xs
mcons (Just x) xs = x:xs
+-- | apply binary function to maybes, but ignores Nothing by using id if possible, unlike fmap/ap
+ignoreNothing :: (a -> a -> a) -> Maybe a -> Maybe a -> Maybe a
+ignoreNothing _ Nothing y = y
+ignoreNothing _ x Nothing = x
+ignoreNothing f (Just x) (Just y) = Just $ f x y
+
newtype NTop a = NTop { nBot :: a } -- treat Nothing as Top for Ord (Maybe a); default implementation treats Nothing as bottom
instance Eq a => Eq (NTop (Maybe a)) where
@@ -459,6 +598,12 @@ formResultToMaybe :: Alternative m => FormResult a -> m a
formResultToMaybe (FormSuccess x) = pure x
formResultToMaybe _ = empty
+maybeThrow :: (MonadThrow m, Exception e) => e -> Maybe a -> m a
+maybeThrow exc = maybe (throwM exc) return
+
+maybeThrowM :: (MonadThrow m, Exception e) => m e -> Maybe a -> m a
+maybeThrowM excM = maybe (throwM =<< excM) return
+
------------
-- Either --
------------
@@ -490,6 +635,12 @@ maybeExceptT err act = lift act >>= maybe (throwE err) return
maybeMExceptT :: Monad m => m e -> m (Maybe b) -> ExceptT e m b
maybeMExceptT err act = lift act >>= maybe (lift err >>= throwE) return
+maybeTExceptT :: Monad m => e -> MaybeT m b -> ExceptT e m b
+maybeTExceptT err act = maybeExceptT err $ runMaybeT act
+
+maybeTMExceptT :: Monad m => m e -> MaybeT m b -> ExceptT e m b
+maybeTMExceptT err act = maybeMExceptT err $ runMaybeT act
+
whenExceptT :: Monad m => Bool -> e -> ExceptT e m ()
whenExceptT b err = when b $ throwE err
@@ -547,7 +698,7 @@ ifM c m m' =
ifNotM :: Monad m => m Bool -> m a -> m a -> m a
ifNotM c = flip $ ifM c
--- | Monadic boolean function, copied from Andreas Abel's utility function
+-- | Short-circuiting monadic boolean function, copied from Andreas Abel's utility function
and2M, or2M :: Monad m => m Bool -> m Bool -> m Bool
and2M ma mb = ifM ma mb (return False)
or2M ma = ifM ma (return True)
@@ -556,6 +707,7 @@ andM, orM :: (Foldable f, Monad m) => f (m Bool) -> m Bool
andM = Fold.foldr and2M (return True)
orM = Fold.foldr or2M (return False)
+-- | Short-circuiting monady any
allM, anyM :: (Functor f, Foldable f, Monad m) => f a -> (a -> m Bool) -> m Bool
allM xs f = andM $ fmap f xs
anyM xs f = orM $ fmap f xs
@@ -590,6 +742,16 @@ mconcatForM = flip mconcatMapM
findM :: (Monad m, Foldable f) => (a -> MaybeT m b) -> f a -> m (Maybe b)
findM f = runMaybeT . Fold.foldr (\x as -> f x <|> as) mzero
+-------------
+-- Conduit --
+-------------
+
+peekN :: (Integral n, Monad m) => n -> Consumer a m [a]
+peekN n = do
+ peeked <- catMaybes <$> replicateM (fromIntegral n) await
+ mapM_ leftover peeked
+ return peeked
+
-----------------
-- Alternative --
-----------------
@@ -606,6 +768,15 @@ choice = asum --
-- Sessions --
--------------
+data SessionKey = SessionActiveAuthTags | SessionInactiveAuthTags
+ | SessionNewStudyTerms
+ | SessionBearer
+ deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
+instance Universe SessionKey
+instance Finite SessionKey
+
+nullaryPathPiece ''SessionKey $ camelToPathPiece' 1
+
setSessionJson :: (PathPiece k, ToJSON v, MonadHandler m) => k -> v -> m ()
setSessionJson (toPathPiece -> key) (LBS.toStrict . Aeson.encode -> val) = setSessionBS key val
@@ -629,10 +800,10 @@ takeSessionJson key = lookupSessionJson key <* deleteSession (toPathPiece key)
-- Moved to Utils.Parameters
---------------------------------
--- Custom HTTP Request-Headers --
+-- Custom HTTP Headers --
---------------------------------
-data CustomHeader = HeaderIsModal | HeaderDBTableShortcircuit
+data CustomHeader = HeaderIsModal | HeaderDBTableShortcircuit | HeaderMassInputShortcircuit | HeaderAlerts
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
instance Universe CustomHeader
@@ -640,11 +811,42 @@ instance Finite CustomHeader
nullaryPathPiece ''CustomHeader (intercalate "-" . drop 1 . splitCamel)
lookupCustomHeader :: (MonadHandler m, PathPiece result) => CustomHeader -> m (Maybe result)
-lookupCustomHeader ident = (>>= fromPathPiece . decodeUtf8) <$> lookupHeader (CI.mk . encodeUtf8 $ toPathPiece ident)
+lookupCustomHeader ident = (=<<) (fromPathPiece <=< either (const Nothing) Just . Text.decodeUtf8') <$> lookupHeader (CI.mk . encodeUtf8 $ toPathPiece ident)
hasCustomHeader :: MonadHandler m => CustomHeader -> m Bool
hasCustomHeader ident = isJust <$> lookupHeader (CI.mk . encodeUtf8 $ toPathPiece ident)
+addCustomHeader, replaceOrAddCustomHeader :: (MonadHandler m, PathPiece payload) => CustomHeader -> payload -> m ()
+addCustomHeader ident payload = addHeader (toPathPiece ident) (toPathPiece payload)
+replaceOrAddCustomHeader ident payload = replaceOrAddHeader (toPathPiece ident) (toPathPiece payload)
+
+------------------
+-- HTTP Headers --
+------------------
+
+data ContentDisposition = ContentInline | ContentAttachment
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+instance Universe ContentDisposition
+instance Finite ContentDisposition
+nullaryPathPiece ''ContentDisposition $ camelToPathPiece' 1
+
+setContentDisposition :: MonadHandler m => ContentDisposition -> Maybe FilePath -> m ()
+-- ^ Set a @Content-Disposition@-header using `replaceOrAddHeader`
+--
+-- Takes care of correct formatting and encoding of non-ascii filenames
+setContentDisposition cd (fmap pack -> mFName) = replaceOrAddHeader "Content-Disposition" headerVal
+ where
+ headerVal
+ | Just fName <- mFName
+ , Text.all isAscii fName
+ , Text.all (not . flip elem ['"', '\\']) fName
+ = [st|#{toPathPiece cd}; filename="#{fName}"|]
+ | Just fName <- mFName
+ = let encoded = decodeUtf8 . urlEncode True $ encodeUtf8 fName
+ in [st|#{toPathPiece cd}; filename*=UTF-8''#{encoded}|]
+ | otherwise
+ = toPathPiece cd
+
------------------
-- Cryptography --
------------------
@@ -696,7 +898,7 @@ encodedSecretBoxOpen' :: (FromJSON a, MonadError EncodedSecretBoxException m)
=> SecretBox.Key
-> Text -> m a
encodedSecretBoxOpen' sKey chunked = do
- let unchunked = Text.filter (not . isSpace) chunked
+ let unchunked = stripAll chunked
decoded <- either (throwError . EncodedSecretBoxInvalidBase64) return . Base64.decode $ encodeUtf8 unchunked
unless (BS.length decoded >= Saltine.secretBoxNonce + Saltine.secretBoxMac) $
@@ -730,3 +932,54 @@ encodedSecretBoxOpen :: ( FromJSON a, MonadError EncodedSecretBoxException m, Mo
encodedSecretBoxOpen ciphertext = do
sKey <- secretBoxKey
encodedSecretBoxOpen' sKey ciphertext
+
+-------------
+-- Caching --
+-------------
+
+cachedByBinary :: (Binary a, Typeable b, MonadHandler m) => a -> m b -> m b
+cachedByBinary k = cachedBy (toStrict $ Binary.encode k)
+
+cachedHere :: Q Exp
+cachedHere = do
+ loc <- location
+ [e| cachedByBinary loc |]
+
+cachedHereBinary :: Q Exp
+cachedHereBinary = do
+ loc <- location
+ [e| \k -> cachedByBinary (loc, k) |]
+
+hashToText :: Hashable a => a -> Text
+hashToText = decodeUtf8 . Base64.encode . toStrict . Binary.encode . hash
+
+setEtagHashable, setWeakEtagHashable :: (MonadHandler m, Hashable a) => a -> m ()
+setEtagHashable = setEtag . hashToText
+setWeakEtagHashable = setEtag . hashToText
+
+setLastModified :: (MonadHandler m, MonadLogger m) => UTCTime -> m ()
+setLastModified lastModified = do
+ rMethod <- requestMethod <$> waiRequest
+
+ when (rMethod `elem` safeMethods) $ do
+ ifModifiedSince <- (=<<) (parseTimeM True defaultTimeLocale "%a, %d %b %Y %X %Z" . unpack <=< either (const Nothing) Just . Text.decodeUtf8') <$> lookupHeader "If-Modified-Since"
+ $logDebugS "LastModified" $ tshow (lastModified, ifModifiedSince)
+ when (maybe False ((lastModified <=) . addUTCTime precision) ifModifiedSince)
+ notModified
+
+ addHeader "Last-Modified" $ formatRFC1123 lastModified
+ where
+ precision :: NominalDiffTime
+ precision = 1
+
+ safeMethods = [ methodGet, methodHead, methodOptions ]
+
+--------------
+-- Lattices --
+--------------
+
+foldJoin :: (MonoFoldable mono, BoundedJoinSemiLattice (Element mono)) => mono -> Element mono
+foldJoin = foldr (\/) bottom
+
+foldMeet :: (MonoFoldable mono, BoundedMeetSemiLattice (Element mono)) => mono -> Element mono
+foldMeet = foldr (/\) top
diff --git a/src/Utils/DB.hs b/src/Utils/DB.hs
index cb8b80d4e..de6a3c0fa 100644
--- a/src/Utils/DB.hs
+++ b/src/Utils/DB.hs
@@ -10,8 +10,13 @@ import qualified Data.Set as Set
import qualified Database.Esqueleto as E
-- import Database.Persist -- currently not needed here
-emptyOrIn :: PersistField typ =>
- E.SqlExpr (E.Value typ) -> Set typ -> E.SqlExpr (E.Value Bool)
+import Utils
+
+
+
+
+emptyOrIn :: PersistField typ
+ => E.SqlExpr (E.Value typ) -> Set typ -> E.SqlExpr (E.Value Bool)
emptyOrIn criterion testSet
| Set.null testSet = E.val True
| otherwise = criterion `E.in_` E.valList (Set.toList testSet)
@@ -20,27 +25,44 @@ entities2map :: PersistEntity record => [Entity record] -> Map (Key record) reco
entities2map = foldl' (\m entity -> Map.insert (entityKey entity) (entityVal entity) m) Map.empty
getKeyBy :: (PersistEntityBackend record ~ BaseBackend backend, PersistEntity record, PersistUniqueRead backend, MonadIO m)
- => Unique record -> ReaderT backend m (Maybe (Key record))
+ => Unique record -> ReaderT backend m (Maybe (Key record))
getKeyBy u = fmap entityKey <$> getBy u -- TODO optimize this, so that DB does not deliver entire record!
getKeyBy404 :: (PersistEntityBackend record ~ BaseBackend backend, PersistEntity record, PersistUniqueRead backend, MonadIO m)
- => Unique record -> ReaderT backend m (Key record)
+ => Unique record -> ReaderT backend m (Key record)
getKeyBy404 = fmap entityKey . getBy404 -- TODO optimize this, so that DB does not deliver entire record!
existsBy :: (PersistEntityBackend record ~ BaseBackend backend, PersistEntity record, PersistUniqueRead backend, MonadIO m)
- => Unique record -> ReaderT backend m Bool
+ => Unique record -> ReaderT backend m Bool
existsBy = fmap isJust . getBy -- TODO optimize, so that DB does not deliver entire record
existsKey :: (PersistEntityBackend record ~ BaseBackend backend, PersistEntity record, PersistStoreRead backend, MonadIO m)
- => Key record -> ReaderT backend m Bool
+ => Key record -> ReaderT backend m Bool
existsKey = fmap isJust . get -- TODO optimize, so that DB does not deliver entire record
-myReplaceUnique -- Identical to Database.Persist.Class, except for the better type signature (original requires Eq record which is not needed anyway)
- :: (MonadIO m
- ,Eq (Unique record)
- ,PersistRecordBackend record backend
- ,PersistUniqueWrite backend)
- => Key record -> record -> ReaderT backend m (Maybe (Unique record))
+updateBy :: (PersistUniqueRead backend, PersistStoreWrite backend, MonadIO m, PersistRecordBackend record backend )
+ => Unique record -> [Update record] -> ReaderT backend m ()
+updateBy uniq updates = do
+ key <- getKeyBy uniq
+ for_ key $ flip update updates
+
+-- | Like 'myReplaceUnique' or 'replaceUnique' but with reversed result: returns 'Nothing' if the replacement was not possible,
+-- and 'Just key' for the successfully replaced record
+uniqueReplace :: ( MonadIO m
+ , Eq (Unique record)
+ , PersistRecordBackend record backend
+ , PersistUniqueWrite backend
+ )
+ => Key record -> record -> ReaderT backend m (Maybe (Key record))
+uniqueReplace key datumNew = flipMaybe key <$> myReplaceUnique key datumNew
+
+-- | Identical to 'Database.Persist.Class', except for the better type signature (original requires Eq record which is not needed anyway)
+myReplaceUnique :: ( MonadIO m
+ , Eq (Unique record)
+ , PersistRecordBackend record backend
+ , PersistUniqueWrite backend
+ )
+ => Key record -> record -> ReaderT backend m (Maybe (Unique record))
myReplaceUnique key datumNew = getJust key >>= replaceOriginal
where
uniqueKeysNew = persistUniqueKeys datumNew
@@ -53,12 +75,12 @@ myReplaceUnique key datumNew = getJust key >>= replaceOriginal
changedKeys = uniqueKeysNew List.\\ uniqueKeysOriginal
uniqueKeysOriginal = persistUniqueKeys original
-checkUniqueKeys
- :: (MonadIO m
- ,PersistEntity record
- ,PersistUniqueRead backend
- ,PersistRecordBackend record backend)
- => [Unique record] -> ReaderT backend m (Maybe (Unique record))
+checkUniqueKeys :: ( MonadIO m
+ , PersistEntity record
+ , PersistUniqueRead backend
+ , PersistRecordBackend record backend
+ )
+ => [Unique record] -> ReaderT backend m (Maybe (Unique record))
checkUniqueKeys [] = return Nothing
checkUniqueKeys (x:xs) = do
y <- getBy x
diff --git a/src/Utils/DateTime.hs b/src/Utils/DateTime.hs
index 0b5855566..3f66c65ee 100644
--- a/src/Utils/DateTime.hs
+++ b/src/Utils/DateTime.hs
@@ -1,5 +1,4 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-{-# OPTIONS_GHC -fno-warn-orphans #-}
module Utils.DateTime
( timeLocaleMap
@@ -14,10 +13,9 @@ module Utils.DateTime
import ClassyPrelude.Yesod hiding (lift)
import System.Locale.Read
-import Data.Time (TimeZone(..), TimeLocale(..))
+import Data.Time (TimeLocale(..))
import Data.Time.Zones (TZ)
import Data.Time.Zones.TH (includeSystemTZ)
-import Data.Time.Clock.POSIX
import Language.Haskell.TH
import Language.Haskell.TH.Syntax (Lift(..))
@@ -35,11 +33,8 @@ import Data.Aeson.TH
import Utils.PathPiece
-deriving instance Lift TimeZone
-deriving instance Lift TimeLocale
-
-instance Hashable UTCTime where
- hashWithSalt s = hashWithSalt s . toRational . utcTimeToPOSIXSeconds
+import Data.Time.Format.Instances ()
+
-- $(timeLocaleMap _) :: [Lang] -> TimeLocale
timeLocaleMap :: [(Lang, String)] -- ^ Languages and matching locales, first is taken as default
@@ -91,7 +86,7 @@ instance Finite SelDateTimeFormat
instance Hashable SelDateTimeFormat
deriveJSON defaultOptions
- { constructorTagModifier = intercalate "-" . map toLower . drop 2 . splitCamel
+ { constructorTagModifier = camelToPathPiece' 2
} ''SelDateTimeFormat
instance ToJSONKey SelDateTimeFormat where
diff --git a/src/Utils/Form.hs b/src/Utils/Form.hs
index 96dec5423..8ada2cc6d 100644
--- a/src/Utils/Form.hs
+++ b/src/Utils/Form.hs
@@ -1,3 +1,4 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# OPTIONS_GHC -fno-warn-deprecations #-}
module Utils.Form where
@@ -8,7 +9,7 @@ import Settings
import Utils.Parameters
--- import Text.Blaze (toMarkup) -- for debugging
+import Text.Blaze (Markup)
import qualified Text.Blaze.Internal as Blaze (null)
import qualified Data.Text as T
@@ -23,6 +24,11 @@ import qualified Data.Set as Set
import Control.Monad.Trans.Maybe (MaybeT(..))
import Control.Monad.Reader.Class (MonadReader(..))
import Control.Monad.Writer.Class (MonadWriter(..))
+import Control.Monad.State.Class (MonadState(..))
+import Control.Monad.Trans.RWS (RWST, execRWST, mapRWST)
+import Control.Monad.Trans.Except (ExceptT, runExceptT)
+import Control.Monad.Fix (MonadFix)
+import Control.Monad.Morph (MFunctor(..))
import Data.List ((!!))
@@ -32,12 +38,21 @@ import Web.PathPieces
import Data.UUID
-import Utils.Message
-import Utils.PathPiece
-import Utils.Route
+import Data.Ratio ((%))
+import Data.Fixed
+import Data.Scientific
+
+import Utils
+-- import Utils.Message
+-- import Utils.PathPiece
+-- import Utils.Route
import Data.Proxy
+import Text.HTML.SanitizeXSS (sanitizeBalance)
+import Text.Blaze (preEscapedText)
+import Text.Blaze.Html.Renderer.Pretty (renderHtml)
+
@@ -81,23 +96,16 @@ fslpI lbl placeholder
, fsAttrs = [("placeholder", placeholder)]
}
+
+-- NOTE: see Utils.insertAttrs for inserting/merging generic [[(Text,Text)] attributes
+
addAttr :: Text -> Text -> FieldSettings site -> FieldSettings site
-addAttr attr valu fs = fs { fsAttrs = newAttrs $ fsAttrs fs }
- where
- newAttrs :: [(Text,Text)] -> [(Text,Text)]
- newAttrs [] = [(attr, valu)]
- newAttrs (p@(a,v) : t)
- | attr==a = (a, T.append valu $ cons ' ' v) : t
- | otherwise = p : newAttrs t
+addAttr attr valu fs = fs { fsAttrs = insertAttr attr valu $ fsAttrs fs }
addAttrs :: Text -> [Text] -> FieldSettings site -> FieldSettings site
-addAttrs attr valus fs = fs { fsAttrs = newAttrs $ fsAttrs fs }
+addAttrs attr valus fs = fs { fsAttrs = insertAttr attr valu $ fsAttrs fs }
where
- newAttrs :: [(Text, Text)] -> [(Text, Text)]
- newAttrs [] = [(attr, T.intercalate " " valus)]
- newAttrs (p@(a,v) : t)
- | attr==a = ( a, T.intercalate " " $ v : valus ) : t
- | otherwise = p : newAttrs t
+ valu = T.intercalate " " valus
addPlaceholder :: Text -> FieldSettings site -> FieldSettings site
addPlaceholder placeholder fs = fs { fsAttrs = (placeholderAttr, placeholder) : filter ((/= placeholderAttr) . fst) (fsAttrs fs) }
@@ -158,7 +166,12 @@ inputReadonly :: FieldSettings site -> FieldSettings site
inputReadonly = addAttr "readonly" ""
addAutosubmit :: FieldSettings site -> FieldSettings site
-addAutosubmit = addAttr "data-autosubmit" ""
+addAutosubmit = addAttr "uw-auto-submit-input" ""
+
+-- | Asynchronous Submit, e.g. use with forms in modals
+asyncSubmitAttr :: (Text,Text)
+asyncSubmitAttr = ("uw-async-form", "")
+
------------------------------------------------
-- Unique Form Identifiers to avoid accidents --
@@ -168,6 +181,7 @@ data FormIdentifier
= FIDcourse
| FIDcourseRegister
| FIDsheet
+ | FIDmaterial
| FIDsubmission
| FIDsettings
| FIDcorrectors
@@ -187,8 +201,12 @@ data FormIdentifier
| FIDCourseRegister
| FIDuserRights
| FIDcUserNote
+ | FIDcRegField
+ | FIDcRegButton
| FIDAdminDemo
| FIDUserDelete
+ | FIDCommunication
+ | FIDAssignSubmissions
deriving (Eq, Ord, Read, Show)
instance PathPiece FormIdentifier where
@@ -343,21 +361,26 @@ combinedButtonFieldF_ _ = void . combinedButtonFieldF @m @a
submitButton :: (Button (HandlerSite m) ButtonSubmit, MonadHandler m) => AForm m ()
submitButton = combinedButtonFieldF_ (Proxy @ButtonSubmit) ""
-autosubmitButton :: (Button (HandlerSite m) ButtonSubmit, MonadHandler m) => AForm m ()
-autosubmitButton = combinedButtonFieldF_ (Proxy @ButtonSubmit) $ "" & addAutosubmit
-
-- | just Html for a Submit-Button
submitButtonView :: forall site . Button site ButtonSubmit => WidgetT site IO ()
-submitButtonView = do
- let bField :: Field (HandlerT site IO) ButtonSubmit
- bField = buttonField BtnSubmit
+submitButtonView = buttonView BtnSubmit
+
+buttonView :: forall site button. Button site button => button -> WidgetT site IO ()
+buttonView btn = do
+ let bField :: Field (HandlerT site IO) button
+ bField = buttonField btn
btnId <- newIdent
- fieldView bField btnId "" mempty (Right BtnSubmit) False
+ fieldView bField btnId "" mempty (Right btn) False
+-- | generate a form that only shows a finite amount of buttons
buttonForm :: (Button site a, Finite a) => Html -> MForm (HandlerT site IO) (FormResult a, WidgetT site IO ())
-buttonForm csrf = do
- (res, ($ []) -> fViews) <- aFormToForm . disambiguateButtons $ combinedButtonFieldF ""
+buttonForm = buttonForm' universeF
+
+-- | like `buttonForm`, but for a given list of buttons, i.e. a subset or for buttons outside the Finite typeclass
+buttonForm' :: Button site a => [a] -> Html -> MForm (HandlerT site IO) (FormResult a, WidgetT site IO ())
+buttonForm' btns csrf = do
+ (res, ($ []) -> fViews) <- aFormToForm . disambiguateButtons $ combinedButtonField btns ""
return (res, [whamlet|
$newline never
#{csrf}
@@ -365,6 +388,34 @@ buttonForm csrf = do
^{fvInput bView}
|])
+-- | buttons-only form complete with widget-generation and evaluation; return type determines buttons shown.
+runButtonForm ::(PathPiece ident, Eq ident, RenderMessage site FormMessage,
+ Button site ButtonSubmit, Button site a, Finite a)
+ => ident -> HandlerT site IO (WidgetT site IO (), Maybe a)
+runButtonForm fid = do
+ currentRoute <- getCurrentRoute
+ ((btnResult, btnWdgt), btnEnctype) <- runFormPost $ identifyForm fid buttonForm
+ let btnForm = wrapForm btnWdgt def { formAction = SomeRoute <$> currentRoute
+ , formEncoding = btnEnctype
+ , formSubmit = FormNoSubmit
+ }
+ res <- formResultMaybe btnResult (return . Just)
+ return (btnForm, res)
+
+-- | like `runButtonForm` but showing only a given list of buttons, especially for buttons that are not in the Finite typeclass.
+runButtonForm' ::(PathPiece ident, Eq ident, RenderMessage site FormMessage,
+ Button site ButtonSubmit, Button site a)
+ => [a] -> ident -> HandlerT site IO (WidgetT site IO (), Maybe a)
+runButtonForm' btns fid = do
+ currentRoute <- getCurrentRoute
+ ((btnResult, btnWdgt), btnEnctype) <- runFormPost $ identifyForm fid $ buttonForm' btns
+ let btnForm = wrapForm btnWdgt def { formAction = SomeRoute <$> currentRoute
+ , formEncoding = btnEnctype
+ , formSubmit = FormNoSubmit
+ }
+ res <- formResultMaybe btnResult (return . Just)
+ return (btnForm, res)
+
-------------------
-- Custom Fields --
@@ -440,6 +491,82 @@ optionsFinite = do
}
return . mkOptionList $ mkOption <$> universeF
+fractionalField :: forall m a.
+ ( RealFrac a
+ , Monad m
+ , RenderMessage (HandlerSite m) FormMessage
+ ) => Field m a
+-- | Form `Field` for any `Fractional` number
+--
+-- Use more specific `Field`s (i.e. `fixedPrecField`) whenever they exist
+fractionalField = Field{..}
+ where
+ scientific' :: Iso' a Scientific
+ scientific' = iso (fromRational . toRational) (fromRational . toRational)
+
+ fieldEnctype = UrlEncoded
+ fieldView theId name attrs (fmap $ view scientific' -> val) isReq
+ = [whamlet|
+ $newline never
+
+ |]
+ fieldParse = parseHelper $ \t ->
+ maybe (Left $ MsgInvalidNumber t) (Right . review scientific') (readMay t :: Maybe Scientific)
+
+fixedPrecField :: forall m p.
+ ( Monad m
+ , RenderMessage (HandlerSite m) FormMessage
+ , HasResolution p
+ ) => Field m (Fixed p)
+fixedPrecField = Field{..}
+ where
+ resolution' :: Integer
+ resolution' = resolution $ Proxy @p
+
+ step = showFixed True (fromRational $ 1 % resolution' :: Fixed p)
+
+ fieldEnctype = UrlEncoded
+ fieldView theId name attrs val isReq
+ = [whamlet|
+ $newline never
+
+ |]
+ fieldParse = parseHelper $ \t -> do
+ sci <- maybe (Left $ MsgInvalidNumber t) Right (readMay t :: Maybe Scientific)
+ return . fromRational $ round (sci * fromIntegral resolution') % resolution'
+
+rationalField :: (MonadHandler m, RenderMessage (HandlerSite m) FormMessage) => Field m Rational
+rationalField = fractionalField
+
+data SecretJSONFieldException = SecretJSONFieldDecryptFailure
+ deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
+instance Exception SecretJSONFieldException
+
+secretJsonField :: ( ToJSON a, FromJSON a
+ , MonadHandler m
+ , MonadSecretBox (ExceptT EncodedSecretBoxException m)
+ , MonadSecretBox (WidgetT (HandlerSite m) IO)
+ , RenderMessage (HandlerSite m) FormMessage
+ , RenderMessage (HandlerSite m) SecretJSONFieldException
+ )
+ => Field m a
+secretJsonField = Field{..}
+ where
+ fieldParse [v] [] = bimap (\_ -> SomeMessage SecretJSONFieldDecryptFailure) Just <$> runExceptT (encodedSecretBoxOpen v)
+ fieldParse [] [] = return $ Right Nothing
+ fieldParse _ _ = return . Left $ SomeMessage MsgValueRequired
+ fieldView theId name attrs val _isReq = do
+ val' <- traverse (encodedSecretBox SecretBoxShort) val
+ [whamlet|
+
+ |]
+ fieldEnctype = UrlEncoded
+
+htmlFieldSmall :: forall m. (Monad m, RenderMessage (HandlerSite m) FormMessage) => Field m Html
+htmlFieldSmall = checkMMap sanitize (pack . renderHtml) textField
+ where
+ sanitize :: Text -> m (Either FormMessage Html)
+ sanitize = return . Right . preEscapedText . sanitizeBalance
-----------
-- Forms --
@@ -470,8 +597,11 @@ instance Default (FormSettings site) where
, formAnchor = Nothing :: Maybe Text
}
-wrapForm :: (Button site ButtonSubmit) => WidgetT site IO () -> FormSettings site -> WidgetT site IO ()
-wrapForm formWidget FormSettings{..} = do
+wrapForm :: Button site ButtonSubmit => WidgetT site IO () -> FormSettings site -> WidgetT site IO ()
+wrapForm = wrapForm' BtnSubmit
+
+wrapForm' :: Button site button => button -> WidgetT site IO () -> FormSettings site -> WidgetT site IO ()
+wrapForm' btn formWidget FormSettings{..} = do
formId <- maybe newIdent (return . toPathPiece) formAnchor
formActionUrl <- traverse toTextUrl formAction
$(widgetFile "widgets/form/form")
@@ -490,12 +620,21 @@ renderAForm formLayout aform fragment = do
let widget = $(widgetFile "widgets/aform/aform")
return (res, widget)
+renderWForm :: MonadHandler m => FormLayout -> WForm m (FormResult a) -> -- Form a -- (Synonym unavailable here)
+ (Markup -> MForm m (FormResult a, WidgetT (HandlerSite m) IO ()))
+renderWForm formLayout = renderAForm formLayout . wFormToAForm
+
-- | special id to identify form section headers, see 'aformSection' and 'formSection'
-- currently only treated by form generation through 'renderAForm'
idFormSectionNoinput :: Text
idFormSectionNoinput = "form-section-noinput"
+-- | special id to identify form messages, see 'aformMessage' and 'formMessage'
+-- currently only treated by form generation through 'renderAForm'
+idFormMessageNoinput :: Text
+idFormMessageNoinput = "form-message-noinput"
+
-- | Generates a form having just a form-section-header and no input title.
-- Currently only correctly rendered by 'renderAForm' and mforms using 'widget/form.hamlet'
-- Usage:
@@ -510,7 +649,10 @@ idFormSectionNoinput = "form-section-noinput"
aformSection :: (MonadHandler m, site ~ HandlerSite m, RenderMessage site msg) => msg -> AForm m ()
aformSection = formToAForm . fmap (second pure) . formSection
-formSection :: (MonadHandler m, site ~ HandlerSite m, RenderMessage site msg) => msg -> MForm m (FormResult (), FieldView site) -- TODO: WIP, delete
+wformSection :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => msg -> WForm m ()
+wformSection = void . aFormToWForm . aformSection
+
+formSection :: (MonadHandler m, site ~ HandlerSite m, RenderMessage site msg) => msg -> MForm m (FormResult (), FieldView site)
formSection formSectionTitle = do
mr <- getMessageRender
return (FormSuccess (), FieldView
@@ -522,8 +664,6 @@ formSection formSectionTitle = do
, fvInput = mempty
})
-
-
-------------------
-- Special Forms --
-------------------
@@ -542,6 +682,33 @@ formSection' formSectionTitleSettings = mreq noinputField sectionSettings Nothin
sectionSettings = formSectionTitleSettings { fsId = Just idFormSectionNoinput }
+-- | Similar to aformSection, generates a form having just a view widget, but no input.
+-- Currently only correctly rendered by 'renderAForm' and mforms using 'widget/form.hamlet'
+-- Usage:
+-- @
+-- (,) <$ formMessage (Message Info html1)
+-- <*> areq intField "int here" Nothing
+-- <* formSection (Message Warning html2)
+-- <*> areq doubleField "double there " Nothing
+-- <* submitButton
+-- @
+
+aformMessage :: (MonadHandler m) => Message -> AForm m ()
+aformMessage = formToAForm . fmap (second pure) . formMessage
+
+wformMessage :: (MonadHandler m) => Message -> WForm m ()
+wformMessage = void . aFormToWForm . aformMessage
+
+formMessage :: (MonadHandler m) => Message -> MForm m (FormResult (), FieldView site)
+formMessage Message{..} = do
+ return (FormSuccess (), FieldView
+ { fvLabel = mempty
+ , fvTooltip = Nothing
+ , fvId = idFormMessageNoinput
+ , fvErrors = Nothing
+ , fvRequired = False
+ , fvInput = [whamlet|#{messageContent}|]
+ })
---------------------
-- Form evaluation --
@@ -567,7 +734,11 @@ formFailure errs' = do
mr <- getMessageRender
return . FormFailure $ map mr errs'
+-- | Turn form errors into alerts, but otherwise do nothing at all
+formFailure2Alerts :: MonadHandler m => FormResult a -> m ()
+formFailure2Alerts = flip formResult $ const $ return ()
+-- | Turns errors into alerts, ignores missing forms and applies processing function
formResult :: MonadHandler m => FormResult a -> (a -> m ()) -> m ()
formResult res f = void . formResultMaybe res $ \x -> Nothing <$ f x
@@ -582,18 +753,25 @@ formResult' (FormFailure _) = Nothing
formResult' (FormSuccess x) = Just x
runInputGetMaybe, runInputPostMaybe, runInputMaybe :: MonadHandler m => FormInput m a -> m (Maybe a)
-runInputGetMaybe form = do
- res <- runInputGetResult form
- return $ case res of
- FormSuccess suc -> Just suc
- _other -> Nothing
-runInputPostMaybe form = do
- res <- runInputPostResult form
- return $ case res of
- FormSuccess suc -> Just suc
- _other -> Nothing
+runInputGetMaybe = fmap formResult' . runInputGetResult
+runInputPostMaybe = fmap formResult' . runInputPostResult
runInputMaybe form = runMaybeT $ MaybeT (runInputPostMaybe form) <|> MaybeT (runInputGetMaybe form)
+runInputResult :: MonadHandler m => FormInput m a -> m (FormResult a)
+runInputResult form = do
+ postRes <- runInputPostResult form
+ getRes <- runInputGetResult form
+ return $ case (postRes, getRes) of
+ (FormSuccess a, _) -> FormSuccess a
+ (_, FormSuccess b) -> FormSuccess b
+ (postRes', _) -> postRes'
+
+runInput :: (MonadHandler m, RenderMessage (HandlerSite m) FormMessage) => FormInput m a -> m a
+runInput = runInputResult >=> \case
+ FormFailure errs -> invalidArgs errs
+ FormMissing -> invalidArgsI [MsgValueRequired]
+ FormSuccess a -> return a
+
hoistAForm :: HandlerSite m ~ HandlerSite n => (forall a. m a -> n a) -> AForm m b -> AForm n b
hoistAForm f (AForm g) = AForm (\x y z ->f $ g x y z)
@@ -610,6 +788,73 @@ prismAForm p outer form = review p <$> form inner
where
inner = outer >>= preview p
+newtype FormValidator r m a = FormValidator { unFormValidator :: RWST () [SomeMessage (HandlerSite m)] r m a }
+
+deriving newtype instance Functor m => Functor (FormValidator r m)
+deriving newtype instance Monad m => Applicative (FormValidator r m)
+deriving newtype instance Monad m => Monad (FormValidator r m)
+deriving newtype instance Monad m => MonadState r (FormValidator r m)
+deriving newtype instance MonadFix m => MonadFix (FormValidator r m)
+instance MonadTrans (FormValidator r) where
+ lift = FormValidator . lift
+
+validateForm :: MonadHandler m
+ => FormValidator a m ()
+ -> (Markup -> MForm m (FormResult a, xml))
+ -> (Markup -> MForm m (FormResult a, xml))
+validateForm valF form csrf = do
+ (res, xml) <- form csrf
+ res' <- for res $ lift . execRWST (unFormValidator valF) ()
+ (, xml) <$> case res' of
+ FormSuccess (x, [] ) -> return $ FormSuccess x
+ FormSuccess (_, msgs) -> formFailure msgs
+ FormMissing -> return FormMissing
+ FormFailure errs -> return $ FormFailure errs
+
+validateFormDB :: ( MonadHandler m
+ , YesodPersist (HandlerSite m)
+ )
+ => FormValidator a (YesodDB (HandlerSite m)) ()
+ -> (Markup -> MForm m (FormResult a, xml))
+ -> (Markup -> MForm m (FormResult a, xml))
+validateFormDB (FormValidator valF) = validateForm . FormValidator $ hoist (liftHandlerT . runDB) valF
+
+tellValidationError :: ( MonadHandler m
+ , RenderMessage (HandlerSite m) msg
+ )
+ => msg -> FormValidator r m ()
+tellValidationError = FormValidator . tell . pure . SomeMessage
+
+guardValidation :: ( MonadHandler m
+ , RenderMessage (HandlerSite m) msg
+ )
+ => msg -- ^ Message describing violation
+ -> Bool -- ^ @False@ iff constraint is violated
+ -> FormValidator r m ()
+guardValidation msg isValid = when (not isValid) $ tellValidationError msg
+
+guardValidationM :: ( MonadHandler m
+ , RenderMessage (HandlerSite m) msg
+ )
+ => msg -> m Bool -> FormValidator r m ()
+guardValidationM = (. lift) . (=<<) . guardValidation
+
+-----------------------
+-- Form Manipulation --
+-----------------------
+
+aFormToWForm :: MonadHandler m => AForm m a -> WForm m (FormResult a)
+aFormToWForm = mapRWST mFormToWForm' . over (mapped . _2) ($ []) . aFormToForm
+ where
+ mFormToWForm' f = do
+ ((a, vs), ints, enctype) <- lift f
+ writer ((a, ints, enctype), vs)
+
+infixl 4 `fmapAForm`
+
+fmapAForm :: Functor m => (FormResult a -> FormResult b) -> (AForm m a -> AForm m b)
+fmapAForm f (AForm act) = AForm $ \app env ints -> over _1 f <$> act app env ints
+
---------------------------------------------
-- Special variants of @mopt@, @mreq@, ... --
---------------------------------------------
@@ -635,23 +880,32 @@ mforced Field{..} FieldSettings{..} val = do
aforced :: (RenderMessage site FormMessage, HandlerSite m ~ site, MonadHandler m)
=> Field m a -> FieldSettings site -> a -> AForm m a
-aforced field settings val = formToAForm $ second pure <$> mforced field settings val
-
-apreq :: (RenderMessage site FormMessage, HandlerSite m ~ site, MonadHandler m)
- => Field m a -> FieldSettings site -> Maybe a -> AForm m a
--- ^ Pseudo required
-apreq f fs mx = formToAForm $ do
- mr <- getMessageRender
- over _1 (maybe (FormFailure [mr MsgValueRequired]) return =<<) . over _2 (pure . (\fv -> fv { fvRequired = True } )) <$> mopt f fs (Just <$> mx)
+aforced field settings val = formToAForm $ over _2 pure <$> mforced field settings val
mpreq :: (RenderMessage site FormMessage, HandlerSite m ~ site, MonadHandler m)
=> Field m a -> FieldSettings site -> Maybe a -> MForm m (FormResult a, FieldView site)
+-- ^ Pseudo required
+--
+-- `FieldView` has `fvRequired` set to `True` and @FormSuccess Nothing@ is cast to `FormFailure`.
+-- Otherwise acts exactly like `mopt`.
mpreq f fs mx = do
mr <- getMessageRender
- over _1 (maybe (FormFailure [mr MsgValueRequired]) return =<<) . over _2 (\fv -> fv { fvRequired = True } ) <$> mopt f fs (Just <$> mx)
+ (res, fv) <- mopt f fs (Just <$> mx)
+ let fv' = fv { fvRequired = True }
+ return $ case res of
+ FormSuccess (Just res')
+ -> (FormSuccess res', fv')
+ FormSuccess Nothing
+ -> (FormFailure [mr MsgValueRequired], fv' { fvErrors = Just . toHtml $ mr MsgValueRequired })
+ FormFailure errs
+ -> (FormFailure errs, fv')
+ FormMissing
+ -> (FormMissing, fv')
+
+apreq :: (RenderMessage site FormMessage, HandlerSite m ~ site, MonadHandler m)
+ => Field m a -> FieldSettings site -> Maybe a -> AForm m a
+apreq f fs mx = formToAForm $ over _2 pure <$> mpreq f fs mx
wpreq :: (RenderMessage site FormMessage, HandlerSite m ~ site, MonadHandler m)
=> Field m a -> FieldSettings site -> Maybe a -> WForm m (FormResult a)
-wpreq f fs mx = mFormToWForm $ do
- mr <- getMessageRender
- over _1 (maybe (FormFailure [mr MsgValueRequired]) return =<<) . over _2 (\fv -> fv { fvRequired = True } ) <$> mopt f fs (Just <$> mx)
+wpreq f fs mx = mFormToWForm $ mpreq f fs mx
diff --git a/src/Utils/Frontend/I18n.hs b/src/Utils/Frontend/I18n.hs
new file mode 100644
index 000000000..9c8533496
--- /dev/null
+++ b/src/Utils/Frontend/I18n.hs
@@ -0,0 +1,41 @@
+module Utils.Frontend.I18n
+ ( FrontendMessage(..)
+ ) where
+
+import ClassyPrelude
+import Data.Universe
+
+import Control.Lens
+import Utils.PathPiece
+
+import Web.PathPieces
+import Data.Aeson
+import Data.Aeson.Types (toJSONKeyText)
+import Data.Aeson.TH
+import qualified Data.Char as Char
+
+
+-- | I18n-Messages used in JavaScript-Frontend
+--
+-- Only nullary constructors are supported
+--
+-- @MsgCamelCaseIdentifier@ gets translated to @camelCaseIdentifier@ in Frontend (see `nullaryPathPiece` and `deriveJSON` below)
+data FrontendMessage = MsgFilesSelected
+ | MsgSelectFile
+ | MsgSelectFiles
+ | MsgAsyncFormFailure
+ deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
+instance Universe FrontendMessage
+instance Finite FrontendMessage
+instance Hashable FrontendMessage
+
+nullaryPathPiece ''FrontendMessage $ over _head Char.toLower . mconcat . drop 1 . splitCamel
+
+deriveJSON defaultOptions
+ { constructorTagModifier = over _head Char.toLower . mconcat . drop 1 . splitCamel
+ } ''FrontendMessage
+
+instance ToJSONKey FrontendMessage where
+ toJSONKey = toJSONKeyText toPathPiece
+instance FromJSONKey FrontendMessage where
+ fromJSONKey = FromJSONKeyTextParser $ parseJSON . String
diff --git a/src/Utils/Frontend/Modal.hs b/src/Utils/Frontend/Modal.hs
new file mode 100644
index 000000000..49f1f827e
--- /dev/null
+++ b/src/Utils/Frontend/Modal.hs
@@ -0,0 +1,74 @@
+module Utils.Frontend.Modal
+ ( Modal(..)
+ , customModal
+ , modal, msgModal
+ , addMessageModal
+ ) where
+
+import ClassyPrelude.Yesod
+
+import Control.Lens
+import Utils.Route
+import Utils.Message
+
+import Settings (widgetFile)
+
+import Control.Monad.Random.Class (uniform)
+import Control.Monad.Trans.Random (evalRandTIO)
+
+
+data Modal site = Modal
+ { modalTriggerId
+ , modalId :: Maybe Text
+ , modalTrigger :: Maybe Text {- Dynamic URL -} -> Text {- TriggerId -} -> WidgetT site IO ()
+ , modalContent :: Either (SomeRoute site) (WidgetT site IO ())
+ }
+
+customModal :: Modal site -> WidgetT site IO ()
+customModal Modal{..} = do
+ triggerId' <- maybe newIdent return modalTriggerId
+
+ $(widgetFile "widgets/modal/modal")
+
+ route <- traverse toTextUrl $ modalContent ^? _Left
+ modalTrigger route triggerId'
+
+-- | Create a link to a modal
+modal :: WidgetT site IO () -- ^ Widget that represents the link
+ -> Either (SomeRoute site) (WidgetT site IO ()) -- ^ Modal contant: either dynamic link or static widget
+ -> WidgetT site IO () -- ^ result widget
+modal modalTrigger' modalContent = customModal Modal{..}
+ where
+ modalTriggerId = Nothing
+ modalId = Nothing
+ modalTrigger mRoute triggerId = $(widgetFile "widgets/modal/trigger")
+
+
+-- | Variant of `modal` for use in messages (uses a different id generator to avoid collisions)
+msgModal :: WidgetT site IO ()
+ -> Either (SomeRoute site) (WidgetT site IO ())
+ -> WidgetT site IO ()
+msgModal modalTrigger' modalContent = do
+ let
+ randomIdentifier :: MonadIO m => m Text
+ -- ^ Generates valid CSS-Identifiers with roughly 128 bits of entropy
+ --
+ -- See https://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
+ randomIdentifier = fmap pack . evalRandTIO $ do
+ prefix <- uniform $ ['a'..'z'] ++ ['A'..'Z']
+ suffix <- replicateM 21 . uniform $ ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9']
+ return $ prefix : suffix
+ modalTriggerId <- Just <$> randomIdentifier
+ modalId <- Just <$> randomIdentifier
+ customModal Modal{..}
+ where
+ modalTrigger mRoute triggerId = $(widgetFile "widgets/modal/trigger")
+
+-- | add message alert with a short trigger widget, whose larger content is displayed in a modal
+addMessageModal :: forall m site.
+ ( MonadHandler m
+ , HandlerSite m ~ site
+ , Yesod site
+ ) => MessageStatus -> WidgetT site IO () -> Either (SomeRoute site) (WidgetT site IO ()) -> m ()
+addMessageModal ms trigger content = addMessageWidget ms $ msgModal trigger content
+
diff --git a/src/Utils/Lens.hs b/src/Utils/Lens.hs
index 0abc9a8ee..ad54b3d8d 100644
--- a/src/Utils/Lens.hs
+++ b/src/Utils/Lens.hs
@@ -1,14 +1,16 @@
module Utils.Lens ( module Utils.Lens ) where
-import Import.NoFoundation
-import Control.Lens as Utils.Lens hiding ((<.>))
+import ClassyPrelude.Yesod
+import Model
+import Control.Lens as Utils.Lens hiding ((<.>), universe, snoc)
import Control.Lens.Extras as Utils.Lens (is)
import Utils.Lens.TH as Utils.Lens (makeLenses_, makeClassyFor_)
+import Data.Set.Lens as Utils.Lens
+
+import Yesod.Core.Types (HandlerContents(..), ErrorResponse(..))
import qualified Database.Esqueleto as E (Value(..),InnerJoin(..))
-_unValue :: Lens' (E.Value a) a
-_unValue f (E.Value a) = E.Value <$> f a
_PathPiece :: PathPiece v => Prism' Text v
_PathPiece = prism' toPathPiece fromPathPiece
@@ -25,6 +27,9 @@ _InnerJoinLeft f (E.InnerJoin l r) = (`E.InnerJoin` r) <$> f l
_InnerJoinRight :: Lens' (E.InnerJoin l r) r
_InnerJoinRight f (E.InnerJoin l r) = (l `E.InnerJoin`) <$> f r
+_nullable :: MonoFoldable mono => Prism' mono (NonNull mono)
+_nullable = prism' toNullable fromNullable
+
-----------------------------------
-- Lens Definitions for our Types
@@ -72,12 +77,16 @@ hasEntityUser = hasEntity
makeLenses_ ''SheetCorrector
+makeLenses_ ''Load
+
makeLenses_ ''SubmissionGroup
makeLenses_ ''SheetGrading
makeLenses_ ''SheetType
+makePrisms ''SheetGroup
+
makePrisms ''AuthResult
makePrisms ''FormResult
@@ -90,7 +99,44 @@ makeLenses_ ''StudyTerms
makeLenses_ ''StudyTermCandidate
+makeLenses_ ''FieldView
+
+makePrisms ''HandlerContents
+
+makePrisms ''ErrorResponse
+
+makeLenses_ ''UploadMode
+
+makeLenses_ ''SubmissionMode
+
+makePrisms ''E.Value
+
+makeLenses_ ''OccurrenceSchedule
+
+makePrisms ''OccurrenceSchedule
+
+makeLenses_ ''OccurrenceException
+
+makePrisms ''OccurrenceException
+
+makeLenses_ ''Occurrences
+
+makeLenses_ ''PredDNF
+
+makeLenses_ ''ExamBonusRule
+makeLenses_ ''ExamGradingRule
+
+makeLenses_ ''UTCTime
+
-- makeClassy_ ''Load
+--------------------------
+-- Fields for `UniWorX` --
+--------------------------
+class HasInstanceID s a | s -> a where
+ instanceID :: Lens' s a
+
+class HasJSONWebKeySet s a | s -> a where
+ jsonWebKeySet :: Lens' s a
diff --git a/src/Utils/Message.hs b/src/Utils/Message.hs
index 69ce9e45e..04dc41dcf 100644
--- a/src/Utils/Message.hs
+++ b/src/Utils/Message.hs
@@ -2,11 +2,11 @@ module Utils.Message
( MessageStatus(..)
, UnknownMessageStatus(..)
, addMessage, addMessageI, addMessageIHamlet, addMessageFile, addMessageWidget
+ , statusToUrgencyClass
, Message(..)
, messageI, messageIHamlet, messageFile, messageWidget
) where
-
import Data.Universe
import Utils.PathPiece
import Data.Aeson
@@ -67,6 +67,8 @@ instance FromJSON Message where
messageContent <- preEscapedText . sanitizeBalance <$> o .: "content"
return Message{..}
+statusToUrgencyClass :: MessageStatus -> Text
+statusToUrgencyClass status = "urgency__" <> toPathPiece status
addMessage :: MonadHandler m => MessageStatus -> Html -> m ()
addMessage mc = ClassyPrelude.Yesod.addMessage (toPathPiece mc)
@@ -107,6 +109,7 @@ addMessageWidget :: forall m site.
, Yesod site
) => MessageStatus -> WidgetT site IO () -> m ()
-- ^ _Note_: `addMessageWidget` ignores `pageTitle` and `pageHead`
+-- also see Utils.Frontend.Modal.addMessageModal for large alerts with modal links
addMessageWidget mc wgt = do
PageContent{pageBody} <- liftHandlerT $ widgetToPageContent wgt
addMessageIHamlet mc (const pageBody :: HtmlUrlI18n (SomeMessage site) (Route site))
diff --git a/src/Utils/Modal.hs b/src/Utils/Modal.hs
deleted file mode 100644
index 5dd4ccd3e..000000000
--- a/src/Utils/Modal.hs
+++ /dev/null
@@ -1,42 +0,0 @@
-module Utils.Modal
- ( Modal(..)
- , customModal
- , modal
- ) where
-
-import ClassyPrelude.Yesod
-
-import Control.Lens
-import Control.Lens.Extras (is)
-import Utils.Route
-
-import Settings (widgetFile)
-
-
-data Modal site = Modal
- { modalTriggerId
- , modalId :: Maybe Text
- , modalTrigger :: Maybe Text {- Dynamic URL -} -> Text {- TriggerId -} -> WidgetT site IO ()
- , modalContent :: Either (SomeRoute site) (WidgetT site IO ())
- }
-
-customModal :: Modal site -> WidgetT site IO ()
-customModal Modal{..} = do
- let isDynamic = is _Left modalContent
- modalId' <- maybe newIdent return modalId
- triggerId' <- maybe newIdent return modalTriggerId
-
- $(widgetFile "widgets/modal/modal")
-
- route <- for (modalContent ^? _Left) toTextUrl
- modalTrigger route triggerId'
-
--- | Create a link to a modal
-modal :: WidgetT site IO () -- ^ Widget that represents the link
- -> Either (SomeRoute site) (WidgetT site IO ()) -- ^ Modal contant: either dynamic link or static widget
- -> WidgetT site IO () -- ^ result widget
-modal modalTrigger' modalContent = customModal Modal{..}
- where
- modalTriggerId = Nothing
- modalId = Nothing
- modalTrigger mRoute triggerId = $(widgetFile "widgets/modal/trigger")
diff --git a/src/Utils/Occurrences.hs b/src/Utils/Occurrences.hs
new file mode 100644
index 000000000..28ebdab8d
--- /dev/null
+++ b/src/Utils/Occurrences.hs
@@ -0,0 +1,84 @@
+{-# OPTIONS_GHC -fno-warn-overlapping-patterns #-}
+
+module Utils.Occurrences
+ ( normalizeOccurrences
+ ) where
+
+import ClassyPrelude
+
+import Model.Types
+import Utils
+import Utils.Lens
+
+import Control.Monad.Trans.Reader (runReader, Reader)
+
+import Control.Monad.Trans.Except (ExceptT, throwE, runExceptT)
+
+import qualified Data.Set as Set
+
+import Data.Time
+import Data.Time.Calendar.WeekDate
+
+
+normalizeOccurrences :: Occurrences -> Occurrences
+-- ^
+--
+-- - Removes unnecessary exceptions
+-- - Merges overlapping schedules
+normalizeOccurrences initial
+ | Left new <- runReader (runExceptT go) initial
+ = normalizeOccurrences new
+ | otherwise
+ = initial
+ where
+ go :: ExceptT Occurrences (Reader Occurrences) ()
+ -- Find some inconsistency and `throwE` a version without it
+ go = do
+ scheduled <- view _occurrencesScheduled
+ forM_ scheduled $ \case
+ a@ScheduleWeekly{} -> do
+ let
+ merge b@ScheduleWeekly{}
+ | scheduleDayOfWeek a == scheduleDayOfWeek b -- b starts during a
+ , scheduleStart a <= scheduleStart b
+ , scheduleEnd a >= scheduleStart b
+ = Just $ ScheduleWeekly (scheduleDayOfWeek a) (scheduleStart a) ((max `on` scheduleEnd) a b)
+ | scheduleDayOfWeek a == scheduleDayOfWeek b -- b ends during a
+ , scheduleStart a <= scheduleEnd b
+ , scheduleEnd a >= scheduleEnd b
+ = Just $ ScheduleWeekly (scheduleDayOfWeek a) ((min `on` scheduleStart) a b) (scheduleEnd a)
+ | otherwise
+ = Nothing
+ merge _ = Nothing
+ merges <- views _occurrencesScheduled $ mapMaybe (\b -> (,) <$> pure b <*> merge b) . Set.toList . Set.delete a
+ case merges of
+ [] -> return ()
+ ((b, merged) : _) -> throwE =<< asks (over _occurrencesScheduled $ Set.insert merged . Set.delete b . Set.delete a)
+
+ exceptions <- view _occurrencesExceptions
+ forM_ exceptions $ \case
+ needle@ExceptNoOccur{..} -> do
+ let LocalTime{..} = exceptTime
+ (_, _, toEnum . (`mod` 7) -> localWeekDay) = toWeekDate localDay
+ needed <- views _occurrencesScheduled . any $ \case
+ ScheduleWeekly{..} -> and
+ [ scheduleDayOfWeek == localWeekDay
+ , scheduleStart <= localTimeOfDay
+ , localTimeOfDay <= scheduleEnd
+ ]
+ unless needed $
+ throwE =<< asks (over _occurrencesExceptions $ Set.delete needle)
+ needle@ExceptOccur{..} -> do
+ let (_, _, toEnum . (`mod` 7) -> localWeekDay) = toWeekDate exceptDay
+ -- | Does this ExceptNoOccur target within needle?
+ withinNeedle ExceptNoOccur{..} = LocalTime exceptDay exceptStart <= exceptTime
+ && exceptTime <= LocalTime exceptDay exceptEnd
+ withinNeedle _ = False
+ needed <- views _occurrencesScheduled . none $ \case
+ ScheduleWeekly{..} -> and
+ [ scheduleDayOfWeek == localWeekDay
+ , scheduleStart == exceptStart
+ , scheduleEnd == exceptEnd
+ ]
+ unless needed $
+ throwE =<< asks (over _occurrencesExceptions $ Set.filter (not . withinNeedle) . Set.delete needle)
diff --git a/src/Utils/Parameters.hs b/src/Utils/Parameters.hs
index 81b0c210a..57d1a0cff 100644
--- a/src/Utils/Parameters.hs
+++ b/src/Utils/Parameters.hs
@@ -1,10 +1,10 @@
module Utils.Parameters
( GlobalGetParam(..)
- , lookupGlobalGetParam, hasGlobalGetParam
+ , lookupGlobalGetParam, hasGlobalGetParam, lookupGlobalGetParams
, lookupGlobalGetParamForm, hasGlobalGetParamForm
, globalGetParamField
, GlobalPostParam(..)
- , lookupGlobalPostParam, hasGlobalPostParam
+ , lookupGlobalPostParam, hasGlobalPostParam, lookupGlobalPostParams
, lookupGlobalPostParamForm, hasGlobalPostParamForm
, globalPostParamField
) where
@@ -20,7 +20,7 @@ import Data.Universe
import Control.Monad.Trans.Maybe (MaybeT(..))
-data GlobalGetParam = GetReferer
+data GlobalGetParam = GetReferer | GetBearer | GetRecipient
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
instance Universe GlobalGetParam
@@ -33,6 +33,9 @@ lookupGlobalGetParam ident = (>>= fromPathPiece) <$> lookupGetParam (toPathPiece
hasGlobalGetParam :: MonadHandler m => GlobalGetParam -> m Bool
hasGlobalGetParam ident = isJust <$> lookupGetParam (toPathPiece ident)
+lookupGlobalGetParams :: (MonadHandler m, PathPiece result) => GlobalGetParam -> m [result]
+lookupGlobalGetParams ident = mapMaybe fromPathPiece <$> lookupGetParams (toPathPiece ident)
+
lookupGlobalGetParamForm :: (Monad m, PathPiece result) => GlobalGetParam -> MForm m (Maybe result)
lookupGlobalGetParamForm ident = runMaybeT $ do
@@ -42,7 +45,7 @@ lookupGlobalGetParamForm ident = runMaybeT $ do
hasGlobalGetParamForm :: Monad m => GlobalGetParam -> MForm m Bool
hasGlobalGetParamForm ident = maybe False (Map.member $ toPathPiece ident) <$> askParams
-globalGetParamField :: Monad m => GlobalPostParam -> Field m a -> MForm m (Maybe a)
+globalGetParamField :: Monad m => GlobalGetParam -> Field m a -> MForm m (Maybe a)
globalGetParamField ident Field{fieldParse} = runMaybeT $ do
ts <- fromMaybe [] . Map.lookup (toPathPiece ident) <$> MaybeT askParams
fs <- fromMaybe [] . Map.lookup (toPathPiece ident) <$> MaybeT askFiles
@@ -51,6 +54,7 @@ globalGetParamField ident Field{fieldParse} = runMaybeT $ do
data GlobalPostParam = PostFormIdentifier
| PostDeleteTarget
| PostMassInputShape
+ | PostBearer
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
instance Universe GlobalPostParam
@@ -62,7 +66,11 @@ lookupGlobalPostParam ident = (>>= fromPathPiece) <$> lookupPostParam (toPathPie
hasGlobalPostParam :: MonadHandler m => GlobalPostParam -> m Bool
hasGlobalPostParam ident = isJust <$> lookupPostParam (toPathPiece ident)
+
+lookupGlobalPostParams :: (MonadHandler m, PathPiece result) => GlobalPostParam -> m [result]
+lookupGlobalPostParams ident = mapMaybe fromPathPiece <$> lookupPostParams (toPathPiece ident)
+
lookupGlobalPostParamForm :: (Monad m, PathPiece result) => GlobalPostParam -> MForm m (Maybe result)
lookupGlobalPostParamForm ident = runMaybeT $ do
ps <- MaybeT askParams
diff --git a/src/Utils/PathPiece.hs b/src/Utils/PathPiece.hs
index 5e0dd8621..f3b8e0e7b 100644
--- a/src/Utils/PathPiece.hs
+++ b/src/Utils/PathPiece.hs
@@ -1,9 +1,11 @@
module Utils.PathPiece
( finiteFromPathPiece
, nullaryToPathPiece
- , nullaryPathPiece
+ , nullaryPathPiece, finitePathPiece
, splitCamel
, camelToPathPiece, camelToPathPiece'
+ , tuplePathPiece
+ , pathPieceJSON, pathPieceJSONKey
) where
import ClassyPrelude.Yesod
@@ -15,8 +17,17 @@ import Data.Universe
import qualified Data.Text as Text
import qualified Data.Char as Char
+import Data.Map ((!), (!?))
+import qualified Data.Map as Map
+
import Numeric.Natural
+import Data.List (foldl)
+
+import Data.Aeson.Types
+import qualified Data.Aeson.Types as Aeson
+
+
finiteFromPathPiece :: (PathPiece a, Finite a) => Text -> Maybe a
finiteFromPathPiece text = case filter (\c -> toPathPiece c == text) universeF of
[x] -> Just x
@@ -40,6 +51,16 @@ nullaryPathPiece nullaryType mangle =
, funD 'fromPathPiece
[ clause [] (normalB [e|finiteFromPathPiece|]) [] ]
]
+
+finitePathPiece :: Name -> [Text] -> DecsQ
+finitePathPiece finiteType verbs =
+ pure <$> instanceD (cxt []) [t|PathPiece $(conT finiteType)|]
+ [ funD 'toPathPiece
+ [ clause [] (normalB [|(Map.fromList (zip universeF verbs) !)|]) [] ]
+ , funD 'fromPathPiece
+ [ clause [] (normalB [e|(Map.fromList (zip verbs universeF) !?)|]) [] ]
+ ]
+
splitCamel :: Textual t => t -> [t]
splitCamel = map fromList . reverse . helper (error "hasChange undefined at start of string") [] "" . otoList
@@ -63,3 +84,51 @@ camelToPathPiece' dropN = intercalate "-" . map toLower . drop (fromIntegral dro
camelToPathPiece :: Textual t => t -> t
camelToPathPiece = camelToPathPiece' 0
+
+
+tuplePathPiece :: Int -> DecQ
+tuplePathPiece tupleDim = do
+ let
+ tupleSeparator :: Text
+ tupleSeparator = ","
+
+ xs <- replicateM tupleDim $ newName "x" :: Q [Name]
+ xs' <- replicateM tupleDim $ newName "x'" :: Q [Name]
+
+ let tupleType = foldl appT (tupleT tupleDim) $ map varT xs
+ tCxt = cxt
+ [ [t|PathPiece $(varT x)|] | x <- xs ]
+
+ t <- newName "t"
+
+ instanceD tCxt [t|PathPiece $(tupleType)|]
+ [ funD 'toPathPiece
+ [ clause [tupP $ map varP xs] (normalB [e|Text.intercalate tupleSeparator $(listE $ map (appE [e|toPathPiece|] . varE) xs)|]) []
+ ]
+ , funD 'fromPathPiece
+ [ clause [varP t] (normalB . doE $ concat
+ [ pure $ bindS (listP $ map varP xs) [e|return $ Text.splitOn tupleSeparator $(varE t)|]
+ , [ bindS (varP x') [e|fromPathPiece $(varE x)|] | (x, x') <- zip xs xs' ]
+ , pure $ noBindS [e|return $(tupE $ map varE xs')|]
+ ]) []
+ ]
+ ]
+
+
+pathPieceJSONKey :: Name -> DecsQ
+-- ^ Derive `ToJSONKey`- and `FromJSONKey`-Instances from a `PathPiece`-Instance
+pathPieceJSONKey tName
+ = [d| instance ToJSONKey $(conT tName) where
+ toJSONKey = toJSONKeyText toPathPiece
+ instance FromJSONKey $(conT tName) where
+ fromJSONKey = FromJSONKeyTextParser $ \t -> maybe (fail $ "Could not parse ‘" <> unpack t <> "’ as value for " <> $(TH.lift $ nameBase tName) <> " via PathPiece") return $ fromPathPiece t
+ |]
+
+pathPieceJSON :: Name -> DecsQ
+-- ^ Derive `ToJSON`- and `FromJSON`-Instances from a `PathPiece`-Instance
+pathPieceJSON tName
+ = [d| instance ToJSON $(conT tName) where
+ toJSON = Aeson.String . toPathPiece
+ instance FromJSON $(conT tName) where
+ parseJSON = Aeson.withText $(TH.lift $ nameBase tName) $ \t -> maybe (fail $ "Could not parse ‘" <> unpack t <> "’ as value for " <> $(TH.lift $ nameBase tName) <> " via PathPiece") return $ fromPathPiece t
+ |]
diff --git a/src/Utils/Sheet.hs b/src/Utils/Sheet.hs
index 595c0729e..192019102 100644
--- a/src/Utils/Sheet.hs
+++ b/src/Utils/Sheet.hs
@@ -3,6 +3,7 @@ module Utils.Sheet where
import Import.NoFoundation
import qualified Database.Esqueleto as E
+import Database.Esqueleto.Internal.Language (From) -- cannot be avoided here
-- DB Queries for Sheets that are used in several places
@@ -44,3 +45,66 @@ sheetOldUnassigned tid ssh csh = do
[] -> Nothing
[E.Value shn] -> Just shn
_ -> error "SQL Query with limit 1 returned more than one result"
+
+-- | Return a specfic file from a `Sheet`
+sheetFileQuery :: MonadResource m => TermId -> SchoolId -> CourseShorthand -> SheetName -> SheetFileType -> FilePath -> Source (SqlPersistT m) (Entity File)
+sheetFileQuery tid ssh csh shn sft title = E.selectSource $ E.from $
+ \(course `E.InnerJoin` sheet `E.InnerJoin` sFile `E.InnerJoin` file) -> do
+ -- Restrict to consistent rows that correspond to each other
+ E.on (file E.^. FileId E.==. sFile E.^. SheetFileFile)
+ E.on (sFile E.^. SheetFileSheet E.==. sheet E.^. SheetId)
+ E.on (sheet E.^. SheetCourse E.==. course E.^. CourseId)
+ -- filter to requested file
+ E.where_ ((file E.^. FileTitle E.==. E.val title)
+ E.&&. (sFile E.^. SheetFileType E.==. E.val sft )
+ E.&&. (sheet E.^. SheetName E.==. E.val shn )
+ E.&&. (course E.^. CourseShorthand E.==. E.val csh )
+ E.&&. (course E.^. CourseSchool E.==. E.val ssh )
+ E.&&. (course E.^. CourseTerm E.==. E.val tid )
+ )
+ -- return file entity
+ return file
+
+-- | Return all files of a certain `SheetFileType` for a `Sheet`
+sheetFilesAllQuery :: MonadResource m => TermId -> SchoolId -> CourseShorthand -> SheetName -> SheetFileType -> Source (SqlPersistT m) (Entity File)
+sheetFilesAllQuery tid ssh csh shn sft = E.selectSource $ E.from $
+ \(course `E.InnerJoin` sheet `E.InnerJoin` sFile `E.InnerJoin` file) -> do
+ -- Restrict to consistent rows that correspond to each other
+ E.on (file E.^. FileId E.==. sFile E.^. SheetFileFile)
+ E.on (sFile E.^. SheetFileSheet E.==. sheet E.^. SheetId)
+ E.on (sheet E.^. SheetCourse E.==. course E.^. CourseId)
+ -- filter to requested file
+ E.where_ ((sheet E.^. SheetName E.==. E.val shn )
+ E.&&. (course E.^. CourseShorthand E.==. E.val csh )
+ E.&&. (course E.^. CourseSchool E.==. E.val ssh )
+ E.&&. (course E.^. CourseTerm E.==. E.val tid )
+ E.&&. (sFile E.^. SheetFileType E.==. E.val sft )
+ )
+ -- return file entity
+ return file
+
+-- | Return all files of certain `SheetFileTypes` for a `Sheet`
+sheetFilesSFTsQuery :: MonadResource m => TermId -> SchoolId -> CourseShorthand -> SheetName -> [SheetFileType] -> Source (SqlPersistT m) (Entity File)
+sheetFilesSFTsQuery tid ssh csh shn sfts = E.selectSource $ E.from $
+ \(course `E.InnerJoin` sheet `E.InnerJoin` sFile `E.InnerJoin` file) -> do
+ -- Restrict to consistent rows that correspond to each other
+ E.on (file E.^. FileId E.==. sFile E.^. SheetFileFile)
+ E.on (sFile E.^. SheetFileSheet E.==. sheet E.^. SheetId)
+ E.on (sheet E.^. SheetCourse E.==. course E.^. CourseId)
+ -- filter to requested file
+ E.where_ ((sheet E.^. SheetName E.==. E.val shn )
+ E.&&. (course E.^. CourseShorthand E.==. E.val csh )
+ E.&&. (course E.^. CourseSchool E.==. E.val ssh )
+ E.&&. (course E.^. CourseTerm E.==. E.val tid )
+ E.&&. (sFile E.^. SheetFileType `E.in_` E.valList sfts )
+ )
+ -- return file entity
+ return file
+
+-- | Check whether a sheet has any files for a given file type
+hasSheetFileQuery :: Database.Esqueleto.Internal.Language.From query expr backend (expr (Entity SheetFile))
+ => expr (Entity Sheet) -> SheetFileType -> expr (E.Value Bool)
+hasSheetFileQuery sheet sft =
+ E.exists $ E.from $ \sFile ->
+ E.where_ ((sFile E.^. SheetFileSheet E.==. sheet E.^. SheetId)
+ E.&&. (sFile E.^. SheetFileType E.==. E.val sft ))
diff --git a/src/Utils/TH.hs b/src/Utils/TH.hs
index 5e7b1f36d..a1f9e9163 100644
--- a/src/Utils/TH.hs
+++ b/src/Utils/TH.hs
@@ -95,9 +95,6 @@ afterN n = do
deriveShowWith :: (String -> String) -> Name -> Q [Dec]
deriveShowWith = deriveSimpleWith ''Show 'show
--- deriveDisplayWith :: (String -> String) -> Name -> Q [Dec]
--- deriveDisplayWith = deriveSimpleWith ''DisplayAble 'display
-
deriveSimpleWith :: Name -> Name -> (String -> String) -> Name -> Q [Dec]
deriveSimpleWith cls fun strOp ty = do
(TyConI tyCon) <- reify ty
diff --git a/src/Utils/Tokens.hs b/src/Utils/Tokens.hs
new file mode 100644
index 000000000..5f8107573
--- /dev/null
+++ b/src/Utils/Tokens.hs
@@ -0,0 +1,174 @@
+module Utils.Tokens
+ ( bearerToken
+ , encodeToken, BearerTokenException(..), decodeToken
+ , tokenParseJSON'
+ , askJwt
+ , formEmbedJwtPost, formEmbedJwtGet
+ ) where
+
+import ClassyPrelude.Yesod
+
+import Yesod.Auth (AuthId)
+
+import Utils (NTop(..), hoistMaybe, SessionKey(..))
+import Utils.Parameters
+import Utils.Lens
+
+import Model
+import Model.Tokens
+
+import Jose.Jwk (JwkSet(..))
+import Jose.Jwt (Jwt(..))
+import qualified Jose.Jwt as Jose
+
+import Data.Aeson.Types (Parser)
+import qualified Data.Aeson as JSON
+import qualified Data.Aeson.Parser as JSON
+import qualified Data.Aeson.Parser.Internal as JSON (jsonEOF')
+import qualified Data.Aeson.Internal as JSON (iparse, formatError)
+
+import qualified Data.HashMap.Strict as HashMap
+
+import Data.Time.Clock
+
+import Control.Monad.Random (MonadRandom(..))
+import Control.Monad.Trans.Maybe (MaybeT(..))
+
+import Settings
+
+import CryptoID
+
+import Text.Blaze (Markup)
+
+
+tokenParseJSON' :: forall m.
+ ( HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser)
+ , ParseRoute (HandlerSite m)
+ , Hashable (Route (HandlerSite m))
+ , MonadHandler m
+ , MonadCrypto m
+ , MonadCryptoKey m ~ CryptoIDKey
+ )
+ => m (Value -> Parser (BearerToken (HandlerSite m)))
+-- ^ Read `CryptoIDKey` from monadic context and construct a `Parser` for `BearerToken`s
+tokenParseJSON' = do
+ cidKey <- cryptoIDKey return
+ return $ flip runReaderT cidKey . tokenParseJSON
+
+
+bearerToken :: forall m.
+ ( MonadHandler m
+ , HasInstanceID (HandlerSite m) InstanceId
+ , HasCryptoUUID (AuthId (HandlerSite m)) m
+ , HasAppSettings (HandlerSite m)
+ )
+ => AuthId (HandlerSite m)
+ -> Maybe (HashSet (Route (HandlerSite m)))
+ -> Maybe AuthDNF
+ -> Maybe (Maybe UTCTime) -- ^ @Nothing@ determines default expiry time automatically
+ -> Maybe UTCTime -- ^ @Nothing@ means token starts to be valid immediately
+ -> m (BearerToken (HandlerSite m))
+-- ^ Smart constructor for `BearerToken`, does not set route restrictions (due to polymorphism), use `tokenRestrict`
+bearerToken tokenAuthority tokenRoutes tokenAddAuth mTokenExpiresAt tokenStartsAt = do
+ tokenIdentifier <- liftIO getRandom
+ tokenIssuedAt <- liftIO getCurrentTime
+ tokenIssuedBy <- getsYesod $ view instanceID
+
+ defaultExpiration <- getsYesod $ view _appJwtExpiration
+
+ let tokenExpiresAt
+ | Just t <- mTokenExpiresAt
+ = t
+ | Just tDiff <- defaultExpiration
+ = Just $ tDiff `addUTCTime` fromMaybe tokenIssuedAt tokenStartsAt
+ | otherwise
+ = Nothing
+ tokenRestrictions = HashMap.empty
+
+ return BearerToken{..}
+
+
+encodeToken :: forall m.
+ ( MonadHandler m
+ , HasJSONWebKeySet (HandlerSite m) JwkSet
+ , HasInstanceID (HandlerSite m) InstanceId
+ , HasAppSettings (HandlerSite m)
+ , HasCryptoUUID (AuthId (HandlerSite m)) m
+ , RenderRoute (HandlerSite m)
+ )
+ => BearerToken (HandlerSite m) -> m Jwt
+-- ^ Call `tokenToJSON` and encode the result as a `Jwt` according to `appJwtEncoding`
+encodeToken token = do
+ payload <- Jose.Claims . toStrict . JSON.encode <$> tokenToJSON token
+ JwkSet jwks <- getsYesod $ view jsonWebKeySet
+ jwtEncoding <- getsYesod $ view _appJwtEncoding
+ either throwM return =<< liftIO (Jose.encode jwks jwtEncoding payload)
+
+
+data BearerTokenException
+ = BearerTokenJwtError Jose.JwtError -- ^ An Error occurred in the underlying `Jwt`-Implementation
+ | BearerTokenUnsecured -- ^ `Jwt` is insufficiently secured (unsigned and not encrypted)
+ | BearerTokenInvalidFormat String -- ^ Content of the `Jwt` could not be parsed as a `BearerToken`
+ | BearerTokenExpired | BearerTokenNotStarted
+ deriving (Eq, Show, Generic, Typeable)
+
+instance Exception BearerTokenException
+
+decodeToken :: forall m.
+ ( MonadHandler m
+ , HasJSONWebKeySet (HandlerSite m) JwkSet
+ , HasCryptoUUID (AuthId (HandlerSite m)) (ReaderT CryptoIDKey Parser)
+ , MonadCryptoKey m ~ CryptoIDKey
+ , MonadCrypto m
+ , MonadThrow m
+ , ParseRoute (HandlerSite m)
+ , Hashable (Route (HandlerSite m))
+ )
+ => Jwt -> m (BearerToken (HandlerSite m))
+-- ^ Decode a `Jwt` and call `tokenParseJSON`
+--
+-- Throws `bearerTokenException`s
+decodeToken (Jwt bs) = do
+ JwkSet jwks <- getsYesod $ view jsonWebKeySet
+ content <- either (throwM . BearerTokenJwtError) return =<< liftIO (Jose.decode jwks Nothing bs)
+ content' <- case content of
+ Jose.Unsecured _ -> throwM BearerTokenUnsecured
+ Jose.Jws (_header, payload) -> return payload
+ Jose.Jwe (_header, payload) -> return payload
+ parser <- tokenParseJSON'
+ token@BearerToken{..} <- either (throwM . BearerTokenInvalidFormat . uncurry JSON.formatError) return $ JSON.eitherDecodeStrictWith JSON.jsonEOF' (JSON.iparse parser) content'
+ now <- liftIO getCurrentTime
+ unless (NTop tokenExpiresAt > NTop (Just now)) $
+ throwM BearerTokenExpired
+ unless (tokenStartsAt <= Just now) $
+ throwM BearerTokenNotStarted
+ return token
+
+
+askJwt :: forall m. ( MonadHandler m )
+ => m (Maybe Jwt)
+-- ^ Retrieve current `Jwt` from HTTP-Header, POST-Parameter, or GET-Parameter
+askJwt = runMaybeT $ asum
+ [ MaybeT lookupBearerAuth >>= hoistMaybe . fromPathPiece
+ , MaybeT $ lookupGlobalPostParam PostBearer
+ , MaybeT $ lookupGlobalGetParam GetBearer
+ , fmap Jwt . MaybeT $ lookupSessionBS (toPathPiece SessionBearer)
+ ]
+
+formEmbedJwtPost, formEmbedJwtGet :: MonadHandler m => (Markup -> m a) -> (Markup -> m a)
+formEmbedJwtPost f fragment = do
+ mJwt <- askJwt
+ f [shamlet|
+ $newline never
+ $maybe jwt <- mJwt
+
+ #{fragment}
+ |]
+formEmbedJwtGet f fragment = do
+ mJwt <- askJwt
+ f [shamlet|
+ $newline never
+ $maybe jwt <- mJwt
+
+ #{fragment}
+ |]
diff --git a/src/Web/PathPieces/Instances.hs b/src/Web/PathPieces/Instances.hs
new file mode 100644
index 000000000..a47711a8e
--- /dev/null
+++ b/src/Web/PathPieces/Instances.hs
@@ -0,0 +1,12 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Web.PathPieces.Instances
+ (
+ ) where
+
+import Prelude
+
+import Utils.PathPiece
+
+
+$(mapM tuplePathPiece [2..4])
diff --git a/src/Yesod/Core/Instances.hs b/src/Yesod/Core/Instances.hs
index 6512c936a..153cbec8e 100644
--- a/src/Yesod/Core/Instances.hs
+++ b/src/Yesod/Core/Instances.hs
@@ -15,37 +15,61 @@ import Data.ByteString.Builder (toLazyByteString)
import System.FilePath ((>))
import Data.Aeson
+import Data.Aeson.Types
import Control.Monad.Fix
import Control.Monad.Fail (MonadFail)
import qualified Control.Monad.Fail as MonadFail
import Control.Monad.Except (MonadError(..))
import Data.Functor.Extend
-
+
+import Data.Binary (Binary)
+import qualified Data.Binary as Binary
+
+
+routeFromPathPiece :: ParseRoute site => Text -> Maybe (Route site)
+routeFromPathPiece
+ = parseRoute
+ . over (_2.traverse._2) (fromMaybe "")
+ . over _2 queryToQueryText
+ . decodePath
+ . encodeUtf8
+
+routeToPathPiece :: RenderRoute site => Route site -> Text
+routeToPathPiece
+ = pack
+ . ("/" >)
+ . unpack
+ . decodeUtf8
+ . toLazyByteString
+ . uncurry encodePath
+ . over _2 queryTextToQuery
+ . over (_2.traverse._2) (assertM' $ not . null)
+ . renderRoute
+
instance (RenderRoute site, ParseRoute site) => PathPiece (Route site) where
- fromPathPiece
- = parseRoute
- . over (_2.traverse._2) (fromMaybe "")
- . over _2 queryToQueryText
- . decodePath
- . encodeUtf8
- toPathPiece
- = pack
- . ("/" >)
- . unpack
- . decodeUtf8
- . toLazyByteString
- . uncurry encodePath
- . over _2 queryTextToQuery
- . over (_2.traverse._2) (assertM' $ not . null)
- . renderRoute
+ fromPathPiece = routeFromPathPiece
+ toPathPiece = routeToPathPiece
-instance (RenderRoute site, ParseRoute site) => FromJSON (Route site) where
- parseJSON = withText "Route" $ maybe (fail "Could not parse route") return . fromPathPiece
+instance ParseRoute site => FromJSON (Route site) where
+ parseJSON = withText "Route" $ maybe (fail "Could not parse route") return . routeFromPathPiece
-instance (RenderRoute site, ParseRoute site) => ToJSON (Route site) where
- toJSON = String . toPathPiece
+instance RenderRoute site => ToJSON (Route site) where
+ toJSON = String . routeToPathPiece
+
+instance ParseRoute site => FromJSONKey (Route site) where
+ fromJSONKey = FromJSONKeyTextParser $ maybe (fail "Coulde not parse route") return . routeFromPathPiece
+
+instance RenderRoute site => ToJSONKey (Route site) where
+ toJSONKey = toJSONKeyText routeToPathPiece
+
+instance (RenderRoute site, ParseRoute site) => Binary (Route site) where
+ put = Binary.put . toPathPiece
+ get = Binary.get >>= maybe (fail "Could not parse route") return . fromPathPiece
+
+instance RenderRoute site => Hashable (Route site) where
+ hashWithSalt s = hashWithSalt s . routeToPathPiece
instance Monad FormResult where
@@ -77,3 +101,5 @@ instance Extend FormResult where
duplicated (FormSuccess x) = FormSuccess $ FormSuccess x
duplicated FormMissing = FormMissing
duplicated (FormFailure errs) = FormFailure errs
+
+deriving instance Eq a => Eq (FormResult a)
diff --git a/src/Yesod/Core/Types/Instances.hs b/src/Yesod/Core/Types/Instances.hs
index e296d0c52..2f03d0e94 100644
--- a/src/Yesod/Core/Types/Instances.hs
+++ b/src/Yesod/Core/Types/Instances.hs
@@ -1,16 +1,63 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving, UndecidableInstances #-}
module Yesod.Core.Types.Instances
- (
+ ( CachedMemoT
+ , runCachedMemoT
) where
-import ClassyPrelude
+import ClassyPrelude.Yesod
import Yesod.Core.Types
import Control.Monad.Fix
+import Control.Monad.Memo
+
+import Data.Binary (Binary)
+
+import Control.Monad.Logger (MonadLoggerIO)
+
+import Utils
+
+import Language.Haskell.TH
+
+import Control.Monad.Reader (MonadReader(..))
+import Control.Monad.Trans.Reader (ReaderT, mapReaderT, runReaderT)
+
+
instance MonadFix m => MonadFix (HandlerT site m) where
mfix f = HandlerT $ \r -> mfix $ \a -> unHandlerT (f a) r
instance MonadFix m => MonadFix (WidgetT site m) where
mfix f = WidgetT $ \r -> mfix $ \ ~(a, _) -> unWidgetT (f a) r
+
+
+-- | Type-level tags for compatability of Yesod `cached`-System with `MonadMemo`
+newtype CachedMemoT k v m a = CachedMemoT { runCachedMemoT' :: ReaderT Loc m a }
+ deriving newtype ( Functor, Applicative, Alternative, Monad, MonadPlus, MonadFix
+ , MonadIO
+ , MonadThrow, MonadCatch, MonadMask, MonadLogger, MonadLoggerIO
+ , MonadResource, MonadHandler, MonadWidget
+ )
+
+deriving newtype instance MonadBase b m => MonadBase b (CachedMemoT k v m)
+deriving newtype instance MonadBaseControl b m => MonadBaseControl b (CachedMemoT k v m)
+
+instance MonadReader r m => MonadReader r (CachedMemoT k v m) where
+ reader = CachedMemoT . lift . reader
+ local f (CachedMemoT act) = CachedMemoT $ mapReaderT (local f) act
+
+instance MonadTrans (CachedMemoT k v) where
+ lift = CachedMemoT . lift
+
+
+-- | Uses `cachedBy` with a `Binary`-encoded @k@
+instance (Typeable v, Binary k, MonadHandler m) => MonadMemo k v (CachedMemoT k v m) where
+ memo act key = do
+ loc <- CachedMemoT ask
+ cachedByBinary (loc, key) $ act key
+
+runCachedMemoT :: Q Exp
+runCachedMemoT = do
+ loc <- location
+ [e| flip runReaderT loc . runCachedMemoT' |]
diff --git a/src/index.md b/src/index.md
index d90c78eb2..4dceca669 100644
--- a/src/index.md
+++ b/src/index.md
@@ -7,8 +7,7 @@ Utils, Utils.*
: Hilfsfunktionionen _unabhängig von Foundation_
Utils
- : Yesod Hilfsfunktionen und Instanzen, Text-HTML-Widget-Konvertierungen
- (`DisplayAble`), Crud, `NTop`, Utility-Funktionen für `MonadPlus`, `Maybe`,
+ : Yesod Hilfsfunktionen und Instanzen, Crud, `NTop`, Utility-Funktionen für `MonadPlus`, `Maybe`,
`MaybeT`, `Map`, und Attrs-Lists
Utils.TH
diff --git a/stack.nix b/stack.nix
index e986ba349..a9a2af3de 100644
--- a/stack.nix
+++ b/stack.nix
@@ -1,16 +1,11 @@
{ ghc, nixpkgs ? import
}:
let
- snapshot = "lts-10.5";
- stackage = import (fetchTarball {
- url = "https://stackage.serokell.io/drczwlyf6mi0ilh3kgv01wxwjfgvq14b-stackage/default.nix.tar.gz";
- sha256 = "1bwlbxx6np0jfl6z9gkmmcq22crm0pa07a8zrwhz5gkal64y6jpz";
- });
- inherit (nixpkgs { overlays = [ stackage."${snapshot}" ]; }) haskell pkgs;
-
- haskellPackages = pkgs.haskell.packages."${snapshot}";
-in haskell.lib.buildStackProject {
+ haskellPackages = import ./stackage.nix { inherit nixpkgs; };
+ inherit (nixpkgs {}) pkgs;
+in pkgs.haskell.lib.buildStackProject {
inherit ghc;
+ inherit (haskellPackages) stack;
name = "stackenv";
buildInputs = (with pkgs;
[ postgresql zlib libsodium
diff --git a/stack.yaml b/stack.yaml
index 94be126d8..ae77b3f1d 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -9,20 +9,15 @@ extra-package-dbs: []
packages:
- .
- - location:
- git: https://github.com/pngwjpgh/zip-stream.git
- commit: 9272bbed000928d500febad1cdc98d1da29d399e
- extra-dep: true
- - location:
- git: https://github.com/pngwjpgh/encoding.git
- commit: 67bb87ceff53f0178c988dd4e15eeb2daee92b84
- extra-dep: true
- - location:
- git: https://github.com/pngwjpgh/memcached-binary.git
- commit: b5461747e7be226d3b67daebc3c9aefe8a4490ad
- extra-dep: true
extra-deps:
+ - git: https://github.com/pngwjpgh/zip-stream.git
+ commit: 9272bbed000928d500febad1cdc98d1da29d399e
+ - git: https://github.com/pngwjpgh/encoding.git
+ commit: 67bb87ceff53f0178c988dd4e15eeb2daee92b84
+ - git: https://github.com/pngwjpgh/memcached-binary.git
+ commit: b5461747e7be226d3b67daebc3c9aefe8a4490ad
+
- colonnade-1.2.0
- yesod-colonnade-1.2.0
@@ -49,4 +44,10 @@ extra-deps:
- quickcheck-classes-0.4.14
- semirings-0.2.1.1
+ - systemd-1.2.0
+
+ - filepath-1.4.2
+
+ - haskell-src-exts-util-0.2.1.2
+
resolver: lts-10.5
diff --git a/stackage.nix b/stackage.nix
new file mode 100644
index 000000000..4fb2c4623
--- /dev/null
+++ b/stackage.nix
@@ -0,0 +1,30 @@
+{ nixpkgs ? import
+, snapshot ? "lts-10.5"
+}:
+
+let
+ stackage = import (fetchTarball {
+ url = "https://stackage.serokell.io/drczwlyf6mi0ilh3kgv01wxwjfgvq14b-stackage/default.nix.tar.gz";
+ sha256 = "1bwlbxx6np0jfl6z9gkmmcq22crm0pa07a8zrwhz5gkal64y6jpz";
+ });
+
+ overlays =
+ [ stackage."${snapshot}"
+ (self: super: {
+ haskell = super.haskell // {
+ packages = super.haskell.packages // {
+ "${snapshot}" = super.haskell.packages."${snapshot}".override {
+ overrides = hself: hsuper: {
+ zip-archive = self.haskell.lib.overrideCabal hsuper.zip-archive (old: {
+ testToolDepends = old.testToolDepends ++ (with self; [ unzip ]);
+ });
+ };
+ };
+ };
+ };
+ }
+ )
+ ];
+
+ inherit (nixpkgs { inherit overlays; }) pkgs;
+in pkgs.haskell.packages."${snapshot}"
diff --git a/start.sh b/start.sh
index b72d043c2..cdad4b731 100755
--- a/start.sh
+++ b/start.sh
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
unset HOST
-export DETAILED_LOGGING=true
-export LOG_ALL=false
-export LOGLEVEL=info
-export DUMMY_LOGIN=true
-export ALLOW_DEPRECATED=true
-export PWFILE=users.yml
+export DETAILED_LOGGING=${DETAILED_LOGGIN:-true}
+export LOG_ALL=${LOG_ALL:-false}
+export LOGLEVEL=${LOGLEVEL:-info}
+export DUMMY_LOGIN=${DUMMY_LOGIN:-true}
+export ALLOW_DEPRECATED=${ALLOW_DEPRECATED:-true}
move-back() {
mv -v .stack-work .stack-work-run
diff --git a/static/css/vendor/fontawesome.css b/static/css/vendor/fontawesome.css
deleted file mode 100644
index 68b26ef9b..000000000
--- a/static/css/vendor/fontawesome.css
+++ /dev/null
@@ -1,5 +0,0 @@
-/*!
- * Font Awesome Free 5.1.0 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
- */
-.fa,.fab,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{animation:a 2s infinite linear}.fa-pulse{animation:a 1s infinite steps(8)}@keyframes a{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";transform:scaleX(-1)}.fa-flip-vertical{transform:scaleY(-1)}.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-horizontal.fa-flip-vertical{transform:scale(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-balance-scale:before{content:"\f24e"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bicycle:before{content:"\f206"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blind:before{content:"\f29d"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-open:before{content:"\f518"}.fa-bookmark:before{content:"\f02e"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-certificate:before{content:"\f0a3"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-concierge-bell:before{content:"\f562"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-credit-card:before{content:"\f09d"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-delicious:before{content:"\f1a5"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-deviantart:before{content:"\f1bd"}.fa-diagnoses:before{content:"\f470"}.fa-dice:before{content:"\f522"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edit:before{content:"\f044"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-gift:before{content:"\f06b"}.fa-git:before{content:"\f1d3"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-handshake:before{content:"\f2b5"}.fa-hashtag:before{content:"\f292"}.fa-hdd:before{content:"\f0a0"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hockey-puck:before{content:"\f453"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hot-tub:before{content:"\f593"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-houzz:before{content:"\f27c"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-instagram:before{content:"\f16d"}.fa-internet-explorer:before{content:"\f26b"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mercury:before{content:"\f223"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-motorcycle:before{content:"\f21c"}.fa-mouse-pointer:before{content:"\f245"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-nintendo-switch:before{content:"\f418"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-old-republic:before{content:"\f510"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-osi:before{content:"\f41a"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-people-carry:before{content:"\f4ce"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-volume:before{content:"\f2a0"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poo:before{content:"\f2fe"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-r-project:before{content:"\f4f7"}.fa-random:before{content:"\f074"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-rendact:before{content:"\f3e4"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-retweet:before{content:"\f079"}.fa-ribbon:before{content:"\f4d6"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-rupee-sign:before{content:"\f156"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-sass:before{content:"\f41e"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-search:before{content:"\f002"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skull:before{content:"\f54c"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowflake:before{content:"\f2dc"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-swatchbook:before{content:"\f5c3"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toolbox:before{content:"\f552"}.fa-tooth:before{content:"\f5c9"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-train:before{content:"\f238"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-moving:before{content:"\f4df"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-uikit:before{content:"\f403"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}
\ No newline at end of file
diff --git a/static/css/fonts.css b/static/fonts/fonts.css
similarity index 100%
rename from static/css/fonts.css
rename to static/fonts/fonts.css
diff --git a/static/img/lmu/sigillum.svg b/static/img/lmu/sigillum.svg
new file mode 120000
index 000000000..bf7553917
--- /dev/null
+++ b/static/img/lmu/sigillum.svg
@@ -0,0 +1 @@
+../../../assets/lmu/sigillum.svg
\ No newline at end of file
diff --git a/static/js/utils/alerts.js b/static/js/utils/alerts.js
deleted file mode 100644
index b854495a0..000000000
--- a/static/js/utils/alerts.js
+++ /dev/null
@@ -1,96 +0,0 @@
-(function() {
- 'use strict';
-
- window.utils = window.utils || {};
-
- var ALERTS_CLASS = 'alerts';
- var ALERTS_TOGGLER_CLASS = 'alerts__toggler';
- var ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible';
- var ALERTS_TOGGLER_APPEAR_DELAY = 120;
-
- var ALERT_CLASS = 'alert';
- var ALERT_CLOSER_CLASS = 'alert__closer';
- var ALERT_INVISIBLE_CLASS = 'alert--invisible';
- var ALERT_AUTO_HIDE_DELAY = 10;
- var ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success';
-
- var JS_INITIALIZED_CLASS = 'js-initialized';
-
- window.utils.alerts = function(alertsEl) {
-
- if (alertsEl.classList.contains(JS_INITIALIZED_CLASS)) {
- return false;
- }
-
- if (!alertsEl || !alertsEl.classList.contains(ALERTS_CLASS)) {
- throw new Error('utils.alerts has to be called with alerts element');
- }
-
- var togglerCheckRequested = false;
-
- var togglerEl = alertsEl.querySelector('.' + ALERTS_TOGGLER_CLASS);
-
- var alertElements = Array.from(alertsEl.querySelectorAll('.' + ALERT_CLASS))
- .filter(function(alert) {
- return !alert.classList.contains(JS_INITIALIZED_CLASS);
- });
-
- function initToggler() {
- togglerEl.addEventListener('click', function() {
- alertElements.forEach(function(alertEl) {
- toggleAlert(alertEl, true);
- });
- togglerEl.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS);
- });
- alertsEl.classList.add(JS_INITIALIZED_CLASS);
- }
-
- function initAlert(alertEl) {
- var autoHideDelay = ALERT_AUTO_HIDE_DELAY;
- if (alertEl.dataset.decay) {
- autoHideDelay = parseInt(alertEl.dataset.decay, 10);
- }
-
- var closeEl = alertEl.querySelector('.' + ALERT_CLOSER_CLASS);
- closeEl.addEventListener('click', function() {
- toggleAlert(alertEl);
- });
-
- if (autoHideDelay > 0 && alertEl.matches(ALERT_AUTOCLOSING_MATCHER)) {
- window.setTimeout(function() {
- toggleAlert(alertEl);
- }, autoHideDelay * 1000);
- }
-
- alertEl.classList.add(JS_INITIALIZED_CLASS);
- }
-
- function toggleAlert(alertEl, visible) {
- alertEl.classList.toggle(ALERT_INVISIBLE_CLASS, !visible);
- checkToggler();
- }
-
- function checkToggler() {
- if (togglerCheckRequested) {
- return;
- }
-
- var alertsHidden = alertElements.reduce(function(acc, alert) {
- return acc && alert.classList.contains(ALERT_INVISIBLE_CLASS);
- }, true);
-
- window.setTimeout(function() {
- togglerEl.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden);
- togglerCheckRequested = false;
- }, ALERTS_TOGGLER_APPEAR_DELAY);
- }
-
- initToggler();
- alertElements.forEach(initAlert);
-
- return {
- scope: alertsEl,
- destroy: function() {},
- };
- };
-})();
diff --git a/static/js/utils/asidenav.js b/static/js/utils/asidenav.js
deleted file mode 100644
index bb95f6455..000000000
--- a/static/js/utils/asidenav.js
+++ /dev/null
@@ -1,64 +0,0 @@
-(function() {
- 'use strict';
-
- window.utils = window.utils || {};
-
- var FAVORITES_BTN_CLASS = 'navbar__list-item--favorite';
- var FAVORITES_BTN_ACTIVE_CLASS = 'navbar__list-item--active';
- var ASIDENAV_EXPANDED_CLASS = 'main__aside--expanded';
- var ASIDENAV_LIST_ITEM_CLASS = 'asidenav__list-item';
- var ASIDENAV_SUBMENU_CLASS = 'asidenav__nested-list-wrapper';
-
- window.utils.aside = function(asideEl) {
-
- if (!asideEl) {
- throw new Error('asideEl not defined');
- }
-
- function initFavoritesButton() {
- var favoritesBtn = document.querySelector('.' + FAVORITES_BTN_CLASS);
- favoritesBtn.addEventListener('click', function(event) {
- favoritesBtn.classList.toggle(FAVORITES_BTN_ACTIVE_CLASS);
- asideEl.classList.toggle(ASIDENAV_EXPANDED_CLASS);
- event.preventDefault();
- }, true);
- }
-
- function initAsidenavSubmenus() {
- var asidenavLinksWithSubmenus = Array.from(asideEl.querySelectorAll('.' + ASIDENAV_LIST_ITEM_CLASS))
- .map(function(listItem) {
- var submenu = listItem.querySelector('.' + ASIDENAV_SUBMENU_CLASS);
- return { listItem, submenu };
- }).filter(function(union) {
- return union.submenu !== null;
- });
-
- asidenavLinksWithSubmenus.forEach(function(union) {
- union.listItem.addEventListener('mouseover', createMouseoverHandler(union));
- });
- }
-
- function createMouseoverHandler(union) {
- return function mouseoverHanlder(event) {
- var rectListItem = union.listItem.getBoundingClientRect();
- var rectSubMenu = union.submenu.getBoundingClientRect();
-
- union.submenu.style.left = (rectListItem.left + rectListItem.width) + 'px';
- if (window.innerHeight - rectListItem.top < rectSubMenu.height) {
- union.submenu.style.top = (rectListItem.top + rectListItem.height - rectSubMenu.height) + 'px';
- } else {
- union.submenu.style.top = rectListItem.top + 'px';
- }
-
- };
- }
-
- initFavoritesButton();
- initAsidenavSubmenus();
-
- return {
- scope: asideEl,
- destroy: function() {},
- };
- };
-})();
diff --git a/static/js/utils/asyncForm.js b/static/js/utils/asyncForm.js
deleted file mode 100644
index aa57ed2a0..000000000
--- a/static/js/utils/asyncForm.js
+++ /dev/null
@@ -1,86 +0,0 @@
-(function collonadeClosure() {
- 'use strict';
-
- window.utils = window.utils || {};
-
- var ASYNC_FORM_RESPONSE_CLASS = 'async-form-response';
- var ASYNC_FORM_LOADING_CLASS = 'async-form-loading';
- var ASYNC_FORM_MIN_DELAY = 600;
- var DEFAULT_FAILURE_MESSAGE = 'The response we received from the server did not match what we expected. Please let us know this happened via the help widget in the top navigation.';
-
- window.utils.asyncForm = function(formElement, options) {
-
- options = options || {};
-
- var lastRequestTimestamp = 0;
-
- function setup() {
- formElement.addEventListener('submit', submitHandler);
- }
-
- function processResponse(response) {
- var responseElement = makeResponseElement(response.content, response.status);
- var parentElement = formElement.parentElement;
-
- // make sure there is a delay between click and response
- var delay = Math.max(0, ASYNC_FORM_MIN_DELAY + lastRequestTimestamp - Date.now());
-
- setTimeout(function() {
- parentElement.insertBefore(responseElement, formElement);
- formElement.remove();
- }, delay);
- }
-
- function makeResponseElement(content, status) {
- var responseElement = document.createElement('div');
- status = status || 'info';
- responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS);
- responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS + '--' + status);
- responseElement.innerHTML = content;
- return responseElement;
- }
-
- function submitHandler(event) {
- event.preventDefault();
-
- formElement.classList.add(ASYNC_FORM_LOADING_CLASS)
- lastRequestTimestamp = Date.now();
-
- var url = formElement.getAttribute('action');
- var headers = { };
- var body = new FormData(formElement);
-
- if (options && options.headers) {
- Object.keys(options.headers).forEach(function(headerKey) {
- headers[headerKey] = options.headers[headerKey];
- });
- }
-
- window.utils.httpClient.post(url, headers, body)
- .then(function(response) {
- if (response.headers.get("content-type").indexOf("application/json") !== -1) {// checking response header
- return response.json();
- } else {
- throw new TypeError('Unexpected Content-Type. Expected Content-Type: "application/json". Requested URL:' + url + '"');
- }
- }).then(function(response) {
- processResponse(response[0]);
- }).catch(function(error) {
- var failureMessage = DEFAULT_FAILURE_MESSAGE;
- if (options.i18n && options.i18n.asyncFormFailure) {
- failureMessage = options.i18n.asyncFormFailure;
- }
- processResponse({ content: failureMessage });
-
- formElement.classList.remove(ASYNC_FORM_LOADING_CLASS);
- });
- }
-
- setup();
-
- return {
- scope: formElement,
- destroy: function() {},
- };
- };
-})();
diff --git a/static/js/utils/asyncTable.js b/static/js/utils/asyncTable.js
deleted file mode 100644
index e4b7d87bc..000000000
--- a/static/js/utils/asyncTable.js
+++ /dev/null
@@ -1,237 +0,0 @@
-(function collonadeClosure() {
- 'use strict';
-
- window.utils = window.utils || {};
-
- var HEADER_HEIGHT = 80;
- var RESET_OPTIONS = [ 'scrollTo' ];
- var TABLE_FILTER_FORM_CLASS = 'table-filter-form';
- var ASYNC_TABLE_CONTENT_CHANGED_CLASS = 'async-table--changed';
- var ASYNC_TABLE_LOADING_CLASS = 'async-table--loading';
- var JS_INITIALIZED_CLASS = 'js-async-table-initialized';
-
- window.utils.asyncTable = function(wrapper, options) {
-
- options = options || {};
- var tableIdent = options.dbtIdent;
- var shortCircuitHeader = options ? options.headerDBTableShortcircuit : null;
-
- var ths = [];
- var pageLinks = [];
- var pagesizeForm;
- var scrollTable;
-
- var utilInstances = [];
-
- function init() {
- var table = wrapper.querySelector('#' + tableIdent);
-
- if (!table) {
- return;
- }
-
- scrollTable = wrapper.querySelector('.scrolltable');
-
- // sortable table headers
- ths = Array.from(table.querySelectorAll('th.sortable')).map(function(th) {
- return { element: th };
- });
-
- // pagination links
- var pagination = wrapper.querySelector('#' + tableIdent + '-pagination');
- if (pagination) {
- pageLinks = Array.from(pagination.querySelectorAll('.page-link')).map(function(link) {
- return { element: link };
- });
- }
-
- // pagesize form
- pagesizeForm = wrapper.querySelector('#' + tableIdent + '-pagesize-form');
-
- // check all
- utilInstances.push(window.utils.setup('checkAll', wrapper));
-
- // showhide
- utilInstances.push(window.utils.setup('showHide', wrapper));
-
- // filter
- var filterForm = wrapper.querySelector('.' + TABLE_FILTER_FORM_CLASS);
- if (filterForm) {
- options.updateTableFrom = updateTableFrom;
- utilInstances.push(window.utils.setup('asyncTableFilter', filterForm, options));
- }
-
- // take options into account
- if (options.scrollTo) {
- window.scrollTo(options.scrollTo);
- }
-
- if (options.horizPos && scrollTable) {
- scrollTable.scrollLeft = options.horizPos;
- }
-
- setupListeners();
- wrapper.classList.add(JS_INITIALIZED_CLASS);
- }
-
- function setupListeners() {
- ths.forEach(function(th) {
- th.clickHandler = function(event) {
- var boundClickHandler = clickHandler.bind(this);
- var horizPos = (scrollTable || {}).scrollLeft;
- boundClickHandler(event, { horizPos });
- };
- th.element.addEventListener('click', th.clickHandler);
- });
-
- pageLinks.forEach(function(link) {
- link.clickHandler = function(event) {
- var boundClickHandler = clickHandler.bind(this);
- var tableBoundingRect = scrollTable.getBoundingClientRect();
- var tableOptions = {};
- if (tableBoundingRect.top < HEADER_HEIGHT) {
- tableOptions.scrollTo = {
- top: (scrollTable.offsetTop || 0) - HEADER_HEIGHT,
- left: scrollTable.offsetLeft || 0,
- behavior: 'smooth',
- };
- }
- boundClickHandler(event, tableOptions);
- }
- link.element.addEventListener('click', link.clickHandler);
- });
-
- if (pagesizeForm) {
- var pagesizeSelect = pagesizeForm.querySelector('[name=' + tableIdent + '-pagesize]');
- pagesizeSelect.addEventListener('change', changePagesizeHandler);
- }
- }
-
- function removeListeners() {
- ths.forEach(function(th) {
- th.element.removeEventListener('click', th.clickHandler);
- });
-
- pageLinks.forEach(function(link) {
- link.element.removeEventListener('click', link.clickHandler);
- });
-
- if (pagesizeForm) {
- var pagesizeSelect = pagesizeForm.querySelector('[name=' + tableIdent + '-pagesize]')
- pagesizeSelect.removeEventListener('change', changePagesizeHandler);
- }
- }
-
- function clickHandler(event, tableOptions) {
- event.preventDefault();
- var url = getClickDestination(this);
- if (!url.match(/^http/)) {
- url = new URL(window.location.origin + window.location.pathname + getClickDestination(this));
- }
- updateTableFrom(url, tableOptions);
- }
-
- function getClickDestination(el) {
- if (!el.querySelector('a')) {
- return '';
- }
- return el.querySelector('a').getAttribute('href');
- }
-
- function changePagesizeHandler(event) {
- var pagesizeParamKey = tableIdent + '-pagesize';
- var pageParamKey = tableIdent + '-page';
- var url = new URL(options.currentUrl || window.location.href);
- url.searchParams.set(pagesizeParamKey, event.target.value);
- url.searchParams.set(pageParamKey, 0);
- updateTableFrom(url);
- }
-
- // fetches new sorted table from url with params and replaces contents of current table
- function updateTableFrom(url, tableOptions, callback) {
- if (!window.utils.httpClient) {
- throw new Error('httpClient not found!');
- }
-
- wrapper.classList.add(ASYNC_TABLE_LOADING_CLASS);
-
- tableOptions = tableOptions || {};
- var headers = {
- 'Accept': 'text/html',
- [shortCircuitHeader]: tableIdent
- };
- window.utils.httpClient.get(url, headers).then(function(response) {
- if (!response.ok) {
- throw new Error('Looks like there was a problem fetching ' + url.href + '. Status Code: ' + response.status);
- }
- return response.text();
- }).then(function(data) {
- tableOptions.currentUrl = url.href;
- removeListeners();
- updateWrapperContents(data, tableOptions);
- if (callback && typeof callback === 'function') {
- callback(wrapper);
- }
-
- wrapper.classList.remove(ASYNC_TABLE_LOADING_CLASS);
- }).catch(function(err) {
- console.error(err);
- });
- }
-
- function updateWrapperContents(newHtml, tableOptions) {
- tableOptions = tableOptions || {};
- wrapper.innerHTML = newHtml;
- wrapper.classList.remove(JS_INITIALIZED_CLASS);
- wrapper.classList.add(ASYNC_TABLE_CONTENT_CHANGED_CLASS);
-
- destroyUtils();
-
- // merge global options and table specific options
- var resetOptions = {};
- Object.keys(options)
- .filter(function(key) {
- return !RESET_OPTIONS.includes(key);
- })
- .forEach(function(key) {
- resetOptions[key] = options[key];
- });
- var combinedOptions = {};
- combinedOptions = Object.keys(tableOptions)
- .filter(function(key) {
- return tableOptions.hasOwnProperty(key);
- })
- .map(function(key) {
- return { key, value: tableOptions[key] }
- })
- .reduce(function(cumulatedOpts, opt) {
- cumulatedOpts[opt.key] = opt.value;
- return cumulatedOpts;
- }, resetOptions);
-
- window.utils.setup('asyncTable', wrapper, combinedOptions);
-
- Array.from(wrapper.querySelectorAll('form')).forEach(function(form) {
- utilInstances.push(window.utils.setup('form', form));
- });
- Array.from(wrapper.querySelectorAll('.modal')).forEach(function(modal) {
- utilInstances.push(window.utils.setup('modal', modal));
- });
- }
-
- function destroyUtils() {
- utilInstances.filter(function(utilInstance) {
- return !!utilInstance;
- }).forEach(function(utilInstance) {
- utilInstance.destroy();
- });
- }
-
- init();
-
- return {
- scope: wrapper,
- destroy: destroyUtils,
- };
- };
-})();
diff --git a/static/js/utils/asyncTableFilter.js b/static/js/utils/asyncTableFilter.js
deleted file mode 100644
index 6030ead66..000000000
--- a/static/js/utils/asyncTableFilter.js
+++ /dev/null
@@ -1,171 +0,0 @@
-(function () {
- 'use strict';
-
- window.utils = window.utils || {};
-
- var ASYNC_TABLE_FILTER_LOADING_CLASS = 'async-table-filter--loading';
- var JS_INITIALIZED_CLASS = 'js-async-table-filter-initialized';
- var INPUT_DEBOUNCE = 600;
-
- // debounce function, taken from Underscore.js
- function debounce(func, wait, immediate) {
- var timeout;
- return function() {
- var context = this, args = arguments;
- var later = function() {
- timeout = null;
- if (!immediate) func.apply(context, args);
- };
- var callNow = immediate && !timeout;
- clearTimeout(timeout);
- timeout = setTimeout(later, wait);
- if (callNow) func.apply(context, args);
- };
- };
-
- window.utils.asyncTableFilter = function(formElement, options) {
- if (!options || !options.updateTableFrom) {
- return false;
- }
-
- if (formElement.matches('.' + JS_INITIALIZED_CLASS)) {
- return false;
- }
-
- var formIdElement = formElement.querySelector('[name="form-identifier"]');
- if (!formIdElement) {
- return;
- }
-
- options = options || {};
- var tableIdent = options.dbtIdent;
- var formId = formIdElement.value;
- var inputs = {
- search: [],
- input: [],
- change: [],
- select: [],
- }
-
- function setup() {
- gatherInputs();
- addEventListeners();
- }
-
- function gatherInputs() {
- Array.from(formElement.querySelectorAll('input[type="search"]')).forEach(function(input) {
- inputs.search.push(input);
- });
-
- Array.from(formElement.querySelectorAll('input[type="text"]')).forEach(function(input) {
- inputs.input.push(input);
- });
-
- Array.from(formElement.querySelectorAll('input:not([type="text"]):not([type="search"])')).forEach(function(input) {
- inputs.change.push(input);
- });
-
- Array.from(formElement.querySelectorAll('select')).forEach(function(input) {
- inputs.select.push(input);
- });
- }
-
- function addEventListeners() {
- inputs.search.forEach(function(input) {
- var debouncedInput = debounce(function() {
- if (input.value.length === 0 || input.value.length > 2) {
- updateTable();
- }
- }, INPUT_DEBOUNCE);
- input.addEventListener('input', debouncedInput);
- });
-
- inputs.input.forEach(function(input) {
- var debouncedInput = debounce(function() {
- if (input.value.length === 0 || input.value.length > 2) {
- updateTable();
- }
- }, INPUT_DEBOUNCE);
- input.addEventListener('input', debouncedInput);
- });
-
- inputs.change.forEach(function(input) {
- input.addEventListener('change', function() {
- updateTable();
- });
- });
-
- inputs.select.forEach(function(input) {
- input.addEventListener('change', function() {
- updateTable();
- });
- });
-
- formElement.addEventListener('submit', function(event) {
- event.preventDefault();
- updateTable();
- });
- }
-
- function updateTable() {
- var url = serializeFormToURL();
- var callback = null;
-
- formElement.classList.add(ASYNC_TABLE_FILTER_LOADING_CLASS);
-
- var focusedSearch = inputs.search.reduce(function(acc, input) {
- return acc || (input.matches(':focus') && input);
- }, null);
- // focus search input
- if (focusedSearch) {
- var selectionStart = focusedSearch.selectionStart;
- callback = function(wrapper) {
- var search = wrapper.querySelector('input[type="search"]');
- if (search) {
- search.focus();
- search.selectionStart = selectionStart;
- }
- };
- }
- options.updateTableFrom(url, options, callback);
- }
-
- function serializeFormToURL() {
- var url = new URL(options.currentUrl || window.location.href);
- url.searchParams.set('form-identifier', formId);
- url.searchParams.set('_hasdata', 'true');
- url.searchParams.set(tableIdent + '-page', '0');
-
- inputs.search.forEach(function(input) {
- url.searchParams.set(input.name, input.value);
- });
-
- inputs.input.forEach(function(input) {
- url.searchParams.set(input.name, input.value);
- });
-
- inputs.change.forEach(function(input) {
- if (input.checked) {
- url.searchParams.set(input.name, input.value);
- }
- });
-
- inputs.select.forEach(function(select) {
- var options = Array.from(select.querySelectorAll('option'));
- var selected = options.find(function(option) { return option.selected });
- if (selected) {
- url.searchParams.set(select.name, selected.value);
- }
- });
-
- return url;
- }
-
- setup();
-
- return {
- scope: formElement,
- destroy: function() {},
- };
- }
-})();
diff --git a/static/js/utils/checkAll.js b/static/js/utils/checkAll.js
deleted file mode 100644
index b37a89454..000000000
--- a/static/js/utils/checkAll.js
+++ /dev/null
@@ -1,131 +0,0 @@
-(function() {
- 'use strict';
-
- window.utils = window.utils || {};
-
- var ASYNC_TABLE_CONTENT_CHANGED_CLASS = 'async-table--changed';
- var JS_INITIALIZED_CLASS = 'js-check-all-initialized';
- var CHECKBOX_SELECTOR = '[type="checkbox"]';
-
- function getCheckboxId() {
- return 'check-all-checkbox-' + Math.floor(Math.random() * 100000);
- }
-
- window.utils.checkAll = function(wrapper, options) {
-
- if ((!wrapper || wrapper.classList.contains(JS_INITIALIZED_CLASS)) && !wrapper.classList.contains(ASYNC_TABLE_CONTENT_CHANGED_CLASS)) {
- return false;
- }
- options = options || {};
-
- var columns = [];
- var checkboxColumn = [];
- var checkAllCheckbox = null;
-
- var utilInstances = [];
-
- function init() {
-
- columns = gatherColumns(wrapper);
-
- setupCheckAllCheckbox(findCheckboxColumn(columns));
-
- wrapper.classList.add(JS_INITIALIZED_CLASS);
- }
-
- function gatherColumns(table) {
- var rows = Array.from(table.querySelectorAll('tr'));
- var cols = [];
- rows.forEach(function(tr) {
- var cells = Array.from(tr.querySelectorAll('td'));
- cells.forEach(function(cell, cellIndex) {
- if (!cols[cellIndex]) {
- cols[cellIndex] = [];
- }
- cols[cellIndex].push(cell);
- });
- });
- return cols;
- }
-
- function findCheckboxColumn(columns) {
- var checkboxColumnId = null;
- columns.forEach(function(col, i) {
- if (isCheckboxColumn(col)) {
- checkboxColumnId = i;
- }
- });
- return checkboxColumnId;
- }
-
- function isCheckboxColumn(col) {
- var onlyCheckboxes = true;
- col.forEach(function(cell) {
- if (onlyCheckboxes && !cell.querySelector(CHECKBOX_SELECTOR)) {
- onlyCheckboxes = false;
- }
- });
- return onlyCheckboxes;
- }
-
- function setupCheckAllCheckbox(columnId) {
- if (columnId === null) {
- return;
- }
-
- checkboxColumn = columns[columnId];
- var firstRow = wrapper.querySelector('tr');
- var th = Array.from(firstRow.querySelectorAll('th, td'))[columnId];
- th.innerHTML = 'test';
- checkAllCheckbox = document.createElement('input');
- checkAllCheckbox.setAttribute('type', 'checkbox');
- checkAllCheckbox.setAttribute('id', getCheckboxId());
- th.innerHTML = '';
- th.insertBefore(checkAllCheckbox, null);
- utilInstances.push(window.utils.setup('checkbox', checkAllCheckbox));
-
- checkAllCheckbox.addEventListener('input', onCheckAllCheckboxInput);
- setupCheckboxListeners();
- }
-
- function onCheckAllCheckboxInput() {
- toggleAll(checkAllCheckbox.checked);
- }
-
- function setupCheckboxListeners() {
- checkboxColumn
- .map(function(cell) {
- return cell.querySelector(CHECKBOX_SELECTOR);
- })
- .forEach(function(checkbox) {
- checkbox.addEventListener('input', updateCheckAllCheckboxState);
- });
- }
-
- function updateCheckAllCheckboxState() {
- var allChecked = checkboxColumn.reduce(function(acc, cell) {
- return acc && cell.querySelector(CHECKBOX_SELECTOR).checked;
- }, true);
- checkAllCheckbox.checked = allChecked;
- }
-
- function toggleAll(checked) {
- checkboxColumn.forEach(function(cell) {
- cell.querySelector(CHECKBOX_SELECTOR).checked = checked;
- });
- }
-
- function destroy() {
- utilInstances.forEach(function(util) {
- util.destroy();
- });
- }
-
- init();
-
- return {
- scope: wrapper,
- destroy: destroy,
- };
- };
-})();
diff --git a/static/js/utils/form.js b/static/js/utils/form.js
deleted file mode 100644
index 77437a3e1..000000000
--- a/static/js/utils/form.js
+++ /dev/null
@@ -1,202 +0,0 @@
-(function() {
- 'use strict';
-
- window.utils = window.utils || {};
-
- var JS_INITIALIZED = 'js-form-initialized';
- var SUBMIT_BUTTON_SELECTOR = '[type="submit"]:not([formnovalidate])';
- var AUTOSUBMIT_BUTTON_SELECTOR = '[type="submit"][data-autosubmit]';
- var AJAX_SUBMIT_FLAG = 'ajaxSubmit';
-
- var FORM_GROUP_CLASS = 'form-group';
- var FORM_GROUP_WITH_ERRORS_CLASS = 'form-group--has-error';
-
- function formValidator(inputs) {
- var done = true;
- inputs.forEach(function(inp) {
- var len = inp.value.trim().length;
- if (done && len === 0) {
- done = false;
- }
- });
- return done;
- }
-
- window.utils.form = function(form, options) {
- options = options || {};
-
- // dont initialize form if it is in a modal and is not forced
- if (form.closest('.modal') && !options.force) {
- return false;
- }
-
- // dont initialize form if already initialized and should not be force-initialized
- if (form.classList.contains(JS_INITIALIZED) && !options.force) {
- return false;
- }
-
- var utilInstances = [];
-
- // reactive buttons
- utilInstances.push(window.utils.setup('reactiveButton', form));
-
- // conditonal fieldsets
- var fieldSets = Array.from(form.querySelectorAll('fieldset[data-conditional-id][data-conditional-value]'));
- utilInstances.push(window.utils.setup('interactiveFieldset', form, { fieldSets }));
-
- // hide autoSubmit submit button
- utilInstances.push(window.utils.setup('autoSubmit', form, options));
-
- // async form
- if (AJAX_SUBMIT_FLAG in form.dataset) {
- utilInstances.push(window.utils.setup('asyncForm', form, options));
- }
-
- // inputs
- utilInstances.push(window.utils.setup('inputs', form, options));
-
- // form group errors
- var formGroups = Array.from(form.querySelectorAll('.' + FORM_GROUP_CLASS));
- formGroups.forEach(function(formGroup) {
- utilInstances.push(window.utils.setup('errorRemover', formGroup, options));
- });
-
- form.classList.add(JS_INITIALIZED);
-
- function destroyUtils() {
- utilInstances.filter(function(utilInstance) {
- return !!utilInstance;
- }).forEach(function(utilInstance) {
- utilInstance.destroy();
- });
- }
-
- return {
- scope: form,
- destroy: destroyUtils,
- };
- };
-
- // registers input-listener for each element in (array) and
- // enables