Implement lecturer invitations with new system

This commit is contained in:
Gregor Kleen 2019-04-23 01:22:36 +02:00
parent 7f6d30c0d6
commit 5bc0254f7f
8 changed files with 139 additions and 129 deletions

View File

@ -800,4 +800,6 @@ InvitationMissingRestrictions: Authorisierungs-Token fehlen benötigte Daten
InvitationCollision: Einladung konnte nicht angenommen werden da ein derartiger Eintrag bereits existiert
InvitationDeclined: Einladung wurde abgelehnt
BtnInviteAccept: Einladung annehmen
BtnInviteDecline: Einladung ablehnen
BtnInviteDecline: Einladung ablehnen
LecturerType: Rolle

2
routes
View File

@ -76,7 +76,7 @@
/ CShowR GET !free
/register CRegisterR POST !timeANDcapacity
/edit CEditR GET POST
/lecturer-invite/#UserEmail CLecInviteR GET POST
/lecturer-invite CLecInviteR GET POST
/delete CDeleteR GET POST !lecturerANDempty
/users CUsersR GET POST
/users/#CryptoUUIDUser CUserR GET POST !lecturerANDparticipant

View File

@ -15,6 +15,7 @@ import Handler.Utils.Delete
import Handler.Utils.Database
import Handler.Utils.Table.Cells
import Handler.Utils.Table.Columns
import Handler.Utils.Invitations
import Database.Persist.Sql (deleteWhereCount)
import qualified Database.Esqueleto.Utils as E
import Database.Esqueleto.Utils.TH
@ -37,6 +38,11 @@ import Text.Blaze.Html.Renderer.Text (renderHtml)
import Jobs.Queue
import Data.Aeson hiding (Result(..))
import Text.Hamlet (ihamlet)
-- NOTE: Outdated way to use dbTable; see ProfileDataR Handler for a more recent method.
type CourseTableData = DBRow (Entity Course, Int, Bool, Entity School)
@ -500,12 +506,10 @@ courseEditHandler miButtonAction mbCourseForm = do
, courseDeregisterUntil = cfDeRegUntil res
}
whenIsJust insertOkay $ \cid -> do
forM_ (cfLecturers res) $ \case
Right (lid, lty) -> insert_ $ Lecturer lid cid lty
Left (lEmail, mLTy) -> do
insert_ $ LecturerInvitation lEmail cid mLTy
queueDBJob . JobLecturerInvitation aid $ LecturerInvitation lEmail cid mLTy
insert_ $ CourseEdit aid now cid
let (invites, adds) = partitionEithers $ cfLecturers res
insertMany_ $ map (\(lid, lty) -> Lecturer lid cid lty) adds
sinkInvitationsF lecturerInvitationConfig $ map (\(lEmail, mLty) -> (lEmail, cid, (InvDBDataLecturer mLty, InvTokenDataLecturer))) invites
insert_ $ CourseEdit aid now cid
return insertOkay
case insertOkay of
Just _ -> do
@ -545,16 +549,11 @@ courseEditHandler miButtonAction mbCourseForm = do
(Just _) -> addMessageI Warning (MsgCourseEditDupShort tid ssh csh) $> False
Nothing -> do
deleteWhere [LecturerCourse ==. cid]
deleteWhere [LecturerInvitationCourse ==. cid, LecturerInvitationEmail /<-. toListOf (folded . _Left . _1) (cfLecturers res)]
forM_ (cfLecturers res) $ \case
Right (lid, lty) -> insert_ $ Lecturer lid cid lty
Left (lEmail, mLTy) -> do
insertRes <- insertUnique (LecturerInvitation lEmail cid mLTy)
case insertRes of
Just _ ->
queueDBJob . JobLecturerInvitation aid $ LecturerInvitation lEmail cid mLTy
Nothing ->
updateBy (UniqueLecturerInvitation lEmail cid) [ LecturerInvitationType =. mLTy ]
deleteWhere [InvitationFor ==. invRef @Lecturer cid, InvitationEmail /<-. toListOf (folded . _Left . _1) (cfLecturers res)]
let (invites, adds) = partitionEithers $ cfLecturers res
insertMany_ $ map (\(lid, lty) -> Lecturer lid cid lty) adds
sinkInvitationsF lecturerInvitationConfig $ map (\(lEmail, mLty) -> (lEmail, cid, (InvDBDataLecturer mLty, InvTokenDataLecturer))) invites
insert_ $ CourseEdit aid now cid
addMessageI Success $ MsgCourseEditOk tid ssh csh
return True
@ -568,6 +567,65 @@ courseEditHandler miButtonAction mbCourseForm = do
}
instance IsInvitableJunction Lecturer where
type InvitationFor Lecturer = Course
data InvitableJunction Lecturer = JunctionLecturer
{ jLecturerType :: LecturerType
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
data InvitationDBData Lecturer = InvDBDataLecturer
{ invDBLecturerType :: Maybe LecturerType
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
data InvitationTokenData Lecturer = InvTokenDataLecturer
deriving (Eq, Ord, Read, Show, Generic, Typeable)
_InvitableJunction = iso
(\Lecturer{..} -> (lecturerUser, lecturerCourse, JunctionLecturer lecturerType))
(\(lecturerUser, lecturerCourse, JunctionLecturer lecturerType) -> Lecturer{..})
instance ToJSON (InvitableJunction Lecturer) where
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
instance FromJSON (InvitableJunction Lecturer) where
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 1 }
instance ToJSON (InvitationDBData Lecturer) where
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
instance FromJSON (InvitationDBData Lecturer) where
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
instance ToJSON (InvitationTokenData Lecturer) where
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
instance FromJSON (InvitationTokenData Lecturer) where
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
lecturerInvitationConfig :: InvitationConfig Lecturer
lecturerInvitationConfig = InvitationConfig{..}
where
invitationRoute Course{..} _ = return . CourseR courseTerm courseSchool courseShorthand $ CLecInviteR
invitationResolveFor = do
Just (CourseR tid csh ssh CLecInviteR) <- getCurrentRoute
getKeyBy404 $ TermSchoolCourseShort tid csh ssh
invitationSubject Course{..} _ = SomeMessage $ MsgMailSubjectLecturerInvitation courseTerm courseSchool courseShorthand
invitationHeading Course{..} _ = SomeMessage $ MsgCourseLecInviteHeading $ CI.original courseName
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgCourseLecInviteExplanation}|]
invitationTokenConfig _ _ = do
itAuthority <- liftHandlerT requireAuthId
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
invitationRestriction _ _ = return Authorized
invitationForm _ (InvDBDataLecturer mlType, _) = hoistAForm liftHandlerT $ toJunction <$> case mlType of
Nothing -> areq (selectField optionsFinite) lFs Nothing
Just lType -> aforced (selectField optionsFinite) lFs lType
where
toJunction jLecturerType = JunctionLecturer{..}
lFs = fslI MsgLecturerType & setTooltip MsgCourseLecturerRightsIdentical
invitationSuccessMsg Course{..} (Entity _ Lecturer{..}) = do
MsgRenderer mr <- getMsgRenderer
return . SomeMessage $ MsgLecturerInvitationAccepted (mr lecturerType) courseShorthand
invitationUltDest Course{..} _ = return . SomeRoute $ CourseR courseTerm courseSchool courseShorthand CShowR
data CourseForm = CourseForm
{ cfCourseId :: Maybe CourseId
, cfName :: CourseName
@ -1131,54 +1189,6 @@ postCCommR tid ssh csh = do
}
data ButtonLecInvite = BtnLecInvAccept | BtnLecInvDecline
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
instance Universe ButtonLecInvite
instance Finite ButtonLecInvite
nullaryPathPiece ''ButtonLecInvite $ camelToPathPiece' 3
embedRenderMessage ''UniWorX ''ButtonLecInvite id
instance Button UniWorX ButtonLecInvite where
btnClasses BtnLecInvAccept = [BCIsButton, BCPrimary]
btnClasses BtnLecInvDecline = [BCIsButton, BCDanger]
getCLecInviteR, postCLecInviteR :: TermId -> SchoolId -> CourseShorthand -> UserEmail -> Handler Html
getCLecInviteR, postCLecInviteR :: TermId -> SchoolId -> CourseShorthand -> Handler Html
getCLecInviteR = postCLecInviteR
postCLecInviteR tid ssh csh email = do
uid <- requireAuthId
(Entity cid Course{..}, Entity liId LecturerInvitation{..}) <- runDB $ do
cRes@(Entity cid _) <- getBy404 $ TermSchoolCourseShort tid ssh csh
iRes <- getBy404 $ UniqueLecturerInvitation email cid
return (cRes, iRes)
((btnResult, btnInnerWidget), btnEncoding) <- runFormPost . formEmbedJwtPost $ \csrf -> do
(ltRes, ltView) <- case lecturerInvitationType of
Nothing -> mreq (selectField optionsFinite) "" Nothing
Just lType -> mforced (selectField optionsFinite) "" lType
(btnRes, btnWdgt) <- buttonForm mempty
return ((,) <$> ltRes <*> btnRes, toWidget csrf <> fvInput ltView <> btnWdgt)
let btnWidget = wrapForm btnInnerWidget def
{ formEncoding = btnEncoding
, formAction = Just . SomeRoute . CourseR tid ssh csh $ CLecInviteR email
, formSubmit = FormNoSubmit
}
formResult btnResult $ \case
(lType, BtnLecInvAccept) -> do
runDB $ do
delete liId
insert_ $ Lecturer uid cid lType
MsgRenderer mr <- getMsgRenderer
addMessageI Success $ MsgLecturerInvitationAccepted (mr lType) csh
redirect $ CourseR tid ssh csh CShowR
(_, BtnLecInvDecline) -> do
runDB $
delete liId
addMessageI Info $ MsgLecturerInvitationDeclined csh
redirect HomeR
siteLayoutMsg (MsgCourseLecInviteHeading $ CI.original courseName) $ do
setTitleI . MsgCourseLecInviteHeading $ CI.original courseName
$(widgetFile "courseLecInvite")
postCLecInviteR = invitationR lecturerInvitationConfig

