Better UI for submission users & submission user invitations

This commit is contained in:
Gregor Kleen 2019-05-09 14:53:48 +02:00
parent 091e5da9df
commit 06df42e43b
27 changed files with 485 additions and 109 deletions

View File

@ -198,11 +198,13 @@ SubmissionWrongSheet: Abgabenummer gehört nicht zum angegebenen Übungsblatt.
SubmissionAlreadyExists: Sie haben bereits eine Abgabe zu diesem Übungsblatt. SubmissionAlreadyExists: Sie haben bereits eine Abgabe zu diesem Übungsblatt.
SubmissionEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh} #{sheetName}: Abgabe editieren/anlegen SubmissionEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh} #{sheetName}: Abgabe editieren/anlegen
CorrectionHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName cid@CryptoFileNameSubmission: #{display tid}-#{display ssh}-#{csh} #{sheetName}: Korrektur CorrectionHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName cid@CryptoFileNameSubmission: #{display tid}-#{display ssh}-#{csh} #{sheetName}: Korrektur
SubmissionMember n@Int: Mitabgebende(r) ##{display n} SubmissionMembers: Abgebende
SubmissionArchive: Zip-Archiv der Abgabedatei(en) SubmissionArchive: Zip-Archiv der Abgabedatei(en)
SubmissionFile: Datei zur Abgabe SubmissionFile: Datei zur Abgabe
SubmissionFiles: Abgegebene Dateien SubmissionFiles: Abgegebene Dateien
SubmissionAlreadyExistsFor email@UserEmail: #{email} hat bereits eine Abgabe zu diesem bÜbungsblatt. SubmissionAlreadyExistsFor email@UserEmail: #{email} hat bereits eine Abgabe zu diesem Übungsblatt.
SubmissionUsersEmpty: Es kann keine Abgabe ohne Abgebende erstellt werden
SubmissionUserAlreadyAdded: Dieser Nutzer ist bereits als Mitabgebende(r) eingetragen
SubmissionsDeleteQuestion n@Int: Wollen Sie #{pluralDE n "die unten aufgeführte Abgabe" "die unten aufgeführten Abgaben"} wirklich löschen? SubmissionsDeleteQuestion n@Int: Wollen Sie #{pluralDE n "die unten aufgeführte Abgabe" "die unten aufgeführten Abgaben"} wirklich löschen?
SubmissionsDeleted n@Int: #{pluralDE n "Abgabe gelöscht" "Abgaben gelöscht"} SubmissionsDeleted n@Int: #{pluralDE n "Abgabe gelöscht" "Abgaben gelöscht"}
@ -508,6 +510,7 @@ BothSubmissions: Abgabe direkt & extern mit Pseudonym
SheetCorrectorSubmissionsTip: Abgabe erfolgt über ein Uni2work-externes Verfahren (zumeist in Papierform durch Einwurf) unter Angabe eines persönlichen Pseudonyms. Korrektorn können mithilfe des Pseudonyms später Korrekturergebnisse in Uni2work eintragen, damit Sie sie einsehen können. SheetCorrectorSubmissionsTip: Abgabe erfolgt über ein Uni2work-externes Verfahren (zumeist in Papierform durch Einwurf) unter Angabe eines persönlichen Pseudonyms. Korrektorn können mithilfe des Pseudonyms später Korrekturergebnisse in Uni2work eintragen, damit Sie sie einsehen können.
SubmissionNoUploadExpected: Es ist keine Abgabe von Dateien vorgesehen. SubmissionNoUploadExpected: Es ist keine Abgabe von Dateien vorgesehen.
SubmissionReplace: Abgabe ersetzen
AdminFeaturesHeading: Studiengänge AdminFeaturesHeading: Studiengänge
StudyTerms: Studiengänge StudyTerms: Studiengänge
@ -586,6 +589,8 @@ MailSubjectCorrectorInvitation tid@TermId ssh@SchoolId csh@CourseShorthand shn@S
MailSubjectTutorInvitation tid@TermId ssh@SchoolId csh@CourseShorthand tutn@TutorialName: [#{display tid}-#{display ssh}-#{csh}] Einladung zum Tutor für #{tutn} MailSubjectTutorInvitation tid@TermId ssh@SchoolId csh@CourseShorthand tutn@TutorialName: [#{display tid}-#{display ssh}-#{csh}] Einladung zum Tutor für #{tutn}
MailSubjectSubmissionUserInvitation tid@TermId ssh@SchoolId csh@CourseShorthand shn@SheetName: [#{display tid}-#{display ssh}-#{csh}] Einladung zu einer Abgabe für #{shn}
SheetGrading: Bewertung SheetGrading: Bewertung
SheetGradingPoints maxPoints@Points: #{tshow maxPoints} Punkte SheetGradingPoints maxPoints@Points: #{tshow maxPoints} Punkte
SheetGradingPassPoints maxPoints@Points passingPoints@Points: Bestanden ab #{tshow passingPoints} von #{tshow maxPoints} Punkten SheetGradingPassPoints maxPoints@Points passingPoints@Points: Bestanden ab #{tshow passingPoints} von #{tshow maxPoints} Punkten
@ -863,6 +868,11 @@ TutorInvitationDeclined tutn@TutorialName: Sie haben die Einladung, Tutor für #
TutorInviteHeading tutn@TutorialName: Einladung zum Tutor für #{tutn} TutorInviteHeading tutn@TutorialName: Einladung zum Tutor für #{tutn}
TutorInviteExplanation: Sie wurden eingeladen, Tutor zu sein. TutorInviteExplanation: Sie wurden eingeladen, Tutor zu sein.
SubmissionUserInvitationAccepted shn@SheetName: Sie wurden als Mitabgebende(r) für eine Abgabe zu #{shn} eingetragen
SubmissionUserInvitationDeclined shn@SheetName: Sie haben die Einladung, Mitabgebende(r) für #{shn} zu werden, abgelehnt
SubmissionUserInviteHeading shn@SheetName: Einladung zu einer Abgabe für #{shn}
SubmissionUserInviteExplanation: Sie wurden eingeladen, Mitabgebende(r) bei einer Abgabe zu sein.
InvitationAction: Aktion InvitationAction: Aktion
InvitationActionTip: Abgelehnte Einladungen können nicht mehr angenommen werden InvitationActionTip: Abgelehnte Einladungen können nicht mehr angenommen werden
InvitationMissingRestrictions: Authorisierungs-Token fehlen benötigte Daten InvitationMissingRestrictions: Authorisierungs-Token fehlen benötigte Daten

1
routes
View File

@ -107,6 +107,7 @@
/delete SubDelR GET POST !ownerANDtime /delete SubDelR GET POST !ownerANDtime
/assign SAssignR GET POST !lecturerANDtime /assign SAssignR GET POST !lecturerANDtime
/correction CorrectionR GET POST !corrector !ownerANDreadANDrated /correction CorrectionR GET POST !corrector !ownerANDreadANDrated
/invite SInviteR GET POST !ownerANDtime
!/#SubmissionFileType/*FilePath SubDownloadR GET !owner !corrector !/#SubmissionFileType/*FilePath SubDownloadR GET !owner !corrector
/correctors SCorrR GET POST /correctors SCorrR GET POST
/pseudonym SPseudonymR GET POST !course-registeredANDcorrector-submissions /pseudonym SPseudonymR GET POST !course-registeredANDcorrector-submissions

View File

@ -155,6 +155,11 @@ instance HasAppSettings UniWorX where
-- type Widget = WidgetT UniWorX IO () -- type Widget = WidgetT UniWorX IO ()
mkYesodData "UniWorX" $(parseRoutesFile "routes") mkYesodData "UniWorX" $(parseRoutesFile "routes")
deriving instance Generic CourseR
deriving instance Generic SheetR
deriving instance Generic SubmissionR
deriving instance Generic MaterialR
deriving instance Generic TutorialR
deriving instance Generic (Route UniWorX) deriving instance Generic (Route UniWorX)
-- | Convenient Type Synonyms: -- | Convenient Type Synonyms:
@ -503,13 +508,19 @@ validateToken mAuthId' route' isWrite' token' = runCachedMemoT $ for4 memo valid
User{userTokensIssuedAfter} <- maybeMExceptT (unauthorizedI MsgUnauthorizedTokenInvalidAuthority) $ get tokenAuthority User{userTokensIssuedAfter} <- maybeMExceptT (unauthorizedI MsgUnauthorizedTokenInvalidAuthority) $ get tokenAuthority
guardMExceptT (Just tokenIssuedAt >= userTokensIssuedAfter) (unauthorizedI MsgUnauthorizedTokenExpired) guardMExceptT (Just tokenIssuedAt >= userTokensIssuedAfter) (unauthorizedI MsgUnauthorizedTokenExpired)
let
-- Prevent infinite loops
noTokenAuth :: AuthDNF -> AuthDNF
noTokenAuth = over _dnfTerms . Set.filter . noneOf (re _nullable . folded) $ (== AuthToken) . plVar
authorityVal <- do authorityVal <- do
dnf <- either throwM return $ routeAuthTags route dnf <- either throwM return $ routeAuthTags route
fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ (/=) AuthToken) dnf (Just tokenAuthority) route isWrite fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) (noTokenAuth dnf) (Just tokenAuthority) route isWrite
guardExceptT (is _Authorized authorityVal) authorityVal guardExceptT (is _Authorized authorityVal) authorityVal
whenIsJust tokenAddAuth $ \addDNF -> do whenIsJust tokenAddAuth $ \addDNF -> do
additionalVal <- fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ (/=) AuthToken) addDNF mAuthId route isWrite $logDebugS "validateToken" $ tshow addDNF
additionalVal <- fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) (noTokenAuth addDNF) mAuthId route isWrite
guardExceptT (is _Authorized additionalVal) additionalVal guardExceptT (is _Authorized additionalVal) additionalVal
return Authorized return Authorized
@ -2108,6 +2119,14 @@ pageActions (CSheetR tid ssh csh shn SSubsR) =
, menuItemModal = False , menuItemModal = False
, menuItemAccessCallback' = return True , menuItemAccessCallback' = return True
} }
, MenuItem
{ menuItemType = PageActionPrime
, menuItemLabel = MsgMenuSubmissionNew
, menuItemIcon = Nothing
, menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SubmissionNewR
, menuItemModal = True
, menuItemAccessCallback' = return True
}
] ]
pageActions (CSubmissionR tid ssh csh shn cid SubShowR) = pageActions (CSubmissionR tid ssh csh shn cid SubShowR) =
[ MenuItem [ MenuItem
@ -2409,7 +2428,9 @@ routeNormalizers =
-- How to run database actions. -- How to run database actions.
instance YesodPersist UniWorX where instance YesodPersist UniWorX where
type YesodPersistBackend UniWorX = SqlBackend type YesodPersistBackend UniWorX = SqlBackend
runDB action = runSqlPool action =<< appConnPool <$> getYesod runDB action = do
$logDebugS "YesodPersist" "runDB"
runSqlPool action =<< appConnPool <$> getYesod
instance YesodPersistRunner UniWorX where instance YesodPersistRunner UniWorX where
getDBRunner = defaultGetDBRunner appConnPool getDBRunner = defaultGetDBRunner appConnPool

View File

@ -191,12 +191,10 @@ postAdminTestR = do
(intRes, intView) <- mreq intField ("" & addName (nudge "int")) $ initial <|> Just cData (intRes, intView) <- mreq intField ("" & addName (nudge "int")) $ initial <|> Just cData
return (intRes, toWidget csrf >> fvInput intView) return (intRes, toWidget csrf >> fvInput intView)
-- | How does the shape (`ListLength`) change if a certain cell is deleted? -- | How does the shape (`ListLength`) change if a certain cell is deleted?
deleteCell :: ListLength -- ^ Current shape deleteCell :: Map ListPosition Int -- ^ Current shape, including initialisation data
-> ListPosition -- ^ Coordinate to delete -> ListPosition -- ^ Coordinate to delete
-> MaybeT (MForm (HandlerT UniWorX IO)) (Map ListPosition ListPosition) -- ^ Monadically compute a set of new positions and their associated old positions -> MaybeT (MForm (HandlerT UniWorX IO)) (Map ListPosition ListPosition) -- ^ Monadically compute a set of new positions and their associated old positions
deleteCell l pos deleteCell = miDeleteList
| l >= 2 = return . Map.fromSet (\pos' -> bool pos' (succ pos') $ pos' >= pos) $ Set.fromList [0..fromIntegral (l - 2)] -- Close gap left by deleted position with values from further down the list; try prohibiting certain deletions by utilising `guard`
| otherwise = return Map.empty
-- | Make a decision on whether an add widget should be allowed to further cells, given the current @liveliness@ (i.e. before performing the addition) -- | Make a decision on whether an add widget should be allowed to further cells, given the current @liveliness@ (i.e. before performing the addition)
allowAdd :: ListPosition -> Natural -> ListLength -> Bool allowAdd :: ListPosition -> Natural -> ListLength -> Bool
allowAdd _ _ l = l < 7 -- Limit list length; much more complicated checks are possible (this could in principle be monadic, but @massInput@ is probably already complicated enough to cover just current (2019-03) usecases) allowAdd _ _ l = l < 7 -- Limit list length; much more complicated checks are possible (this could in principle be monadic, but @massInput@ is probably already complicated enough to cover just current (2019-03) usecases)

View File

@ -686,14 +686,14 @@ instance FromJSON (InvitationDBData Lecturer) where
instance ToJSON (InvitationTokenData Lecturer) where instance ToJSON (InvitationTokenData Lecturer) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 } toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 3 } toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
instance FromJSON (InvitationTokenData Lecturer) where instance FromJSON (InvitationTokenData Lecturer) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 } parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
lecturerInvitationConfig :: InvitationConfig Lecturer lecturerInvitationConfig :: InvitationConfig Lecturer
lecturerInvitationConfig = InvitationConfig{..} lecturerInvitationConfig = InvitationConfig{..}
where where
invitationRoute Course{..} _ = return . CourseR courseTerm courseSchool courseShorthand $ CLecInviteR invitationRoute (Entity _ Course{..}) _ = return . CourseR courseTerm courseSchool courseShorthand $ CLecInviteR
invitationResolveFor = do invitationResolveFor = do
Just (CourseR tid csh ssh CLecInviteR) <- getCurrentRoute Just (CourseR tid csh ssh CLecInviteR) <- getCurrentRoute
getKeyBy404 $ TermSchoolCourseShort tid csh ssh getKeyBy404 $ TermSchoolCourseShort tid csh ssh
@ -802,7 +802,7 @@ makeCourseForm miButtonAction template = identifyForm FIDcourse $ \html -> do
let lrwView' = $(widgetFile "course/lecturerMassInput/cellInvitation") let lrwView' = $(widgetFile "course/lecturerMassInput/cellInvitation")
return (lrwRes,lrwView') return (lrwRes,lrwView')
miDelete :: ListLength -- ^ Current shape miDelete :: Map ListPosition (Either UserEmail UserId) -- ^ Current shape
-> ListPosition -- ^ Coordinate to delete -> ListPosition -- ^ Coordinate to delete
-> MaybeT (MForm (HandlerT UniWorX IO)) (Map ListPosition ListPosition) -> MaybeT (MForm (HandlerT UniWorX IO)) (Map ListPosition ListPosition)
miDelete = miDeleteList miDelete = miDeleteList

