diff --git a/.gitignore b/.gitignore index c37cbe326..f744360b3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ uniworx.nix src/Handler/Assist.bak src/Handler/Course.SnapCustom.hs *.orig +/instance .stack-work-* .directory tags diff --git a/app/DevelMain.hs b/app/DevelMain.hs index b327943d8..0a7a89562 100644 --- a/app/DevelMain.hs +++ b/app/DevelMain.hs @@ -33,13 +33,15 @@ module DevelMain where import Prelude import Application (getApplicationRepl, shutdownApp) -import Control.Exception (finally) +import Control.Monad.Catch (finally) import Control.Monad ((>=>)) import Control.Concurrent import Data.IORef import Foreign.Store import Network.Wai.Handler.Warp import GHC.Word +import Control.Monad.Trans.Resource +import Control.Monad.IO.Class -- | Start or restart the server. -- newStore is from foreign-store. @@ -71,13 +73,14 @@ update = do -- | Start the server in a separate thread. start :: MVar () -- ^ Written to when the thread is killed. -> IO ThreadId - start done = do - (port, site, app) <- getApplicationRepl - forkIO (finally (runSettings (setPort port defaultSettings) app) - -- Note that this implies concurrency - -- between shutdownApp and the next app that is starting. - -- Normally this should be fine - (putMVar done () >> shutdownApp site)) + start done = runResourceT $ do + (port, site, app) <- getApplicationRepl + resourceForkIO $ do + finally (liftIO $ runSettings (setPort port defaultSettings) app) + -- Note that this implies concurrency + -- between shutdownApp and the next app that is starting. + -- Normally this should be fine + (liftIO $ putMVar done () >> shutdownApp site) -- | kill the server shutdown :: IO () diff --git a/config/keter_testworx.yml b/config/keter_testworx.yml index 102573866..d719af918 100644 --- a/config/keter_testworx.yml +++ b/config/keter_testworx.yml @@ -31,9 +31,21 @@ stanzas: - DUMMY_LOGIN - DETAILED_LOGGING - LOG_ALL + - LOGLEVEL + - ALLOW_DEPRECATED - PWFILE - CRYPTOID_KEYFILE - IP_FROM_HEADER + - MAILFROM_NAME + - MAILFROM_EMAIL + - MAILOBJECT_DOMAIN + - SMTPHOST + - SMTPPORT + - SMTPSSL + - SMTPUSER + - SMTPPASS + - SMTPTIMEOUT + - SMTPLIMIT # Use the following to automatically copy your bundle upon creation via `yesod # keter`. Uses `scp` internally, so you can set it to a remote destination diff --git a/config/keter_uni2work.yml b/config/keter_uni2work.yml index aefd5a30a..d6c440632 100644 --- a/config/keter_uni2work.yml +++ b/config/keter_uni2work.yml @@ -30,9 +30,22 @@ stanzas: - LDAPTIMEOUT - DETAILED_LOGGING - LOG_ALL + - LOGLEVEL + - ALLOW_DEPRECATED - PWFILE - CRYPTOID_KEYFILE - IP_FROM_HEADER + - MAILFROM_NAME + - MAILFROM_EMAIL + - MAILOBJECT_DOMAIN + - SMTPHOST + - SMTPPORT + - SMTPSSL + - SMTPUSER + - SMTPPASS + - SMTPTIMEOUT + - SMTPLIMIT + # Use the following to automatically copy your bundle upon creation via `yesod # keter`. Uses `scp` internally, so you can set it to a remote destination diff --git a/config/settings.yml b/config/settings.yml index 75d5af052..02598c3f6 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -8,6 +8,19 @@ host: "_env:HOST:*4" # any IPv4 host port: "_env:PORT:3000" ip-from-header: "_env:IP_FROM_HEADER:false" approot: "_env:APPROOT:http://localhost:3000" +mail-from: + name: "_env:MAILFROM_NAME:Uni2Work" + email: "_env:MAILFROM_EMAIL:uniworx@localhost" +mail-object-domain: "_env:MAILOBJECT_DOMAIN:localhost" +mail-verp: + separator: "+" + at-replacement: "=" + +job-workers: "_env:JOB_WORKERS:10" +job-flush-interval: "_env:JOB_FLUSH:30" +job-cron-interval: "_env:CRON_INTERVAL:60" +job-stale-threshold: 300 +notification-rate-limit: 3600 detailed-logging: "_env:DETAILED_LOGGING:false" should-log-all: "_env:LOG_ALL:false" @@ -44,6 +57,19 @@ ldap: scope: "_env:LDAPSCOPE:WholeSubtree" timeout: "_env:LDAPTIMEOUT:5" +smtp: + host: "_env:SMTPHOST:" + port: "_env:SMTPPORT:25" + ssl: "_env:SMTPSSL:starttls" + auth: + type: "login" + user: "_env:SMTPUSER:" + pass: "_env:SMTPPASS:" + pool: + stripes: "_env:SMTPSTRIPES:1" + timeout: "_env:SMTPTIMEOUT:20" + limit: "_env:SMTPLIMIT:10" + user-defaults: max-favourites: 12 theme: Default @@ -53,3 +79,4 @@ user-defaults: download-files: false cryptoid-keyfile: "_env:CRYPTOID_KEYFILE:cryptoid_key.bf" +instance-idfile: "_env:INSTANCEID_FILE:instance" diff --git a/db.hs b/db.hs index 3bb77bcf5..adf008619 100755 --- a/db.hs +++ b/db.hs @@ -1,11 +1,12 @@ #!/usr/bin/env stack --- stack runghc +-- stack runghc --package uniworx {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PackageImports #-} {-# LANGUAGE NoImplicitPrelude #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeFamilies #-} import "uniworx" Import hiding (Option(..)) import "uniworx" Application (db, getAppDevSettings) @@ -20,6 +21,8 @@ import System.IO (hPutStrLn, stderr) import qualified Data.ByteString as BS +import Database.Persist.Sql (toSqlKey) + import Data.Time @@ -62,6 +65,8 @@ fillDb = do AppSettings{ appUserDefaults = UserDefaultConf{..}, .. } <- getsYesod appSettings now <- liftIO getCurrentTime let + insert' :: PersistRecordBackend r (YesodPersistBackend UniWorX) => r -> YesodDB UniWorX (Key r) + insert' = fmap (either entityKey id) . insertBy summer2017 = TermIdentifier 2017 Summer winter2017 = TermIdentifier 2017 Winter summer2018 = TermIdentifier 2018 Summer @@ -78,6 +83,8 @@ fillDb = do , userDateFormat = userDefaultDateFormat , userTimeFormat = userDefaultTimeFormat , userDownloadFiles = userDefaultDownloadFiles + , userMailLanguages = MailLanguages ["en"] + , userNotificationSettings = def } fhamann <- insert User { userIdent = "felix.hamann@campus.lmu.de" @@ -92,6 +99,8 @@ fillDb = do , userDateFormat = userDefaultDateFormat , userTimeFormat = userDefaultTimeFormat , userDownloadFiles = userDefaultDownloadFiles + , userMailLanguages = MailLanguages ["de"] + , userNotificationSettings = def } jost <- insert User { userIdent = "jost@tcs.ifi.lmu.de" @@ -106,6 +115,8 @@ fillDb = do , userDateFormat = userDefaultDateFormat , userTimeFormat = userDefaultTimeFormat , userDownloadFiles = userDefaultDownloadFiles + , userMailLanguages = MailLanguages ["de"] + , userNotificationSettings = def } void . insert $ User { userIdent = "max@campus.lmu.de" @@ -120,6 +131,8 @@ fillDb = do , userDateFormat = userDefaultDateFormat , userTimeFormat = userDefaultTimeFormat , userDownloadFiles = userDefaultDownloadFiles + , userMailLanguages = MailLanguages ["de"] + , userNotificationSettings = def } void . insert $ User { userIdent = "tester@campus.lmu.de" @@ -134,8 +147,10 @@ fillDb = do , userDateFormat = userDefaultDateFormat , userTimeFormat = userDefaultTimeFormat , userDownloadFiles = userDefaultDownloadFiles + , userMailLanguages = MailLanguages ["de"] + , userNotificationSettings = def } - void . insert $ Term + void . repsert (TermKey summer2017) $ Term { termName = summer2017 , termStart = fromGregorian 2017 04 09 , termEnd = fromGregorian 2017 07 14 @@ -144,7 +159,7 @@ fillDb = do , termLectureEnd = fromGregorian 2018 07 14 , termActive = False } - void . insert $ Term + void . repsert (TermKey winter2017) $ Term { termName = winter2017 , termStart = fromGregorian 2017 10 16 , termEnd = fromGregorian 2018 02 10 @@ -153,7 +168,7 @@ fillDb = do , termLectureEnd = fromGregorian 2018 02 10 , termActive = True } - void . insert $ Term + void . repsert (TermKey summer2018) $ Term { termName = summer2018 , termStart = fromGregorian 2018 04 09 , termEnd = fromGregorian 2018 07 14 @@ -162,22 +177,28 @@ fillDb = do , termLectureEnd = fromGregorian 2018 07 14 , termActive = True } - ifi <- insert $ School "Institut für Informatik" "IfI" - mi <- insert $ School "Institut für Mathematik" "MI" - void . insert $ UserAdmin gkleen ifi - void . insert $ UserAdmin gkleen mi - void . insert $ UserAdmin fhamann ifi - void . insert $ UserAdmin jost ifi - void . insert $ UserAdmin jost mi - void . insert $ UserLecturer gkleen ifi - void . insert $ UserLecturer fhamann ifi - void . insert $ UserLecturer jost ifi - sdBsc <- insert $ StudyDegree 82 (Just "BSc") (Just "Bachelor" ) - sdMst <- insert $ StudyDegree 88 (Just "MSc") (Just "Master" ) - sdInf <- insert $ StudyTerms 79 (Just "Inf") (Just "Informatik") - sdMath <- insert $ StudyTerms 105 (Just "M" ) (Just "Mathematik") + ifi <- insert' $ School "Institut für Informatik" "IfI" + mi <- insert' $ School "Institut für Mathematik" "MI" + void . insert' $ UserAdmin gkleen ifi + void . insert' $ UserAdmin gkleen mi + void . insert' $ UserAdmin fhamann ifi + void . insert' $ UserAdmin jost ifi + void . insert' $ UserAdmin jost mi + void . insert' $ UserLecturer gkleen ifi + void . insert' $ UserLecturer fhamann ifi + void . insert' $ UserLecturer jost ifi + let + sdBsc = StudyDegreeKey' 82 + sdMst = StudyDegreeKey' 88 + repsert sdBsc $ StudyDegree 82 (Just "BSc") (Just "Bachelor" ) + repsert sdMst $ StudyDegree 88 (Just "MSc") (Just "Master" ) + let + sdInf = StudyTermsKey' 79 + sdMath = StudyTermsKey' 105 + repsert sdInf $ StudyTerms 79 (Just "Inf") (Just "Informatik") + repsert sdMath $ StudyTerms 105 (Just "M" ) (Just "Mathematik") -- FFP - ffp <- insert Course + ffp <- insert' Course { courseName = "Fortgeschrittene Funktionale Programmierung" , courseDescription = Nothing , courseLinkExternal = Nothing @@ -203,7 +224,7 @@ fillDb = do sheetkey <- insert $ Sheet ffp "Keine Gruppen" Nothing NotGraded NoGroups Nothing Nothing now now Nothing Nothing (Upload True) insert_ $ SheetEdit gkleen now sheetkey -- EIP - eip <- insert Course + eip <- insert' Course { courseName = "Einführung in die Programmierung" , courseDescription = Nothing , courseLinkExternal = Nothing @@ -218,10 +239,10 @@ fillDb = do , courseMaterialFree = True } insert_ $ CourseEdit fhamann now eip - void . insert $ DegreeCourse eip sdBsc sdInf - void . insert $ Lecturer fhamann eip + void . insert' $ DegreeCourse eip sdBsc sdInf + void . insert' $ Lecturer fhamann eip -- interaction design - ixd <- insert Course + ixd <- insert' Course { courseName = "Interaction Design (User Experience Design I & II)" , courseDescription = Nothing , courseLinkExternal = Nothing @@ -236,10 +257,10 @@ fillDb = do , courseMaterialFree = True } insert_ $ CourseEdit fhamann now ixd - void . insert $ DegreeCourse ixd sdBsc sdInf - void . insert $ Lecturer fhamann ixd + void . insert' $ DegreeCourse ixd sdBsc sdInf + void . insert' $ Lecturer fhamann ixd -- concept development - ux3 <- insert Course + ux3 <- insert' Course { courseName = "Concept Development (User Experience Design III)" , courseDescription = Nothing , courseLinkExternal = Nothing @@ -254,10 +275,10 @@ fillDb = do , courseMaterialFree = True } insert_ $ CourseEdit fhamann now ux3 - void . insert $ DegreeCourse ux3 sdBsc sdInf - void . insert $ Lecturer fhamann ux3 + void . insert' $ DegreeCourse ux3 sdBsc sdInf + void . insert' $ Lecturer fhamann ux3 -- promo - pmo <- insert Course + pmo <- insert' Course { courseName = "Programmierung und Modellierung" , courseDescription = Nothing , courseLinkExternal = Nothing @@ -298,7 +319,7 @@ fillDb = do void . insert $ SheetFile sh1 h103 SheetSolution void . insert $ SheetFile sh1 pdf10 SheetExercise -- datenbanksysteme - dbs <- insert Course + dbs <- insert' Course { courseName = "Datenbanksysteme" , courseDescription = Nothing , courseLinkExternal = Nothing @@ -313,7 +334,7 @@ fillDb = do , courseMaterialFree = True } insert_ $ CourseEdit gkleen now dbs - void . insert $ DegreeCourse dbs sdBsc sdInf - void . insert $ DegreeCourse dbs sdBsc sdMath - void . insert $ Lecturer gkleen dbs - void . insert $ Lecturer jost dbs + void . insert' $ DegreeCourse dbs sdBsc sdInf + void . insert' $ DegreeCourse dbs sdBsc sdMath + void . insert' $ Lecturer gkleen dbs + void . insert' $ Lecturer jost dbs diff --git a/ghci.sh b/ghci.sh index 5139c7c72..825a936f0 100755 --- a/ghci.sh +++ b/ghci.sh @@ -16,4 +16,4 @@ if [[ -d .stack-work-ghci ]]; then trap move-back EXIT fi -stack ghci --flag uniworx:dev --flag uniworx:library-only +stack ghci --flag uniworx:dev --flag uniworx:library-only ${@} diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index 1bd1ddd42..afbffb76e 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -245,6 +245,7 @@ RatingComment: Kommentar SubmissionUsers: Studenten Rating: Korrektur RatingPoints: Punkte +RatingDone: Bewertung fertiggestellt RatingPercent: Erreicht RatingFiles: Korrigierte Dateien PointsNotPositive: Punktzahl darf nicht negativ sein @@ -274,6 +275,7 @@ DateFormat: Datumsformat TimeFormat: Uhrzeitformat DownloadFiles: Dateien automatisch herunterladen DownloadFilesTip: Wenn gesetzt werden Dateien von Abgaben und Übungsblättern automatisch als Download behandelt, ansonsten ist das Verhalten browserabhängig (es können z.B. PDFs im Browser geöffnet werden). +NotificationSettings: Erwünschte Benachrichtigungen InvalidDateTimeFormat: Ungültiges Datums- und Zeitformat, JJJJ-MM-TTTHH:MM[:SS] Format erwartet AmbiguousUTCTime: Der angegebene Zeitpunkt lässt sich nicht eindeutig zu UTC konvertieren @@ -309,3 +311,47 @@ SubmissionNoUploadExpected: Es ist keine Abgabe von Dateien vorgesehen. FieldPrimary: Hauptfach FieldSecondary: Nebenfach + +MailTestFormEmail: Email-Addresse +MailTestFormLanguages: Spracheinstellungen + +MailTestSubject: Uni2Work Test-Email +MailTestContent: Dies ist eine Test-Email versandt von Uni2Work. Von Ihrer Seite ist keine Handlung notwendig. +MailTestDateTime: Test der Datumsformattierung: + +German: Deutsch +GermanGermany: Deutsch (Deutschland) + +MailSubjectSubmissionRated csh@CourseShorthand: Ihre #{csh}-Abgabe wurde korrigiert +MailSubmissionRatedIntro courseName@Text termDesc@Text: Ihre Abgabe im Kurs #{courseName} (#{termDesc}) wurde korrigiert. + +MailSubjectSheetActive csh@CourseShorthand sheetName@SheetName: #{sheetName} in #{csh} wurde herausgegeben +MailSheetActiveIntro courseName@Text termDesc@Text sheetName@SheetName: Sie können nun #{sheetName} im Kurs #{courseName} (#{termDesc}) herunterladen. + +MailSubjectSheetInactive csh@CourseShorthand sheetName@SheetName: #{sheetName} in #{csh} kann nur noch kurze Zeit abgegeben werden +MailSheetInactiveIntro courseName@Text termDesc@Text sheetName@SheetName: Dia Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) endet in Kürze. + +SheetTypeBonus: Bonus +SheetTypeNormal: Normal +SheetTypePass: Bestehen +SheetTypeNotGraded: Keine Wertung + +SheetTypeBonus' maxPoints@Points: #{tshow maxPoints} Bonuspunkte +SheetTypeNormal' maxPoints@Points: #{tshow maxPoints} Punkte +SheetTypePass' maxPoints@Points passingPoints@Points: Bestanden ab #{tshow passingPoints} von #{tshow maxPoints} Punkten +SheetTypeNotGraded': Nicht gewertet + +SheetTypeMaxPoints: Maximalpunktzahl +SheetTypePassingPoints: Notwendig zum Bestehen + +SheetGroupArbitrary: Arbiträre Gruppen +SheetGroupRegisteredGroups: Registrierte Gruppen +SheetGroupNoGroups: Keine Gruppenabgabe +SheetGroupMaxGroupsize: Maximale Gruppengröße + +SheetFiles: Übungsblatt-Dateien + +NotificationTriggerSubmissionRatedGraded: Meine Abgabe in einem gewerteten Übungsblatt wurde korrigiert +NotificationTriggerSubmissionRated: Meine Abgabe wurde korrigiert +NotificationTriggerSheetActive: Ich kann ein neues Übungsblatt herunterladen +NotificationTriggerSheetInactive: Ich kann ein Übungsblatt bald nicht mehr abgeben \ No newline at end of file diff --git a/models b/models index 194a9c063..cb599b025 100644 --- a/models +++ b/models @@ -11,6 +11,8 @@ User json dateFormat DateTimeFormat "default='%d.%m.%Y'" timeFormat DateTimeFormat "default='%R'" downloadFiles Bool default=false + mailLanguages MailLanguages "default='[]'" + notificationSettings NotificationSettings UniqueAuthentication ident UniqueEmail email deriving Show @@ -221,3 +223,15 @@ Exam -- -- CONTINUE HERE: Include rating in this table or separately? -- UniqueExamUser user examId -- By default this file is used in Model.hs (which is imported by Foundation.hs) +QueuedJob + content Value + creationInstance InstanceId + creationTime UTCTime + lockInstance InstanceId Maybe + lockTime UTCTime Maybe + deriving Eq Read Show Generic Typeable +CronLastExec + job Value + time UTCTime + instance InstanceId + UniqueCronLastExec job \ No newline at end of file diff --git a/package.yaml b/package.yaml index 613489a82..c9f2e9858 100644 --- a/package.yaml +++ b/package.yaml @@ -77,6 +77,9 @@ dependencies: - parsec - uuid - exceptions +- stm +- stm-chans +- stm-conduit - lens - MonadRandom - email-validate @@ -92,6 +95,15 @@ dependencies: - universe-base - random-shuffle - th-abstraction +- HaskellNet +- HaskellNet-SSL +- network +- resource-pool +- mime-mail +- hashable +- aeson-pretty +- resourcet +- postgresql-simple # The library contains all of our application code. The executable # defined below is just a thin wrapper. diff --git a/src/Application.hs b/src/Application.hs index 9c4cb5a54..4f07f3de0 100644 --- a/src/Application.hs +++ b/src/Application.hs @@ -5,6 +5,7 @@ {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE ViewPatterns #-} {-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE FlexibleContexts #-} {-# OPTIONS_GHC -fno-warn-orphans #-} module Application ( getApplicationDev, getAppDevSettings @@ -13,6 +14,7 @@ module Application , makeFoundation , makeLogWare -- * for DevelMain + , foundationStoreNum , getApplicationRepl , shutdownApp -- * for GHCI @@ -21,7 +23,7 @@ module Application , addPWEntry ) where -import Control.Monad.Logger (liftLoc, runLoggingT) +import Control.Monad.Logger (liftLoc, LoggingT(..), MonadLoggerIO(..)) import Database.Persist.Postgresql (createPostgresqlPool, pgConnStr, pgPoolSize, runSqlPool) import Import @@ -38,12 +40,30 @@ import Network.Wai.Middleware.RequestLogger (Destination (Logger), import System.Log.FastLogger (defaultBufSize, newStdoutLoggerSet, toLogStr) +import Foreign.Store + +import qualified Data.UUID as UUID +import qualified Data.UUID.V4 as UUID + +import System.Directory +import System.FilePath + +import Jobs + import qualified Data.Text.Encoding as Text import Yesod.Auth.Util.PasswordStore -import qualified Data.ByteString.Char8 as BS -import qualified Data.Yaml as Yaml +import qualified Data.ByteString.Lazy as LBS +import Network.HaskellNet.SSL hiding (Settings) +import Network.HaskellNet.SMTP.SSL as SMTP hiding (Settings) +import Data.Pool + +import Control.Monad.Trans.Resource + +import System.Log.FastLogger.Date +import qualified Yesod.Core.Types as Yesod (Logger(..)) + -- Import all relevant handler modules here. -- (HPack takes care to add new modules to our cabal file nowadays.) import Handler.Common @@ -69,52 +89,107 @@ mkYesodDispatch "UniWorX" resourcesUniWorX -- performs initialization and returns a foundation datatype value. This is also -- the place to put your migrate statements to have automatic database -- migrations handled by Yesod. -makeFoundation :: AppSettings -> IO UniWorX +makeFoundation :: (MonadResource m, MonadBaseControl IO m) => AppSettings -> m UniWorX makeFoundation appSettings@(AppSettings{..}) = do -- Some basic initializations: HTTP connection manager, logger, and static -- subsite. appHttpManager <- newManager - appLogger <- newStdoutLoggerSet defaultBufSize >>= makeYesodLogger - appStatic <- - (if appMutableStatic then staticDevel else static) - appStaticDir + appLogger <- liftIO $ do + tgetter <- newTimeCache "%Y-%m-%d %T %z" + loggerSet <- newStdoutLoggerSet defaultBufSize + return $ Yesod.Logger loggerSet tgetter + appStatic <- liftIO $ bool static staticDevel appMutableStatic appStaticDir appCryptoIDKey <- readKeyFile appCryptoIDKeyFile + appInstanceID <- liftIO $ maybe UUID.nextRandom readInstanceIDFile appInstanceIDFile + + (appJobCtl, recvChans) <- fmap unzip . atomically . replicateM appJobWorkers $ do + chan <- newBroadcastTMChan + recvChan <- dupTMChan chan + return (chan, recvChan) -- We need a log function to create a connection pool. We need a connection -- pool to create our foundation. And we need our foundation to get a -- logging function. To get out of this loop, we initially create a -- temporary foundation without a real connection pool, get a log function -- from there, and then create the real foundation. - let mkFoundation appConnPool = UniWorX {..} + let mkFoundation appConnPool appSmtpPool = UniWorX {..} -- The UniWorX {..} syntax is an example of record wild cards. For more -- information, see: -- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html - tempFoundation = mkFoundation $ error "connPool forced in tempFoundation" + tempFoundation = mkFoundation (error "connPool forced in tempFoundation") (error "smtpPool forced in tempFoundation") logFunc = messageLoggerSource tempFoundation appLogger - -- Create the database connection pool - pool <- flip runLoggingT logFunc $ createPostgresqlPool + flip runLoggingT logFunc $ do + $logDebugS "InstanceID" $ UUID.toText appInstanceID + -- $logDebugS "Configuration" $ tshow appSettings + + smtpPool <- traverse createSmtpPool appSmtpConf + + -- Create the database connection pool + sqlPool <- createPostgresqlPool (pgConnStr appDatabaseConf) (pgPoolSize appDatabaseConf) + + -- Perform database migration using our application's logging settings. + migrateAll `runSqlPool` sqlPool - -- Perform database migration using our application's logging settings. - runLoggingT (runSqlPool migrateAll pool) logFunc + handleJobs recvChans $ mkFoundation sqlPool smtpPool - -- Return the foundation - return $ mkFoundation pool + -- Return the foundation + return $ mkFoundation sqlPool smtpPool + +readInstanceIDFile :: MonadIO m => FilePath -> m UUID +readInstanceIDFile idFile = liftIO . handle generateInstead $ LBS.readFile idFile >>= parseBS + where + parseBS :: LBS.ByteString -> IO UUID + parseBS = maybe (throwString "appInstanceIDFile does not contain an UUID encoded in network byte order") return . UUID.fromByteString + generateInstead :: IOException -> IO UUID + generateInstead e + | isDoesNotExistError e = do + createDirectoryIfMissing True $ takeDirectory idFile + instanceId <- UUID.nextRandom + LBS.writeFile idFile $ UUID.toByteString instanceId + return instanceId + | otherwise = throw e + +createSmtpPool :: MonadLoggerIO m => SmtpConf -> m SMTPPool +createSmtpPool SmtpConf{ smtpPool = ResourcePoolConf{..}, .. } = do + logFunc <- askLoggerIO + let + withLogging :: LoggingT IO a -> IO a + withLogging = flip runLoggingT logFunc + + mkConnection = withLogging $ do + $logInfoS "SMTP" "Opening new connection" + liftIO mkConnection' + mkConnection' + | SmtpSslNone <- smtpSsl = connectSMTPPort smtpHost smtpPort + | SmtpSslSmtps <- smtpSsl = connectSMTPSSLWithSettings smtpHost $ defaultSettingsWithPort smtpPort + | SmtpSslStarttls <- smtpSsl = connectSMTPSTARTTLSWithSettings smtpHost $ defaultSettingsWithPort smtpPort + reapConnection conn = withLogging $ do + $logDebugS "SMTP" "Closing connection" + liftIO $ closeSMTP conn + applyAuth :: SmtpAuthConf -> SMTPConnection -> IO SMTPConnection + applyAuth SmtpAuthConf{..} conn = withLogging $ do + $logDebugS "SMTP" "Doing authentication" + authSuccess <- liftIO $ SMTP.authenticate smtpAuthType smtpAuthUsername smtpAuthPassword conn + when (not authSuccess) $ do + fail "SMTP authentication failed" + return conn + liftIO $ createPool (mkConnection >>= maybe return applyAuth smtpAuth) reapConnection poolStripes poolTimeout poolLimit -- | Convert our foundation to a WAI Application by calling @toWaiAppPlain@ and -- applying some additional middlewares. -makeApplication :: UniWorX -> IO Application -makeApplication foundation = do +makeApplication :: MonadIO m => UniWorX -> m Application +makeApplication foundation = liftIO $ do logWare <- makeLogWare foundation -- Create the WAI application and apply middlewares appPlain <- toWaiAppPlain foundation return $ logWare $ defaultMiddlewaresNoLogging appPlain -makeLogWare :: UniWorX -> IO Middleware -makeLogWare foundation = +makeLogWare :: MonadIO m => UniWorX -> m Middleware +makeLogWare foundation = liftIO $ mkRequestLogger def { outputFormat = if appDetailedRequestLogging $ appSettings foundation @@ -143,26 +218,29 @@ warpSettings foundation = defaultSettings -- | For yesod devel, return the Warp settings and WAI Application. -getApplicationDev :: IO (Settings, Application) +getApplicationDev :: (MonadResource m, MonadBaseControl IO m) => m (Settings, Application) getApplicationDev = do settings <- getAppDevSettings foundation <- makeFoundation settings - wsettings <- getDevSettings $ warpSettings foundation + wsettings <- liftIO . getDevSettings $ warpSettings foundation app <- makeApplication foundation return (wsettings, app) -getAppDevSettings :: IO AppSettings -getAppDevSettings = loadYamlSettings [configSettingsYml] [configSettingsYmlValue] useEnv +getAppDevSettings :: MonadIO m => m AppSettings +getAppDevSettings = liftIO $ loadYamlSettings [configSettingsYml] [configSettingsYmlValue] useEnv -- | main function for use by yesod devel develMain :: IO () -develMain = develMainHelper getApplicationDev +develMain = runResourceT $ do + app <- getApplicationDev + liftIO . develMainHelper $ return app -- | The @main@ function for an executable running this site. -appMain :: IO () -appMain = do +appMain :: MonadResourceBase m => m () +appMain = runResourceT $ do -- Get the settings from all relevant sources - settings <- loadYamlSettingsArgs + settings <- liftIO $ + loadYamlSettingsArgs -- fall back to compile-time values, set to [] to require values at runtime [configSettingsYmlValue] @@ -176,22 +254,31 @@ appMain = do app <- makeApplication foundation -- Run the application with Warp - runSettings (warpSettings foundation) app + liftIO $ runSettings (warpSettings foundation) app -------------------------------------------------------------- -- Functions for DevelMain.hs (a way to run the app from GHCi) -------------------------------------------------------------- -getApplicationRepl :: IO (Int, UniWorX, Application) +foundationStoreNum :: Word32 +foundationStoreNum = 2 + +getApplicationRepl :: (MonadResource m, MonadBaseControl IO m) => m (Int, UniWorX, Application) getApplicationRepl = do settings <- getAppDevSettings foundation <- makeFoundation settings - wsettings <- getDevSettings $ warpSettings foundation + wsettings <- liftIO . getDevSettings $ warpSettings foundation app1 <- makeApplication foundation + + let foundationStore = Store foundationStoreNum + liftIO $ deleteStore foundationStore + liftIO $ writeStore foundationStore foundation + return (getPort wsettings, foundation, app1) -shutdownApp :: UniWorX -> IO () -shutdownApp _ = return () +shutdownApp :: MonadIO m => UniWorX -> m () +shutdownApp UniWorX{..} = do + liftIO . atomically $ mapM_ closeTMChan appJobCtl --------------------------------------------- @@ -200,7 +287,7 @@ shutdownApp _ = return () -- | Run a handler handler :: Handler a -> IO a -handler h = getAppDevSettings >>= makeFoundation >>= flip unsafeHandler h +handler h = runResourceT $ getAppDevSettings >>= makeFoundation >>= liftIO . flip unsafeHandler h -- | Run DB queries db :: ReaderT SqlBackend (HandlerT UniWorX IO) a -> IO a @@ -209,7 +296,7 @@ db = handler . runDB addPWEntry :: User -> Text {-^ Password -} -> IO () -addPWEntry User{..} (Text.encodeUtf8 -> pw) = db $ do +addPWEntry User{ userAuthentication = _, ..} (Text.encodeUtf8 -> pw) = db $ do PWHashConf{..} <- getsYesod $ appAuthPWHash . appSettings (AuthPWHash . Text.decodeUtf8 -> userAuthentication) <- liftIO $ makePasswordWith pwHashAlgorithm pw pwHashStrength void $ insert User{..} diff --git a/src/Cron.hs b/src/Cron.hs new file mode 100644 index 000000000..a17230f15 --- /dev/null +++ b/src/Cron.hs @@ -0,0 +1,228 @@ +{-# LANGUAGE NoImplicitPrelude + , RecordWildCards + , PatternGuards + , ViewPatterns + , DeriveFunctor + , TemplateHaskell + , NamedFieldPuns + #-} + +module Cron + ( CronNextMatch(..) + , nextCronMatch + , module Cron.Types + ) where + +import ClassyPrelude +import Prelude (lcm) +import Cron.Types + +import Data.Time +import Data.Time.Calendar.OrdinalDate (toOrdinalDate, fromOrdinalDateValid) +import Data.Time.Calendar.WeekDate (toWeekDate, fromWeekDate, fromWeekDateValid) +import Data.Time.Zones + +import Numeric.Natural +import Data.Ratio ((%)) + +import qualified Data.Set as Set + +import Data.List.NonEmpty (NonEmpty(..)) +import qualified Data.List.NonEmpty as NonEmpty + +import Utils.Lens.TH +import Control.Lens + + +data CronDate = CronDate + { cdYear, cdWeekOfYear, cdDayOfYear + , cdMonth, cdWeekOfMonth, cdDayOfMonth + , cdDayOfWeek + , cdHour, cdMinute, cdSecond :: Natural + } deriving (Eq, Show, Read) + +makeLenses_ ''CronDate + + +evalCronMatch :: CronMatch -> Natural -> Bool +evalCronMatch CronMatchAny _ = True +evalCronMatch CronMatchNone _ = False +evalCronMatch (CronMatchSome set) x = Set.member x $ toNullable set +evalCronMatch (CronMatchStep step) x = (x `mod` step) == 0 +evalCronMatch (CronMatchContiguous from to) x = from <= x && x <= to +evalCronMatch (CronMatchIntersect a b) x = evalCronMatch a x && evalCronMatch b x +evalCronMatch (CronMatchUnion a b) x = evalCronMatch a x || evalCronMatch b x + +toCronDate :: LocalTime -> CronDate +toCronDate LocalTime{..} = CronDate{..} + where + (fromInteger -> cdYear, fromIntegral -> cdMonth, fromIntegral -> cdDayOfMonth) + = toGregorian localDay + (_, fromIntegral -> cdDayOfYear) + = toOrdinalDate localDay + (_, fromIntegral -> cdWeekOfYear, fromIntegral -> cdDayOfWeek) + = toWeekDate localDay + cdWeekOfMonth = go 1 localDay + where + go :: Natural -> Day -> Natural + go n day + | dow /= 4 = go n $ fromWeekDate y w 4 -- According to ISO week of month is determined by Thursday + | m == m' = go (succ n) day' + | otherwise = n + where + (y, w, dow) = toWeekDate day + day' + | w /= 0 = fromWeekDate y (pred w) dow + | otherwise = fromWeekDate (pred y) 53 dow + (_, m, _) = toGregorian day + (_, m', _) = toGregorian day' + TimeOfDay + { todHour = fromIntegral -> cdHour + , todMin = fromIntegral -> cdMinute + , todSec = round -> cdSecond + } = localTimeOfDay + +consistentCronDate :: CronDate -> Bool +consistentCronDate cd@CronDate{ cdWeekOfMonth = _, ..} = fromMaybe False $ do + gDay <- fromGregorianValid (fromIntegral cdYear) (fromIntegral cdMonth) (fromIntegral cdDayOfMonth) + wDay <- fromWeekDateValid (fromIntegral cdYear) (fromIntegral cdWeekOfYear) (fromIntegral cdDayOfWeek) + guard $ gDay == wDay + oDay <- fromOrdinalDateValid (fromIntegral cdYear) (fromIntegral cdDayOfYear) + guard $ wDay == oDay + guard $ ((==) `on` cdWeekOfMonth) cd (toCronDate $ LocalTime wDay (error "TimeOfDay inspected in toCronDate")) + return True + + +data CronNextMatch a = MatchAsap | MatchAt a | MatchNone + deriving (Eq, Ord, Show, Read, Functor) + +instance Applicative CronNextMatch where + pure = MatchAt + _ <*> MatchNone = MatchNone + MatchNone <*> _ = MatchNone + _ <*> MatchAsap = MatchAsap + MatchAsap <*> _ = MatchAsap + MatchAt f <*> MatchAt x = MatchAt $ f x + +instance Alternative CronNextMatch where + empty = MatchNone + x <|> MatchNone = x + MatchNone <|> x = x + _ <|> MatchAsap = MatchAsap + MatchAsap <|> _ = MatchAsap + (MatchAt a) <|> (MatchAt _) = MatchAt a + + +listToMatch :: [a] -> CronNextMatch a +listToMatch [] = MatchNone +listToMatch (t:_) = MatchAt t + +genMatch :: Int -- ^ Period + -> Bool -- ^ Modular + -> Natural -- ^ Start value + -> CronMatch + -> [Natural] +genMatch p m st CronMatchAny = take p $ map (bool id (succ . (`mod` fromIntegral p)) m) [st..] +genMatch _ _ _ CronMatchNone = [] +genMatch p m _ (CronMatchSome set) = take p . map (bool id (succ . (`mod` fromIntegral p)) m) . Set.toAscList $ toNullable set +genMatch p m st (CronMatchStep step) = do + start <- [st..st + step] + guard $ (start `mod` step) == 0 + take (ceiling $ fromIntegral p % step) $ map (bool id (succ . (`mod` fromIntegral p)) m) [start,start + step..] +genMatch p m st (CronMatchContiguous from to) = take p . map (bool id (succ . (`mod` fromIntegral p)) m) $ [max st from..to] +genMatch _ _ _ (CronMatchIntersect CronMatchNone _) = [] +genMatch _ _ _ (CronMatchIntersect _ CronMatchNone) = [] +genMatch p m st (CronMatchIntersect CronMatchAny other) = genMatch p m st other +genMatch p m st (CronMatchIntersect other CronMatchAny) = genMatch p m st other +genMatch p m st (CronMatchIntersect (CronMatchStep st1) (CronMatchStep st2)) + = genMatch p m st . CronMatchStep $ lcm st1 st2 +genMatch p m st (CronMatchIntersect aGen bGen) + | [] <- as' = [] + | (a:as) <- as' = mergeAnd (a:as) (genMatch p m a bGen) + where + as' = genMatch p m st aGen + mergeAnd [] _ = [] + mergeAnd _ [] = [] + mergeAnd (a:as) (b:bs) + | a < b = mergeAnd as (b:bs) + | a == b = a : mergeAnd as bs + | a > b = mergeAnd (a:as) bs +genMatch p m st (CronMatchUnion CronMatchNone other) = genMatch p m st other +genMatch p m st (CronMatchUnion other CronMatchNone) = genMatch p m st other +genMatch p m st (CronMatchUnion CronMatchAny _) = genMatch p m st CronMatchAny +genMatch p m st (CronMatchUnion _ CronMatchAny) = genMatch p m st CronMatchAny +genMatch p m st (CronMatchUnion aGen bGen) = merge (genMatch p m st aGen) (genMatch p m st bGen) + where + merge [] bs = bs + merge as [] = as + merge (a:as) (b:bs) + | a < b = a : merge as (b:bs) + | a == b = a : merge as bs + | a > b = b : merge (a:as) bs + +nextCronMatch :: TZ -- ^ Timezone of the `Cron`-Entry + -> Maybe UTCTime -- ^ Time of last execution of the job + -> UTCTime -- ^ Current time, used only for `CronCalendar` + -> Cron + -> CronNextMatch UTCTime +nextCronMatch tz mPrev now c@Cron{..} + | isNothing mPrev + = execRef now False cronInitial + | Just prevT <- mPrev + = case cronRepeat of + CronRepeatOnChange + | not $ matchesCron tz Nothing prevT c + -> let + cutoffTime = addUTCTime cronRateLimit prevT + in case execRef now False cronInitial of + MatchAsap + | now < cutoffTime -> MatchAt cutoffTime + MatchAt ts + | ts < cutoffTime -> MatchAt cutoffTime + other -> other + CronRepeatScheduled cronNext + -> case cronNext of + CronAsap + | addUTCTime cronRateLimit prevT <= now + -> MatchAsap + | otherwise + -> MatchAt $ addUTCTime cronRateLimit prevT + cronNext + -> execRef (addUTCTime cronRateLimit prevT) True cronNext + _other -> MatchNone + where + execRef ref wasExecd cronAbsolute = case cronAbsolute of + CronAsap -> MatchAsap + CronTimestamp{ cronTimestamp = localTimeToUTCTZ tz -> ts } + | ref <= ts -> MatchAt ts + | not wasExecd -> MatchAsap + | otherwise -> MatchNone + CronCalendar{..} -> listToMatch $ do + let CronDate{..} = toCronDate $ utcToLocalTimeTZ tz ref + cronYear <- genMatch 400 False cdYear cronYear + cronWeekOfYear <- genMatch 53 True cdWeekOfYear cronWeekOfYear + cronDayOfYear <- genMatch 366 True cdDayOfYear cronDayOfYear + cronMonth <- genMatch 12 True cdMonth cronMonth + cronWeekOfMonth <- genMatch 5 True cdWeekOfMonth cronWeekOfMonth + cronDayOfMonth <- genMatch 31 True cdDayOfMonth cronDayOfMonth + cronDayOfWeek <- genMatch 7 True cdDayOfWeek cronDayOfWeek + cronHour <- genMatch 24 True cdHour cronHour + cronMinute <- genMatch 60 True cdMinute cronMinute + cronSecond <- genMatch 60 True cdSecond cronSecond + guard $ consistentCronDate CronDate{..} + localDay <- maybeToList $ fromGregorianValid (fromIntegral cronYear) (fromIntegral cronMonth) (fromIntegral cronDayOfMonth) + let localTimeOfDay = TimeOfDay (fromIntegral cronHour) (fromIntegral cronMinute) (fromIntegral cronSecond) + return $ localTimeToUTCTZ tz LocalTime{..} + +matchesCron :: TZ -- ^ Timezone of the `Cron`-Entry + -> Maybe UTCTime -- ^ Previous execution of the job + -> UTCTime -- ^ "Current" time + -> Cron + -> Bool +-- ^ @matchesCron tz prev prec now c@ determines whether the given `Cron` +-- specification @c@ should match @now@, under the assumption that the next +-- check will occur no earlier than @now + prec@. +matchesCron tz mPrev now cron = case nextCronMatch tz mPrev now cron of + MatchAsap -> True + MatchNone -> False + MatchAt ts -> ts <= now diff --git a/src/Cron/Types.hs b/src/Cron/Types.hs new file mode 100644 index 000000000..bb6753f73 --- /dev/null +++ b/src/Cron/Types.hs @@ -0,0 +1,65 @@ +{-# LANGUAGE NoImplicitPrelude + , TemplateHaskell + , DuplicateRecordFields + #-} + +module Cron.Types + ( Cron(..), Crontab + , CronMatch(..) + , CronAbsolute(..) + , CronRepeat(..) + ) where + +import ClassyPrelude + +import Utils.Lens.TH + +import Data.Time + +import Numeric.Natural + +import Data.HashMap.Strict (HashMap) + + +data CronMatch + = CronMatchAny + | CronMatchNone + | CronMatchSome (NonNull (Set Natural)) + | CronMatchStep Natural + | CronMatchContiguous Natural Natural + | CronMatchIntersect CronMatch CronMatch + | CronMatchUnion CronMatch CronMatch + deriving (Eq, Show, Read) + +data CronAbsolute + = CronAsap + | CronTimestamp + { cronTimestamp :: LocalTime + } + | CronCalendar + { cronYear, cronWeekOfYear, cronDayOfYear + , cronMonth, cronWeekOfMonth, cronDayOfMonth + , cronDayOfWeek + , cronHour, cronMinute, cronSecond :: CronMatch + } + | CronNotScheduled + deriving (Eq, Show, Read) + +makeLenses_ ''CronAbsolute + +data CronRepeat + = CronRepeatOnChange + | CronRepeatScheduled CronAbsolute + | CronRepeatNever + deriving (Eq, Show, Read) + +data Cron = Cron + { cronInitial :: CronAbsolute + , cronRepeat :: CronRepeat + , cronRateLimit :: NominalDiffTime + } + deriving (Eq, Show) + +makeLenses_ ''Cron + +type Crontab a = HashMap a Cron diff --git a/src/Data/Universe/Instances/Reverse/Hashable.hs b/src/Data/Universe/Instances/Reverse/Hashable.hs new file mode 100644 index 000000000..e7459f613 --- /dev/null +++ b/src/Data/Universe/Instances/Reverse/Hashable.hs @@ -0,0 +1,16 @@ +{-# LANGUAGE NoImplicitPrelude + , ScopedTypeVariables + #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.Universe.Instances.Reverse.Hashable + ( + ) where + +import ClassyPrelude + +import Data.Universe + + +instance (Hashable a, Hashable b, Finite a) => Hashable (a -> b) where + hashWithSalt s f = s `hashWithSalt` [ (k, f k) | k <- universeF ] diff --git a/src/Data/Universe/Instances/Reverse/JSON.hs b/src/Data/Universe/Instances/Reverse/JSON.hs new file mode 100644 index 000000000..60b7ba6ae --- /dev/null +++ b/src/Data/Universe/Instances/Reverse/JSON.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE NoImplicitPrelude + , ScopedTypeVariables + #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.Universe.Instances.Reverse.JSON + ( + ) where + +import ClassyPrelude + +import Data.Aeson +import Data.Aeson.Types (Parser) + +import qualified Data.HashSet as HashSet +import qualified Data.HashMap.Strict as HashMap +import Data.HashMap.Strict ((!)) + +import Data.Universe + + +instance (Eq a, Hashable a, Finite a, ToJSON b, ToJSONKey a) => ToJSON (a -> b) where + toJSON f = toJSON $ HashMap.fromList [(k, f k) | k <- universeF] + +instance (Eq a, Hashable a, Finite a, FromJSON b, FromJSONKey a) => FromJSON (a -> b) where + parseJSON val = do + vMap <- parseJSON val :: Parser (HashMap a b) + unless (HashSet.fromMap (HashMap.map (const ()) vMap) == HashSet.fromList universeF) $ + fail "Not all required keys found" + return $ (vMap !) diff --git a/src/Foundation.hs b/src/Foundation.hs index 105c859ab..6bef15386 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -20,12 +20,12 @@ import Database.Persist.Sql (ConnectionPool, runSqlPool) import Text.Hamlet (hamletFile) import Text.Jasmine (minifym) --- Used only when in "auth-dummy-login" setting is enabled. import Yesod.Auth.Message import Yesod.Auth.Dummy import Auth.LDAP import Auth.PWHash import Auth.Dummy +import Jobs.Types import qualified Network.Wai as W (requestMethod, pathInfo) @@ -59,6 +59,7 @@ import qualified Data.Map as Map import Data.Monoid (Any(..)) +import Data.Pool import Data.Conduit (($$)) import Data.Conduit.List (sourceList) @@ -67,7 +68,7 @@ import qualified Database.Esqueleto as E import Control.Monad.Except (MonadError(..), runExceptT) import Control.Monad.Trans.Maybe (MaybeT(..)) -import Control.Monad.Trans.Reader (runReader) +import Control.Monad.Trans.Reader (runReader, mapReaderT) import Control.Monad.Trans.Writer (WriterT(..)) import Control.Monad.Writer.Class (MonadWriter(..)) import Control.Monad.Catch (handleAll) @@ -88,6 +89,9 @@ import qualified Data.Yaml as Yaml import Text.Shakespeare.Text (st) +import Yesod.Form.I18n.German +import qualified Yesod.Auth.Message as Auth + instance DisplayAble b => DisplayAble (E.CryptoID a b) where display = display . ciphertext @@ -112,11 +116,16 @@ data UniWorX = UniWorX { appSettings :: AppSettings , appStatic :: Static -- ^ Settings for static file serving. , appConnPool :: ConnectionPool -- ^ Database connection pool. + , appSmtpPool :: Maybe SMTPPool , appHttpManager :: Manager , appLogger :: Logger , appCryptoIDKey :: CryptoIDKey + , appInstanceID :: InstanceId + , appJobCtl :: [TMChan JobCtl] } +type SMTPPool = Pool SMTPConnection + -- This is where we define all of the routes in our application. For a full -- explanation of the syntax, please see: -- http://www.yesodweb.com/book/routing-and-handlers @@ -135,6 +144,7 @@ mkYesodData "UniWorX" $(parseRoutesFile "routes") type DB a = YesodDB UniWorX a type Form x = Html -> MForm (HandlerT UniWorX IO) (FormResult x, Widget) type MsgRenderer = MsgRendererS UniWorX -- see Utils +type MailM a = MailT (HandlerT UniWorX IO) a -- Pattern Synonyms for convenience pattern CSheetR tid ssh csh shn ptn @@ -173,7 +183,7 @@ mkMessageVariant "UniWorX" "PWHash" "messages/pw-hash" "de" -- This instance is required to use forms. You can modify renderMessage to -- achieve customized and internationalized form validation messages. instance RenderMessage UniWorX FormMessage where - renderMessage _ _ = defaultFormMessage + renderMessage _ _ = germanFormMessage -- TODO instance RenderMessage UniWorX TermIdentifier where renderMessage foundation ls TermIdentifier{..} = case season of @@ -182,10 +192,9 @@ instance RenderMessage UniWorX TermIdentifier where where renderMessage' = renderMessage foundation ls instance RenderMessage UniWorX StudyFieldType where - renderMessage foundation ls = \case - FieldPrimary -> renderMessage' MsgFieldPrimary - FieldSecondary -> renderMessage' MsgFieldSecondary - where renderMessage' = renderMessage foundation ls + renderMessage foundation ls = renderMessage foundation ls . \case + FieldPrimary -> MsgFieldPrimary + FieldSecondary -> MsgFieldSecondary newtype ShortTermIdentifier = ShortTermIdentifier TermIdentifier deriving (Eq, Ord, Read, Show) @@ -200,27 +209,47 @@ instance RenderMessage UniWorX String where renderMessage f ls str = renderMessage f ls $ Text.pack str instance RenderMessage UniWorX SheetFileType where - renderMessage foundation ls = \case - SheetExercise -> renderMessage' MsgSheetExercise - SheetHint -> renderMessage' MsgSheetHint - SheetSolution -> renderMessage' MsgSheetSolution - SheetMarking -> renderMessage' MsgSheetMarking - where renderMessage' = renderMessage foundation ls + renderMessage foundation ls = renderMessage foundation ls . \case + SheetExercise -> MsgSheetExercise + SheetHint -> MsgSheetHint + SheetSolution -> MsgSheetSolution + SheetMarking -> MsgSheetMarking instance RenderMessage UniWorX CorrectorState where - renderMessage foundation ls = \case - CorrectorNormal -> renderMessage' MsgCorrectorNormal - CorrectorMissing -> renderMessage' MsgCorrectorMissing - CorrectorExcused -> renderMessage' MsgCorrectorExcused - where renderMessage' = renderMessage foundation ls + renderMessage foundation ls = renderMessage foundation ls . \case + CorrectorNormal -> MsgCorrectorNormal + CorrectorMissing -> MsgCorrectorMissing + CorrectorExcused -> MsgCorrectorExcused instance RenderMessage UniWorX Load where - renderMessage foundation ls = \case - (Load {byTutorial=Nothing , byProportion=p}) -> renderMessage' $ MsgCorByProportionOnly p - (Load {byTutorial=Just True , byProportion=p}) -> renderMessage' $ MsgCorByProportionIncludingTutorial p - (Load {byTutorial=Just False, byProportion=p}) -> renderMessage' $ MsgCorByProportionExcludingTutorial p - where renderMessage' = renderMessage foundation ls + renderMessage foundation ls = renderMessage foundation ls . \case + (Load {byTutorial=Nothing , byProportion=p}) -> MsgCorByProportionOnly p + (Load {byTutorial=Just True , byProportion=p}) -> MsgCorByProportionIncludingTutorial p + (Load {byTutorial=Just False, byProportion=p}) -> MsgCorByProportionExcludingTutorial p + +instance RenderMessage UniWorX SheetType where + renderMessage foundation ls = renderMessage foundation ls . \case + Bonus{..} -> MsgSheetTypeBonus' maxPoints + Normal{..} -> MsgSheetTypeNormal' maxPoints + Pass{..} -> MsgSheetTypePass' maxPoints passingPoints + NotGraded{} -> MsgSheetTypeNotGraded' + +newtype MsgLanguage = MsgLanguage Lang + deriving (Eq, Ord, Show, Read) +instance RenderMessage UniWorX MsgLanguage where + renderMessage foundation ls (MsgLanguage lang) + | lang == "de-DE" = mr MsgGermanGermany + | "de" `isPrefixOf` lang = mr MsgGerman + where + mr = renderMessage foundation ls + +instance RenderMessage UniWorX NotificationTrigger where + renderMessage foundation ls = renderMessage foundation ls . \case + NTSubmissionRatedGraded -> MsgNotificationTriggerSubmissionRatedGraded + NTSubmissionRated -> MsgNotificationTriggerSubmissionRated + NTSheetActive -> MsgNotificationTriggerSheetActive + NTSheetInactive -> MsgNotificationTriggerSheetInactive instance RenderMessage UniWorX (UnsupportedAuthPredicate (Route UniWorX)) where @@ -242,6 +271,22 @@ getTimeLocale' = $(timeLocaleMap [("de", "de_DE.utf8")]) appTZ :: TZ appTZ = $(includeSystemTZ "Europe/Berlin") +appLanguages :: ( MonadHandler m + , HandlerSite m ~ UniWorX + ) => m (OptionList Lang) +-- ^ Authoritive list of supported Languages +appLanguages = do + mr <- getsYesod renderMessage + let mkOption l = Option + { optionDisplay = mr (l : filter (/= l) (optionInternalValue <$> langOptions)) (MsgLanguage l) + , optionInternalValue = l + , optionExternalValue = l + } + langOptions = map mkOption + [ "de-DE" + ] + return $ mkOptionList langOptions + -- Access Control data AccessPredicate @@ -454,14 +499,14 @@ route2ap r = foldr orAP adminAP attrsAND -- adminAP causes all to be in DB!!! GK attrsAND = map splitAND $ Set.toList $ routeAttrs r splitAND = foldr1 andAP . map tag2ap . Text.splitOn "AND" -evalAccessDB :: Route UniWorX -> Bool -> DB AuthResult -- all requests, regardless of POST/GET, use isWriteRequest otherwise -evalAccessDB r w = case route2ap r of +evalAccessDB :: (MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> ReaderT (YesodPersistBackend UniWorX) m AuthResult -- all requests, regardless of POST/GET, use isWriteRequest otherwise +evalAccessDB r w = mapReaderT liftHandlerT $ case route2ap r of (APPure p) -> lift $ runReader (p r w) <$> getMsgRenderer (APHandler p) -> lift $ p r w (APDB p) -> p r w -evalAccess :: Route UniWorX -> Bool -> Handler AuthResult -evalAccess r w = case route2ap r of +evalAccess :: (MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> m AuthResult +evalAccess r w = liftHandlerT $ case route2ap r of (APPure p) -> runReader (p r w) <$> getMsgRenderer (APHandler p) -> p r w (APDB p) -> runDB $ p r w @@ -1223,12 +1268,14 @@ instance YesodAuth UniWorX where let newUser = User - { userMaxFavourites = userDefaultMaxFavourites - , userTheme = userDefaultTheme - , userDateTimeFormat = userDefaultDateTimeFormat - , userDateFormat = userDefaultDateFormat - , userTimeFormat = userDefaultTimeFormat - , userDownloadFiles = userDefaultDownloadFiles + { userMaxFavourites = userDefaultMaxFavourites + , userTheme = userDefaultTheme + , userDateTimeFormat = userDefaultDateTimeFormat + , userDateFormat = userDefaultDateFormat + , userTimeFormat = userDefaultTimeFormat + , userDownloadFiles = userDefaultDownloadFiles + , userNotificationSettings = def + , userMailLanguages = def , .. } userUpdate = [ UserMatrikelnummer =. userMatrikelnummer @@ -1271,6 +1318,8 @@ instance YesodAuth UniWorX where authHttpManager = getHttpManager + renderAuthMessage _ _ = Auth.germanMessage -- TODO + instance YesodAuthPersist UniWorX -- Useful when writing code that is re-usable outside of the Handler context. @@ -1283,6 +1332,25 @@ unsafeHandler :: UniWorX -> Handler a -> IO a unsafeHandler = Unsafe.fakeHandlerGetLogger appLogger +instance YesodMail UniWorX where + defaultFromAddress = getsYesod $ appMailFrom . appSettings + mailObjectIdDomain = getsYesod $ appMailObjectDomain . appSettings + mailVerp = getsYesod $ appMailVerp . appSettings + mailDateTZ = return appTZ + mailSmtp act = do + pool <- maybe (throwM MailNotAvailable) return =<< getsYesod appSmtpPool + withResource pool act + mailT ctx mail = defMailT ctx $ do + setMailObjectId + setDateCurrent + replaceMailHeader "Auto-Submitted" $ Just "auto-generated" + + ret <- mail + + setMailSmtpData + return ret + + instance (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadCrypto m where type MonadCryptoKey m = CryptoIDKey cryptoIDKey f = getsYesod appCryptoIDKey >>= f diff --git a/src/Handler/Admin.hs b/src/Handler/Admin.hs index 156961629..6de79e526 100644 --- a/src/Handler/Admin.hs +++ b/src/Handler/Admin.hs @@ -7,11 +7,13 @@ {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} module Handler.Admin where import Import import Handler.Utils +import Jobs -- import Data.Time -- import qualified Data.Text as T @@ -20,6 +22,8 @@ import Handler.Utils import Web.PathPieces (showToPathPiece, readFromPathPiece) +import Database.Persist.Sql (fromSqlKey) + -- import Colonnade hiding (fromMaybe) -- import Yesod.Colonnade @@ -41,22 +45,54 @@ instance Button UniWorX CreateButton where cssClass CreateInf = BCPrimary -- END Button needed here +emailTestForm :: AForm (HandlerT UniWorX IO) (Email, MailContext) +emailTestForm = (,) + <$> areq emailField (fslI MsgMailTestFormEmail) Nothing + <*> ( MailContext + <$> (MailLanguages <$> areq (reorderField appLanguages) (fslI MsgMailTestFormLanguages) Nothing) + <*> (toMailDateTimeFormat + <$> areq (selectField $ dateTimeFormatOptions SelFormatDateTime) (fslI MsgDateTimeFormat) Nothing + <*> areq (selectField $ dateTimeFormatOptions SelFormatDate) (fslI MsgDateFormat) Nothing + <*> areq (selectField $ dateTimeFormatOptions SelFormatTime) (fslI MsgTimeFormat) Nothing + ) + ) + <* submitButton + where + toMailDateTimeFormat dt d t = \case + SelFormatDateTime -> dt + SelFormatDate -> d + SelFormatTime -> t -getAdminTestR :: Handler Html -- Demo Page. Referenzimplementierungen sollte hier gezeigt werden! -getAdminTestR = do - (btnWdgt, btnEnctype) <- generateFormPost (buttonForm :: Form CreateButton) - defaultLayout $ do - -- setTitle "Uni2work Admin Testpage" - $(widgetFile "adminTest") -postAdminTestR :: Handler Html +getAdminTestR, postAdminTestR :: Handler Html -- Demo Page. Referenzimplementierungen sollte hier gezeigt werden! +getAdminTestR = postAdminTestR postAdminTestR = do - ((btnResult,_), _) <- runFormPost $ buttonForm + ((btnResult, btnWdgt), btnEnctype) <- runFormPost $ identifyForm "buttons" (buttonForm :: Form CreateButton) case btnResult of (FormSuccess CreateInf) -> addMessage Info "Informatik-Knopf gedrückt" (FormSuccess CreateMath) -> addMessage Warning "Knopf Mathematik erkannt" + FormMissing -> return () _other -> addMessage Warning "KEIN Knopf erkannt" - getAdminTestR + + ((emailResult, emailWidget), emailEnctype) <- runFormPost . identifyForm "email" $ renderAForm FormStandard emailTestForm + case emailResult of + (FormSuccess (email, ls)) -> do + jId <- runDB $ do + jId <- queueJob $ JobSendTestEmail email ls + addMessage Success [shamlet|Email-test gestartet (Job ##{tshow (fromSqlKey jId)})|] + return jId + writeJobCtl $ JobCtlPerform jId + FormMissing -> return () + (FormFailure errs) -> forM_ errs $ addMessage Error . toHtml + + let emailWidget' = [whamlet| +