feat(csv): allow customisation of csv-export-options

This commit is contained in:
Gregor Kleen 2019-09-30 15:53:29 +02:00
parent 64f771518e
commit 95ceeddc83
22 changed files with 367 additions and 52 deletions

View File

@ -1499,8 +1499,8 @@ Proportion c@Text of@Text prop@Rational: #{c}/#{of} (#{rationalToFixed2 (100 * p
ExamUserCsvName tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-teilnehmer ExamUserCsvName tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-teilnehmer
CourseApplicationsTableCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungen CourseApplicationsTableCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungen
CsvColumnsExplanationsLabel: Spalten CsvColumnsExplanationsLabel: Spalten- & Zellenformat
CsvColumnsExplanationsTip: Bedeutung der in der CSV-Datei enthaltenen Spalten CsvColumnsExplanationsTip: Bedeutung und Format der in der CSV-Datei enthaltenen Spalten
CsvColumnExamUserSurname: Nachname(n) des Teilnehmers CsvColumnExamUserSurname: Nachname(n) des Teilnehmers
CsvColumnExamUserFirstName: Vorname(n) des Teilnehmers CsvColumnExamUserFirstName: Vorname(n) des Teilnehmers
CsvColumnExamUserName: Voller Name des Teilnehmers (gewöhnlicherweise inkl. Vor- und Nachname(n)) CsvColumnExamUserName: Voller Name des Teilnehmers (gewöhnlicherweise inkl. Vor- und Nachname(n))
@ -1797,4 +1797,28 @@ AcceptApplicationsInvite: Einladungen verschicken
AcceptApplicationsSecondary: Gleichstände auflösen AcceptApplicationsSecondary: Gleichstände auflösen
AcceptApplicationsSecondaryTip: Wenn es im Laufe des Verfahrens mehrere Bewerber mit der selben Bewertung für den selben Platz gibt, wie soll der Gleichstand aufgelöst werden? AcceptApplicationsSecondaryTip: Wenn es im Laufe des Verfahrens mehrere Bewerber mit der selben Bewertung für den selben Platz gibt, wie soll der Gleichstand aufgelöst werden?
AcceptApplicationsSecondaryRandom: Zufällig AcceptApplicationsSecondaryRandom: Zufällig
AcceptApplicationsSecondaryTime: Nach Zeitpunkt der Bewerbung AcceptApplicationsSecondaryTime: Nach Zeitpunkt der Bewerbung
CsvOptions: CSV-Optionen
CsvOptionsTip: Diese Einstellungen betreffen nur den CSV-Export; beim Import werden die verwendeten Einstellungen automatisch ermittelt.
CsvPresetRFC: Standard-Konform (RFC 4180)
CsvPresetExcel: Excel-Kompatibel
CsvCustom: Benutzerdefiniert
CsvDelimiter: Trennzeichen
CsvUseCrLf: Zeilenumbrüche
CsvQuoting: Quoting
CsvQuotingTip: Wann sollen Anführungszeichen (") um Felder platziert werden, um Interpretation von im Feld enthaltenen Zeichen als Trennzeichen zu verhindern?
CsvDelimiterNull: Null-Byte
CsvDelimiterTab: Tabulator
CsvDelimiterComma: Komma
CsvDelimiterColon: Doppelpunkt
CsvDelimiterBar: Senkrechter Strich
CsvDelimiterSpace: Leerzeichen
CsvDelimiterUnitSep: Teilgruppentrennzeichen
CsvCrLf: DOS (CRLF)
CsvLf: Unix (LF)
CsvQuoteNone: Nie
CsvQuoteMinimal: Nur wenn nötig
CsvQuoteAll: Immer
CsvOptionsUpdated: CSV-Optionen erfolgreich angepasst
CsvChangeOptionsLabel: Export-Optionen

View File

@ -30,6 +30,7 @@ User json -- Each Uni2work user has a corresponding row in this table; create
mailLanguages MailLanguages "default='[]'::jsonb" -- Preferred language for eMail; i18n not yet implemented; user-defined mailLanguages MailLanguages "default='[]'::jsonb" -- Preferred language for eMail; i18n not yet implemented; user-defined
notificationSettings NotificationSettings -- Bit-array for which events email notifications are requested by user; user-defined notificationSettings NotificationSettings -- Bit-array for which events email notifications are requested by user; user-defined
warningDays NominalDiffTime default=1209600 -- timedistance to pending deadlines for homepage infos warningDays NominalDiffTime default=1209600 -- timedistance to pending deadlines for homepage infos
csvOptions CsvOptions "default='{}'::jsonb"
UniqueAuthentication ident -- Column 'ident' can be used as a row-key in this table UniqueAuthentication ident -- Column 'ident' can be used as a row-key in this table
UniqueEmail email -- Column 'email' can be used as a row-key in this table UniqueEmail email -- Column 'email' can be used as a row-key in this table
deriving Show Eq Ord Generic -- Haskell-specific settings for runtime-value representing a row in memory deriving Show Eq Ord Generic -- Haskell-specific settings for runtime-value representing a row in memory

1
routes
View File

@ -72,6 +72,7 @@
/user/profile ProfileDataR GET !free /user/profile ProfileDataR GET !free
/user/authpreds AuthPredsR GET POST !free /user/authpreds AuthPredsR GET POST !free
/user/set-display-email SetDisplayEmailR GET POST !free /user/set-display-email SetDisplayEmailR GET POST !free
/user/csv-options CsvOptionsR GET POST !free
/exam-office ExamOfficeR !exam-office: /exam-office ExamOfficeR !exam-office:
/ EOExamsR GET / EOExamsR GET

View File

@ -315,6 +315,8 @@ embedRenderMessage ''UniWorX ''UploadModeDescr id
embedRenderMessage ''UniWorX ''SecretJSONFieldException id embedRenderMessage ''UniWorX ''SecretJSONFieldException id
embedRenderMessage ''UniWorX ''AFormMessage $ concat . drop 2 . splitCamel embedRenderMessage ''UniWorX ''AFormMessage $ concat . drop 2 . splitCamel
embedRenderMessage ''UniWorX ''SchoolFunction id embedRenderMessage ''UniWorX ''SchoolFunction id
embedRenderMessage ''UniWorX ''CsvPreset id
embedRenderMessage ''UniWorX ''Quoting ("Csv" <>)
embedRenderMessage ''UniWorX ''AuthenticationMode id embedRenderMessage ''UniWorX ''AuthenticationMode id
@ -3287,6 +3289,7 @@ upsertCampusUser ldapData Creds{..} = do
, userWarningDays = userDefaultWarningDays , userWarningDays = userDefaultWarningDays
, userNotificationSettings = def , userNotificationSettings = def
, userMailLanguages = def , userMailLanguages = def
, userCsvOptions = def
, userTokensIssuedAfter = Nothing , userTokensIssuedAfter = Nothing
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Just now , userLastLdapSynchronisation = Just now

View File

@ -1,4 +1,11 @@
module Handler.Profile where module Handler.Profile
( getProfileR, postProfileR
, getProfileDataR, makeProfileData
, getAuthPredsR, postAuthPredsR
, getUserNotificationR, postUserNotificationR
, getSetDisplayEmailR, postSetDisplayEmailR
, getCsvOptionsR, postCsvOptionsR
) where
import Import import Import
@ -796,3 +803,25 @@ postSetDisplayEmailR = do
siteLayoutMsg MsgTitleChangeUserDisplayEmail $ do siteLayoutMsg MsgTitleChangeUserDisplayEmail $ do
setTitleI MsgTitleChangeUserDisplayEmail setTitleI MsgTitleChangeUserDisplayEmail
$(i18nWidgetFile "set-display-email") $(i18nWidgetFile "set-display-email")
getCsvOptionsR, postCsvOptionsR :: Handler Html
getCsvOptionsR = postCsvOptionsR
postCsvOptionsR = do
Entity uid User{userCsvOptions} <- requireAuth
((optionsRes, optionsWgt'), optionsEnctype) <- runFormPost . renderAForm FormStandard $
csvOptionsForm (fslI MsgCsvOptions & setTooltip MsgCsvOptionsTip) (Just userCsvOptions)
formResultModal optionsRes CsvOptionsR $ \opts -> do
lift . runDB $ update uid [ UserCsvOptions =. opts ]
tell . pure =<< messageI Success MsgCsvOptionsUpdated
siteLayoutMsg MsgCsvOptions $ do
setTitleI MsgCsvOptions
isModal <- hasCustomHeader HeaderIsModal
wrapForm optionsWgt' def
{ formAction = Just $ SomeRoute CsvOptionsR
, formEncoding = optionsEnctype
, formAttrs = [ asyncSubmitAttr | isModal ]
}

View File

@ -73,6 +73,7 @@ postAdminUserAddR = do
, userWarningDays = userDefaultWarningDays , userWarningDays = userDefaultWarningDays
, userNotificationSettings = def , userNotificationSettings = def
, userMailLanguages = def , userMailLanguages = def
, userCsvOptions = def
, userTokensIssuedAfter = Nothing , userTokensIssuedAfter = Nothing
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing

View File

@ -98,19 +98,23 @@ decodeCsv = transPipe throwExceptT $ do
encodeCsv :: ( ToNamedRecord csv encodeCsv :: ( ToNamedRecord csv
, Monad m , MonadHandler m
, HandlerSite m ~ UniWorX
) )
=> Header => Header
-> ConduitT csv ByteString m () -> ConduitT csv ByteString m ()
-- ^ Encode a stream of records -- ^ Encode a stream of records
-- --
-- Currently not streaming -- Currently not streaming
encodeCsv hdr = fmap (encodeByName hdr) (C.foldMap pure) >>= C.sourceLazy encodeCsv hdr = do
csvOpts <- fmap (maybe def (userCsvOptions . entityVal)) . lift $ liftHandler maybeAuth
fmap (encodeByNameWith (csvOpts ^. _CsvEncodeOptions) hdr) (C.foldMap pure) >>= C.sourceLazy
encodeDefaultOrderedCsv :: forall csv m. encodeDefaultOrderedCsv :: forall csv m.
( ToNamedRecord csv ( ToNamedRecord csv
, DefaultOrdered csv , DefaultOrdered csv
, Monad m , MonadHandler m
, HandlerSite m ~ UniWorX
) )
=> ConduitT csv ByteString m () => ConduitT csv ByteString m ()
encodeDefaultOrderedCsv = encodeCsv $ headerOrder (error "headerOrder" :: csv) encodeDefaultOrderedCsv = encodeCsv $ headerOrder (error "headerOrder" :: csv)
@ -118,33 +122,32 @@ encodeDefaultOrderedCsv = encodeCsv $ headerOrder (error "headerOrder" :: csv)
respondCsv :: ToNamedRecord csv respondCsv :: ToNamedRecord csv
=> Header => Header
-> ConduitT () csv (HandlerFor site) () -> ConduitT () csv Handler ()
-> HandlerFor site TypedContent -> Handler TypedContent
respondCsv hdr src = respondSource typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk respondCsv hdr src = respondSource typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk
respondDefaultOrderedCsv :: forall csv site. respondDefaultOrderedCsv :: forall csv.
( ToNamedRecord csv ( ToNamedRecord csv
, DefaultOrdered csv , DefaultOrdered csv
) )
=> ConduitT () csv (HandlerFor site) () => ConduitT () csv Handler ()
-> HandlerFor site TypedContent -> Handler TypedContent
respondDefaultOrderedCsv = respondCsv $ headerOrder (error "headerOrder" :: csv) respondDefaultOrderedCsv = respondCsv $ headerOrder (error "headerOrder" :: csv)
respondCsvDB :: ( ToNamedRecord csv respondCsvDB :: ( ToNamedRecord csv
, YesodPersistRunner site , YesodPersistRunner UniWorX
) )
=> Header => Header
-> ConduitT () csv (YesodDB site) () -> ConduitT () csv DB ()
-> HandlerFor site TypedContent -> Handler TypedContent
respondCsvDB hdr src = respondSourceDB typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk respondCsvDB hdr src = respondSourceDB typeCsv' $ src .| encodeCsv hdr .| awaitForever sendChunk
respondDefaultOrderedCsvDB :: forall csv site. respondDefaultOrderedCsvDB :: forall csv.
( ToNamedRecord csv ( ToNamedRecord csv
, DefaultOrdered csv , DefaultOrdered csv
, YesodPersistRunner site
) )
=> ConduitT () csv (YesodDB site) () => ConduitT () csv DB ()
-> HandlerFor site TypedContent -> Handler TypedContent
respondDefaultOrderedCsvDB = respondCsvDB $ headerOrder (error "headerOrder" :: csv) respondDefaultOrderedCsvDB = respondCsvDB $ headerOrder (error "headerOrder" :: csv)
fileSourceCsv :: ( FromNamedRecord csv fileSourceCsv :: ( FromNamedRecord csv

View File

@ -12,6 +12,7 @@ import Handler.Utils.Form.Types
import Handler.Utils.DateTime import Handler.Utils.DateTime
import Import import Import
import Data.Char (chr, ord)
import qualified Data.Char as Char import qualified Data.Char as Char
import qualified Data.Text as Text import qualified Data.Text as Text
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
@ -220,11 +221,7 @@ multiAction :: forall action a.
-> Maybe action -> Maybe action
-> (Html -> MForm Handler (FormResult a, [FieldView UniWorX])) -> (Html -> MForm Handler (FormResult a, [FieldView UniWorX]))
multiAction acts fs@FieldSettings{..} defAction csrf = do multiAction acts fs@FieldSettings{..} defAction csrf = do
mr <- getMessageRender (actionRes, actionView) <- mreq (selectField . optionsF $ Map.keysSet acts) fs defAction
let
options = OptionList [ Option (mr a) a (toPathPiece a) | a <- Map.keys acts ] fromPathPiece
(actionRes, actionView) <- mreq (selectField $ return options) fs defAction
results <- mapM (fmap (over _2 ($ [])) . aFormToForm) acts results <- mapM (fmap (over _2 ($ [])) . aFormToForm) acts
let actionResults = view _1 <$> results let actionResults = view _1 <$> results
@ -1199,3 +1196,84 @@ examPassedField :: forall m.
) )
=> Field m ExamPassed => Field m ExamPassed
examPassedField = hoistField liftHandler $ selectField optionsFinite examPassedField = hoistField liftHandler $ selectField optionsFinite
data CsvOptions' = CsvOptionsPreset' CsvPreset
| CsvOptionsCustom'
deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriveFinite ''CsvOptions'
instance PathPiece CsvOptions' where
toPathPiece = \case
CsvOptionsPreset' p -> toPathPiece p
CsvOptionsCustom' -> "custom"
fromPathPiece t = fromPathPiece t
<|> guardOn (t == "custom") CsvOptionsCustom'
instance RenderMessage UniWorX CsvOptions' where
renderMessage m ls = \case
CsvOptionsPreset' p -> mr p
CsvOptionsCustom' -> mr MsgCsvCustom
where
mr :: forall msg. RenderMessage UniWorX msg => msg -> Text
mr = renderMessage m ls
csvOptionsForm :: forall m.
( MonadHandler m
, HandlerSite m ~ UniWorX
)
=> FieldSettings UniWorX
-> Maybe CsvOptions
-> AForm m CsvOptions
csvOptionsForm fs mPrev = hoistAForm liftHandler . multiActionA csvActs fs $ classifyCsvOptions <$> mPrev
where
csvActs :: Map CsvOptions' (AForm Handler CsvOptions)
csvActs = mapF $ \case
CsvOptionsPreset' preset
-> pure $ csvPreset # preset
CsvOptionsCustom'
-> CsvOptions
<$> areq (selectField delimiterOpts) (fslI MsgCsvDelimiter) (csvDelimiter <$> mPrev)
<*> areq (selectField lineEndOpts) (fslI MsgCsvUseCrLf) (csvUseCrLf <$> mPrev)
<*> areq (selectField quoteOpts) (fslI MsgCsvQuoting & setTooltip MsgCsvQuotingTip) (csvQuoting <$> mPrev)
delimiterOpts :: Handler (OptionList Char)
delimiterOpts = do
MsgRenderer mr <- getMsgRenderer
let
opts =
[ (MsgCsvDelimiterNull, '\0')
, (MsgCsvDelimiterTab, '\t')
, (MsgCsvDelimiterComma, ',')
, (MsgCsvDelimiterColon, chr 58)
, (MsgCsvDelimiterBar, '|')
, (MsgCsvDelimiterSpace, ' ')
, (MsgCsvDelimiterUnitSep, chr 31)
]
olReadExternal t = do
i <- readMay t
guard $ i >= 0 && i <= 255
let c = chr i
guard $ any ((== c) . view _2) opts
return c
olOptions = [ Option (mr msg) c (tshow $ ord c)
| (msg, c) <- opts
]
return OptionList{..}
lineEndOpts :: Handler (OptionList Bool)
lineEndOpts = optionsPathPiece
[ (MsgCsvCrLf, True )
, (MsgCsvLf, False)
]
quoteOpts :: Handler (OptionList Quoting)
quoteOpts = optionsF
[ QuoteMinimal
, QuoteAll
]
classifyCsvOptions :: CsvOptions -> CsvOptions'
classifyCsvOptions opts
| Just preset <- opts ^? csvPreset
= CsvOptionsPreset' preset
| otherwise
= CsvOptionsCustom'

View File

@ -49,6 +49,7 @@ import Handler.Utils.Form
import Handler.Utils.Csv import Handler.Utils.Csv
import Handler.Utils.ContentDisposition import Handler.Utils.ContentDisposition
import Handler.Utils.I18n import Handler.Utils.I18n
import Handler.Utils.Widgets
import Utils import Utils
import Utils.Lens import Utils.Lens

View File

@ -96,3 +96,9 @@ editedByW fmt tm usr = do
heat :: Integral a => a -> a -> Double heat :: Integral a => a -> a -> Double
heat (toInteger -> full) (toInteger -> achieved) heat (toInteger -> full) (toInteger -> achieved)
= roundToDigits 3 $ cutOffPercent 0.3 (fromIntegral full^2) (fromIntegral achieved^2) = roundToDigits 3 $ cutOffPercent 0.3 (fromIntegral full^2) (fromIntegral achieved^2)
i18n :: forall m msg.
( MonadWidget m
, RenderMessage (HandlerSite m) msg
) => msg -> m ()
i18n = toWidget . (SomeMessage :: msg -> SomeMessage (HandlerSite m))

View File

@ -1,3 +1,5 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-| {-|
Module: Model.Types.Misc Module: Model.Types.Misc
Description: Additional uncategorized types Description: Additional uncategorized types
@ -5,6 +7,7 @@ Description: Additional uncategorized types
module Model.Types.Misc module Model.Types.Misc
( module Model.Types.Misc ( module Model.Types.Misc
, Quoting(..)
) where ) where
import Import.NoModel import Import.NoModel
@ -14,6 +17,11 @@ import Data.Maybe (fromJust)
import qualified Data.Text as Text import qualified Data.Text as Text
import qualified Data.Text.Lens as Text import qualified Data.Text.Lens as Text
import Data.Csv (Quoting(..))
import qualified Data.Csv as Csv
import qualified Data.Aeson as JSON
data StudyFieldType = FieldPrimary | FieldSecondary data StudyFieldType = FieldPrimary | FieldSecondary
deriving (Eq, Ord, Enum, Show, Read, Bounded, Generic) deriving (Eq, Ord, Enum, Show, Read, Bounded, Generic)
@ -43,3 +51,75 @@ nullaryPathPiece ''Theme $ camelToPathPiece' 1
$(deriveSimpleWith ''ToMessage 'toMessage (over Text.packed $ Text.intercalate " " . unsafeTail . splitCamel) ''Theme) -- describe theme to user $(deriveSimpleWith ''ToMessage 'toMessage (over Text.packed $ Text.intercalate " " . unsafeTail . splitCamel) ''Theme) -- describe theme to user
derivePersistField "Theme" derivePersistField "Theme"
deriving instance Generic Quoting
deriving instance Ord Quoting
deriving instance Read Quoting
deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece' 1
} ''Quoting
deriveFinite ''Quoting
nullaryPathPiece ''Quoting $ \q -> if
| q == "QuoteNone" -> "never"
| otherwise -> camelToPathPiece' 1 q
data CsvOptions
= CsvOptions
{ csvDelimiter :: Char
, csvUseCrLf :: Bool
, csvQuoting :: Csv.Quoting
}
deriving (Eq, Ord, Read, Show, Generic, Typeable)
instance Default CsvOptions where
def = csvPreset # CsvPresetRFC
data CsvPreset = CsvPresetRFC
| CsvPresetExcel
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
instance Universe CsvPreset
instance Finite CsvPreset
csvPreset :: Prism' CsvOptions CsvPreset
csvPreset = prism' fromPreset toPreset
where
fromPreset :: CsvPreset -> CsvOptions
fromPreset CsvPresetRFC = CsvOptions { csvDelimiter = ',', csvUseCrLf = True, csvQuoting = QuoteMinimal }
fromPreset CsvPresetExcel = CsvOptions { csvDelimiter = ';', csvUseCrLf = True, csvQuoting = QuoteAll }
toPreset :: CsvOptions -> Maybe CsvPreset
toPreset opts = case filter (\p -> fromPreset p == opts) universeF of
[p] -> Just p
_other -> Nothing
_CsvEncodeOptions :: Iso' CsvOptions Csv.EncodeOptions
_CsvEncodeOptions = iso toEncode fromEncode
where
toEncode CsvOptions{..} = Csv.defaultEncodeOptions
{ Csv.encDelimiter = fromIntegral $ fromEnum csvDelimiter
, Csv.encUseCrLf = csvUseCrLf
, Csv.encQuoting = csvQuoting
, Csv.encIncludeHeader = True
}
fromEncode encOpts = CsvOptions
{ csvDelimiter = toEnum . fromIntegral $ Csv.encDelimiter encOpts
, csvUseCrLf = Csv.encUseCrLf encOpts
, csvQuoting = Csv.encQuoting encOpts
}
instance ToJSON CsvOptions where
toJSON CsvOptions{..} = JSON.object
[ "delimiter" JSON..= fromEnum csvDelimiter
, "use-cr-lf" JSON..= csvUseCrLf
, "quoting" JSON..= csvQuoting
]
instance FromJSON CsvOptions where
parseJSON = JSON.withObject "CsvOptions" $ \o -> do
csvDelimiter <- fmap (fmap toEnum) (o JSON..:? "delimiter") JSON..!= csvDelimiter def
csvUseCrLf <- o JSON..:? "use-cr-lf" JSON..!= csvUseCrLf def
csvQuoting <- o JSON..:? "quoting" JSON..!= csvQuoting def
return CsvOptions{..}
derivePersistFieldJSON ''CsvOptions
nullaryPathPiece ''CsvPreset $ camelToPathPiece' 2

View File

@ -412,7 +412,8 @@ mapSymmDiff a b = Map.fromListWith Set.union . map (over _2 Set.singleton) . Set
assocsSet :: Ord (k, v) => Map k v -> Set (k, v) assocsSet :: Ord (k, v) => Map k v -> Set (k, v)
assocsSet = setOf folded . imap (,) assocsSet = setOf folded . imap (,)
mapF :: (Ord k, Finite k) => (k -> v) -> Map k v
mapF = flip Map.fromSet $ Set.fromList universeF
--------------- ---------------
-- Functions -- -- Functions --

View File

@ -491,6 +491,24 @@ reorderField optList = Field{..}
withNum t n = tshow n <> "." <> t withNum t n = tshow n <> "." <> t
$(widgetFile "widgets/permutation/permutation") $(widgetFile "widgets/permutation/permutation")
optionsPathPiece :: ( MonadHandler m
, HandlerSite m ~ site
, MonoFoldable mono
, Element mono ~ (msg, val)
, RenderMessage site msg
, PathPiece val
)
=> mono -> m (OptionList val)
optionsPathPiece (otoList -> opts) = do
mr <- getMessageRender
let
mkOption (m, a) = Option
{ optionDisplay = mr m
, optionInternalValue = a
, optionExternalValue = toPathPiece a
}
return . mkOptionList $ mkOption <$> opts
optionsF :: ( MonadHandler m optionsF :: ( MonadHandler m
, RenderMessage site (Element mono) , RenderMessage site (Element mono)
, HandlerSite m ~ site , HandlerSite m ~ site
@ -498,16 +516,7 @@ optionsF :: ( MonadHandler m
, MonoFoldable mono , MonoFoldable mono
) )
=> mono -> m (OptionList (Element mono)) => mono -> m (OptionList (Element mono))
optionsF (otoList -> opts) = do optionsF = optionsPathPiece . map (id &&& id) . otoList
mr <- getMessageRender
let
mkOption a = Option
{ optionDisplay = mr a
, optionInternalValue = a
, optionExternalValue = toPathPiece a
}
return . mkOptionList $ mkOption <$> opts
optionsFinite :: ( MonadHandler m optionsFinite :: ( MonadHandler m
, Finite a , Finite a

View File

@ -27,6 +27,7 @@ import Control.Monad.Base (MonadBase)
import Control.Monad.Trans.Control (MonadBaseControl) import Control.Monad.Trans.Control (MonadBaseControl)
import Control.Monad.Catch (MonadMask, MonadCatch) import Control.Monad.Catch (MonadMask, MonadCatch)
import Control.Monad.Random.Class (MonadRandom) import Control.Monad.Random.Class (MonadRandom)
import Control.Monad.Morph (MFunctor, MMonad)
deriving via (ReaderT (HandlerData site site) IO) instance MonadFix (HandlerFor site) deriving via (ReaderT (HandlerData site site) IO) instance MonadFix (HandlerFor site)
@ -52,6 +53,7 @@ newtype CachedMemoT k v m a = CachedMemoT { runCachedMemoT' :: ReaderT Loc m a }
, MonadThrow, MonadCatch, MonadMask, MonadLogger, MonadLoggerIO , MonadThrow, MonadCatch, MonadMask, MonadLogger, MonadLoggerIO
, MonadResource, MonadHandler, MonadWidget , MonadResource, MonadHandler, MonadWidget
) )
deriving newtype ( MFunctor, MMonad, MonadTrans )
deriving newtype instance MonadBase b m => MonadBase b (CachedMemoT k v m) deriving newtype instance MonadBase b m => MonadBase b (CachedMemoT k v m)
deriving newtype instance MonadBaseControl b m => MonadBaseControl b (CachedMemoT k v m) deriving newtype instance MonadBaseControl b m => MonadBaseControl b (CachedMemoT k v m)
@ -60,7 +62,8 @@ instance MonadReader r m => MonadReader r (CachedMemoT k v m) where
reader = CachedMemoT . lift . reader reader = CachedMemoT . lift . reader
local f (CachedMemoT act) = CachedMemoT $ mapReaderT (local f) act local f (CachedMemoT act) = CachedMemoT $ mapReaderT (local f) act
deriving via (ReaderT Loc) instance MonadTrans (CachedMemoT k v) instance MonadUnliftIO m => MonadUnliftIO (CachedMemoT k v m) where
askUnliftIO = (\UnliftIO{..} -> UnliftIO $ \(CachedMemoT f) -> unliftIO f) <$> CachedMemoT askUnliftIO
-- | Uses `cachedBy` with a `Binary`-encoded @k@ -- | Uses `cachedBy` with a `Binary`-encoded @k@
@ -73,3 +76,7 @@ runCachedMemoT :: Q Exp
runCachedMemoT = do runCachedMemoT = do
loc <- location loc <- location
[e| flip runReaderT loc . runCachedMemoT' |] [e| flip runReaderT loc . runCachedMemoT' |]
instance site ~ site' => ToWidget site (SomeMessage site') where
toWidget msg = toWidget =<< (getMessageRender <*> pure msg)

View File

@ -1,12 +1,23 @@
$newline never
<h3>Hinweise zum Import von CSV-Dateien <h3>Hinweise zum Import von CSV-Dateien
<dl .deflist> <dl .deflist>
<dt .deflist__dt>Datenformat
<dd .deflist__dd>
Beim Import wird, pro Spalte, das selbe Datenformat erwartet, wie es beim #
Export produziert wird (siehe <i>Spalten- & Zellenformat</i> #
unter <i>CSV-Export</i>).<br />
Spalten können beliebig permutiert werden und dürfen auch fehlen (in #
diesem Fall wird die fehlende Spalte so behandelt als enthielte sie in #
jeder Zeile eine leere Zelle).<br />
Spalten werden an ihrer Überschrift identifiziert. #
Die Überschrift darf daher nicht verändert oder entfernt werden.
<dt .deflist__dt>Änderungen <dt .deflist__dt>Änderungen
<dd .deflist__dd> <dd .deflist__dd>
Einige Zellen können durch den Import verändert werden. Einige Zellen können durch den Import verändert werden.<br />
Nicht-änderbare Zellen werden ignoriert, falls diese verändert wurden. Nicht-änderbare Zellen werden ignoriert, falls diese verändert wurden.
<dt .deflist__dt>Vorschau <dt .deflist__dt>Vorschau
<dd .deflist__dd> <dd .deflist__dd>
Es wird eine Vorschau angezeigt, bevor irgendetwas tatsächlich geändert wird. Es wird eine Vorschau angezeigt, bevor irgendetwas tatsächlich geändert wird.<br />
In der Vorschau können dann auch nur teilweise Änderungen ausgewählt werden. In der Vorschau können dann auch nur teilweise Änderungen ausgewählt werden.
<dt .deflist__dt>Leere Zellen <dt .deflist__dt>Leere Zellen
<dd .deflist__dd> <dd .deflist__dd>
@ -16,22 +27,22 @@
<p> <p>
Es werden nur konsistente Änderungen akzeptiert! Es werden nur konsistente Änderungen akzeptiert!
<p> <p>
Daraus folgt, dass es sinnvoll sein kann, gewisse Zellen frei zu lassen; Daraus folgt, dass es sinnvoll sein kann, gewisse Zellen frei zu lassen; #
z.B. ändert man ein Studienfachzuordnung eines Teilnehmers ab, ändert man z.B. die Studienfachzuordnung eines Teilnehmers ab, #
dann müsste man auch Abschluss und Semesterzahl passend ändern. so müsste man auch Abschluss und Fachsemester passend ändern.<br />
Da diese jedoch eindeutig sind, kann man diese Zellen einfach frei lassen. Da diese jedoch eindeutig sind, kann man diese Zellen einfach frei lassen.
<dt .deflist__dt>Zeilen Identifikation <dt .deflist__dt>Zeilen Identifikation
<dd .deflist__dd> <dd .deflist__dd>
Mehrere Spalten werden zur Identifikation der Zeile verwendet. Mehrere Spalten werden zur Identifikation der Zeile verwendet.<br />
Es muss nicht in jeder Spalte der Zeile ein Wert vorhanden sein, Es muss nicht in jeder Spalte der Zeile ein Wert vorhanden sein, #
so lange die Identifikation noch eindeutig ist. so lange die Identifikation noch eindeutig ist.<br />
Sind mehrere Werte vorhanden, so müssen diese natürlich zueinander passen. Sind mehrere Werte vorhanden, so müssen diese natürlich zueinander passen.
<dt .deflist__dt>Zeilen hinzufügen <dt .deflist__dt>Zeilen hinzufügen
<dd .deflist__dd> <dd .deflist__dd>
Es können auch neue Zeilen hinzugefügt werden, so fern ausreichend Es können auch neue Zeilen hinzugefügt werden, sofern ausreichend #
eindeutige Informationen vorhanden sind; eindeutige Informationen vorhanden sind; #
z.B. können so Prüfungsteilnehmer nachgemeldet werden. z.B. können so Prüfungsteilnehmer nachgemeldet werden.
<dt .deflist__dt>Zeilen löschen <dt .deflist__dt>Zeilen löschen
<dd .deflist__dd> <dd .deflist__dd>
Fehlende Zeilen werden in der Vorschau zur Löschung angeboten Fehlende Zeilen werden in der Vorschau zur Löschung angeboten #
und dann ggf. gelöscht. und dann ggf. gelöscht.

View File

@ -14,5 +14,7 @@ $if is _Just dbtCsvEncode
<div .csv-export__content> <div .csv-export__content>
<p> <p>
^{csvColExplanations'} ^{csvColExplanations'}
<p>
^{modal (i18n MsgCsvChangeOptionsLabel) (Left (SomeRoute CsvOptionsR))}
^{csvExportWdgt'} ^{csvExportWdgt'}

View File

@ -111,6 +111,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = csvPreset # CsvPresetRFC
} }
fhamann <- insert User fhamann <- insert User
{ userIdent = "felix.hamann@campus.lmu.de" { userIdent = "felix.hamann@campus.lmu.de"
@ -135,6 +136,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = csvPreset # CsvPresetExcel
} }
jost <- insert User jost <- insert User
{ userIdent = "jost@tcs.ifi.lmu.de" { userIdent = "jost@tcs.ifi.lmu.de"
@ -159,6 +161,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = def
} }
maxMuster <- insert User maxMuster <- insert User
{ userIdent = "max@campus.lmu.de" { userIdent = "max@campus.lmu.de"
@ -183,6 +186,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = def
} }
tinaTester <- insert $ User tinaTester <- insert $ User
{ userIdent = "tester@campus.lmu.de" { userIdent = "tester@campus.lmu.de"
@ -207,6 +211,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = def
} }
svaupel <- insert User svaupel <- insert User
{ userIdent = "vaupel.sarah@campus.lmu.de" { userIdent = "vaupel.sarah@campus.lmu.de"
@ -231,6 +236,7 @@ fillDb = do
, userNotificationSettings = def , userNotificationSettings = def
, userCreated = now , userCreated = now
, userLastLdapSynchronisation = Nothing , userLastLdapSynchronisation = Nothing
, userCsvOptions = def
} }
void . repsert (TermKey summer2017) $ Term void . repsert (TermKey summer2017) $ Term
{ termName = summer2017 { termName = summer2017

View File

@ -18,10 +18,11 @@ instance Arbitrary (Route Auth) where
instance Arbitrary (Route EmbeddedStatic) where instance Arbitrary (Route EmbeddedStatic) where
arbitrary = do arbitrary = do
let printableText = pack . filter (/= '/') . getPrintableString <$> arbitrary let printableText = pack . filter (/= '/') . getPrintableString <$> arbitrary
printableText' = printableText `suchThat` (not . null)
pathLength <- getPositive <$> arbitrary pathLength <- getPositive <$> arbitrary
path <- replicateM pathLength printableText path <- replicateM pathLength printableText'
paramNum <- getNonNegative <$> arbitrary paramNum <- getNonNegative <$> arbitrary
params <- replicateM paramNum $ (,) <$> printableText <*> printableText params <- replicateM paramNum $ (,) <$> printableText' <*> printableText
return $ embeddedResourceR path params return $ embeddedResourceR path params
instance Arbitrary SchoolR where instance Arbitrary SchoolR where

View File

@ -0,0 +1,12 @@
module Model.MigrationSpec where
import TestImport
import Model.Migration
spec :: Spec
spec = withApp $ -- `withApp` does migration, if needed
describe "Migration" $
it "is idempotent" $
(`shouldBe` False) <$> runDB requiresMigration -- Migration shouldn't be needed after `withApp` above

View File

@ -8,6 +8,7 @@ import Settings
import Control.Lens (review, preview) import Control.Lens (review, preview)
import Data.Aeson (Value) import Data.Aeson (Value)
import qualified Data.Aeson as Aeson import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Types as Aeson
import MailSpec () import MailSpec ()
@ -32,6 +33,8 @@ import Data.Scientific
import Utils.Lens import Utils.Lens
import qualified Data.Char as Char
instance (Arbitrary a, MonoFoldable a) => Arbitrary (NonNull a) where instance (Arbitrary a, MonoFoldable a) => Arbitrary (NonNull a) where
arbitrary = arbitrary `suchThatMap` fromNullable arbitrary = arbitrary `suchThatMap` fromNullable
@ -250,6 +253,28 @@ instance Arbitrary ExamPassed where
arbitrary = genericArbitrary arbitrary = genericArbitrary
shrink = genericShrink shrink = genericShrink
instance Arbitrary Quoting where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary CsvOptions where
arbitrary = CsvOptions
<$> suchThat arbitrary validDelimiter
<*> arbitrary
<*> arbitrary
where
validDelimiter c = and
[ Char.isLatin1 c
, c /= '"'
, c /= '\r'
, c /= '\n'
]
shrink = genericShrink
instance Arbitrary CsvPreset where
arbitrary = genericArbitrary
shrink = genericShrink
spec :: Spec spec :: Spec
spec = do spec = do
@ -334,6 +359,12 @@ spec = do
[ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ] [ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ]
lawsCheckHspec (Proxy @ExamPassed) lawsCheckHspec (Proxy @ExamPassed)
[ eqLaws, ordLaws, showReadLaws, finiteLaws, jsonLaws, pathPieceLaws, persistFieldLaws, csvFieldLaws ] [ eqLaws, ordLaws, showReadLaws, finiteLaws, jsonLaws, pathPieceLaws, persistFieldLaws, csvFieldLaws ]
lawsCheckHspec (Proxy @Quoting)
[ eqLaws, ordLaws, jsonLaws, showReadLaws, finiteLaws, pathPieceLaws ]
lawsCheckHspec (Proxy @CsvOptions)
[ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ]
lawsCheckHspec (Proxy @CsvPreset)
[ eqLaws, ordLaws, showReadLaws, boundedEnumLaws, finiteLaws, pathPieceLaws ]
describe "TermIdentifier" $ do describe "TermIdentifier" $ do
it "has compatible encoding/decoding to/from Text" . property $ it "has compatible encoding/decoding to/from Text" . property $
@ -365,6 +396,9 @@ spec = do
parse "1.8" `shouldSatisfy` is _Left parse "1.8" `shouldSatisfy` is _Left
parse "voided" `shouldBe` Right ExamVoided parse "voided" `shouldBe` Right ExamVoided
parse "no-show" `shouldBe` Right ExamNoShow parse "no-show" `shouldBe` Right ExamNoShow
describe "CsvOptions" $
it "json-decodes from empty object" . example $
Aeson.parseMaybe Aeson.parseJSON (Aeson.object []) `shouldBe` Just (def :: CsvOptions)
termExample :: (TermIdentifier, Text) -> Expectation termExample :: (TermIdentifier, Text) -> Expectation
termExample (term, encoded) = example $ do termExample (term, encoded) = example $ do

View File

@ -22,10 +22,13 @@ import Utils
import System.FilePath import System.FilePath
import Data.Time import Data.Time
import Mail (MailLanguages(..))
instance Arbitrary EmailAddress where instance Arbitrary EmailAddress where
arbitrary = do arbitrary = do
local <- suchThat arbitrary (\l -> isEmail l (CBS.pack "example.com")) local <- suchThat (CBS.pack . getPrintableString <$> arbitrary) (\l -> isEmail l (CBS.pack "example.com"))
domain <- suchThat arbitrary (\d -> isEmail (CBS.pack "example") d) domain <- suchThat (CBS.pack . getPrintableString <$> arbitrary) (\d -> isEmail (CBS.pack "example") d)
let (Just result) = emailAddress (makeEmailLike local domain) let (Just result) = emailAddress (makeEmailLike local domain)
pure result pure result
@ -100,8 +103,9 @@ instance Arbitrary User where
userDownloadFiles <- arbitrary userDownloadFiles <- arbitrary
userWarningDays <- arbitrary userWarningDays <- arbitrary
userMailLanguages <- arbitrary userMailLanguages <- fmap MailLanguages $ sublistOf =<< shuffle (toList appLanguages)
userNotificationSettings <- arbitrary userNotificationSettings <- arbitrary
userCsvOptions <- arbitrary
userCreated <- arbitrary userCreated <- arbitrary
userLastLdapSynchronisation <- arbitrary userLastLdapSynchronisation <- arbitrary

View File

@ -140,6 +140,7 @@ createUser adjUser = do
userNotificationSettings = def userNotificationSettings = def
userCreated = now userCreated = now
userLastLdapSynchronisation = Nothing userLastLdapSynchronisation = Nothing
userCsvOptions = def
runDB . insertEntity $ adjUser User{..} runDB . insertEntity $ adjUser User{..}
lawsCheckHspec :: Typeable a => Proxy a -> [Proxy a -> Laws] -> Spec lawsCheckHspec :: Typeable a => Proxy a -> [Proxy a -> Laws] -> Spec