View File

@ -705,7 +705,7 @@ correctorForm shid = wFormToAForm $ do
return (res, $(widgetFile "sheetCorrectors/cell")) return (res, $(widgetFile "sheetCorrectors/cell"))
miDelete :: ListLength miDelete :: Map ListPosition (Either UserEmail UserId)
-> ListPosition -> ListPosition
-> MaybeT (MForm Handler) (Map ListPosition ListPosition) -> MaybeT (MForm Handler) (Map ListPosition ListPosition)
miDelete = miDeleteList miDelete = miDeleteList
@ -814,14 +814,14 @@ instance FromJSON (InvitationDBData SheetCorrector) where
instance ToJSON (InvitationTokenData SheetCorrector) where instance ToJSON (InvitationTokenData SheetCorrector) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
instance FromJSON (InvitationTokenData SheetCorrector) where instance FromJSON (InvitationTokenData SheetCorrector) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
correctorInvitationConfig :: InvitationConfig SheetCorrector correctorInvitationConfig :: InvitationConfig SheetCorrector
correctorInvitationConfig = InvitationConfig{..} correctorInvitationConfig = InvitationConfig{..}
where where
invitationRoute Sheet{..} _ = do invitationRoute (Entity _ Sheet{..}) _ = do
Course{..} <- get404 sheetCourse Course{..} <- get404 sheetCourse
return $ CSheetR courseTerm courseSchool courseShorthand sheetName SCorrInviteR return $ CSheetR courseTerm courseSchool courseShorthand sheetName SCorrInviteR
invitationResolveFor = do invitationResolveFor = do

View File