View File

@ -1,4 +1,5 @@
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
module Handler.Utils.Invitations
( -- * Procedure
@ -6,9 +7,10 @@ module Handler.Utils.Invitations
-- $procedure
IsInvitableJunction(..)
, _invitationDBData, _invitationTokenData
, InvitationReference(..), invRef
, InvitationConfig(..), InvitationTokenConfig(..)
, sinkInvitations, sinkInvitationsF
, invitationR
, invitationR', InvitationR(..)
) where
import Import
@ -30,11 +32,15 @@ import Data.Aeson (fromJSON)
import qualified Data.Aeson as JSON
import Data.Aeson.TH
import Data.Proxy (Proxy(..))
import Data.Typeable
class ( PersistRecordBackend junction (YesodPersistBackend UniWorX)
, ToJSON (InvitationDBData junction), ToJSON (InvitationTokenData junction)
, FromJSON (InvitationDBData junction), FromJSON (InvitationTokenData junction)
, PersistRecordBackend (InvitationFor junction) (YesodPersistBackend UniWorX)
, Typeable junction
) => IsInvitableJunction junction where
-- | One side of the junction is always `User`; `InvitationFor junction` is the other
type InvitationFor junction :: *
@ -72,6 +78,32 @@ _invitationTokenData :: IsInvitableJunction junction => Lens' (InvitationData ju
_invitationTokenData = _InvitationData . _2
data InvitationReference junction = IsInvitableJunction junction => InvRef (Key (InvitationFor junction))
deriving instance Eq (InvitationReference junction)
deriving instance Ord (InvitationReference junction)
deriving instance IsInvitableJunction junction => Read (InvitationReference junction)
deriving instance Show (InvitationReference junction)
instance ToJSON (InvitationReference junction) where
toJSON (InvRef fId) = JSON.object
[ "type" JSON..= show (typeRep (Proxy @junction))
, "key" JSON..= fId
]
instance IsInvitableJunction junction => FromJSON (InvitationReference junction) where
parseJSON = JSON.withObject "InvitationReference" $ \o -> do
table <- o JSON..: "type"
key <- o JSON..: "key"
unless (table == show (typeRep (Proxy @junction))) $
fail "Unexpected table"
return $ InvRef key
invRef :: forall junction. IsInvitableJunction junction => Key (InvitationFor junction) -> JSON.Value
invRef = toJSON . InvRef @junction
-- | Configuration needed for creating and accepting/declining `Invitation`s
--
-- It is advisable to define this once per `junction` in a global constant
@ -147,13 +179,13 @@ sinkInvitations InvitationConfig{..} = determineExists .| C.foldMap pure >>= lif
= C.map Right
| otherwise
= C.mapM $ \inp@(email, fid, dat) ->
maybe (Right inp) (Left . (, dat)) <$> getKeyBy (UniqueInvitation email (toJSON fid))
maybe (Right inp) (Left . (, dat)) <$> getKeyBy (UniqueInvitation email (toJSON $ InvRef @junction fid))
sinkInvitations' :: [Either (InvitationId, InvitationData junction) (UserEmail, Key (InvitationFor junction), InvitationData junction)]
-> YesodJobDB UniWorX ()
sinkInvitations' (partitionEithers -> (existing, new)) = do
when (is _Nothing (ephemeralInvitation @junction)) $ do
insertMany_ $ map (\(email, fid, dat) -> Invitation email (toJSON fid) (toJSON $ dat ^. _invitationDBData)) new
insertMany_ $ map (\(email, fid, dat) -> Invitation email (toJSON $ InvRef @junction fid) (toJSON $ dat ^. _invitationDBData)) new
forM_ existing $ \(iid, dat) -> update iid [ InvitationData =. toJSON (dat ^. _invitationDBData) ]
forM_ new $ \(jInvitee, fid, dat) -> do
app <- getYesod
@ -201,15 +233,15 @@ instance Button UniWorX ButtonInvite where
btnValidate _ BtnInviteAccept = True
btnValidate _ BtnInviteDecline = False
invitationR :: forall junction m.
( IsInvitableJunction junction
, MonadHandler m
, HandlerSite m ~ UniWorX
)
=> InvitationConfig junction
-> m Html
invitationR' :: forall junction m.
( IsInvitableJunction junction
, MonadHandler m
, HandlerSite m ~ UniWorX
)
=> InvitationConfig junction
-> m Html
-- | Generic handler for incoming invitations
invitationR InvitationConfig{..} = liftHandlerT $ do
invitationR' InvitationConfig{..} = liftHandlerT $ do
InvitationTokenRestriction{..} <- maybeM (invalidArgsI [MsgInvitationMissingRestrictions]) return currentTokenRestrictions :: Handler (InvitationTokenRestriction junction)
invitee <- requireAuthId
Just cRoute <- getCurrentRoute
@ -218,7 +250,7 @@ invitationR InvitationConfig{..} = liftHandlerT $ do
Entity fid fRec <- invitationResolveFor >>= (\k -> Entity k <$> get404 k)
dbData <- case ephemeralInvitation @junction of
Nothing -> do
Invitation{..} <- entityVal <$> getBy404 (UniqueInvitation itEmail $ toJSON fid)
Invitation{..} <- entityVal <$> getBy404 (UniqueInvitation itEmail . toJSON $ InvRef @junction fid)
case fromJSON invitationData of
JSON.Success dbData -> return dbData
JSON.Error str -> fail $ "Could not decode invitationData: " <> str
@ -243,7 +275,7 @@ invitationR InvitationConfig{..} = liftHandlerT $ do
fmap (, (dataWidget, dataEnctype), heading, explanation) . formResultMaybe dataRes $ \case
Nothing -> do
addMessageI Info MsgInvitationDeclined
deleteBy . UniqueInvitation itEmail $ toJSON fid
deleteBy . UniqueInvitation itEmail . toJSON $ InvRef @junction fid
return . Just $ SomeRoute HomeR
Just jData -> do
mResult <- insertUniqueEntity $ review _InvitableJunction (invitee, fid, jData)
@ -265,6 +297,20 @@ invitationR InvitationConfig{..} = liftHandlerT $ do
siteLayoutMsg heading $(widgetFile "widgets/invitation-site")
class InvitationR a where
invitationR :: forall junction.
( IsInvitableJunction junction
)
=> InvitationConfig junction
-> a
instance InvitationR (Handler Html) where
invitationR = invitationR'
instance InvitationR b => InvitationR (a -> b) where
invitationR cfg _ = invitationR cfg
-- $procedure
--
-- `Invitation`s encode a pending entry of some junction table between some

