This repository has been archived on 2024-10-24. You can view files and clone it, but cannot push or open issues or pull requests.
fradrive-old/src/Handler/Utils/Zip/Rating.hs
2017-10-12 04:37:29 +02:00

392 lines
15 KiB
Haskell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE DeriveGeneric, DeriveDataTypeable #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# OPTIONS_GHC -fno-warn-name-shadowing #-}
module Handler.Utils.Zip.Rating
( Rating(..), Rating'(..)
, getRating
, formatRating
, ratingFile
, RatingException(..)
, UnicodeException(..)
, isRatingFile
, parseRating
, SubmissionContent
, extractRatings
, SubmissionSinkException(..)
, sinkSubmission
) where
import Import hiding ((</>))
import Text.PrettyPrint.Leijen.Text hiding ((<$>))
import Control.Monad.Trans.Maybe
import Control.Monad.State hiding (forM_)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.Text.Encoding.Error (UnicodeException(..))
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Monoid (Monoid, Any(..))
import Generics.Deriving.Monoid (memptydefault, mappenddefault)
import qualified Data.Text.Lazy.Encoding as Lazy.Text
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
import qualified Data.ByteString.Lazy as Lazy (ByteString)
import qualified Data.ByteString.Lazy as Lazy.ByteString
import Text.Read (readEither)
import GHC.Generics (Generic)
import Data.Typeable (Typeable)
import CryptoID.Base32 as Base32
import System.FilePath
import qualified Database.Esqueleto as E
import qualified Data.Conduit.List as Conduit
instance HasResolution prec => Pretty (Fixed prec) where
pretty = pretty . show
instance Pretty x => Pretty (CI x) where
pretty = pretty . CI.original
data Rating = Rating
{ ratingCourseName :: Text
, ratingSheetName :: Text
, ratingValues :: Rating'
} deriving (Read, Show, Eq, Generic, Typeable)
data Rating' = Rating'
{ ratingPoints :: Maybe Points
, ratingComment :: Maybe Text
, ratingTime :: Maybe UTCTime
} deriving (Read, Show, Eq, Generic, Typeable)
data RatingException = RatingNotUnicode UnicodeException -- ^ Rating failed to parse as unicode
| RatingMissingSeparator -- ^ Could not split rating header from comments
| RatingMultiple -- ^ Encountered multiple point values in rating
| RatingInvalid String -- ^ Failed to parse rating point value
| RatingFileIsDirectory -- ^ We do not expect this to, it's included for totality
deriving (Show, Eq, Generic, Typeable)
instance Exception RatingException
getRating :: SubmissionId -> YesodDB UniWorX (Maybe Rating)
getRating submissionId = runMaybeT $ do
let query = E.select . E.from $ \(submission `E.InnerJoin` sheet `E.InnerJoin` course) -> do
E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourseId
E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheetId
E.where_ $ submission E.^. SubmissionId E.==. E.val submissionId
-- Yes, we can only pass a tuple through 'E.select'
return ( course E.^. CourseName
, sheet E.^. SheetName
, submission E.^. SubmissionRatingPoints
, submission E.^. SubmissionRatingComment
, submission E.^. SubmissionRatingTime
)
[ ( E.unValue -> ratingCourseName
, E.unValue -> ratingSheetName
, E.unValue -> ratingPoints
, E.unValue -> ratingComment
, E.unValue -> ratingTime
) ] <- lift query
return Rating{ ratingValues = Rating'{..}, .. }
formatRating :: CryptoFileNameSubmission -> Rating -> Lazy.ByteString
formatRating cID Rating{ ratingValues = Rating'{..}, ..} = let
doc = renderPretty 1 45 $ foldr (<$$>) mempty
[ "= Bitte nur Bewertung und Kommentare ändern ="
, "============================================="
, "========== UniWorx Bewertungsdatei =========="
, "======= diese Datei ist UTF8 encodiert ======"
, "Informationen zum Übungsblatt:"
, indent 2 $ foldr (<$$>) mempty
[ "Veranstaltung:" <+> pretty ratingCourseName
, "Blatt:" <+> pretty ratingSheetName
]
, "Abgabe-Id:" <+> pretty (ciphertext cID)
, "============================================="
, "Bewertung:" <+> pretty ratingPoints
, "=========== Beginn der Kommentare ==========="
, pretty ratingComment
]
in Lazy.Text.encodeUtf8 . (<> "\n") $ displayT doc
ratingFile :: CryptoFileNameSubmission -> Rating -> Maybe File
ratingFile cID rating@(Rating{ ratingValues = Rating'{..}, .. }) = do
fileModified <- ratingTime
let
fileTitle = "bewertung_" <> (Text.unpack . CI.foldedCase $ ciphertext (cID :: CryptoFileNameSubmission)) <.> "txt"
fileContent = Just . Lazy.ByteString.toStrict $ formatRating cID rating
return File{..}
parseRating :: MonadThrow m => File -> m Rating'
parseRating File{ fileContent = Just input, .. } = do
inputText <- either (throw . RatingNotUnicode) return $ Text.decodeUtf8' input
let
(headerLines, commentLines) = break (sep `Text.isInfixOf`) $ Text.lines inputText
ratingLines = filter (rating `Text.isInfixOf`) headerLines
sep = "Beginn der Kommentare"
rating = "Bewertung:"
comment' <- case commentLines of
(_:commentLines') -> return . Text.strip $ Text.unlines commentLines'
_ -> throw RatingMissingSeparator
let
ratingComment
| Text.null comment' = Nothing
| otherwise = Just comment'
ratingLine' <- case ratingLines of
[l] -> return l
_ -> throw RatingMultiple
let
(_, ratingLine) = Text.breakOnEnd rating ratingLine'
ratingStr = Text.unpack $ Text.strip ratingLine
ratingPoints <- case () of
_ | null ratingStr -> return Nothing
| otherwise -> either (throw . RatingInvalid) return $ Just <$> readEither ratingStr
return Rating'{ ratingTime = Just fileModified, .. }
parseRating _ = throwM RatingFileIsDirectory
type SubmissionContent = Either File (SubmissionId, Rating')
extractRatings :: ( MonadHandler m
, HandlerSite m ~ UniWorX
, MonadCatch m
) => Conduit File m SubmissionContent
extractRatings = Conduit.mapM $ \f@File{..} -> do
msId <- isRatingFile fileTitle
case () of
_ | Just sId <- msId
, isJust fileContent
-> Right . (sId, ) <$> parseRating f
| otherwise -> return $ Left f
isRatingFile :: ( MonadHandler m
, HandlerSite m ~ UniWorX
, MonadCatch m
) => FilePath -> m (Maybe SubmissionId)
isRatingFile fName
| Just cID <- isRatingFile' fName = do
cIDKey <- getsYesod appCryptoIDKey
(Just <$> Base32.decrypt cIDKey cID) `catch` decryptErrors
| otherwise = return Nothing
where
decryptErrors (CiphertextConversionFailed _) = return Nothing
decryptErrors InvalidNamespaceDetected = return Nothing
decryptErrors DeserializationError = return Nothing
decryptErrors err = throwM err
isRatingFile' :: FilePath -> Maybe CryptoFileNameSubmission
isRatingFile' (normalise -> fName)
| (bName, ".txt") <- splitExtension fName
, Just (CI.mk . Text.pack -> ciphertext) <- stripPrefix "bewertung_" bName
= Just CryptoID{..}
| otherwise
= Nothing
data SubmissionSinkState = SubmissionSinkState
{ sinkSeenRating :: Any
, sinkSubmissionTouched :: Any
, sinkFilenames :: Set FilePath
} deriving (Show, Eq, Generic, Typeable)
instance Monoid SubmissionSinkState where
mempty = memptydefault
mappend = mappenddefault
data SubmissionSinkException = DuplicateFileTitle FilePath
| DuplicateRating
| RatingWithoutUpdate
| ForeignRating
deriving (Typeable, Show)
instance Exception SubmissionSinkException
sinkSubmission :: SheetId
-> UserId
-> Maybe (SubmissionId, Bool {-^ Is this a correction -})
-> Sink SubmissionContent (YesodDB UniWorX) SubmissionId
sinkSubmission sheetId userId mExists = do
now <- liftIO getCurrentTime
let
submissionSheetId = sheetId
submissionRatingPoints = Nothing
submissionRatingComment = Nothing
submissionRatingBy = Nothing
submissionRatingTime = Nothing
submissionCreated = now
submissionChanged = now
submissionCreatedBy = userId
submissionChangedBy = userId
(sId, isUpdate) <- lift $ maybe ((, False) <$> insert Submission{..}) return mExists
sId <$ sinkSubmission' sId isUpdate
where
tell = modify . mappend
sinkSubmission' :: SubmissionId
-> Bool -- ^ Is this a correction
-> Sink SubmissionContent (YesodDB UniWorX) ()
sinkSubmission' submissionId isUpdate = lift . finalize <=< execStateLC mempty . Conduit.mapM_ $ \case
Left file@(File{..}) -> do
alreadySeen <- gets (Set.member fileTitle . sinkFilenames)
when alreadySeen . throwM $ DuplicateFileTitle fileTitle
tell $ mempty{ sinkFilenames = Set.singleton fileTitle }
collidingFiles <- lift . E.select . E.from $ \(sf `E.InnerJoin` f) -> do
E.on $ sf E.^. SubmissionFileFileId E.==. f E.^. FileId
E.where_ $ sf E.^. SubmissionFileSubmissionId E.==. E.val submissionId
E.where_ $ sf E.^. SubmissionFileIsUpdate E.==. E.val isUpdate
E.where_ $ f E.^. FileTitle E.==. E.val fileTitle -- 'Zip.hs' normalises filenames already, so this should work
return (f, sf)
let anyChanges
| not (null collidingFiles) = any (/= file) [ f | (Entity _ f, _) <- collidingFiles ]
| otherwise = True
undoneDeletion = any submissionFileIsDeletion [ sf | (_, Entity _ sf) <- collidingFiles ]
-- The Eq Instance for File compares modification time exactly even
-- though zip archives have very limited accuracy and range regarding
-- timestamps.
-- We thus expect to replace files a little more often than is actually
-- necessary.
-- This was done on the premise that changes in file modification time
-- break file identity under upload and re-download.
--
-- We could check whether the new version of the file matches the
-- version of the file for which 'isUpdate' is different from this
-- one's, and, if so, simply delete the version for which 'isUpdate' is
-- 'True', reverting the correction.
--
-- This idea was discarded since modification times make this difficult
-- to implement properly should we equate file versions that differ in
-- modification time?
when anyChanges $ do
touchSubmission
when (not $ null collidingFiles) $
lift $ deleteCascadeWhere [ FileId <-. [ fId | (Entity fId _, _) <- collidingFiles ] ]
fileId <- lift $ insert file
lift . insert_ $ SubmissionFile
{ submissionFileSubmissionId = submissionId
, submissionFileFileId = fileId
, submissionFileIsUpdate = isUpdate
, submissionFileIsDeletion = False
}
when undoneDeletion $ do
touchSubmission
lift $ deleteWhere [ SubmissionFileId <-. [ sfId | (_, Entity sfId sf) <- collidingFiles, submissionFileIsDeletion sf ] ]
Right (submissionId', Rating'{..}) -> do
unless (submissionId' == submissionId) $ throwM ForeignRating
alreadySeen <- gets $ getAny . sinkSeenRating
when alreadySeen $ throwM DuplicateRating
tell $ mempty{ sinkSeenRating = Any True }
unless isUpdate $ throwM RatingWithoutUpdate
Submission{..} <- lift $ getJust submissionId
let anyChanges = or $
[ submissionRatingPoints /= ratingPoints
, submissionRatingComment /= ratingComment
]
-- 'ratingTime' is ignored for consistency with 'File's:
--
-- 'fileModified' is simply stored and never inspected while
-- 'submissionChanged' is always set to @now@.
when anyChanges $ do
touchSubmission
lift $ update submissionId
[ SubmissionRatingPoints =. ratingPoints
, SubmissionRatingComment =. ratingComment
]
where
touchSubmission :: StateT SubmissionSinkState (YesodDB UniWorX) ()
touchSubmission = do
alreadyTouched <- gets $ getAny . sinkSubmissionTouched
when (not alreadyTouched) $ do
now <- liftIO getCurrentTime
lift . update submissionId $ case isUpdate of
False -> [ SubmissionChangedBy =. userId, SubmissionChanged =. now ]
True -> [ SubmissionRatingBy =. Just userId, SubmissionRatingTime =. Just now ]
tell $ mempty{ sinkSubmissionTouched = Any True }
finalize :: SubmissionSinkState -> YesodDB UniWorX ()
finalize SubmissionSinkState{..} = do
missingFiles <- E.select . E.from $ \(sf `E.InnerJoin` f) -> E.distinctOnOrderBy [E.asc $ f E.^. FileTitle] $ do
E.on $ sf E.^. SubmissionFileFileId E.==. f E.^. FileId
E.where_ $ sf E.^. SubmissionFileSubmissionId E.==. E.val submissionId
when (not isUpdate) $
E.where_ $ sf E.^. SubmissionFileIsUpdate E.==. E.val isUpdate
E.where_ $ f E.^. FileTitle `E.notIn` E.valList (Set.toList sinkFilenames)
E.orderBy [E.desc $ sf E.^. SubmissionFileIsUpdate]
return (f, sf)
case isUpdate of
False -> deleteCascadeWhere [ FileId <-. [ fileId | (Entity fileId _, _) <- missingFiles ] ]
True -> forM_ missingFiles $ \(Entity fileId File{..}, Entity sfId SubmissionFile{..}) -> do
shadowing <- E.select . E.from $ \(sf `E.InnerJoin` f) -> do
E.on $ sf E.^. SubmissionFileFileId E.==. f E.^. FileId
E.where_ $ sf E.^. SubmissionFileSubmissionId E.==. E.val submissionId
E.where_ $ sf E.^. SubmissionFileIsUpdate E.==. E.val (not isUpdate)
E.where_ $ f E.^. FileTitle E.==. E.val fileTitle
return $ f E.^. FileId
case (shadowing, submissionFileIsUpdate) of
([], _) -> deleteCascade fileId
(E.Value f:_, False) -> do
insert_ $ SubmissionFile
{ submissionFileSubmissionId = submissionId
, submissionFileFileId = f
, submissionFileIsUpdate = True
, submissionFileIsDeletion = True
}
(E.Value f:_, True) -> do
update sfId [ SubmissionFileFileId =. f, SubmissionFileIsDeletion =. True ]
deleteCascade fileId
when (isUpdate && not (getAny sinkSeenRating)) $
update submissionId
[ SubmissionRatingTime =. Nothing
, SubmissionRatingPoints =. Nothing
, SubmissionRatingBy =. Nothing
, SubmissionRatingComment =. Nothing
]