@ -1,15 +1,21 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Handler.Submission where module Handler.Submission where
import Import import Import
import Jobs import Jobs
import Utils.Lens
-- import Yesod.Form.Bootstrap3 -- import Yesod.Form.Bootstrap3
import Handler.Utils import Handler.Utils
import Handler.Utils.Delete import Handler.Utils.Delete
import Handler.Utils.Submission import Handler.Utils.Submission
import Handler.Utils.Table.Cells import Handler.Utils.Table.Cells
import Handler.Utils.Form.MassInput
import Handler.Utils.Invitations
import Network.Mime import Network.Mime
@ -22,9 +28,6 @@ import Data.Maybe (fromJust)
-- import qualified Data.Maybe -- import qualified Data.Maybe
import qualified Data.Text.Encoding as Text import qualified Data.Text.Encoding as Text
import Data.CaseInsensitive (CI)
-- import qualified Data.CaseInsensitive as CI
import qualified Database.Esqueleto as E import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Internal.Sql as E (unsafeSqlFunction) import qualified Database.Esqueleto.Internal.Sql as E (unsafeSqlFunction)
@ -33,12 +36,16 @@ import qualified Data.Conduit.List as Conduit
-- import Data.Set (Set) -- import Data.Set (Set)
import qualified Data.Set as Set import qualified Data.Set as Set
import Data.Map (Map) import Data.Map (Map, (!), (!?))
import qualified Data.Map as Map import qualified Data.Map as Map
-- import Data.Bifunctor -- import Data.Bifunctor
import System.FilePath import System.FilePath
import Text.Blaze (Markup)
import Data.Aeson hiding (Result(..))
import Text.Hamlet (ihamlet)
-- import Colonnade hiding (bool, fromMaybe) -- import Colonnade hiding (bool, fromMaybe)
-- import qualified Yesod.Colonnade as Yesod -- import qualified Yesod.Colonnade as Yesod
-- import qualified Text.Blaze.Html5.Attributes as HA -- import qualified Text.Blaze.Html5.Attributes as HA
@ -48,30 +55,203 @@ import System.FilePath
-- numberOfSubmissionEditDates = 3 -- for debugging only, should be 1 in production. -- numberOfSubmissionEditDates = 3 -- for debugging only, should be 1 in production.
makeSubmissionForm :: Maybe SubmissionId -> UploadMode -> SheetGroup -> NonEmpty UserEmail -> Form (Maybe (Source Handler File), NonEmpty UserEmail) instance IsInvitableJunction SubmissionUser where
makeSubmissionForm msmid uploadMode grouping (self :| buddies) = identifyForm FIDsubmission $ \html -> do type InvitationFor SubmissionUser = Submission
let data InvitableJunction SubmissionUser = JunctionSubmissionUser
fileUploadForm = case uploadMode of deriving (Eq, Ord, Read, Show, Generic, Typeable)
NoUpload -> pure Nothing data InvitationDBData SubmissionUser = InvDBDataSubmissionUser
(Upload unpackZips) -> (bool (\f fs _ -> Just <$> areq f fs Nothing) aopt $ isJust msmid) (zipFileField unpackZips) (fsm $ bool MsgSubmissionFile MsgSubmissionArchive unpackZips) Nothing deriving (Eq, Ord, Read, Show, Generic, Typeable)
flip (renderAForm FormStandard) html $ (,) data InvitationTokenData SubmissionUser = InvTokenDataSubmissionUser
<$> fileUploadForm deriving (Eq, Ord, Read, Show, Generic, Typeable)
<*> ( (:|)
-- #227 Part I: change aforced to areq if the user is the lecturer or an admin (lecturer can upload for students)
<$> aforced ciField (fslpI (MsgSubmissionMember 1) "user@campus.lmu.de" ) self
<*> (catMaybes <$> sequenceA [bool aforced' aopt editableBuddies ciField (fslpI (MsgSubmissionMember g) "user@campus.lmu.de" ) buddy
| g <- [2..(fromIntegral groupNr)]
| buddy <- map (Just . Just) buddies ++ repeat Nothing -- show current buddies
])
)
where
(groupNr, editableBuddies)
| Arbitrary{..} <- grouping = (maxParticipants, True)
| RegisteredGroups <- grouping = (fromIntegral $ length buddies, False)
| otherwise = (0, False)
aforced' f fs (Just (Just v)) = Just <$> aforced f fs v _InvitableJunction = iso
aforced' _ _ _ = error "Cannot happen since groupNr==0 if grouping/=Arbitrary" (\SubmissionUser{..} -> (submissionUserUser, submissionUserSubmission, JunctionSubmissionUser))
(\(submissionUserUser, submissionUserSubmission, JunctionSubmissionUser) -> SubmissionUser{..})
instance ToJSON (InvitableJunction SubmissionUser) where
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
instance FromJSON (InvitableJunction SubmissionUser) where
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
instance ToJSON (InvitationDBData SubmissionUser) where
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
instance FromJSON (InvitationDBData SubmissionUser) where
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
instance ToJSON (InvitationTokenData SubmissionUser) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
instance FromJSON (InvitationTokenData SubmissionUser) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
submissionUserInvitationConfig :: InvitationConfig SubmissionUser
submissionUserInvitationConfig = InvitationConfig{..}
where
invitationRoute (Entity subId Submission{..}) _ = do
Sheet{..} <- getJust submissionSheet
Course{..} <- getJust sheetCourse
cID <- encrypt subId
return $ CSubmissionR courseTerm courseSchool courseShorthand sheetName cID SInviteR
invitationResolveFor = do
Just (CSubmissionR _tid _ssh _csh _shn cID SInviteR) <- getCurrentRoute
subId <- decrypt cID
bool notFound (return subId) =<< existsKey subId
invitationSubject Submission{..} _ = do
Sheet{..} <- getJust submissionSheet
Course{..} <- getJust sheetCourse
return . SomeMessage $ MsgMailSubjectSubmissionUserInvitation courseTerm courseSchool courseShorthand sheetName
invitationHeading Submission{..} _ = do
Sheet{..} <- getJust submissionSheet
return . SomeMessage $ MsgSubmissionUserInviteHeading sheetName
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgSubmissionUserInviteExplanation}|]
invitationTokenConfig Submission{..} _ = do
Sheet{..} <- getJust submissionSheet
Course{..} <- getJust sheetCourse
itAuthority <- liftHandlerT requireAuthId
itAddAuth <- either throwM (return . Just) $ routeAuthTags (CSheetR courseTerm courseSchool courseShorthand sheetName SubmissionNewR)
let itExpiresAt = Nothing
itStartsAt = Nothing
return InvitationTokenConfig{..}
invitationRestriction _ _ = return Authorized
invitationForm _ _ = pure JunctionSubmissionUser
invitationSuccessMsg Submission{..} _ = do
Sheet{..} <- getJust submissionSheet
return . SomeMessage $ MsgSubmissionUserInvitationAccepted sheetName
invitationUltDest Submission{..} (Entity _ SubmissionUser{..}) = do
Sheet{..} <- getJust submissionSheet
Course{..} <- getJust sheetCourse
cID <- encrypt submissionUserSubmission
return . SomeRoute $ CSubmissionR courseTerm courseSchool courseShorthand sheetName cID SubShowR
makeSubmissionForm :: CourseId -> Maybe SubmissionId -> UploadMode -> SheetGroup -> Bool -> Set (Either UserEmail UserId) -> Form (Maybe (Source Handler File), Set (Either UserEmail UserId))
makeSubmissionForm cid msmid uploadMode grouping isLecturer prefillUsers = identifyForm FIDsubmission . renderAForm FormStandard $ (,)
<$> fileUploadForm
<*> wFormToAForm submittorsForm
where
fileUploadForm = case uploadMode of
NoUpload
-> pure Nothing
(Upload unpackZips)
-> (bool (\f fs _ -> Just <$> areq f fs Nothing) aopt $ isJust msmid) (zipFileField unpackZips) (fsm $ bool MsgSubmissionFile MsgSubmissionArchive unpackZips) Nothing
miCell' :: Markup -> Either UserEmail UserId -> Widget
miCell' csrf (Left email) = $(widgetFile "widgets/massinput/submissionUsers/cellInvitation")
miCell' csrf (Right uid) = do
User{..} <- liftHandlerT . runDB $ getJust uid
$(widgetFile "widgets/massinput/submissionUsers/cellKnown")
miLayout :: ListLength
-> Map ListPosition (Either UserEmail UserId, FormResult ()) -- ^ massInput state
-> Map ListPosition Widget -- ^ Cell widgets
-> Map ListPosition (FieldView UniWorX) -- ^ Deletion buttons
-> Map (Natural, ListPosition) Widget -- ^ Addition widgets
-> Widget
miLayout lLength _ cellWdgts delButtons addWdgts = $(widgetFile "widgets/massinput/submissionUsers/layout")
miIdent :: Text
miIdent = "submittors"
courseUsers :: E.SqlQuery (E.SqlExpr (Entity User))
courseUsers = E.from $ \(user `E.InnerJoin` participant) -> do
E.on $ user E.^. UserId E.==. participant E.^. CourseParticipantUser
E.where_ $ participant E.^. CourseParticipantCourse E.==. E.val cid
E.orderBy [E.asc $ user E.^. UserEmail]
return user
addField :: (MonadHandler m, HandlerSite m ~ UniWorX) => Bool -> Field m (Set (Either UserEmail UserId))
addField isAdmin = multiUserField True $ courseUsers <$ guard isAdmin
addFieldSettings, submittorSettings :: FieldSettings UniWorX
addFieldSettings = fslI MsgSubmissionMembers
submittorSettings = fslI MsgSubmissionMembers & setTooltip MsgMassInputTip
miButtonAction' :: forall p. PathPiece p => Maybe (Route UniWorX) -> p -> Maybe (SomeRoute UniWorX)
miButtonAction' mCurrent frag = mCurrent <&> \current -> SomeRoute (current :#: frag)
submittorsForm
| isLecturer = do-- Form is being used by lecturer; allow Everything™
let
miAdd :: (Text -> Text) -> FieldView UniWorX -> Form ([Either UserEmail UserId] -> FormResult [Either UserEmail UserId])
miAdd nudge btn csrf = do
MsgRenderer mr <- getMsgRenderer
(addRes, addView) <- mpreq (addField True) (addFieldSettings & addName (nudge "emails")) Nothing
let addRes' = addRes <&> \newData oldData -> if
| existing <- newData `Set.intersection` Set.fromList oldData
, not $ Set.null existing
-> FormFailure [mr MsgSubmissionUserAlreadyAdded]
| otherwise
-> FormSuccess $ Set.toList newData
return (addRes', $(widgetFile "widgets/massinput/submissionUsers/add"))
mRoute <- getCurrentRoute
submittors <- massInputAccumW miAdd (miCell' mempty) (miButtonAction' mRoute) miLayout miIdent submittorSettings True (Just $ Set.toList prefillUsers)
MsgRenderer mr <- getMsgRenderer
return $ submittors >>= \submittors' -> if
| null submittors' -> FormFailure [mr MsgSubmissionUsersEmpty]
| otherwise -> FormSuccess $ Set.fromList submittors'
| otherwise = do
uid <- liftHandlerT requireAuthId
mRoute <- getCurrentRoute
let
maxSize
| Arbitrary{..} <- grouping = Just maxParticipants
| otherwise = Nothing
mayEdit = is _Arbitrary grouping
miAdd :: ListPosition
-> Natural
-> (Text -> Text)
-> FieldView UniWorX
-> Maybe (Form (Map ListPosition (Either UserEmail UserId) -> FormResult (Map ListPosition (Either UserEmail UserId))))
miAdd _ _ nudge btn = Just $ \csrf -> do
MsgRenderer mr <- getMsgRenderer
(addRes, addView) <- mpreq (addField True) (addFieldSettings & addName (nudge "emails")) Nothing
let addRes' = addRes <&> \newData oldData -> if
| existing <- newData `Set.intersection` setOf folded oldData
, not $ Set.null existing
-> FormFailure [mr MsgSubmissionUserAlreadyAdded]
| otherwise -> let numStart = maybe 0 (succ . fst) $ Map.lookupMax oldData
in FormSuccess . Map.fromList . zip [numStart..] $ Set.toList newData
return (addRes', $(widgetFile "widgets/massinput/submissionUsers/add"))
miCell :: ListPosition
-> Either UserEmail UserId
-> Maybe ()
-> (Text -> Text)
-> Form ()
miCell _ dat _ _ csrf = return (FormSuccess (), miCell' csrf dat)
miDelete :: Map ListPosition (Either UserEmail UserId)
-> ListPosition
-> MaybeT (MForm Handler) (Map ListPosition ListPosition)
miDelete dat delPos = do
guard mayEdit
guard $ Map.size dat > 1
-- User may drop from submission only if it already exists; no directly creating submissions for other people
guard $ maybe True (/= Right uid) (dat !? delPos) || isJust msmid
miDeleteList dat delPos
miAllowAdd :: ListPosition
-> Natural
-> ListLength
-> Bool
miAllowAdd _ _ l = mayEdit && maybe False ((l <) . fromIntegral) maxSize
miAddEmpty _ _ _ = Set.empty
miButtonAction :: forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX)
miButtonAction = miButtonAction' mRoute
postProcess :: Map ListPosition (Either UserEmail UserId, ()) -> Set (Either UserEmail UserId)
postProcess = setOf $ folded . _1
fmap postProcess <$> massInputW MassInput{..} submittorSettings True (Just . Map.fromList . zip [0..] . map (, ()) $ Set.toList prefillUsers)
getSubmissionNewR, postSubmissionNewR :: TermId -> SchoolId -> CourseShorthand -> SheetName -> Handler Html getSubmissionNewR, postSubmissionNewR :: TermId -> SchoolId -> CourseShorthand -> SheetName -> Handler Html
getSubmissionNewR = postSubmissionNewR getSubmissionNewR = postSubmissionNewR
@ -100,13 +280,14 @@ getSubmissionOwnR tid ssh csh shn = do
submissionHelper :: TermId -> SchoolId -> CourseShorthand -> SheetName -> Maybe CryptoFileNameSubmission -> Handler Html submissionHelper :: TermId -> SchoolId -> CourseShorthand -> SheetName -> Maybe CryptoFileNameSubmission -> Handler Html
submissionHelper tid ssh csh shn mcid = do submissionHelper tid ssh csh shn mcid = do
(Entity uid userData) <- requireAuth uid <- requireAuthId
msmid <- traverse decrypt mcid msmid <- traverse decrypt mcid
actionUrl <- Data.Maybe.fromJust <$> getCurrentRoute Just actionUrl <- getCurrentRoute
maySubmit <- (== Authorized) <$> isAuthorized actionUrl True -- affects visibility of Edit-Dates, Submission-Button, etc.
(Entity shid Sheet{..}, buddies, lastEdits) <- runDB $ do (Entity shid Sheet{..}, buddies, lastEdits, maySubmit, isLecturer, isOwner) <- runDB $ do
csheet@(Entity shid Sheet{..}) <- fetchSheet tid ssh csh shn csheet@(Entity shid Sheet{..}) <- fetchSheet tid ssh csh shn
maySubmit <- (== Authorized) <$> evalAccessDB actionUrl True
isLecturer <- (== Authorized) <$> evalAccessDB (CSheetR tid ssh csh shn SSubsR) True
case msmid of case msmid of
Nothing -> do Nothing -> do
submissions <- E.select . E.from $ \(submission `E.InnerJoin` submissionUser) -> do submissions <- E.select . E.from $ \(submission `E.InnerJoin` submissionUser) -> do
@ -132,8 +313,8 @@ submissionHelper tid ssh csh shn mcid = do
E.where_ $ submissionUser E.^. SubmissionUserSubmission `E.in_` oldids E.where_ $ submissionUser E.^. SubmissionUserSubmission `E.in_` oldids
E.&&. submissionUser E.^. SubmissionUserUser E.!=. E.val uid E.&&. submissionUser E.^. SubmissionUserUser E.!=. E.val uid
E.orderBy [E.asc $ user E.^. UserEmail] E.orderBy [E.asc $ user E.^. UserEmail]
return $ user E.^. UserEmail return $ user E.^. UserId
return (csheet, map E.unValue buddies, []) return (csheet, Set.fromList $ map (Right . E.unValue) buddies, [], maySubmit, isLecturer, not isLecturer)
(E.Value smid:_) -> do (E.Value smid:_) -> do
cID <- encrypt smid cID <- encrypt smid
addMessageI Info MsgSubmissionAlreadyExists addMessageI Info MsgSubmissionAlreadyExists
@ -146,15 +327,18 @@ submissionHelper tid ssh csh shn mcid = do
invalidArgsI [MsgSubmissionWrongSheet] invalidArgsI [MsgSubmissionWrongSheet]
-- fetch buddies from current submission -- fetch buddies from current submission
(Any isOwner, buddies) <- do (Any isOwner, buddies) <- do
submitters <- E.select . E.from $ \(submissionUser `E.InnerJoin` user) -> do submittors <- E.select . E.from $ \(submissionUser `E.InnerJoin` user) -> do
E.on (submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId) E.on (submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId)
E.where_ $ submissionUser E.^. SubmissionUserSubmission E.==. E.val smid E.where_ $ submissionUser E.^. SubmissionUserSubmission E.==. E.val smid
E.orderBy [E.asc $ user E.^. UserEmail] E.orderBy [E.asc $ user E.^. UserEmail]
return (user E.^. UserId, user E.^. UserEmail) return $ user E.^. UserId
let breakUserFromBuddies (E.Value userID, E.Value email) let breakUserFromBuddies (E.Value userID)
| uid == userID = (Any True , []) | uid == userID = (Any True , mempty )
| otherwise = (Any False, [email]) | otherwise = (mempty , Set.singleton $ Right userID)
return $ foldMap breakUserFromBuddies submitters
invites <- sourceInvitationsList smid <&> Set.fromList . map (\(email, InvDBDataSubmissionUser) -> Left email)
return . over _2 (Set.union invites) $ foldMap breakUserFromBuddies submittors
lastEdits <- do lastEdits <- do
raw <- E.select . E.from $ \(user `E.InnerJoin` submissionEdit) -> do raw <- E.select . E.from $ \(user `E.InnerJoin` submissionEdit) -> do
@ -167,38 +351,38 @@ submissionHelper tid ssh csh shn mcid = do
else E.nothing else E.nothing
return (userName, submissionEdit E.^. SubmissionEditTime) return (userName, submissionEdit E.^. SubmissionEditTime)
forM raw $ \(E.Value name, E.Value time) -> (name, ) <$> formatTime SelFormatDateTime time forM raw $ \(E.Value name, E.Value time) -> (name, ) <$> formatTime SelFormatDateTime time
return (csheet,buddies,lastEdits) return (csheet,buddies,lastEdits,maySubmit,isLecturer,isOwner)
((res,formWidget'), formEnctype) <- runFormPost $ makeSubmissionForm msmid (fromMaybe (Upload True) . submissionModeUser $ sheetSubmissionMode) sheetGrouping (userEmail userData :| buddies) ((res,formWidget'), formEnctype) <- runFormPost . makeSubmissionForm sheetCourse msmid (fromMaybe (Upload True) . submissionModeUser $ sheetSubmissionMode) sheetGrouping isLecturer $ bool id (Set.insert $ Right uid) isOwner buddies
let formWidget = wrapForm formWidget' def let formWidget = wrapForm formWidget' def
{ formAction = Just $ SomeRoute actionUrl { formAction = Just $ SomeRoute actionUrl
, formEncoding = formEnctype , formEncoding = formEnctype
} }
mCID <- fmap join . msgSubmissionErrors . runDBJobs $ do mCID <- fmap join . msgSubmissionErrors . runDBJobs $ do
res' <- case res of res' <- case res of
FormMissing -> return FormMissing FormMissing -> return FormMissing
(FormFailure failmsgs) -> return $ FormFailure failmsgs (FormFailure failmsgs) -> return $ FormFailure failmsgs
-- #227 Part II: no longer ignore submitter, if the user is lecturer or admin (allow lecturers to submit for their students) -- #227 Part II: no longer ignore submitter, if the user is lecturer or admin (allow lecturers to submit for their students)
(FormSuccess (mFiles,_submitter:|[])) -> return $ FormSuccess (mFiles,[]) -- Type change (FormSuccess res'@(_, groupMembers))
(FormSuccess (mFiles,_submitter:|gEMails@(_:_))) -- Validate AdHoc Group Members | Set.null groupMembers -> return $ FormSuccess res'
| Arbitrary{..} <- sheetGrouping -> do | Arbitrary{..} <- sheetGrouping -> do -- Validate AdHoc Group Members
-- , length gEMails < maxParticipants -> do -- < since submitting user is already accounted for -- , length gEMails < maxParticipants -> do -- < since submitting user is already accounted for
let prep :: [(E.Value UserEmail, (E.Value UserId, E.Value Bool, E.Value Bool))] -> Map (CI Text) (Maybe (UserId, Bool, Bool)) let (gEMails, gIds) = partitionEithers $ Set.toList groupMembers
prep :: [(E.Value UserEmail, (E.Value UserId, E.Value Bool, E.Value Bool))] -> Map UserEmail (Maybe (UserId, Bool, Bool))
prep ps = Map.filter (maybe True $ \(i,_,_) -> i /= uid) . Map.fromList $ map (, Nothing) gEMails ++ [(m, Just (i,p,s))|(E.Value m, (E.Value i, E.Value p, E.Value s)) <- ps] prep ps = Map.filter (maybe True $ \(i,_,_) -> i /= uid) . Map.fromList $ map (, Nothing) gEMails ++ [(m, Just (i,p,s))|(E.Value m, (E.Value i, E.Value p, E.Value s)) <- ps]
participants <- fmap prep . E.select . E.from $ \user -> do participants <- fmap prep . E.select . E.from $ \user -> do
E.where_ $ (user E.^. UserEmail) `E.in_` E.valList gEMails E.where_ $ (user E.^. UserId) `E.in_` E.valList gIds
let let
isParticipant = E.sub_select . E.from $ \courseParticipant -> do isParticipant = E.exists . E.from $ \courseParticipant -> do
E.where_ $ user E.^. UserId E.==. courseParticipant E.^. CourseParticipantUser E.where_ $ user E.^. UserId E.==. courseParticipant E.^. CourseParticipantUser
E.&&. courseParticipant E.^. CourseParticipantCourse E.==. E.val sheetCourse E.&&. courseParticipant E.^. CourseParticipantCourse E.==. E.val sheetCourse
return $ E.countRows E.>. E.val (0 :: Int64) hasSubmitted = E.exists . E.from $ \(submissionUser `E.InnerJoin` submission) -> do
hasSubmitted = E.sub_select . E.from $ \(submissionUser `E.InnerJoin` submission) -> do
E.on $ submissionUser E.^. SubmissionUserSubmission E.==. submission E.^. SubmissionId E.on $ submissionUser E.^. SubmissionUserSubmission E.==. submission E.^. SubmissionId
E.where_ $ submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId E.where_ $ submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId
E.&&. submission E.^. SubmissionSheet E.==. E.val shid E.&&. submission E.^. SubmissionSheet E.==. E.val shid
case msmid of -- Multiple `E.where_`-Statements are merged with `&&` in esqueleto 2.5.3 case msmid of -- Multiple `E.where_`-Statements are merged with `&&` in esqueleto 2.5.3
Nothing -> return () Nothing -> return ()
Just smid -> E.where_ $ submission E.^. SubmissionId E.!=. E.val smid Just smid -> E.where_ $ submission E.^. SubmissionId E.!=. E.val smid
return $ E.countRows E.>. E.val (0 :: Int64)
return (user E.^. UserEmail, (user E.^. UserId, isParticipant, hasSubmitted)) return (user E.^. UserEmail, (user E.^. UserId, isParticipant, hasSubmitted))
$logDebugS "SUBMISSION.AdHocGroupValidation" $ tshow participants $logDebugS "SUBMISSION.AdHocGroupValidation" $ tshow participants
@ -207,22 +391,22 @@ submissionHelper tid ssh csh shn mcid = do
let let
failmsgs = (concat :: [[Text]] -> [Text]) failmsgs = (concat :: [[Text]] -> [Text])
[ flip Map.foldMapWithKey participants $ \email -> \case [ flip Map.foldMapWithKey participants $ \email -> \case
Nothing -> pure . mr $ MsgEMailUnknown email -- Nothing -> pure . mr $ MsgEMailUnknown email
(Just (_,False,_)) -> pure . mr $ MsgNotAParticipant email tid csh (Just (_,False,_)) -> pure . mr $ MsgNotAParticipant email tid csh
(Just (_,_, True)) -> pure . mr $ MsgSubmissionAlreadyExistsFor email (Just (_,_, True)) -> pure . mr $ MsgSubmissionAlreadyExistsFor email
_other -> mempty _other -> mempty
, case fromIntegral (length participants) `compare` maxParticipants of , case fromIntegral (Map.size participants) `compare` maxParticipants of
LT -> mempty LT -> mempty
_ -> pure $ mr MsgTooManyParticipants _ -> pure $ mr MsgTooManyParticipants
] ]
return $ if null failmsgs return $ if null failmsgs
then FormSuccess (mFiles, foldMap (\(Just (i,_,_)) -> [i]) participants) then FormSuccess res'
else FormFailure failmsgs else FormFailure failmsgs
| otherwise -> return $ FormFailure ["Mismatching number of group participants"] | otherwise -> return $ FormFailure ["Mismatching number of group participants"]
case res' of case res' of
(FormSuccess (mFiles, setFromList -> adhocIds)) -> do (FormSuccess (mFiles, adhocMembers)) -> do
smid <- do smid <- do
smid <- case (mFiles, msmid) of smid <- case (mFiles, msmid) of
(Nothing, Just smid) -- no new files, existing submission partners updated (Nothing, Just smid) -- no new files, existing submission partners updated
@ -238,19 +422,24 @@ submissionHelper tid ssh csh shn mcid = do
, submissionRatingAssigned = Nothing , submissionRatingAssigned = Nothing
, submissionRatingTime = Nothing , submissionRatingTime = Nothing
} }
-- Determine members of pre-registered group subUsers <- if
groupUids <- fmap (setFromList . map E.unValue) . E.select . E.from $ \(submissionGroupUser `E.InnerJoin` submissionGroup `E.InnerJoin` submissionGroupUser') -> do | isLecturer -> return adhocMembers
E.on $ submissionGroup E.^. SubmissionGroupId E.==. submissionGroupUser' E.^. SubmissionGroupUserSubmissionGroup | otherwise -> do
E.on $ submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup E.==. submissionGroup E.^. SubmissionGroupId -- Determine members of pre-registered group
E.where_ $ submissionGroupUser E.^. SubmissionGroupUserUser E.==. E.val uid groupUids <- fmap (setFromList . map (Right . E.unValue)) . E.select . E.from $ \(submissionGroupUser `E.InnerJoin` submissionGroup `E.InnerJoin` submissionGroupUser') -> do
E.&&. submissionGroup E.^. SubmissionGroupCourse E.==. E.val sheetCourse E.on $ submissionGroup E.^. SubmissionGroupId E.==. submissionGroupUser' E.^. SubmissionGroupUserSubmissionGroup
return $ submissionGroupUser' E.^. SubmissionGroupUserUser E.on $ submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup E.==. submissionGroup E.^. SubmissionGroupId
-- SubmissionUser for all group members (pre-registered & ad-hoc) E.where_ $ submissionGroupUser E.^. SubmissionGroupUserUser E.==. E.val uid
let subUsers = Set.insert uid $ groupUids `Set.union` adhocIds E.&&. submissionGroup E.^. SubmissionGroupCourse E.==. E.val sheetCourse
-- remove obsolete old entries return $ submissionGroupUser' E.^. SubmissionGroupUserUser
deleteWhere [SubmissionUserSubmission ==. smid, SubmissionUserUser /<-. setToList subUsers] -- SubmissionUser for all group members (pre-registered & ad-hoc)
-- maybe add current users return $ groupUids `Set.union` adhocMembers
forM_ subUsers $ \uid' -> void . insertUnique $ SubmissionUser uid' smid let (subEmails, subUids) = partitionEithers $ Set.toList subUsers
deleteWhere [SubmissionUserSubmission ==. smid]
deleteWhere [InvitationFor ==. invRef @SubmissionUser smid, InvitationEmail /<-. subEmails]
insertMany_ $ map (flip SubmissionUser smid) subUids
sinkInvitationsF submissionUserInvitationConfig $ map (\lEmail -> (lEmail, smid, (InvDBDataSubmissionUser, InvTokenDataSubmissionUser))) subEmails
return smid return smid
cID <- encrypt smid cID <- encrypt smid
return $ Just cID return $ Just cID
@ -327,6 +516,10 @@ submissionHelper tid ssh csh shn mcid = do
urlOriginal cID = CSubmissionR tid ssh csh shn cID (SubArchiveR (ZIPArchiveName SubmissionOriginal)) urlOriginal cID = CSubmissionR tid ssh csh shn cID (SubArchiveR (ZIPArchiveName SubmissionOriginal))
$(widgetFile "submission") $(widgetFile "submission")
getSInviteR, postSInviteR :: TermId -> SchoolId -> CourseShorthand -> SheetName -> CryptoFileNameSubmission -> Handler Html
getSInviteR = postSInviteR
postSInviteR = invitationR submissionUserInvitationConfig
getSubDownloadR :: TermId -> SchoolId -> CourseShorthand -> SheetName -> CryptoFileNameSubmission -> SubmissionFileType -> FilePath -> Handler TypedContent getSubDownloadR :: TermId -> SchoolId -> CourseShorthand -> SheetName -> CryptoFileNameSubmission -> SubmissionFileType -> FilePath -> Handler TypedContent
getSubDownloadR tid ssh csh shn cID (submissionFileTypeIsUpdate -> isUpdate) path = runDB $ do getSubDownloadR tid ssh csh shn cID (submissionFileTypeIsUpdate -> isUpdate) path = runDB $ do

View File

@ -227,14 +227,14 @@ instance FromJSON (InvitationDBData Tutor) where
instance ToJSON (InvitationTokenData Tutor) where instance ToJSON (InvitationTokenData Tutor) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 } toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
instance FromJSON (InvitationTokenData Tutor) where instance FromJSON (InvitationTokenData Tutor) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 } parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 4 }
tutorInvitationConfig :: InvitationConfig Tutor tutorInvitationConfig :: InvitationConfig Tutor
tutorInvitationConfig = InvitationConfig{..} tutorInvitationConfig = InvitationConfig{..}
where where
invitationRoute Tutorial{..} _ = do invitationRoute (Entity _ Tutorial{..}) _ = do
Course{..} <- get404 tutorialCourse Course{..} <- get404 tutorialCourse
return $ CTutorialR courseTerm courseSchool courseShorthand tutorialName TInviteR return $ CTutorialR courseTerm courseSchool courseShorthand tutorialName TInviteR
invitationResolveFor = do invitationResolveFor = do