View File

@ -60,7 +60,6 @@ import Jobs.Handler.HelpRequest
import Jobs.Handler.SetLogSettings
import Jobs.Handler.DistributeCorrections
import Jobs.Handler.SendCourseCommunication
import Jobs.Handler.LecturerInvitation
import Jobs.Handler.CorrectorInvitation
import Jobs.Handler.Invitation

View File

@ -1,41 +0,0 @@
module Jobs.Handler.LecturerInvitation
( dispatchJobLecturerInvitation
) where
import Import
import Text.Hamlet
import qualified Data.HashSet as HashSet
import qualified Data.CaseInsensitive as CI
import Utils.Lens
dispatchJobLecturerInvitation :: UserId -> LecturerInvitation -> Handler ()
dispatchJobLecturerInvitation jInviter jLecturerInvitation@LecturerInvitation{..} = do
ctx <- runDB . runMaybeT $ do
course <- MaybeT $ get lecturerInvitationCourse
void . MaybeT $ getByValue jLecturerInvitation
user <- MaybeT $ get jInviter
return (course, user)
case ctx of
Just (Course{..}, User{..}) -> do
let baseRoute = CourseR courseTerm courseSchool courseShorthand $ CLecInviteR lecturerInvitationEmail
jwt <- encodeToken =<< bearerToken jInviter (Just $ HashSet.singleton baseRoute) Nothing Nothing Nothing
let
invitationUrl :: SomeRoute UniWorX
invitationUrl = SomeRoute (baseRoute, [(toPathPiece GetBearer, toPathPiece jwt)])
invitationUrl' <- toTextUrl invitationUrl
mailT def $ do
_mailTo .= [Address Nothing $ CI.original lecturerInvitationEmail]
replaceMailHeader "Reply-To" . Just . renderAddress $ Address (Just userDisplayName) (CI.original userEmail)
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectLecturerInvitation courseTerm courseSchool courseShorthand
addPart ($(ihamletFile "templates/mail/lecturerInvitation.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
Nothing -> runDB .
deleteBy $ UniqueLecturerInvitation lecturerInvitationEmail lecturerInvitationCourse

View File

@ -31,9 +31,6 @@ data Job = JobSendNotification { jRecipient :: UserId, jNotification :: Notifica
, jSubject :: Maybe Text
, jMailContent :: Html
}
| JobLecturerInvitation { jInviter :: UserId
, jLecturerInvitation :: LecturerInvitation
}
| JobCorrectorInvitation { jInviter :: UserId
, jCorrectorInvitation :: SheetCorrectorInvitation
}

View File

@ -1,3 +0,0 @@
<p>
_{MsgCourseLecInviteExplanation}
^{btnWidget}