View File

@ -162,7 +162,7 @@ commR CommunicationRoute{..} = do
hasContent c = not (null $ categoryIndices c) || Map.member (1, (EnumPosition c, 0)) addWdgts hasContent c = not (null $ categoryIndices c) || Map.member (1, (EnumPosition c, 0)) addWdgts
categoryIndices c = Set.filter ((== c) . unEnumPosition . fst) $ review liveCoords liveliness categoryIndices c = Set.filter ((== c) . unEnumPosition . fst) $ review liveCoords liveliness
$(widgetFile "widgets/communication/recipientLayout") $(widgetFile "widgets/communication/recipientLayout")
miDelete :: MapLiveliness (EnumLiveliness RecipientCategory) ListLength -> (EnumPosition RecipientCategory, ListPosition) -> MaybeT (MForm Handler) (Map (EnumPosition RecipientCategory, ListPosition) (EnumPosition RecipientCategory, ListPosition)) miDelete :: Map (EnumPosition RecipientCategory, ListPosition) (Either UserEmail UserId) -> (EnumPosition RecipientCategory, ListPosition) -> MaybeT (MForm Handler) (Map (EnumPosition RecipientCategory, ListPosition) (EnumPosition RecipientCategory, ListPosition))
-- miDelete liveliness@(MapLiveliness lMap) (EnumPosition RecipientCustom, delPos) = mappend (Map.fromSet id . Set.filter (\(EnumPosition c, _) -> c /= RecipientCustom) $ review liveCoords liveliness) . fmap (EnumPosition RecipientCustom, ) . Map.mapKeysMonotonic (EnumPosition RecipientCustom, ) <$> miDeleteList (lMap ! EnumPosition RecipientCustom) delPos -- miDelete liveliness@(MapLiveliness lMap) (EnumPosition RecipientCustom, delPos) = mappend (Map.fromSet id . Set.filter (\(EnumPosition c, _) -> c /= RecipientCustom) $ review liveCoords liveliness) . fmap (EnumPosition RecipientCustom, ) . Map.mapKeysMonotonic (EnumPosition RecipientCustom, ) <$> miDeleteList (lMap ! EnumPosition RecipientCustom) delPos
miDelete _ _ = mzero miDelete _ _ = mzero
miIdent :: Text miIdent :: Text

View File

@ -8,7 +8,7 @@ module Handler.Utils.Form.MassInput
, module Handler.Utils.Form.MassInput.Liveliness , module Handler.Utils.Form.MassInput.Liveliness
, massInputA, massInputW , massInputA, massInputW
, massInputList , massInputList
, massInputAccum, massInputAccumA , massInputAccum, massInputAccumA, massInputAccumW
, ListLength(..), ListPosition(..), miDeleteList , ListLength(..), ListPosition(..), miDeleteList
, EnumLiveliness(..), EnumPosition(..) , EnumLiveliness(..), EnumPosition(..)
, MapLiveliness(..) , MapLiveliness(..)
@ -144,10 +144,11 @@ instance (Liveliness l1, Liveliness l2) => Liveliness (MapLiveliness l1 l2) wher
miDeleteList :: Applicative m => ListLength -> ListPosition -> m (Map ListPosition ListPosition) miDeleteList :: Applicative m => Map ListPosition a -> ListPosition -> m (Map ListPosition ListPosition)
miDeleteList l pos miDeleteList dat pos
-- Close gap left by deleted position with values from further down the list; try prohibiting certain deletions by utilising `guard` -- Close gap left by deleted position with values from further down the list; try prohibiting certain deletions by utilising `guard`
| l >= 2 = pure . Map.fromSet (\pos' -> bool pos' (succ pos') $ pos' >= pos) $ Set.fromList [0..fromIntegral (l - 2)] | Just l <- preview liveCoords $ Map.keysSet dat :: Maybe ListLength
, l >= 2 = pure . Map.fromSet (\pos' -> bool pos' (succ pos') $ pos' >= pos) $ Set.fromList [0..fromIntegral (l - 2)]
| otherwise = pure Map.empty | otherwise = pure Map.empty
data ButtonMassInput coord data ButtonMassInput coord
@ -245,7 +246,7 @@ data MassInput handler liveliness cellData cellResult = forall i. PathPiece i =>
-> Maybe cellResult -- Initial result from Argument to @massInput@ -> Maybe cellResult -- Initial result from Argument to @massInput@
-> (Text -> Text) -- Nudge deterministic field ids -> (Text -> Text) -- Nudge deterministic field ids
-> (Markup -> MForm handler (FormResult cellResult, Widget)) -- ^ Construct a singular cell -> (Markup -> MForm handler (FormResult cellResult, Widget)) -- ^ Construct a singular cell
, miDelete :: liveliness , miDelete :: Map (BoxCoord liveliness) cellData
-> BoxCoord liveliness -> BoxCoord liveliness
-> MaybeT (MForm handler) (Map (BoxCoord liveliness) (BoxCoord liveliness)) -- ^ Decide whether a deletion-operation should be permitted and produce a finite map of new coordinates to their old correspondants -> MaybeT (MForm handler) (Map (BoxCoord liveliness) (BoxCoord liveliness)) -- ^ Decide whether a deletion-operation should be permitted and produce a finite map of new coordinates to their old correspondants
, miAllowAdd :: BoxCoord liveliness , miAllowAdd :: BoxCoord liveliness
@ -349,13 +350,12 @@ massInput MassInput{ miIdent = toPathPiece -> miIdent, ..} FieldSettings{..} fvR
addedShape <- if addedShape <- if
| Just s <- addShape -> return s | Just s <- addShape -> return s
| otherwise -> return sentShape' | otherwise -> return sentShape'
addedLiveliness <- maybe (throwM MassInputInvalidShape) return $ Map.keysSet addedShape ^? liveCoords :: MForm handler liveliness
let let
delForm :: BoxCoord liveliness -> MaybeT (MForm handler) (FormResult (Map (BoxCoord liveliness) (BoxCoord liveliness)), FieldView UniWorX) delForm :: BoxCoord liveliness -> MaybeT (MForm handler) (FormResult (Map (BoxCoord liveliness) (BoxCoord liveliness)), FieldView UniWorX)
delForm miCoord = do delForm miCoord = do
(delRes, delView) <- lift $ mopt (buttonField $ MassInputDeleteCell miCoord) ("" & addName MassInputDeleteButton{..} & addFormAction) Nothing (delRes, delView) <- lift $ mopt (buttonField $ MassInputDeleteCell miCoord) ("" & addName MassInputDeleteButton{..} & addFormAction) Nothing
shapeUpdate <- miDelete addedLiveliness miCoord shapeUpdate <- miDelete addedShape miCoord
guard $ isJust (Map.keysSet shapeUpdate ^? liveCoords :: Maybe liveliness) guard $ isJust (Map.keysSet shapeUpdate ^? liveCoords :: Maybe liveliness)
return (shapeUpdate <$ assertM (is _Just) delRes, delView) return (shapeUpdate <$ assertM (is _Just) delRes, delView)
@ -545,6 +545,24 @@ massInputAccumA :: forall handler cellData ident.
massInputAccumA miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev massInputAccumA miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev
= formToAForm $ over _2 pure <$> massInputAccum miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev mempty = formToAForm $ over _2 pure <$> massInputAccum miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev mempty
massInputAccumW :: forall handler cellData ident.
( MonadHandler handler, HandlerSite handler ~ UniWorX
, MonadLogger handler
, ToJSON cellData, FromJSON cellData
, PathPiece ident
)
=> ((Text -> Text) -> FieldView UniWorX -> (Markup -> MForm handler (FormResult ([cellData] -> FormResult [cellData]), Widget)))
-> (cellData -> Widget)
-> (forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX))
-> MassInputLayout ListLength cellData ()
-> ident
-> FieldSettings UniWorX
-> Bool
-> Maybe [cellData]
-> WForm handler (FormResult [cellData])
massInputAccumW miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev
= mFormToWForm $ massInputAccum miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev mempty
massInputA :: forall handler cellData cellResult liveliness. massInputA :: forall handler cellData cellResult liveliness.
( MonadHandler handler, HandlerSite handler ~ UniWorX ( MonadHandler handler, HandlerSite handler ~ UniWorX

View File

@ -113,7 +113,7 @@ invRef = toJSON . InvRef @junction
-- --
-- It is advisable to define this once per `junction` in a global constant -- It is advisable to define this once per `junction` in a global constant
data InvitationConfig junction = InvitationConfig data InvitationConfig junction = InvitationConfig
{ invitationRoute :: InvitationFor junction -> InvitationData junction -> YesodDB UniWorX (Route UniWorX) { invitationRoute :: Entity (InvitationFor junction) -> InvitationData junction -> YesodDB UniWorX (Route UniWorX)
-- ^ Which route calls `invitationR` for this kind of invitation? -- ^ Which route calls `invitationR` for this kind of invitation?
, invitationResolveFor :: YesodDB UniWorX (Key (InvitationFor junction)) , invitationResolveFor :: YesodDB UniWorX (Key (InvitationFor junction))
-- ^ Monadically resolve `InvitationFor` during `inviteR` -- ^ Monadically resolve `InvitationFor` during `inviteR`
@ -200,7 +200,7 @@ sinkInvitations InvitationConfig{..} = determineExists .| C.foldMap pure >>= lif
fRec <- get404 fid fRec <- get404 fid
jInviter <- liftHandlerT requireAuthId jInviter <- liftHandlerT requireAuthId
route <- mapReaderT liftHandlerT $ invitationRoute fRec dat route <- mapReaderT liftHandlerT $ invitationRoute (Entity fid fRec) dat
InvitationTokenConfig{..} <- mapReaderT liftHandlerT $ invitationTokenConfig fRec dat InvitationTokenConfig{..} <- mapReaderT liftHandlerT $ invitationTokenConfig fRec dat
protoToken <- bearerToken itAuthority (Just . HashSet.singleton $ urlRoute route) itAddAuth itExpiresAt itStartsAt protoToken <- bearerToken itAuthority (Just . HashSet.singleton $ urlRoute route) itAddAuth itExpiresAt itStartsAt
let token = protoToken & tokenRestrict (urlRoute route) (InvitationTokenRestriction jInvitee $ dat ^. _invitationTokenData) let token = protoToken & tokenRestrict (urlRoute route) (InvitationTokenRestriction jInvitee $ dat ^. _invitationTokenData)

View File

@ -27,8 +27,8 @@ requireBearerToken = liftHandlerT $ do
guardAuthResult <=< runDB $ validateToken mAuthId currentRoute isWrite token guardAuthResult <=< runDB $ validateToken mAuthId currentRoute isWrite token
return token return token
currentTokenRestrictions :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m, FromJSON a, ToJSON a) => m (Maybe a) currentTokenRestrictions :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m, MonadLogger m, FromJSON a, ToJSON a) => m (Maybe a)
currentTokenRestrictions = runMaybeT $ do currentTokenRestrictions = runMaybeT $ do
token <- MaybeT maybeBearerToken token <- requireBearerToken
route <- MaybeT getCurrentRoute route <- MaybeT getCurrentRoute
hoistMaybe $ preview (_tokenRestrictionIx route) token hoistMaybe $ preview (_tokenRestrictionIx route) token

View File

@ -49,7 +49,7 @@ import GHC.Generics as Import (Generic)
import GHC.Exts as Import (IsList) import GHC.Exts as Import (IsList)
import Data.Hashable as Import import Data.Hashable as Import
import Data.List.NonEmpty as Import (NonEmpty(..)) import Data.List.NonEmpty as Import (NonEmpty(..), nonEmpty)
import Data.List.NonEmpty.Instances as Import () import Data.List.NonEmpty.Instances as Import ()
import Data.NonNull.Instances as Import () import Data.NonNull.Instances as Import ()
import Data.Text.Encoding.Error as Import(UnicodeException(..)) import Data.Text.Encoding.Error as Import(UnicodeException(..))

View File

@ -557,7 +557,7 @@ derivePersistField "Theme"
newtype ZIPArchiveName obj = ZIPArchiveName { unZIPArchiveName :: obj } newtype ZIPArchiveName obj = ZIPArchiveName { unZIPArchiveName :: obj }
deriving (Show, Read, Eq) deriving (Show, Read, Eq, Ord, Generic, Typeable)
instance PathPiece obj => PathPiece (ZIPArchiveName obj) where instance PathPiece obj => PathPiece (ZIPArchiveName obj) where
fromPathPiece = fmap ZIPArchiveName . fromPathPiece <=< (stripSuffix `on` CI.foldCase) ".zip" fromPathPiece = fmap ZIPArchiveName . fromPathPiece <=< (stripSuffix `on` CI.foldCase) ".zip"
@ -832,8 +832,7 @@ data PredLiteral a = PLVariable { plVar :: a } | PLNegated { plVar :: a }
instance Hashable a => Hashable (PredLiteral a) instance Hashable a => Hashable (PredLiteral a)
deriveJSON defaultOptions deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece' 1 { constructorTagModifier = camelToPathPiece' 1
, sumEncoding = ObjectWithSingleField , sumEncoding = TaggedObject "val" "var"
, unwrapUnaryRecords = True
} ''PredLiteral } ''PredLiteral
instance PathPiece a => PathPiece (PredLiteral a) where instance PathPiece a => PathPiece (PredLiteral a) where

View File

@ -77,6 +77,8 @@ import Network.Wai (requestMethod)
import Data.Time.Clock import Data.Time.Clock
import Data.List.NonEmpty (NonEmpty, nonEmpty)
{-# ANN choice ("HLint: ignore Use asum" :: String) #-} {-# ANN choice ("HLint: ignore Use asum" :: String) #-}
@ -363,6 +365,9 @@ partitionWith f (x:xs) = case f x of
Right c -> (bs, c:cs) Right c -> (bs, c:cs)
where (bs,cs) = partitionWith f xs where (bs,cs) = partitionWith f xs
nonEmpty' :: Alternative f => [a] -> f (NonEmpty a)
nonEmpty' = maybe empty pure . nonEmpty
---------- ----------
-- Sets -- -- Sets --
---------- ----------
@ -372,7 +377,8 @@ setIntersections :: Ord a => [Set a] -> Set a
setIntersections [] = Set.empty setIntersections [] = Set.empty
setIntersections (h:t) = foldl' Set.intersection h t 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 -- -- Maps --

View File

@ -636,6 +636,10 @@ aFormToWForm = mapRWST mFormToWForm' . over (mapped . _2) ($ []) . aFormToForm
((a, vs), ints, enctype) <- lift f ((a, vs), ints, enctype) <- lift f
writer ((a, ints, enctype), vs) 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@, ... -- -- Special variants of @mopt@, @mreq@, ... --

View File

@ -27,6 +27,9 @@ _InnerJoinLeft f (E.InnerJoin l r) = (`E.InnerJoin` r) <$> f l
_InnerJoinRight :: Lens' (E.InnerJoin l r) r _InnerJoinRight :: Lens' (E.InnerJoin l r) r
_InnerJoinRight f (E.InnerJoin l r) = (l `E.InnerJoin`) <$> f 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 -- Lens Definitions for our Types
@ -80,6 +83,8 @@ makeLenses_ ''SheetGrading
makeLenses_ ''SheetType makeLenses_ ''SheetType
makePrisms ''SheetGroup
makePrisms ''AuthResult makePrisms ''AuthResult
makePrisms ''FormResult makePrisms ''FormResult
@ -112,6 +117,8 @@ makePrisms ''OccurenceException
makeLenses_ ''Occurences makeLenses_ ''Occurences
makeLenses_ ''PredDNF
-- makeClassy_ ''Load -- makeClassy_ ''Load

View File

@ -116,12 +116,14 @@
var requestBody = serializeForm(submitButton, enctype); var requestBody = serializeForm(submitButton, enctype);
if (requestFn && requestBody) { if (requestFn && requestBody) {
var headers = {'Mass-Input-Shortcircuit': massInputId};
if (enctype !== 'multipart/form-data')
headers['Content-Type'] = enctype;
requestFn( requestFn(
url, url,
{ headers,
'Content-Type': enctype,
'Mass-Input-Shortcircuit': massInputId,
},
requestBody, requestBody,
).then(function(response) { ).then(function(response) {
return response.text(); return response.text();

View File

@ -22,7 +22,9 @@ $maybe cID <- mcid
$nothing $nothing
<li>#{display time} <li>#{display time}
$if maySubmit
$if maySubmit <section>
<section> <h2>_{MsgSubmissionReplace}
^{formWidget} ^{formWidget}
$nothing
^{formWidget}

View File

@ -0,0 +1,6 @@
$newline never
<td colspan=2>
#{csrf}
^{fvInput addView}
<td>
^{fvInput btn}

View File

@ -0,0 +1,10 @@
$newline never
<td>
#{csrf}
<span style="font-family: monospace">
#{email}
<td>
<div .tooltip>
<div .tooltip__handle>
<div .tooltip__content>
_{MsgEmailInvitationWarning}

View File

@ -0,0 +1,4 @@
$newline never
<td colspan=2>
#{csrf}
^{nameEmailWidget userEmail userDisplayName userSurname}

View File

@ -0,0 +1,13 @@
$newline never
<table>
<tbody>
$forall coord <- review liveCoords lLength
<tr .massinput__cell>
^{cellWdgts ! coord}
<td>
$maybe delButton <- delButtons !? coord
^{fvInput delButton}
$maybe addWdgt <- addWdgts !? (0, 0)
<tfoot>
<tr .massinput__cell.massinput__cell--add>
^{addWdgt}

58
test/FoundationSpec.hs Normal file
View File

@ -0,0 +1,58 @@
module FoundationSpec where
import TestImport
import ModelSpec ()
import qualified Data.CryptoID as CID
import Yesod.EmbeddedStatic
instance Arbitrary TermId where
arbitrary = TermKey <$> arbitrary
instance Arbitrary SchoolId where
arbitrary = SchoolKey <$> arbitrary
instance Arbitrary (Route Auth) where
arbitrary = oneof
[ return CheckR
, return LoginR
, return LogoutR
, PluginR <$> arbitrary <*> arbitrary
]
instance Arbitrary (Route EmbeddedStatic) where
arbitrary = embeddedResourceR <$> arbitrary <*> arbitrary
instance Arbitrary CourseR where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary SheetR where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary SubmissionR where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary MaterialR where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary TutorialR where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary (Route UniWorX) where
arbitrary = genericArbitrary
shrink = genericShrink
instance Arbitrary a => Arbitrary (CID.CryptoID ns a) where
arbitrary = CID.CryptoID <$> arbitrary
spec :: Spec
spec = do
parallel $
lawsCheckHspec (Proxy @(Route UniWorX))
[ eqLaws, hashableLaws, jsonLaws, jsonKeyLaws, pathPieceLaws ]

View File

@ -148,6 +148,10 @@ instance Arbitrary AuthenticationMode where
instance Arbitrary LecturerType where instance Arbitrary LecturerType where
arbitrary = genericArbitrary arbitrary = genericArbitrary
shrink = genericShrink shrink = genericShrink
instance Arbitrary a => Arbitrary (ZIPArchiveName a) where
arbitrary = genericArbitrary
shrink = genericShrink
spec :: Spec spec :: Spec
@ -211,6 +215,8 @@ spec = do
[ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ] [ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ]
lawsCheckHspec (Proxy @LecturerType) lawsCheckHspec (Proxy @LecturerType)
[ eqLaws, ordLaws, showReadLaws, boundedEnumLaws, finiteLaws, jsonLaws, pathPieceLaws, persistFieldLaws ] [ eqLaws, ordLaws, showReadLaws, boundedEnumLaws, finiteLaws, jsonLaws, pathPieceLaws, persistFieldLaws ]
lawsCheckHspec (Proxy @(ZIPArchiveName (CI Text)))
[ eqLaws, ordLaws, showReadLaws, 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 $

View File

@ -0,0 +1,17 @@
module Test.QuickCheck.Classes.Binary
( binaryLaws
) where
import ClassyPrelude
import Test.QuickCheck
import Test.QuickCheck.Classes
import Data.Proxy (Proxy(..))
import Data.Binary
import Data.Binary.Put
binaryLaws :: forall a. (Arbitrary a, Binary a, Eq a, Show a) => Proxy a -> Laws
binaryLaws _ = Laws "Binary"
[ ("Partial Isomorphism", property $ \(a :: a) -> decode (encode a) == Just a)
, ("Valid list encoding", property $ \(as :: [a]) -> runPut (putList as) == runPut (put as))
]

View File

@ -26,6 +26,7 @@ import Test.QuickCheck.Classes.Hashable as X
import Test.QuickCheck.Classes.JSON as X import Test.QuickCheck.Classes.JSON as X
import Test.QuickCheck.Classes.HttpApiData as X import Test.QuickCheck.Classes.HttpApiData as X
import Test.QuickCheck.Classes.Universe as X import Test.QuickCheck.Classes.Universe as X
import Test.QuickCheck.Classes.Binary as X
import Data.Proxy as X import Data.Proxy as X
import Data.UUID as X (UUID) import Data.UUID as X (UUID)
import System.IO as X (hPrint, hPutStrLn, stderr) import System.IO as X (hPrint, hPutStrLn, stderr)