Merge branch 'master' into feat/datepicker

This commit is contained in:
Felix Hamann 2018-06-09 19:20:47 +02:00
commit 07e5ca8b79
72 changed files with 5069 additions and 1180 deletions

View File

@ -1,7 +1,7 @@
** Sicherheitsabfragen?
- Verschlüsselung des Zugriffs?
- SheetDelR tid csh sn : GET zeigt Sicherheitsabfrage
- SDelR tid csh sn : GET zeigt Sicherheitsabfrage
POST löscht.
Ist das so sinnvoll?
Sicherheitsabfrage als PopUpMessage?
@ -9,7 +9,7 @@
- Utils.getKeyBy404 effiziente Variante, welche nur den Key liefert? Esq?
(Sheet.hs -> fetchSheet)
- Handler.Sheet.postSheetDelR: deleteCascade für Files? Klappt das?
- Handler.Sheet.postSDelR: deleteCascade für Files? Klappt das?
Kann man abfragen, was bei deleteCascade alles gelöscht wird?
@ -19,7 +19,7 @@
Links -> MenuItems verwenden wie bisher
Page Titles -> setTitleI
Buttons? -> Kann leicht geändert werden!
Was ist mit einfachen Text Feldern, z.B. die Beschriftung von Knöpfen wie in Handler.Course.getCourseListTermR, Zeile 66 "pageActions" für menuItemLabel?
Was ist mit einfachen Text Feldern, z.B. die Beschriftung von Knöpfen wie in Handler.Course.getTermCourseListR, Zeile 66 "pageActions" für menuItemLabel?
** Page pageActions - Berechtigungen prüfen?
=> Eigener Constructor statt NavbarLeft/Right?!

View File

@ -109,7 +109,7 @@ TABLE "user";
DROP TABLE "course" CASCADE;
-- UserId 5 zum Lecturer in SchoolId 1 machen (27 ist laufende Nummer)
INSERT INTO "user_lecturer" (id,"user",school) VALUES (27,5,1);
INSERT INTO "user_lecturer" (id,"user","school") VALUES (27,5,1);
-- Beenden:
\q

View File

@ -89,8 +89,8 @@ main = db $ do
, courseDescription = Nothing
, courseLinkExternal = Nothing
, courseShorthand = "ffp"
, courseTermId = TermKey summer2018
, courseSchoolId = ifi
, courseTerm = TermKey summer2018
, courseSchool = ifi
, courseCapacity = Just 20
, courseHasRegistration = True
, courseRegisterFrom = Just now
@ -104,8 +104,11 @@ main = db $ do
void . insert $ DegreeCourse ffp sdMst sdInf
void . insert $ Lecturer jost ffp
void . insert $ Lecturer gkleen ffp
insert_ $ Corrector gkleen ffp (ByProportion 1)
sheetkey <- insert $ Sheet ffp "Blatt 1" Nothing NotGraded NoGroups Nothing Nothing now now Nothing Nothing
sheetkey <- insert $ Sheet ffp "AdHoc-Gruppen" Nothing NotGraded (Arbitrary 3) Nothing Nothing now now Nothing Nothing
insert_ $ SheetEdit gkleen now sheetkey
sheetkey <- insert $ Sheet ffp "Feste Gruppen" Nothing NotGraded RegisteredGroups Nothing Nothing now now Nothing Nothing
insert_ $ SheetEdit gkleen now sheetkey
sheetkey <- insert $ Sheet ffp "Keine Gruppen" Nothing NotGraded NoGroups Nothing Nothing now now Nothing Nothing
insert_ $ SheetEdit gkleen now sheetkey
-- EIP
eip <- insert Course
@ -113,8 +116,8 @@ main = db $ do
, courseDescription = Nothing
, courseLinkExternal = Nothing
, courseShorthand = "eip"
, courseTermId = TermKey summer2017
, courseSchoolId = ifi
, courseTerm = TermKey summer2017
, courseSchool = ifi
, courseCapacity = Just 20
, courseHasRegistration = False
, courseRegisterFrom = Nothing
@ -132,8 +135,8 @@ main = db $ do
, courseDescription = Nothing
, courseLinkExternal = Nothing
, courseShorthand = "ixd"
, courseTermId = TermKey summer2018
, courseSchoolId = ifi
, courseTerm = TermKey summer2018
, courseSchool = ifi
, courseCapacity = Just 20
, courseHasRegistration = True
, courseRegisterFrom = Just now
@ -151,8 +154,8 @@ main = db $ do
, courseDescription = Nothing
, courseLinkExternal = Nothing
, courseShorthand = "ux3"
, courseTermId = TermKey winter2017
, courseSchoolId = ifi
, courseTerm = TermKey winter2017
, courseSchool = ifi
, courseCapacity = Just 30
, courseHasRegistration = False
, courseRegisterFrom = Nothing
@ -170,8 +173,8 @@ main = db $ do
, courseDescription = Nothing
, courseLinkExternal = Nothing
, courseShorthand = "pmo"
, courseTermId = TermKey summer2017
, courseSchoolId = ifi
, courseTerm = TermKey summer2017
, courseSchool = ifi
, courseCapacity = Just 50
, courseHasRegistration = False
, courseRegisterFrom = Nothing
@ -189,8 +192,8 @@ main = db $ do
, courseDescription = Nothing
, courseLinkExternal = Nothing
, courseShorthand = "dbs"
, courseTermId = TermKey summer2018
, courseSchoolId = ifi
, courseTerm = TermKey summer2018
, courseSchool = ifi
, courseCapacity = Just 50
, courseHasRegistration = False
, courseRegisterFrom = Nothing

View File

@ -1,15 +1,17 @@
SummerTerm year@Integer: Sommersemester #{tshow year}
WinterTerm year@Integer: Wintersemester #{tshow year}/#{tshow $ succ year}
PSLimitNonPositive: “pagesize” muss größer als null sein
Page n@Int64 num@Int64: Seite #{tshow n} von #{tshow num}
Page n@Int64: #{tshow n}
TermEdited tid@TermIdentifier: Semester #{termToText tid} erfolgreich editiert.
TermNewTitle: Semester editiere/anlegen.
InvalidInput: Eingaben bitte korrigieren.
CourseNewOk tid@TermIdentifier courseShortHand@Text: Kurs #{termToText tid}-#{courseShortHand} wurde erfolgreich erstellt.
CourseEditOk tid@TermIdentifier courseShortHand@Text: Kurs #{termToText tid}-#{courseShortHand} wurde erfolgreich geändert.
CourseNewDupShort tid@TermIdentifier courseShortHand@Text: Kurs #{termToText tid}-#{courseShortHand} konnte nicht erstellt werden: Es gibt bereits einen anderen Kurs mit dem Kürzel #{courseShortHand} in diesem Semester.
CourseEditDupShort tid@TermIdentifier courseShortHand@Text: Kurs #{termToText tid}-#{courseShortHand} konnte nicht geändert werden: Es gibt bereits einen anderen Kurs mit dem Kürzel #{courseShortHand} in diesem Semester.
FFSheetName: Name
SheetNewOk tid@TermIdentifier courseShortHand@Text sheetName@Text: Neues Übungsblatt #{sheetName} wurde im Kurs #{termToText tid}-#{courseShortHand} erfolgreich erstellt.
SheetTitle tid@TermIdentifier courseShortHand@Text sheetName@Text: #{termToText tid}-#{courseShortHand} #{sheetName}
SheetTitleNew tid@TermIdentifier courseShortHand@Text : #{termToText tid}-#{courseShortHand}: Neues Übungsblatt
@ -18,9 +20,37 @@ SheetNameDup tid@TermIdentifier courseShortHand@Text sheetName@Text: Es gi
SheetDelTitle tid@TermIdentifier courseShortHand@Text sheetName@Text: Übungsblatt #{sheetName} wirklich aus Kurs #{termToText tid}-#{courseShortHand} herauslöschen?
SheetDelText submissionNo@Int: Dies kann nicht mehr rückgängig gemacht werden! Alle Einreichungen gehen ebenfalls verloren! Es gibt #{show submissionNo} Abgaben.
SheetDelOk tid@TermIdentifier courseShortHand@Text sheetName@Text: #{termToText tid}-#{courseShortHand}: Übungsblatt #{sheetName} gelöscht.
Unauthorized: Sie haben hierfür keine explizite Berechtigung.
UnauthorizedAnd l@Text r@Text: "#{l}" und "#{r}"
UnauthorizedOr l@Text r@Text: "#{l}" oder "#{r}"
UnauthorizedSchoolAdmin: Sie sind nicht als Administrator für dieses Institut eingetragen.
UnauthorizedSchoolLecturer: Sie sind nicht als Veranstalter für dieses Institut eingetragen.
UnauthorizedLecturer: Sie sind nicht als Veranstalter für diese Veranstaltung eingetragen.
UnauthorizedCorrector: Sie sind nicht als Korrektor für diese Veranstaltung eingetragen.
UnauthorizedSheetCorrector: Sie sind nicht als Korrektor für dieses Übungsblatt eingetragen.
UnauthorizedCorrectorAny: Sie sind nicht als Korrektor für eine Veranstaltung eingetragen.
UnauthorizedParticipant: Sie sind nicht als Teilnehmer für diese Veranstaltung registriert.
OnlyUploadOneFile: Bitte nur eine Datei hochladen.
UnauthorizedSheetTime: Dieses Übungsblatt ist momentan nicht freigegeben.
UnauthorizedSubmissionOwner: Sie sind an dieser Abgabe nicht beteiligt.
UnauthorizedSubmissionCorrector: Sie sind nicht der Korrektor für diese Abgabe.
OnlyUploadOneFile: Bitte nur eine Datei hochladen.
DeprecatedRoute: Diese Ansicht ist obsolet und könnte in Zukunft entfallen.
UnfreeMaterials: Die Materialien für diese Veranstaltung sind nicht allgemein freigegeben.
UnauthorizedWrite: Sie haben hierfür keine Schreibberechtigung
SubmissionWrongSheet: Abgabenummer gehört nicht zum angegebenen Übungsblatt.
SubmissionAlreadyExists: Sie haben bereits eine Abgabe zu diesem Übungsblatt.
SubmissionTitle tid@TermIdentifier courseShortHand@Text sheetName@Text: #{termToText tid}-#{courseShortHand} #{sheetName}: Abgabe editieren/anlegen
SubmissionMember g@Int: Mitabgebende(r) ##{tshow g}
SubmissionArchive: Zip-Archiv der Abgabedatei(en)
SubmissionFile: Datei zur Abgabe
SubmissionAlreadyExistsFor user@Text: #{user} hat bereits eine Abgabe zu diesem Übungsblatt.
EMailUnknown email@Text: E-Mail #{email} gehört zu keinem bekannten Benutzer.
NotAParticipant user@Text tid@TermIdentifier csh@Text: #{user} ist nicht im Kurs #{termToText tid}-#{csh} angemeldet.
HomeHeading: Startseite
TermsHeading: Semesterübersicht
NumCourses n@Int64: #{tshow n} Kurse

121
models
View File

@ -1,11 +1,12 @@
User
plugin Text
ident Text
plugin Text
ident Text
matrikelnummer Text Maybe
email Text
displayName Text
maxFavourites Int default=12
email Text
displayName Text
maxFavourites Int default=12
UniqueAuthentication plugin ident
UniqueEmail email
UserAdmin
user UserId
school SchoolId
@ -51,20 +52,20 @@ DegreeCourse json
terms StudyTermsId
UniqueDegreeCourse course degree terms
Course
name Text
description Html Maybe
linkExternal Text Maybe
shorthand Text
termId TermId
schoolId SchoolId
capacity Int Maybe
name Text
description Html Maybe
linkExternal Text Maybe
shorthand Text
term TermId
school SchoolId
capacity Int Maybe
hasRegistration Bool -- canRegisterNow = hasRegistration && maybe False (<= currentTime) registerFrom && maybe True (>= currentTime) registerTo
registerFrom UTCTime Maybe
registerTo UTCTime Maybe
deregisterUntil UTCTime Maybe
registerSecret Text Maybe -- Falls ein Passwort erforderlich ist
materialFree Bool default=true
CourseTermShort termId shorthand
CourseTermShort term shorthand
CourseEdit
user UserId
time UTCTime
@ -75,29 +76,28 @@ CourseFavourite
course CourseId
UniqueCourseFavourite user course
Lecturer
userId UserId
courseId CourseId
UniqueLecturer userId courseId
Corrector
userId UserId
courseId CourseId
user UserId
course CourseId
UniqueLecturer user course
Corrector -- deprecated
user UserId
course CourseId
load Load
-- SELECT submissionID FROM Tutorial, TutorialUser, Submission, Sheet
-- WHERE ( tutorialTutor = correctorUserId
-- && tutorialCourse = correctorCourseId
-- && tutorialUserTutorial = tutorialId
-- && submissionUser = tutorialUserUser
-- && sheetId = submissionSheetId
-- && sheetId = SubmissionSheet
-- && sheetCourse = correctorCourseId
-- )
UniqueCorrector userId courseId
CourseParticipant
courseId CourseId
userId UserId
course CourseId
user UserId
registration UTCTime
UniqueParticipant userId courseId
UniqueParticipant user course
Sheet
courseId CourseId
course CourseId
name Text
description Html Maybe
type SheetType
@ -108,23 +108,28 @@ Sheet
activeTo UTCTime
hintFrom UTCTime Maybe
solutionFrom UTCTime Maybe
CourseSheet courseId name
CourseSheet course name
SheetEdit
user UserId
time UTCTime
sheet SheetId
SheetCorrector
user UserId
sheet SheetId
load Load
UniqueSheetCorrector user sheet
SheetFile
sheetId SheetId
fileId FileId
sheet SheetId
file FileId
type SheetFileType
UniqueSheetFile fileId sheetId type
UniqueSheetFile file sheet type
File
title FilePath
content ByteString Maybe -- Nothing iff this is a directory
modified UTCTime
deriving Show Eq
Submission
sheetId SheetId
sheet SheetId
ratingPoints Points Maybe
ratingComment Text Maybe
ratingBy UserId Maybe
@ -135,37 +140,37 @@ SubmissionEdit
time UTCTime
submission SubmissionId
SubmissionFile
submissionId SubmissionId
fileId FileId
isUpdate Bool
isDeletion Bool
UniqueSubmissionFile fileId submissionId isUpdate
submission SubmissionId
file FileId
isUpdate Bool -- is this the file updated by a corrector (original will always be retained)
isDeletion Bool -- only set if isUpdate is also set, but file was deleted by corrector
UniqueSubmissionFile file submission isUpdate
deriving Show
SubmissionUser
userId UserId
submissionId SubmissionId
UniqueSubmissionUser userId submissionId
user UserId
submission SubmissionId
UniqueSubmissionUser user submission
SubmissionGroup
courseId CourseId
name Text
course CourseId
name Text Maybe
SubmissionGroupEdit
user UserId
time UTCTime
submissionGroup SubmissionGroupId
SubmissionGroupUser
submissionGroupId SubmissionGroupId
userId UserId
UniqueSubmissionGroupUser submissionGroupId userId
submissionGroup SubmissionGroupId
user UserId
UniqueSubmissionGroupUser submissionGroup user
Tutorial json
name Text
tutor UserId
tutor UserId
course CourseId
TutorialUser
userId UserId
tutorialId TutorialId
UniqueTutorialUser userId tutorialId
user UserId
tutorial TutorialId
UniqueTutorialUser user tutorial
Booking
termId TermId
term TermId
begin UTCTime
end UTCTime
weekly Bool
@ -182,17 +187,17 @@ Room
building Text Maybe
-- BookingRoom
-- subject RoomForId
-- roomId RoomId
-- bookingId BookingId
-- UniqueRoomCourse subject roomId bookingId
-- room RoomId
-- booking BookingId
-- UniqueRoomCourse subject room booking
+RoomFor
courseId CourseId
tutorialId TutorialId
examId ExamId
-- data RoomFor = RoomForCourseIdSum CourseId | RoomForTutorialIdSum TutorialId ...
course CourseId
tutorial TutorialId
exam ExamId
-- data RoomFor = RoomForCourseSum CourseId | RoomForTutorialSum TutorialId ...
-- EXAMS ARE TODO:
Exam
courseId CourseId
course CourseId
name Text
description Text
begin UTCTime
@ -207,8 +212,8 @@ Exam
-- time UTCTime
-- exam ExamId
--ExamUser
-- userId UserId
-- user UserId
-- examId ExamId
-- -- CONTINUE HERE: Include rating in this table or separately?
-- UniqueExamUser userId examId
-- UniqueExamUser user examId
-- By default this file is used in Model.hs (which is imported by Foundation.hs)

View File

@ -79,6 +79,8 @@ dependencies:
- parsec
- uuid
- exceptions
- lens
- MonadRandom
# The library contains all of our application code. The executable
# defined below is just a thin wrapper.
@ -91,6 +93,7 @@ library:
- -Wall
- -fwarn-tabs
- -O0
- -ddump-splices
cpp-options: -DDEVELOPMENT
else:
ghc-options:

92
routes
View File

@ -1,41 +1,69 @@
/static StaticR Static appStatic
/auth AuthR Auth getAuth
--
-- Accesss granted via tags; default is no accesss.
-- Permission must be explicitly granted.
--
-- Access permission is the disjunction of permit tags
-- Tags are split on "AND" to encode conjunction.
--
-- Note that nested routes automatically inherit all tags from the parent.
--
-- Admins always have access to entities within their assigned schools.
--
-- Access Tags:
-- !free -- free for all
-- !lecturer -- lecturer for this course (or the school, if route is not connected to a course)
-- !corrector -- corrector for this sheet (or the submission, if route is connected to a submission, or the course, if route is not connected to a sheet, or any course, if route is not connected to a course)
-- !registered -- participant for this course (no effect outside of courses)
-- !owner -- part of the group of owners of this submission
--
-- !materials -- only if course allows all materials to be free (no meaning outside of courses)
-- !time -- access depends on time somehow
-- !isRead -- only if it is read-only access (i.e. GET but not POST)
-- !isWrite -- only if it is write access (i.e. POST only) why needed???
--
-- !deprecated -- like free, but logs and gives a warning
--
/favicon.ico FaviconR GET
/robots.txt RobotsR GET
/static StaticR Static appStatic !free
/auth AuthR Auth getAuth !free
/ HomeR GET POST
/profile ProfileR GET
/users UsersR GET !adminAny
/favicon.ico FaviconR GET !free
/robots.txt RobotsR GET !free
/term TermShowR GET
/term/edit TermEditR GET POST !adminAny
/term/#TermId/edit TermEditExistR GET !adminAny
/ HomeR GET POST !free
/profile ProfileR GET !free
/users UsersR GET -- no tags, i.e. admins only
/course/ CourseListR GET
!/course/new CourseNewR GET POST !lecturerAny
!/course/#TermId CourseListTermR GET
/course/#TermId/#Text CourseR !updateFavourite:
/show CourseShowR GET POST
/edit CourseEditR GET POST !lecturer
/terms TermShowR GET !free
/terms/current TermCurrentR GET !free
/terms/edit TermEditR GET POST
/terms/#TermId/edit TermEditExistR GET
!/terms/#TermId TermCourseListR GET !free
/ex SheetR !registered:
/ SheetListR GET
/#Text/show SheetShowR GET !time
/#Text/#SheetFileType/#FilePath SheetFileR GET !time
/new SheetNewR GET POST !lecturer
/#Text/edit SheetEditR GET POST !lecturer
/#Text/delete SheetDelR GET POST !lecturer
-- For Pattern Synonyms see Foundation
/course/ CourseListR GET !free
!/course/new CourseNewR GET POST !lecturer
/course/#TermId/#Text CourseR !lecturer:
/show CShowR GET POST !free
/edit CEditR GET POST
/ex SheetListR GET !registered !materials
!/ex/new SheetNewR GET POST
/ex/#Text SheetR:
/show SShowR GET !timeANDregistered !timeANDmaterials !corrector
/#SheetFileType/#FilePath SFileR GET !timeANDregistered !timeANDmaterials !corrector
/edit SEditR GET POST
/delete SDelR GET POST
!/sub/new SubmissionNewR GET POST !timeANDregistered
!/sub/own SubmissionOwnR GET !free
!/sub/#CryptoUUIDSubmission SubmissionR GET POST !owner !corrector
!/#UUID CryptoUUIDDispatchR GET !free -- just redirect
-- TODO below
/submission SubmissionListR GET POST
/submission/#CryptoUUIDSubmission SubmissionR GET POST
/submissions.zip SubmissionDownloadMultiArchiveR POST
!/submission/archive/#FilePath SubmissionDownloadArchiveR GET
!/submission/#CryptoUUIDSubmission/#FilePath SubmissionDownloadSingleR GET
!/#{ZIPArchiveName SubmissionId} SubmissionDownloadArchiveR GET !deprecated
!/#CryptoUUIDSubmission/#FilePath SubmissionDownloadSingleR GET !deprecated
!/#UUID CryptoUUIDDispatchR GET
-- For demonstration
/course/#CryptoUUIDCourse/edit CourseEditIDR GET
/submission SubmissionListR GET !deprecated
/submission/#CryptoUUIDSubmission SubmissionDemoR GET POST !deprecated
/submissions.zip SubmissionDownloadMultiArchiveR POST !deprecated

View File

@ -24,13 +24,12 @@ let
override = oldAttrs: {
nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; [ postgresql cabal-install ]) ++ (with haskellPackages; [ stack yesod-bin ]);
shellHook = ''
${oldAttrs.shellHook}
export PROMPT_INFO="${oldAttrs.name}"
pgDir=$(mktemp -d)
pgSockDir=$(mktemp -d)
pgLogFile=$(mktemp)
pg_ctl init -D ''${pgDir}
initdb --no-locale -D ''${pgDir}
pg_ctl start -D ''${pgDir} -l ''${pgLogFile} -w -o "-k ''${pgSockDir} -c listen_addresses=''' -c hba_file='${postgresHba}' -c unix_socket_permissions=0700"
export PGHOST=''${pgSockDir} PGLOG=''${pgLogFile}
psql -f ${postgresSchema} postgres
@ -42,6 +41,8 @@ let
}
trap cleanup EXIT
${oldAttrs.shellHook}
'';
};

View File

@ -1,8 +1,11 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleInstances, TypeSynonymInstances #-}
{-# LANGUAGE RecordWildCards, ViewPatterns, PatternGuards #-}
{-# LANGUAGE PatternSynonyms #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module CryptoID
@ -24,6 +27,9 @@ import System.FilePath.Cryptographic.ImplicitNamespace
import Data.UUID.Types
import Web.PathPieces
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
instance PathPiece UUID where
fromPathPiece = fromString . unpack
@ -35,5 +41,33 @@ decCryptoIDs [ ''SubmissionId
, ''CourseId
, ''SheetId
, ''FileId
, ''UserId
]
{- TODO: Do we need/want CryptoUUIDs for Sheet numbers? -}
newtype SubmissionMode = SubmissionMode (Maybe CryptoUUIDSubmission)
deriving (Show, Read, Eq)
pattern NewSubmission :: SubmissionMode
pattern NewSubmission = SubmissionMode Nothing
pattern ExistingSubmission :: CryptoUUIDSubmission -> SubmissionMode
pattern ExistingSubmission cID = SubmissionMode (Just cID)
instance PathPiece SubmissionMode where
fromPathPiece "new" = Just $ SubmissionMode Nothing
fromPathPiece s = SubmissionMode . Just <$> fromPathPiece s
toPathPiece (SubmissionMode Nothing) = "new"
toPathPiece (SubmissionMode (Just x)) = toPathPiece x
newtype ZIPArchiveName objID = ZIPArchiveName (CryptoID (CI FilePath) objID)
deriving (Show, Read, Eq)
instance PathPiece (ZIPArchiveName objID) where
fromPathPiece (map CI.mk . unpack -> s)
| Just s' <- stripSuffix (map CI.mk ".zip") s = Just . ZIPArchiveName . CryptoID . CI.mk $ map CI.original s'
| otherwise = Nothing
toPathPiece (ZIPArchiveName CryptoID{..}) = pack (CI.foldedCase ciphertext) <> ".zip"

View File

@ -1,14 +1,17 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE TemplateHaskell, QuasiQuotes #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE PatternGuards #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE FlexibleInstances, UndecidableInstances #-}
module Foundation where
@ -29,6 +32,7 @@ import LDAP.Search (LDAPEntry(..))
import Yesod.Default.Util (addStaticContentExternal)
import Yesod.Core.Types (Logger)
import qualified Yesod.Core.Unsafe as Unsafe
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
import qualified Data.Text.Encoding as TE
@ -44,6 +48,13 @@ import qualified Data.ByteString.Lazy as Lazy.ByteString
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.List (foldr1)
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Map (Map, (!?))
import qualified Data.Map as Map
import Data.Conduit (($$))
import Data.Conduit.List (sourceList)
@ -51,12 +62,14 @@ import qualified Database.Esqueleto as E
import Control.Monad.Except (MonadError(..), runExceptT)
import Control.Monad.Trans.Maybe (MaybeT(..))
import Control.Monad.Trans.Reader (runReader)
import System.FilePath
import Handler.Utils.Templates
import Handler.Utils.StudyFeatures
import Control.Lens
import Utils.Lens
-- infixl 9 :$:
-- pattern a :$: b = a b
@ -88,17 +101,29 @@ data UniWorX = UniWorX
-- type Widget = WidgetT UniWorX IO ()
mkYesodData "UniWorX" $(parseRoutesFile "routes")
-- | Convenient Type Synonyms:
type DB a = YesodDB UniWorX a
type Form x = Html -> MForm (HandlerT UniWorX IO) (FormResult x, Widget)
type MsgRenderer = MsgRendererS UniWorX -- see Utils
-- Pattern Synonyms for convenience
pattern CSheetR tid csh ptn = CourseR tid csh (SheetR ptn)
pattern CSheetR tid csh shn ptn
= CourseR tid csh (SheetR shn ptn)
-- Menus and Favourites
data MenuItem = MenuItem
{ menuItemLabel :: Text
, menuItemIcon :: Maybe Text
, menuItemRoute :: Route UniWorX
, menuItemAccessCallback :: Handler Bool
, menuItemAccessCallback' :: Handler Bool -- Check whether action is shown in ADDITION to authorization (which is always checked)
}
menuItemAccessCallback :: MenuItem -> Handler Bool
menuItemAccessCallback MenuItem{..} = (&&) <$> ((==) Authorized <$> authCheck) <*> menuItemAccessCallback'
where
authCheck = handleAny (\_ -> return . Unauthorized $ error "authCheck caught exception") $ isAuthorized menuItemRoute False
data MenuTypes -- Semantische Rolle:
= NavbarAside { menuItem :: MenuItem } -- TODO
| NavbarExtra { menuItem :: MenuItem } -- TODO
@ -107,10 +132,7 @@ data MenuTypes -- Semantische Rolle:
| PageActionPrime { menuItem :: MenuItem } -- Seitenspezifische Aktion, häufig
| PageActionSecondary { menuItem :: MenuItem } -- Seitenspezifische Aktion, selten
-- | Convenient Type Synonyms:
type DB a = YesodDB UniWorX a
type Form x = Html -> MForm (HandlerT UniWorX IO) (FormResult x, Widget)
-- Messages
mkMessage "UniWorX" "messages" "de"
-- This instance is required to use forms. You can modify renderMessage to
@ -125,6 +147,217 @@ instance RenderMessage UniWorX TermIdentifier where
where renderMessage' = renderMessage foundation ls
-- Access Control
data AccessPredicate
= APPure (Route UniWorX -> Reader MsgRenderer AuthResult)
| APHandler (Route UniWorX -> Handler AuthResult)
| APDB (Route UniWorX -> DB AuthResult)
orAR, andAR :: MsgRenderer -> AuthResult -> AuthResult -> AuthResult
orAR _ Authorized _ = Authorized
orAR _ _ Authorized = Authorized
orAR _ AuthenticationRequired _ = AuthenticationRequired
orAR _ _ AuthenticationRequired = AuthenticationRequired
orAR mr (Unauthorized x) (Unauthorized y) = Unauthorized . render mr $ MsgUnauthorizedOr x y
-- and
andAR mr (Unauthorized x) (Unauthorized y) = Unauthorized . render mr $ MsgUnauthorizedAnd x y
andAR _ reason@(Unauthorized x) _ = reason
andAR _ _ reason@(Unauthorized x) = reason
andAR _ Authorized other = other
andAR _ AuthenticationRequired _ = AuthenticationRequired
orAP,andAP :: AccessPredicate -> AccessPredicate -> AccessPredicate
orAP = liftAR orAR (== Authorized)
andAP = liftAR andAR (const False)
liftAR :: (MsgRenderer -> AuthResult -> AuthResult -> AuthResult)
-> (AuthResult -> Bool) -- ^ Predicate to Short-Circuit on first argument
-> AccessPredicate -> AccessPredicate -> AccessPredicate
-- Ensure to first evaluate Pure conditions, then Handler before DB
liftAR op sc (APPure f) (APPure g) = APPure $ \r -> shortCircuitM sc (f r) (g r) . op =<< ask
liftAR op sc (APHandler f) (APHandler g) = APHandler $ \r -> shortCircuitM sc (f r) (g r) . op =<< getMsgRenderer
liftAR op sc (APDB f) (APDB g) = APDB $ \r -> shortCircuitM sc (f r) (g r) . op =<< getMsgRenderer
liftAR op sc (APPure f) apg = liftAR op sc (APHandler $ \r -> runReader (f r) <$> getMsgRenderer) apg
liftAR op sc apf apg@(APPure _) = liftAR op sc apg apf
liftAR op sc (APHandler f) apdb = liftAR op sc (APDB $ lift . f) apdb
liftAR op sc apdb apg@(APHandler _) = liftAR op sc apg apdb
trueAP,falseAP :: AccessPredicate
trueAP = APPure . const $ return Authorized
falseAP = APPure . const $ Unauthorized . ($ MsgUnauthorized) . render <$> ask
-- TODO: I believe falseAP := adminAP
adminAP :: AccessPredicate -- access for admins (of appropriate school in case of course-routes)
adminAP = APDB $ \case
-- Courses: access only to school admins
CourseR tid csh _ -> exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId
[E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` userAdmin) -> do
E.on $ course E.^. CourseSchool E.==. userAdmin E.^. UserAdminSchool
E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val authId
E.&&. course E.^. CourseTerm E.==. E.val tid
E.&&. course E.^. CourseShorthand E.==. E.val csh
return (E.countRows :: E.SqlExpr (E.Value Int64))
guardMExceptT (c > 0) (unauthorizedI MsgUnauthorizedSchoolAdmin)
return Authorized
-- other routes: access to any admin is granted here
_other -> exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId
adrights <- lift $ selectFirst [UserAdminUser ==. authId] []
guardMExceptT (isJust adrights) (unauthorizedI $ MsgUnauthorized)
return Authorized
knownTags :: Map (CI Text) AccessPredicate
knownTags = -- should not throw exceptions, i.e. no getBy404 or requireAuthId
[("free", trueAP)
,("deprecated", APHandler $ \r -> do
$logWarnS "AccessControl" ("deprecated route: " <> tshow r)
addMessageI "error" MsgDeprecatedRoute
return Authorized
)
,("lecturer", APDB $ \case
CourseR tid csh _ -> exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId
[E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` lecturer) -> do
E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse
E.where_ $ lecturer E.^. LecturerUser E.==. E.val authId
E.&&. course E.^. CourseTerm E.==. E.val tid
E.&&. course E.^. CourseShorthand E.==. E.val csh
return (E.countRows :: E.SqlExpr (E.Value Int64))
guardMExceptT (c>0) (unauthorizedI MsgUnauthorizedLecturer)
return Authorized
_ -> exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId
void . maybeMExceptT (unauthorizedI MsgUnauthorizedSchoolLecturer) $ selectFirst [UserLecturerUser ==. authId] []
return Authorized
)
,("corrector", APDB $ \route -> exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId
resList <- lift . E.select . E.from $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do
E.on $ sheetCorrector E.^. SheetCorrectorSheet E.==. sheet E.^. SheetId
E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId
E.where_ $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val authId
return (course E.^. CourseId, sheet E.^. SheetId)
let
resMap :: Map CourseId (Set SheetId)
resMap = Map.fromListWith Set.union [ (cid, Set.singleton sid) | (E.Value cid, E.Value sid) <- resList ]
case route of
CSheetR _ _ _ (SubmissionR cID) -> maybeT (unauthorizedI MsgUnauthorizedSubmissionCorrector) $ do
sid <- catchIfMaybeT (const True :: CryptoIDError -> Bool) $ decrypt cID
Submission{..} <- MaybeT . lift $ get sid
guard $ maybe False (== authId) submissionRatingBy
return Authorized
CSheetR tid csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedSheetCorrector) $ do
Entity cid _ <- MaybeT . lift . getBy $ CourseTermShort tid csh
Entity sid _ <- MaybeT . lift . getBy $ CourseSheet cid shn
guard $ sid `Set.member` fromMaybe Set.empty (resMap !? cid)
return Authorized
CourseR tid csh _ -> maybeT (unauthorizedI MsgUnauthorizedCorrector) $ do
Entity cid _ <- MaybeT . lift . getBy $ CourseTermShort tid csh
guard $ cid `Set.member` Map.keysSet resMap
return Authorized
_ -> do
guardMExceptT (not $ Map.null resMap) (unauthorizedI MsgUnauthorizedCorrectorAny)
return Authorized
)
,("time", APDB $ \case
CSheetR tid csh shn subRoute -> maybeT (unauthorizedI MsgUnauthorizedSheetTime) $ do
Entity cid _ <- MaybeT . getBy $ CourseTermShort tid csh
Entity sid Sheet{..} <- MaybeT . getBy $ CourseSheet cid shn
cTime <- liftIO getCurrentTime
case subRoute of
SFileR SheetExercise _ -> guard $ maybe False (<= cTime) sheetVisibleFrom
SFileR SheetHint _ -> guard $ maybe False (<= cTime) sheetHintFrom
SFileR SheetSolution _ -> guard $ maybe False (<= cTime) sheetSolutionFrom
SFileR SheetMarking _ -> mzero -- only for correctors and lecturers
SubmissionNewR -> guard $ sheetActiveFrom <= cTime && cTime <= sheetActiveTo
_ -> guard $ maybe False (<= cTime) sheetVisibleFrom
return Authorized
r -> do
$logErrorS "AccessControl" $ "'!time' used on route that doesn't support it: " <> tshow r
unauthorizedI MsgUnauthorized
)
,("registered", APDB $ \case
CourseR tid csh _ -> exceptT return return $ do
authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId
[E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` courseParticipant) -> do
E.on $ course E.^. CourseId E.==. courseParticipant E.^. CourseParticipantCourse
E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val authId
E.&&. course E.^. CourseTerm E.==. E.val tid
E.&&. course E.^. CourseShorthand E.==. E.val csh
return (E.countRows :: E.SqlExpr (E.Value Int64))
guardMExceptT (c > 0) (unauthorizedI MsgUnauthorizedParticipant)
return Authorized
r -> do
$logErrorS "AccessControl" $ "'!registered' used on route that doesn't support it: " <> tshow r
unauthorizedI MsgUnauthorized
)
,("materials", APDB $ \case
CourseR tid csh _ -> maybeT (unauthorizedI MsgUnfreeMaterials) $ do
Entity _ Course{..} <- MaybeT . getBy $ CourseTermShort tid csh
guard courseMaterialFree
return Authorized
r -> do
$logErrorS "AccessControl" $ "'!materials' used on route that doesn't support it: " <> tshow r
unauthorizedI MsgUnauthorized
)
,("owner", APDB $ \case
CSheetR _ _ _ (SubmissionR cID) -> exceptT return return $ do
sid <- catchIfMExceptT (const $ unauthorizedI MsgUnauthorizedSubmissionOwner) (const True :: CryptoIDError -> Bool) $ decrypt cID
authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId
void . maybeMExceptT (unauthorizedI MsgUnauthorizedSubmissionOwner) . getBy $ UniqueSubmissionUser authId sid
return Authorized
CSheetR _ _ _ SubmissionNewR -> unauthorizedI MsgUnauthorizedSubmissionOwner
r -> do
$logErrorS "AccessControl" $ "'!owner' used on route that doesn't support it: " <> tshow r
unauthorizedI MsgUnauthorized
)
,("isRead", APHandler $ \route ->
bool <$> return Authorized
<*> unauthorizedI MsgUnauthorizedWrite
<*> isWriteRequest route
)
,("isWrite", APHandler $ \route -> do
write <- isWriteRequest route
if write
then return Authorized
else unauthorizedI MsgUnauthorized
)
]
tag2ap :: Text -> AccessPredicate
tag2ap t = case Map.lookup (CI.mk t) knownTags of
(Just acp) -> acp
Nothing -> APHandler $ \_route -> do -- Can this be pure like falseAP? GK: not if we want to log a message (which we definitely should)
$logWarnS "AccessControl" $ "'" <> t <> "' not known to access control"
unauthorizedI MsgUnauthorized
route2ap :: Route UniWorX -> AccessPredicate
route2ap r = foldr orAP adminAP attrsAND -- adminAP causes all to be in DB!!! GK: Due to shortCircuitM this (while still true) is no longer costly (we do a `runDB` but then only actually send off queries, if needed)
where
attrsAND = map splitAND $ Set.toList $ routeAttrs r
splitAND = foldr1 andAP . map tag2ap . Text.splitOn "AND"
evalAccessDB :: Route UniWorX -> DB AuthResult -- all requests, regardless of POST/GET, use isWriteRequest otherwise
evalAccessDB r = case route2ap r of
(APPure p) -> lift $ runReader (p r) <$> getMsgRenderer
(APHandler p) -> lift $ p r
(APDB p) -> p r
evalAccess :: Route UniWorX -> Handler AuthResult
evalAccess r = case route2ap r of
(APPure p) -> runReader (p r) <$> getMsgRenderer
(APHandler p) -> p r
(APDB p) -> runDB $ p r
-- TODO: isAuthorized = evalAccess'
-- Please see the documentation for the Yesod typeclass. There are a number
-- of settings which can be configured by overriding methods here.
instance Yesod UniWorX where
@ -151,9 +384,9 @@ instance Yesod UniWorX where
yesodMiddleware handler = do
res <- defaultYesodMiddleware handler
void . runMaybeT $ do
route@(routeAttrs -> attrs) <- MaybeT getCurrentRoute
case route of
CourseR tid csh _ | "updateFavourite" `elem` attrs -> do
route <- MaybeT getCurrentRoute
case route of -- update Course Favourites here
CourseR tid csh _ -> do
uid <- MaybeT maybeAuthId
$(logDebug) "Favourites save"
now <- liftIO $ getCurrentTime
@ -161,7 +394,7 @@ instance Yesod UniWorX where
cid <- MaybeT . getKeyBy $ CourseTermShort tid csh
user <- MaybeT $ get uid
-- update Favourites
lift $ upsertBy
void . lift $ upsertBy
(UniqueCourseFavourite uid cid)
(CourseFavourite uid now cid)
[CourseFavouriteTime =. now]
@ -176,26 +409,84 @@ instance Yesod UniWorX where
_other -> return ()
return res
defaultLayout = defaultLinkLayout []
defaultLayout widget = do
master <- getYesod
mmsgs <- getMessages
mcurrentRoute <- getCurrentRoute
-- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance.
(title, parents) <- breadcrumbs
let
menu = defaultLinks ++ maybe [] pageActions mcurrentRoute
menuTypes <- filterM (menuItemAccessCallback . menuItem) menu
-- Lookup Favourites if possible
favourites' <- do
muid <- maybeAuthId
case muid of
Nothing -> return []
(Just uid) -> runDB . E.select . E.from $ \(course `E.InnerJoin` courseFavourite) -> do
E.on (course E.^. CourseId E.==. courseFavourite E.^. CourseFavouriteCourse)
E.where_ (courseFavourite E.^. CourseFavouriteUser E.==. E.val uid)
E.orderBy [ E.asc $ course E.^. CourseShorthand ]
return course
favourites <- forM favourites' $ \(Entity _ c@Course{..})
-> let
courseRoute = CourseR courseTerm courseShorthand CShowR
in (c, courseRoute, ) <$> filterM (menuItemAccessCallback . menuItem) (pageActions courseRoute)
-- We break up the default layout into two components:
-- default-layout is the contents of the body tag, and
-- default-layout-wrapper is the entire page. Since the final
-- value passed to hamletToRepHtml cannot be a widget, this allows
-- you to use normal widget features in default-layout.
let
navbar :: Widget
navbar = $(widgetFile "widgets/navbar")
asidenav :: Widget
asidenav = $(widgetFile "widgets/asidenav")
contentHeadline :: Maybe Widget
contentHeadline = pageHeading =<< mcurrentRoute
breadcrumbs :: Widget
breadcrumbs = $(widgetFile "widgets/breadcrumbs")
pageactionprime :: Widget
pageactionprime = $(widgetFile "widgets/pageactionprime")
-- functions to determine if there are page-actions
isPageActionPrime :: MenuTypes -> Bool
isPageActionPrime (PageActionPrime _) = True
isPageActionPrime _ = False
hasPageActions :: Bool
hasPageActions = any isPageActionPrime menuTypes
pc <- widgetToPageContent $ do
addStylesheetRemote "https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,800,900"
addScript $ StaticR js_zepto_js
addScript $ StaticR js_fetchPolyfill_js
addScript $ StaticR js_urlPolyfill_js
addScript $ StaticR js_featureChecker_js
addScript $ StaticR js_flatpickr_js
addScript $ StaticR js_tabber_js
addStylesheet $ StaticR css_flatpickr_css
addStylesheet $ StaticR css_tabber_css
addStylesheet $ StaticR css_fonts_css
addStylesheet $ StaticR css_icons_css
$(widgetFile "default-layout")
$(widgetFile "standalone/modal")
$(widgetFile "standalone/showHide")
$(widgetFile "standalone/inputs")
$(widgetFile "standalone/tabber")
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")
-- The page to be redirected to when authentication is required.
authRoute _ = Just $ AuthR LoginR
isAuthorized (AuthR _) _ = return Authorized
isAuthorized HomeR _ = return Authorized
isAuthorized FaviconR _ = return Authorized
isAuthorized RobotsR _ = return Authorized
isAuthorized (StaticR _) _ = return Authorized
isAuthorized ProfileR _ = isAuthenticated
isAuthorized TermShowR _ = return Authorized
isAuthorized CourseListR _ = return Authorized
isAuthorized (CourseListTermR _) _ = return Authorized
isAuthorized (CourseR _ _ CourseShowR) _ = return Authorized
isAuthorized (CryptoUUIDDispatchR _) _ = return Authorized
isAuthorized SubmissionListR _ = isAuthenticated
isAuthorized SubmissionDownloadMultiArchiveR _ = isAuthenticated
-- isAuthorized TestR _ = return Authorized
isAuthorized route isWrite = runDB $ isAuthorizedDB route isWrite
isAuthorized route _isWrite = evalAccess route
-- This function creates static content files in the static folder
-- and names them based on a hash of their content. This allows
@ -236,92 +527,8 @@ instance Yesod UniWorX where
makeLogger = return . appLogger
isAuthorizedDB :: Route UniWorX -> Bool -> YesodDB UniWorX AuthResult
isAuthorizedDB route@(routeAttrs -> attrs) writeable
| "adminAny" `member` attrs = adminAccess Nothing
| "lecturerAny" `member` attrs = lecturerAccess Nothing
isAuthorizedDB UsersR _ = adminAccess Nothing
isAuthorizedDB (SubmissionR cID) _ = submissionAccess $ Right cID
isAuthorizedDB (SubmissionDownloadSingleR cID _) _ = submissionAccess $ Right cID
isAuthorizedDB (SubmissionDownloadArchiveR (splitExtension -> (baseName, _))) _ = submissionAccess . Left . CryptoID $ CI.mk baseName
isAuthorizedDB TermEditR _ = adminAccess Nothing
isAuthorizedDB (TermEditExistR _) _ = adminAccess Nothing
isAuthorizedDB CourseNewR _ = lecturerAccess Nothing
isAuthorizedDB (CourseR t c CourseEditR) _ = courseLecturerAccess . entityKey =<< getBy404 (CourseTermShort t c)
isAuthorizedDB (CourseR t c (SheetR SheetListR)) False = return Authorized --
isAuthorizedDB (CourseR t c (SheetR SheetListR)) _ = courseLecturerAccess . entityKey =<< getBy404 (CourseTermShort t c)
isAuthorizedDB (CourseR t c (SheetR (SheetShowR s))) _ = return Authorized -- TODO: nur für angemeldete Kursteilnehmer falls sichtbar, sonst nur Lectrurer oder Korrektor
isAuthorizedDB (CourseR t c (SheetR (SheetFileR s _ _))) _ = return Authorized -- TODO: nur für angemeldete Kursteilnehmer falls sichtbar, sonst nur Lectrurer oder Korrektor
isAuthorizedDB (CourseR t c (SheetR SheetNewR)) _ = courseLecturerAccess . entityKey =<< getBy404 (CourseTermShort t c)
isAuthorizedDB (CourseR t c (SheetR (SheetEditR s))) _ = courseLecturerAccess . entityKey =<< getBy404 (CourseTermShort t c)
isAuthorizedDB (CourseR t c (SheetR (SheetDelR s))) _ = courseLecturerAccess . entityKey =<< getBy404 (CourseTermShort t c)
isAuthorizedDB (CourseEditIDR cID) _ = do
courseId <- decrypt cID
courseLecturerAccess courseId
isAuthorizedDB _route _isWrite = return $ Unauthorized "No access to this route." -- Calling isAuthorized here creates infinite loop!
submissionAccess :: Either CryptoFileNameSubmission CryptoUUIDSubmission -> YesodDB UniWorX AuthResult
submissionAccess cID = do
authId <- lift requireAuthId
submissionId <- either decrypt decrypt cID
Submission{..} <- get404 submissionId
submissionUsers <- map (submissionUserUserId . entityVal) <$> selectList [SubmissionUserSubmissionId ==. submissionId] []
let auth = authId `elem` submissionUsers || Just authId == submissionRatingBy
return $ case auth of
True -> Authorized
False -> Unauthorized "No access to this submission"
adminAccess :: Maybe SchoolId -- ^ If @Just@, matched exactly against 'userAdminSchool'
-> YesodDB UniWorX AuthResult
adminAccess school = do
authId <- lift requireAuthId
adrights <- selectList ((UserAdminUser ==. authId) : maybe [] (\s -> [UserAdminSchool ==. s]) school) []
return $ if (not $ null adrights)
then Authorized
else Unauthorized "No admin access" -- TODO internationalize
lecturerAccess :: Maybe SchoolId
-> YesodDB UniWorX AuthResult
lecturerAccess school = do
authId <- lift requireAuthId
lecrights <- selectList ((UserLecturerUser ==. authId) : maybe [] (\s -> [UserLecturerSchool ==. s]) school) []
return $ if (not $ null lecrights)
then Authorized
else Unauthorized "No lecturer access" -- TODO internationalize
lecturerAccess' :: SchoolId -> YesodDB UniWorX AuthResult
lecturerAccess' = authorizedFor UniqueSchoolLecturer MsgUnauthorizedSchoolLecturer
courseLecturerAccess :: CourseId -> YesodDB UniWorX AuthResult
courseLecturerAccess = authorizedFor UniqueLecturer MsgUnauthorizedLecturer
courseCorrectorAccess :: CourseId -> YesodDB UniWorX AuthResult
courseCorrectorAccess = authorizedFor UniqueCorrector MsgUnauthorizedCorrector
courseParticipantAccess :: CourseId -> YesodDB UniWorX AuthResult
courseParticipantAccess = authorizedFor UniqueParticipant MsgUnauthorizedParticipant
authorizedFor :: ( PersistEntityBackend record ~ BaseBackend backend
, PersistEntity record, PersistUniqueRead backend
, YesodAuth master, RenderMessage master msg
)
=> (AuthId master -> t -> Unique record) -> msg -> t -> ReaderT backend (HandlerT master IO) AuthResult
authorizedFor authType msg courseId = do
authId <- lift requireAuthId
access <- getBy $ authType authId courseId
case access of
(Just _) -> return Authorized
Nothing -> unauthorizedI msg
isAuthorizedDB' :: Route UniWorX -> Bool -> YesodDB UniWorX Bool
isAuthorizedDB' route isWrite = (== Authorized) <$> isAuthorizedDB route isWrite
isAuthorized' :: Route UniWorX -> Bool -> Handler Bool
isAuthorized' route isWrite = runDB $ isAuthorizedDB' route isWrite
-- Define breadcrumbs.
instance YesodBreadcrumbs UniWorX where
breadcrumb TermShowR = return ("Semester", Just HomeR)
@ -329,25 +536,89 @@ instance YesodBreadcrumbs UniWorX where
breadcrumb (TermEditExistR _) = return ("Editieren", Just TermShowR)
breadcrumb CourseListR = return ("Kurs", Just HomeR)
breadcrumb (CourseListTermR term) = return (toPathPiece term, Just TermShowR)
breadcrumb (CourseR term course CourseShowR) = return (course, Just $ CourseListTermR term)
breadcrumb (TermCourseListR term) = return (toPathPiece term, Just TermShowR)
breadcrumb (CourseR term course CShowR) = return (course, Just $ TermCourseListR term)
breadcrumb CourseNewR = return ("Neu", Just CourseListR)
breadcrumb (CourseR _ _ CourseEditR) = return ("Editieren", Just CourseListR)
breadcrumb (CourseR _ _ CEditR) = return ("Editieren", Just CourseListR)
breadcrumb (CourseR tid csh (SheetR SheetListR)) = return ("Übungen",Just $ CourseR tid csh CourseShowR)
breadcrumb (CourseR tid csh (SheetR SheetNewR )) = return ("Neu", Just $ CourseR tid csh $ SheetR SheetListR)
breadcrumb (CourseR tid csh (SheetR (SheetShowR shn))) = return (shn, Just $ CourseR tid csh $ SheetR SheetListR)
breadcrumb (CourseR tid csh (SheetR (SheetEditR shn))) = return ("Edit", Just $ CourseR tid csh $ SheetR $ SheetShowR shn)
breadcrumb (CourseR tid csh (SheetR (SheetDelR shn))) = return ("DELETE", Just $ CourseR tid csh $ SheetR $ SheetShowR shn)
breadcrumb (CourseR tid csh SheetListR) = return ("Übungen",Just $ CourseR tid csh CShowR)
breadcrumb (CourseR tid csh SheetNewR ) = return ("Neu", Just $ CourseR tid csh SheetListR)
breadcrumb (CSheetR tid csh shn SShowR) = return (shn, Just $ CourseR tid csh SheetListR)
breadcrumb (CSheetR tid csh shn SEditR) = return ("Edit", Just $ CSheetR tid csh shn SShowR)
breadcrumb (CSheetR tid csh shn SDelR ) = return ("DELETE", Just $ CSheetR tid csh shn SShowR)
breadcrumb (CSheetR tid csh shn (SubmissionR _)) = return ("Abgabe", Just $ CSheetR tid csh shn SShowR)
breadcrumb SubmissionListR = return ("Abgaben", Just HomeR)
breadcrumb (SubmissionR _) = return ("Abgabe", Just SubmissionListR)
breadcrumb HomeR = return ("UniworkY", Nothing)
breadcrumb HomeR = return ("Uniworky", Nothing)
breadcrumb (AuthR _) = return ("Login", Just HomeR)
breadcrumb ProfileR = return ("Profile", Just HomeR)
breadcrumb _ = return ("home", Nothing)
pageActions :: Route UniWorX -> [MenuTypes]
pageActions (CourseR tid csh CShowR) =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Übungsblätter"
, menuItemIcon = Nothing
, menuItemRoute = CourseR tid csh SheetListR
, menuItemAccessCallback' = return True
}
, PageActionPrime $ MenuItem
{ menuItemLabel = "Kurs Editieren"
, menuItemIcon = Nothing
, menuItemRoute = CourseR tid csh CEditR
, menuItemAccessCallback' = return True
}
]
pageActions (CourseR tid csh SheetListR) =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Neues Übungsblatt"
, menuItemIcon = Nothing
, menuItemRoute = CourseR tid csh SheetNewR
, menuItemAccessCallback' = return True
}
]
pageActions (CSheetR tid csh shn SShowR) =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Abgabe anlegen"
, menuItemIcon = Nothing
, menuItemRoute = CSheetR tid csh shn SubmissionNewR
, menuItemAccessCallback' = return True -- TODO: check that no submission already exists
}
, PageActionPrime $ MenuItem
{ menuItemLabel = "Abgabe"
, menuItemIcon = Nothing
, menuItemRoute = CSheetR tid csh shn SubmissionOwnR
, menuItemAccessCallback' = return True -- TODO: check that a submission already exists
}
]
pageActions TermShowR =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Neues Semester"
, menuItemIcon = Nothing
, menuItemRoute = TermEditR
, menuItemAccessCallback' = return True
}
]
pageActions (TermCourseListR _) =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Neuer Kurs"
, menuItemIcon = Just "book"
, menuItemRoute = CourseNewR
, menuItemAccessCallback' = return True
}
]
pageActions _ = []
pageHeading :: Route UniWorX -> Maybe Widget
pageHeading HomeR
= Just [whamlet|_{MsgHomeHeading}|]
pageHeading TermShowR
= Just [whamlet|_{MsgTermsHeading}|]
pageHeading _
= Nothing
defaultLinks :: [MenuTypes]
defaultLinks = -- Define the menu items of the header.
@ -355,114 +626,46 @@ defaultLinks = -- Define the menu items of the header.
{ menuItemLabel = "Home"
, menuItemIcon = Just "home"
, menuItemRoute = HomeR
, menuItemAccessCallback = return True
, menuItemAccessCallback' = return True
}
, NavbarRight $ MenuItem
{ menuItemLabel = "Profile"
, menuItemIcon = Just "profile"
, menuItemRoute = ProfileR
, menuItemAccessCallback = isJust <$> maybeAuthPair
, menuItemAccessCallback' = isJust <$> maybeAuthPair
}
, NavbarSecondary $ MenuItem
{ menuItemLabel = "Login"
, menuItemIcon = Just "login"
, menuItemRoute = AuthR LoginR
, menuItemAccessCallback = isNothing <$> maybeAuthPair
, menuItemAccessCallback' = isNothing <$> maybeAuthPair
}
, NavbarSecondary $ MenuItem
{ menuItemLabel = "Logout"
, menuItemIcon = Just "logout"
, menuItemRoute = AuthR LogoutR
, menuItemAccessCallback = isJust <$> maybeAuthPair
}
, NavbarAside $ MenuItem
{ menuItemLabel = "Aktuelle Veranstaltungen"
, menuItemIcon = Just "book"
, menuItemRoute = CourseListR -- should be CourseListActiveR or similar in the future
, menuItemAccessCallback = return True
}
, NavbarAside $ MenuItem
{ menuItemLabel = "Alte Veranstaltungen"
, menuItemIcon = Just "book"
, menuItemRoute = CourseListR -- should be CourseListInactiveR or similar in the future
, menuItemAccessCallback = return True
, menuItemAccessCallback' = isJust <$> maybeAuthPair
}
, NavbarAside $ MenuItem
{ menuItemLabel = "Veranstaltungen"
, menuItemIcon = Just "book"
, menuItemRoute = CourseListR
, menuItemAccessCallback = return True
, menuItemRoute = CourseListR -- should be CourseListActiveR or similar in the future
, menuItemAccessCallback' = return True
}
, NavbarAside $ MenuItem
{ menuItemLabel = "Semester"
, menuItemIcon = Nothing
, menuItemRoute = CourseListR -- should be TermListR ,,,
, menuItemAccessCallback' = return True
}
, NavbarAside $ MenuItem
{ menuItemLabel = "Benutzer"
, menuItemIcon = Just "user"
, menuItemRoute = UsersR
, menuItemAccessCallback = return True -- Creates a LOOP: (Authorized ==) <$> isAuthorized UsersR False
, menuItemAccessCallback' = return True -- Creates a LOOP: (Authorized ==) <$> isAuthorized UsersR False
}
]
defaultLinkLayout :: [MenuTypes] -> Widget -> Handler Html
defaultLinkLayout = defaultMenuLayout . (defaultLinks ++)
defaultMenuLayout :: [MenuTypes] -> Widget -> Handler Html
defaultMenuLayout menu widget = do
master <- getYesod
mmsgs <- getMessages
mcurrentRoute <- getCurrentRoute
-- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance.
(title, parents) <- breadcrumbs
menuTypes <- filterM (menuItemAccessCallback . menuItem) menu
-- Lookup Favourites if possible
favourites <- do
muid <- maybeAuthId
case muid of
Nothing -> return []
(Just uid) -> runDB . E.select . E.from $ \(course `E.InnerJoin` courseFavourite) -> do
E.on (course E.^. CourseId E.==. courseFavourite E.^. CourseFavouriteCourse)
E.where_ (courseFavourite E.^. CourseFavouriteUser E.==. E.val uid)
E.orderBy [ E.asc $ course E.^. CourseShorthand ]
return course
-- We break up the default layout into two components:
-- default-layout is the contents of the body tag, and
-- default-layout-wrapper is the entire page. Since the final
-- value passed to hamletToRepHtml cannot be a widget, this allows
-- you to use normal widget features in default-layout.
let
navbar :: Widget
navbar = $(widgetFile "widgets/navbar")
asidenav :: Widget
asidenav = $(widgetFile "widgets/asidenav")
breadcrumbs :: Widget
breadcrumbs = $(widgetFile "widgets/breadcrumbs")
pageactionprime :: Widget
pageactionprime = $(widgetFile "widgets/pageactionprime")
-- functions to determine if there are page-actions
isPageActionPrime :: MenuTypes -> Bool
isPageActionPrime (PageActionPrime _) = True
isPageActionPrime _ = False
hasPageActions :: Bool
hasPageActions = any isPageActionPrime menuTypes
pc <- widgetToPageContent $ do
addStylesheetRemote "https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,800,900"
addScript $ StaticR js_featureChecker_js
addScript $ StaticR js_flatpickr_js
addStylesheet $ StaticR css_fonts_css
addStylesheet $ StaticR css_icons_css
addStylesheet $ StaticR css_flatpickr_css
$(widgetFile "default-layout")
$(widgetFile "standalone/modal")
$(widgetFile "standalone/showHide")
$(widgetFile "standalone/sortable")
$(widgetFile "standalone/inputs")
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")
-- How to run database actions.
instance YesodPersist UniWorX where
type YesodPersistBackend UniWorX = SqlBackend

View File

@ -1,6 +1,7 @@
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE MultiParamTypeClasses #-}
@ -26,11 +27,18 @@ import qualified Data.UUID.Cryptographic as UUID
getCourseListR :: Handler TypedContent
getCourseListR = redirect TermShowR
getCourseListTermR :: TermId -> Handler Html
getCourseListTermR tidini = do
getTermCurrentR :: Handler Html
getTermCurrentR = do
termIds <- runDB $ selectKeysList [TermActive ==. True] [] -- [Desc TermName] does not work, since database representation has wrong ordering
case fromNullable termIds of
Nothing -> notFound
(Just (maximum -> tid)) -> getTermCourseListR tid
getTermCourseListR :: TermId -> Handler Html
getTermCourseListR tidini = do
(term,courses) <- runDB $ (,)
<$> get tidini
<*> selectList [CourseTermId ==. tidini] [Asc CourseShorthand]
<*> selectList [CourseTerm ==. tidini] [Asc CourseShorthand]
when (isNothing term) $ do
addMessage "warning" [shamlet| Semester #{toPathPiece tidini} nicht gefunden. |]
redirect TermShowR
@ -39,51 +47,43 @@ getCourseListTermR tidini = do
[ headed "Kürzel" $ (\ckv ->
let c = entityVal ckv
shd = courseShorthand c
tid = courseTermId c
in [whamlet| <a href=@{CourseR tid shd CourseShowR}>#{shd} |] )
tid = courseTerm c
in [whamlet| <a href=@{CourseR tid shd CShowR}>#{shd} |] )
-- , headed "Institut" $ [shamlet| #{course} |]
, headed "Beginn Anmeldung" $ fromString.(maybe "" formatTimeGerWD).courseRegisterFrom.entityVal
, headed "Ende Anmeldung" $ fromString.(maybe "" formatTimeGerWD).courseRegisterTo.entityVal
, headed "Teilnehmer" $ (\ckv -> do
let cid = entityKey ckv
partiNum <- handlerToWidget $ runDB $ count [CourseParticipantCourseId ==. cid]
partiNum <- handlerToWidget $ runDB $ count [CourseParticipantCourse ==. cid]
[whamlet| #{show partiNum} |]
)
, headed " " $ (\ckv ->
let c = entityVal ckv
shd = courseShorthand c
tid = courseTermId c
tid = courseTerm c
in do
adminLink <- handlerToWidget $ isAuthorized (CourseR tid shd CourseEditR) False
-- if (adminLink==Authorized) then linkButton "Ändern" BCWarning (CourseEditR tid shd) else ""
adminLink <- handlerToWidget $ isAuthorized (CourseR tid shd CEditR) False
-- if (adminLink==Authorized) then linkButton "Ändern" BCWarning (CEditR tid shd) else ""
[whamlet|
$if adminLink == Authorized
<a href=@{CourseR tid shd CourseEditR}>
<a href=@{CourseR tid shd CEditR}>
editieren
|]
)
]
let pageLinks =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Neuer Kurs"
, menuItemIcon = Just "book"
, menuItemRoute = CourseNewR
, menuItemAccessCallback = (== Authorized) <$> isAuthorized CourseNewR False
}
]
let coursesTable = encodeWidgetTable tableSortable colonnadeTerms courses
defaultLinkLayout pageLinks $ do
defaultLayout $ do
setTitle "Semesterkurse"
$(widgetFile "courses")
getCourseShowR :: TermId -> Text -> Handler Html
getCourseShowR tid csh = do
getCShowR :: TermId -> Text -> Handler Html
getCShowR tid csh = do
mbAid <- maybeAuthId
(courseEnt,(schoolMB,participants,mbRegistered)) <- runDB $ do
courseEnt@(Entity cid course) <- getBy404 $ CourseTermShort tid csh
dependent <- (,,)
<$> get (courseSchoolId course) -- join
<*> count [CourseParticipantCourseId ==. cid] -- join
<$> get (courseSchool course) -- join
<*> count [CourseParticipantCourse ==. cid] -- join
<*> (case mbAid of -- TODO: Someone please refactor this late-night mess here!
Nothing -> return False
(Just aid) -> do
@ -92,15 +92,7 @@ getCourseShowR tid csh = do
return $ (courseEnt,dependent)
let course = entityVal courseEnt
(regWidget, regEnctype) <- generateFormPost $ identifyForm "registerBtn" $ registerButton $ mbRegistered
let pageActions =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Übungsblätter"
, menuItemIcon = Nothing
, menuItemRoute = CSheetR tid csh SheetListR
, menuItemAccessCallback = (== Authorized) <$> isAuthorized (CSheetR tid csh SheetListR) False
}
]
defaultLinkLayout pageActions $ do
defaultLayout $ do
setTitle $ [shamlet| #{toPathPiece tid} - #{csh}|]
$(widgetFile "course")
@ -111,8 +103,8 @@ registerButton registered = renderAForm FormStandard $
msg = if registered then "Abmelden" else "Anmelden"
regMsg = msg :: BootstrapSubmit Text
postCourseShowR :: TermId -> Text -> Handler Html
postCourseShowR tid csh = do
postCShowR :: TermId -> Text -> Handler Html
postCShowR tid csh = do
aid <- requireAuthId
(cid, registered) <- runDB $ do
(Entity cid _) <- getBy404 $ CourseTermShort tid csh
@ -130,7 +122,7 @@ postCourseShowR tid csh = do
when (isJust regOk) $ addMessage "success" "Erfolgreich angemeldet!"
(_other) -> return () -- TODO check this!
-- redirect or not?! I guess not, since we want GET now
getCourseShowR tid csh
getCShowR tid csh
getCourseNewR :: Handler Html
getCourseNewR = do
@ -140,13 +132,13 @@ getCourseNewR = do
postCourseNewR :: Handler Html
postCourseNewR = courseEditHandler Nothing
getCourseEditR :: TermId -> Text -> Handler Html
getCourseEditR tid csh = do
getCEditR :: TermId -> Text -> Handler Html
getCEditR tid csh = do
course <- runDB $ getBy $ CourseTermShort tid csh
courseEditHandler course
postCourseEditR :: TermId -> Text -> Handler Html
postCourseEditR = getCourseEditR
postCEditR :: TermId -> Text -> Handler Html
postCEditR = getCEditR
getCourseEditIDR :: CryptoUUIDCourse -> Handler Html
getCourseEditIDR cID = do
@ -163,7 +155,7 @@ courseDeleteHandler = undefined
runDB $ deleteCascade cid -- TODO Sicherheitsabfrage einbauen!
let cti = toPathPiece $ cfTerm res
addMessage "info" [shamlet| Kurs #{cti}/#{cfShort res} wurde gelöscht!|]
redirect $ CourseListTermR $ cfTerm res
redirect $ TermCourseListR $ cfTerm res
-}
courseEditHandler :: Maybe (Entity Course) -> Handler Html
@ -183,8 +175,8 @@ courseEditHandler course = do
, courseDescription = cfDesc res
, courseLinkExternal = cfLink res
, courseShorthand = cfShort res
, courseTermId = cfTerm res
, courseSchoolId = cfSchool res
, courseTerm = cfTerm res
, courseSchool = cfSchool res
, courseCapacity = cfCapacity res
, courseHasRegistration = cfHasReg res
, courseRegisterFrom = cfRegFrom res
@ -199,7 +191,7 @@ courseEditHandler course = do
insert_ $ CourseEdit aid now cid
insert_ $ Lecturer aid cid
addMessageI "info" $ MsgCourseNewOk tident csh
redirect $ CourseListTermR tid
redirect $ TermCourseListR tid
Nothing ->
addMessageI "danger" $ MsgCourseNewDupShort tident csh
@ -226,8 +218,8 @@ courseEditHandler course = do
-- , CourseDescription =. cfDesc res
-- , CourseLinkExternal =. cfLink res
-- , CourseShorthand =. cfShort res -- TODO: change here should generate a warning, or only allowed for Admins?!
-- , CourseTermId =. tid -- TODO: change here should generate a warning, or only allowed for Admins?!
-- , CourseSchoolId =. cfSchool res
-- , CourseTerm =. tid -- TODO: change here should generate a warning, or only allowed for Admins?!
-- , CourseSchool =. cfSchool res
-- , CourseCapacity =. cfCapacity res
-- , CourseRegisterFrom =. cfRegFrom res
-- , CourseRegisterTo =. cfRegTo res
@ -239,8 +231,8 @@ courseEditHandler course = do
, courseDescription = cfDesc res
, courseLinkExternal = cfLink res
, courseShorthand = cfShort res
, courseTermId = cfTerm res
, courseSchoolId = cfSchool res
, courseTerm = cfTerm res
, courseSchool = cfSchool res
, courseCapacity = cfCapacity res
, courseHasRegistration = cfHasReg res
, courseRegisterFrom = cfRegFrom res
@ -254,7 +246,7 @@ courseEditHandler course = do
-- if (isNothing updOkay)
-- then do
addMessageI "info" $ MsgCourseEditOk tident csh
-- redirect $ CourseListTermR tid
-- redirect $ TermCourseListR tid
-- else addMessageI "danger" $ MsgCourseEditDupShort tident csh
(FormFailure _) -> addMessageI "warning" MsgInvalidInput
@ -291,8 +283,8 @@ courseToForm cEntity = CourseForm
, cfDesc = courseDescription course
, cfLink = courseLinkExternal course
, cfShort = courseShorthand course
, cfTerm = courseTermId course
, cfSchool = courseSchoolId course
, cfTerm = courseTerm course
, cfSchool = courseSchool course
, cfCapacity = courseCapacity course
, cfHasReg = courseHasRegistration course
, cfRegFrom = courseRegisterFrom course

View File

@ -20,6 +20,8 @@ import Import hiding (Proxy)
import Data.Proxy
import Handler.Utils
import Yesod.Core.Types (HandlerContents(..), ErrorResponse(..))
import qualified Control.Monad.Catch as E (Handler(..))
@ -30,9 +32,13 @@ class CryptoRoute ciphertext plaintext where
instance CryptoRoute UUID SubmissionId where
cryptoIDRoute _ (CryptoID -> cID) = do
(_ :: SubmissionId) <- decrypt cID
return $ SubmissionR cID
(smid :: SubmissionId) <- decrypt cID
(tid,csh,shn) <- runDB $ do
shid <- submissionSheet <$> get404 smid
Sheet{..} <- get404 shid
Course{..} <- get404 sheetCourse
return (courseTerm, courseShorthand, sheetName)
return $ CSheetR tid csh shn $ SubmissionR cID
class Dispatch ciphertext (x :: [*]) where

View File

@ -43,7 +43,7 @@ getHomeR :: Handler Html
getHomeR = do
(btnWdgt, btnEnctype) <- generateFormPost (buttonForm :: Form CreateButton)
defaultLayout $ do
setTitle "Willkommen zum UniworkY Test!"
setTitle "Willkommen zum Uniworky Test!"
$(widgetFile "home")

View File

@ -1,5 +1,6 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE QuasiQuotes #-}
@ -7,6 +8,7 @@
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE TypeOperators #-}
module Handler.Sheet where
@ -61,6 +63,7 @@ data SheetForm = SheetForm
, sfSolutionFrom :: Maybe UTCTime
, sfSolutionF :: Maybe FileInfo
-- Keine SheetId im Formular!
, sfCorrectors :: [(UserId,Load)]
}
@ -68,8 +71,8 @@ makeSheetForm :: Maybe SheetId -> Maybe SheetForm -> Form SheetForm
makeSheetForm msId template = identForm FIDsheet $ \html -> do
let oldFileIds fType
| Just sId <- msId = fmap setFromList . fmap (map E.unValue) . runDB . E.select . E.from $ \(file `E.InnerJoin` sheetFile) -> do
E.on $ file E.^. FileId E.==. sheetFile E.^. SheetFileFileId
E.where_ $ sheetFile E.^. SheetFileSheetId E.==. E.val sId
E.on $ file E.^. FileId E.==. sheetFile E.^. SheetFileFile
E.where_ $ sheetFile E.^. SheetFileSheet E.==. E.val sId
E.&&. sheetFile E.^. SheetFileType E.==. E.val fType
return (file E.^. FileId)
| otherwise = return Set.empty
@ -88,7 +91,8 @@ makeSheetForm msId template = identForm FIDsheet $ \html -> do
<*> fileAFormOpt (fsb "Hinweis")
<*> aopt utcTimeField (fsb "Lösung ab") (sfSolutionFrom <$> template)
<*> fileAFormOpt (fsb "Lösung")
<* submitButton
<*> formToAForm (correctorForm msId (maybe [] sfCorrectors template))
-- <* submitButton
return $ case result of
FormSuccess sheetResult
| errorMsgs <- validateSheet sheetResult
@ -117,36 +121,15 @@ makeSheetForm msId template = identForm FIDsheet $ \html -> do
-- TODO: continue validation here!!!
] ]
correctorForm :: Maybe SheetId -> [(UserId,Load)] -> MForm Handler (FormResult [(UserId,Load)], [FieldView UniWorX])
correctorForm _msid templates = return mempty -- TODO deprecated
-- Datenbank UserId -> UserName
-- Eingabelist für Colonnade
-- enthält die benötigten Felder
-- FormResult konstruieren
-- Eingabebox für Korrektor hinzufügen
-- Eingabe für Korrekt ausgefüllt: FormMissing zurückschicken um dann Feld hinzuzufügen
fetchSheetAux :: ( BaseBackend backend ~ SqlBackend
, E.SqlSelect b a
, Typeable a, MonadHandler m, IsPersistBackend backend
, PersistQueryRead backend, PersistUniqueRead backend
)
=> (E.SqlExpr (Entity Sheet) -> b)
-> Key Term -> Text -> Text -> ReaderT backend m a
fetchSheetAux prj tid csh shn =
let cachId = encodeUtf8 $ tshow (tid,csh,shn)
in cachedBy cachId $ do
-- Mit Yesod:
-- cid <- getKeyBy404 $ CourseTermShort tid csh
-- getBy404 $ CourseSheet cid shn
-- Mit Esqueleto:
sheetList <- E.select . E.from $ \(course `E.InnerJoin` sheet) -> do
E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourseId
E.where_ $ course E.^. CourseTermId E.==. E.val tid
E.&&. course E.^. CourseShorthand E.==. E.val csh
E.&&. sheet E.^. SheetName E.==. E.val shn
return $ prj sheet
case sheetList of
[sheet] -> return sheet
_other -> notFound
fetchSheet :: TermId -> Text -> Text -> YesodDB UniWorX (Entity Sheet)
fetchSheet = fetchSheetAux id
fetchSheetId :: TermId -> Text -> Text -> YesodDB UniWorX (Key Sheet)
fetchSheetId tid cid shn = E.unValue <$> fetchSheetAux (E.^. SheetId) tid cid shn
-- List Sheets
getSheetListCID :: CourseId -> Handler Html
@ -163,16 +146,16 @@ getSheetList courseEnt = do
let cid = entityKey courseEnt
let course = entityVal courseEnt
let csh = courseShorthand course
let tid = courseTermId course
let tid = courseTerm course
sheets <- runDB $ do
rawSheets <- selectList [SheetCourseId ==. cid] [Desc SheetActiveFrom]
rawSheets <- selectList [SheetCourse ==. cid] [Desc SheetActiveFrom]
forM rawSheets $ \(Entity sid sheet) -> do
let sheetsub = [SubmissionSheetId ==. sid]
let sheetsub = [SubmissionSheet ==. sid]
submissions <- count sheetsub
rated <- count $ (SubmissionRatingTime !=. Nothing):sheetsub
return (sid, sheet, (submissions, rated))
let colBase = mconcat
[ headed "Blatt" $ \(sid,sheet,_) -> linkButton (toWgt $ sheetName sheet) BCLink $ CourseR tid csh $ SheetR $ SheetShowR $ sheetName sheet
[ headed "Blatt" $ \(sid,sheet,_) -> simpleLink (toWgt $ sheetName sheet) $ CSheetR tid csh (sheetName sheet) SShowR
, headed "Abgabe ab" $ toWgt . formatTimeGerWD . sheetActiveFrom . snd3
, headed "Abgabe bis" $ toWgt . formatTimeGerWD . sheetActiveTo . snd3
, headed "Bewertung" $ toWgt . show . sheetType . snd3
@ -180,69 +163,93 @@ getSheetList courseEnt = do
let colAdmin = mconcat -- only show edit button for allowed course assistants
[ headed "Korrigiert" $ toWgt . snd . trd3
, headed "Eingereicht" $ toWgt . fst . trd3
, headed "" $ \s -> linkButton "Edit" BCLink $ CourseR tid csh $ SheetR $ SheetEditR $ sheetName $ snd3 s
, headed "" $ \s -> linkButton "Delete" BCLink $ CourseR tid csh $ SheetR $ SheetDelR $ sheetName $ snd3 s
, headed "" $ \s -> simpleLink "Edit" $ CSheetR tid csh (sheetName $ snd3 s) SEditR
, headed "" $ \s -> simpleLink "Delete" $ CSheetR tid csh (sheetName $ snd3 s) SDelR
]
showAdmin <- case sheets of
((_,firstSheet,_):_) -> do
setUltDestCurrent
(Authorized ==) <$> isAuthorized (CourseR tid csh $ SheetR $ SheetEditR $ sheetName firstSheet) False
(Authorized ==) <$> isAuthorized (CSheetR tid csh (sheetName firstSheet) SEditR) False
_otherwise -> return False
let colSheets = if showAdmin
then colBase `mappend` colAdmin
else colBase
let pageActions =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Neues Übungsblatt"
, menuItemIcon = Nothing
, menuItemRoute = CSheetR tid csh SheetNewR
, menuItemAccessCallback = (== Authorized) <$> isAuthorized CourseNewR False
}
]
defaultLinkLayout pageActions $ do
defaultLayout $ do
setTitle $ toHtml $ T.append "Übungsblätter " csh
if null sheets
then [whamlet|Es wurden noch keine Übungsblätter angelegt.|]
else encodeWidgetTable tableDefault colSheets sheets
-- Show single sheet
getSheetShowR :: TermId -> Text -> Text -> Handler Html
getSheetShowR tid csh shn = do
getSShowR :: TermId -> Text -> Text -> Handler Html
getSShowR tid csh shn = do
entSheet <- runDB $ fetchSheet tid csh shn
let sheet = entityVal entSheet
sid = entityKey entSheet
--
fileNameTypes <- runDB $ E.select $ E.from $
\(sheet `E.InnerJoin` sheetFile `E.InnerJoin` file) -> do
-- Restrict to consistent rows that correspond to each other
E.on (file E.^. FileId E.==. sheetFile E.^. SheetFileFileId)
E.on (sheetFile E.^. SheetFileSheetId E.==. sheet E.^. SheetId)
-- filter to requested file
E.where_ (sheet E.^. SheetId E.==. E.val sid )
-- return desired columns
return $ (file E.^. FileTitle, sheetFile E.^. SheetFileType)
let fileLinks = map (\(E.Value fName, E.Value fType) -> CSheetR tid csh (SheetFileR shn fType fName)) fileNameTypes
-- without Colonnade
-- fileNameTypes <- runDB $ E.select $ E.from $
-- \(sheet `E.InnerJoin` sheetFile `E.InnerJoin` file) -> do
-- -- Restrict to consistent rows that correspond to each other
-- E.on (file E.^. FileId E.==. sheetFile E.^. SheetFileFile)
-- E.on (sheetFile E.^. SheetFileSheet E.==. sheet E.^. SheetId)
-- -- filter to requested file
-- E.where_ (sheet E.^. SheetId E.==. E.val sid )
-- -- return desired columns
-- return $ (file E.^. FileTitle, file E.^. FileModified, sheetFile E.^. SheetFileType)
-- let fileLinks = map (\(E.Value fName, E.Value modified, E.Value fType) -> (CSheetR tid csh (SheetFileR shn fType fName),modified)) fileNameTypes
-- with Colonnade
let fileData (sheet `E.InnerJoin` sheetFile `E.InnerJoin` file) = do
-- Restrict to consistent rows that correspond to each other
E.on (file E.^. FileId E.==. sheetFile E.^. SheetFileFile)
E.on (sheetFile E.^. SheetFileSheet E.==. sheet E.^. SheetId)
-- filter to requested file
E.where_ $ sheet E.^. SheetId E.==. E.val sid
E.&&. E.not_ (E.isNothing $ file E.^. FileContent)
-- return desired columns
return $ (file E.^. FileTitle, file E.^. FileModified, sheetFile E.^. SheetFileType)
let colonnadeFiles = mconcat
[ sortable (Just "type") "Typ" $ \(_,_, E.Value ftype) -> textCell $ toPathPiece ftype
, sortable (Just "path") "Dateiname" $ anchorCell (\(E.Value fName,_,E.Value fType) -> CSheetR tid csh shn (SFileR fType fName))
(\(E.Value fName,_,_) -> str2widget fName)
, sortable (Just "time") "Modifikation" $ \(_,E.Value modified,_) -> stringCell $ formatTimeGerWDT (modified :: UTCTime)
]
fileTable <- dbTable def $ DBTable
{ dbtSQLQuery = fileData
, dbtColonnade = colonnadeFiles
, dbtAttrs = tableDefault
, dbtIdent = "files" :: Text
, dbtSorting = [ ( "type"
, SortColumn $ \(sheet `E.InnerJoin` sheetFile `E.InnerJoin` file) -> sheetFile E.^. SheetFileType
)
, ( "path"
, SortColumn $ \(sheet `E.InnerJoin` sheetFile `E.InnerJoin` file) -> file E.^. FileTitle
)
, ( "time"
, SortColumn $ \(sheet `E.InnerJoin` sheetFile `E.InnerJoin` file) -> file E.^. FileModified
)
]
}
defaultLayout $ do
setTitle $ toHtml $ T.append "Übung " $ sheetName sheet
$(widgetFile "sheetShow")
[whamlet| Under Construction !!! |] -- TODO
getSheetFileR :: TermId -> Text -> Text -> SheetFileType -> FilePath -> Handler TypedContent
getSheetFileR tid csh shn typ title = do
getSFileR :: TermId -> Text -> Text -> SheetFileType -> FilePath -> Handler TypedContent
getSFileR tid csh shn typ title = do
content <- runDB $ E.select $ E.from $
\(course `E.InnerJoin` sheet `E.InnerJoin` sheetFile `E.InnerJoin` file) -> do
-- Restrict to consistent rows that correspond to each other
E.on (file E.^. FileId E.==. sheetFile E.^. SheetFileFileId)
E.on (sheetFile E.^. SheetFileSheetId E.==. sheet E.^. SheetId)
E.on (sheet E.^. SheetCourseId E.==. course E.^. CourseId)
E.on (file E.^. FileId E.==. sheetFile E.^. SheetFileFile)
E.on (sheetFile E.^. SheetFileSheet E.==. sheet E.^. SheetId)
E.on (sheet E.^. SheetCourse E.==. course E.^. CourseId)
-- filter to requested file
E.where_ ((file E.^. FileTitle E.==. E.val title)
E.&&. (sheetFile E.^. SheetFileType E.==. E.val typ )
E.&&. (sheet E.^. SheetName E.==. E.val shn )
E.&&. (course E.^. CourseShorthand E.==. E.val csh )
E.&&. (course E.^. CourseTermId E.==. E.val tid )
E.&&. (course E.^. CourseTerm E.==. E.val tid )
)
-- return desired columns
return $ file E.^. FileContent
@ -266,13 +273,13 @@ postSheetNewR :: TermId -> Text -> Handler Html
postSheetNewR = getSheetNewR
getSheetEditR :: TermId -> Text -> Text -> Handler Html
getSheetEditR tid csh shn = do
getSEditR :: TermId -> Text -> Text -> Handler Html
getSEditR tid csh shn = do
(sheetEnt, sheetFileIds) <- runDB $ do
ent <- fetchSheet tid csh shn
fIds <- fmap setFromList . fmap (map E.unValue) . E.select . E.from $ \(file `E.InnerJoin` sheetFile) -> do
E.on $ file E.^. FileId E.==. sheetFile E.^. SheetFileFileId
E.where_ $ sheetFile E.^. SheetFileSheetId E.==. E.val (entityKey ent)
E.on $ file E.^. FileId E.==. sheetFile E.^. SheetFileFile
E.where_ $ sheetFile E.^. SheetFileSheet E.==. E.val (entityKey ent)
E.&&. sheetFile E.^. SheetFileType E.==. E.val SheetExercise
return (file E.^. FileId)
return (ent, fIds)
@ -292,6 +299,7 @@ getSheetEditR tid csh shn = do
, sfHintF = Nothing -- TODO
, sfSolutionFrom = sheetSolutionFrom
, sfSolutionF = Nothing -- TODO
, sfCorrectors = [] -- TODO read correctors from list
}
let action newSheet = do
replaceRes <- myReplaceUnique sid $ newSheet
@ -300,8 +308,8 @@ getSheetEditR tid csh shn = do
(Just _err) -> return $ Nothing -- More specific error message for edit old sheet could go here
handleSheetEdit tid csh (Just sid) template action
postSheetEditR :: TermId -> Text -> Text -> Handler Html
postSheetEditR = getSheetEditR
postSEditR :: TermId -> Text -> Text -> Handler Html
postSEditR = getSEditR
handleSheetEdit :: TermId -> Text -> Maybe SheetId -> Maybe SheetForm -> (Sheet -> YesodDB UniWorX (Maybe SheetId)) -> Handler Html
handleSheetEdit tid csh msId template dbAction = do
@ -315,7 +323,7 @@ handleSheetEdit tid csh msId template dbAction = do
actTime <- liftIO getCurrentTime
cid <- getKeyBy404 $ CourseTermShort tid csh
let newSheet = Sheet
{ sheetCourseId = cid
{ sheetCourse = cid
, sheetName = sfName
, sheetDescription = sfDescription
, sheetType = sfType
@ -337,44 +345,44 @@ handleSheetEdit tid csh msId template dbAction = do
insert_ $ SheetEdit aid actTime sid
addMessageI "info" $ MsgSheetEditOk tident csh sfName
return True
when saveOkay $ redirect $ CSheetR tid csh $ SheetShowR sfName -- redirect must happen outside of runDB
when saveOkay $ redirect $ CSheetR tid csh sfName SShowR -- redirect must happen outside of runDB
(FormFailure msgs) -> forM_ msgs $ (addMessage "warning") . toHtml
_ -> return ()
let pageTitle = maybe (MsgSheetTitleNew tident csh)
(MsgSheetTitle tident csh) mbshn
let formTitle = pageTitle
let formText = Nothing :: Maybe UniWorXMessage
actionUrl <- fromMaybe (CSheetR tid csh SheetNewR) <$> getCurrentRoute
actionUrl <- fromMaybe (CourseR tid csh SheetNewR) <$> getCurrentRoute
defaultLayout $ do
setTitleI pageTitle
$(widgetFile "formPageI18n")
getSheetDelR :: TermId -> Text -> Text -> Handler Html
getSheetDelR tid csh shn = do
getSDelR :: TermId -> Text -> Text -> Handler Html
getSDelR tid csh shn = do
let tident = unTermKey tid
((result,formWidget), formEnctype) <- runFormPost (buttonForm :: Form BtnDelete)
case result of
(FormSuccess BtnAbort) -> redirectUltDest $ CSheetR tid csh $ SheetShowR shn
(FormSuccess BtnAbort) -> redirectUltDest $ CSheetR tid csh shn SShowR
(FormSuccess BtnDelete) -> do
runDB $ fetchSheetId tid csh shn >>= deleteCascade
-- TODO: deleteCascade löscht aber nicht die hochgeladenen Dateien!!!
setMessageI $ MsgSheetDelOk tident csh shn
redirect $ CSheetR tid csh SheetListR
addMessageI "info" $ MsgSheetDelOk tident csh shn
redirect $ CourseR tid csh SheetListR
_other -> do
submissionno <- runDB $ do
sid <- fetchSheetId tid csh shn
count [SubmissionSheetId ==. sid]
count [SubmissionSheet ==. sid]
let formTitle = MsgSheetDelTitle tident csh shn
let formText = Just $ MsgSheetDelText submissionno
let actionUrl = CSheetR tid csh $ SheetDelR shn
let actionUrl = CSheetR tid csh shn SDelR
defaultLayout $ do
setTitleI $ MsgSheetTitle tident csh shn
$(widgetFile "formPageI18n")
postSheetDelR :: TermId -> Text -> Text -> Handler Html
postSheetDelR = getSheetDelR
postSDelR :: TermId -> Text -> Text -> Handler Html
postSDelR = getSDelR
@ -389,8 +397,8 @@ insertSheetFile sid ftype finfo = do
insertSheetFile' :: SheetId -> SheetFileType -> Source Handler (Either FileId File) -> YesodDB UniWorX ()
insertSheetFile' sid ftype fs = do
oldFileIds <- fmap setFromList . fmap (map E.unValue) . E.select . E.from $ \(file `E.InnerJoin` sheetFile) -> do
E.on $ file E.^. FileId E.==. sheetFile E.^. SheetFileFileId
E.where_ $ sheetFile E.^. SheetFileSheetId E.==. E.val sid
E.on $ file E.^. FileId E.==. sheetFile E.^. SheetFileFile
E.where_ $ sheetFile E.^. SheetFileSheet E.==. E.val sid
E.&&. sheetFile E.^. SheetFileType E.==. E.val ftype
return (file E.^. FileId)
keep <- execWriterT . runConduit $ transPipe (lift . lift) fs =$= C.mapM_ finsert

View File

@ -1,6 +1,8 @@
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE ParallelListComp #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE MultiParamTypeClasses #-}
@ -9,6 +11,8 @@
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE PatternGuards #-}
{-# LANGUAGE TypeOperators #-}
module Handler.Submission where
@ -24,8 +28,11 @@ import Control.Monad.Trans.Maybe
import Control.Monad.State.Class
import Control.Monad.Trans.State.Strict (StateT)
import qualified Data.Maybe
import qualified Data.Text 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
@ -33,20 +40,310 @@ import qualified Database.Esqueleto as E
import qualified Data.Conduit.List as Conduit
import Data.Conduit.ResumableSink
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Bifunctor
import System.FilePath
import Colonnade
import Colonnade hiding (bool)
import Yesod.Colonnade
import qualified Text.Blaze.Html5.Attributes as HA
numberOfSubmissionEditDates :: Int64
numberOfSubmissionEditDates = 3 -- for debugging only, should be 1 in production.
makeSubmissionForm :: Maybe SubmissionId -> Bool -> SheetGroup -> [Text] -> Form (Maybe (Source Handler File), [Text])
makeSubmissionForm msmid unpackZips grouping buddies = identForm FIDsubmission $ \html -> do
flip (renderAForm FormStandard) html $ (,)
<$> (bool (\f fs _ -> Just <$> areq f fs Nothing) aopt $ isJust msmid) (zipFileField unpackZips) (fsm $ bool MsgSubmissionFile MsgSubmissionArchive unpackZips) Nothing
<*> (catMaybes <$> sequenceA [bool aforced' aopt editableBuddies textField (fsm $ MsgSubmissionMember g) buddy
| g <- [1..(max groupNr $ length buddies)] -- groupNr might have decreased meanwhile
| buddy <- map (Just . Just) buddies ++ repeat Nothing -- show current buddies
])
<* submitButton
where
(groupNr, editableBuddies)
| Arbitrary{..} <- grouping = (pred maxParticipants, True) -- pred to account for the person submitting
| otherwise = (0, False)
aforced' f fs (Just (Just v)) = Just <$> aforced f fs v
aforced' _ _ _ = error "Cannot happen since groupNr==0 if grouping/=Arbitrary"
getSubmissionNewR, postSubmissionNewR :: TermId -> Text -> Text -> Handler Html
getSubmissionNewR = postSubmissionNewR
postSubmissionNewR tid csh shn = submissionHelper tid csh shn NewSubmission
getSubmissionR, postSubmissionR :: TermId -> Text -> Text -> CryptoUUIDSubmission -> Handler Html
getSubmissionR = postSubmissionR
postSubmissionR tid csh shn cid = submissionHelper tid csh shn $ ExistingSubmission cid
getSubmissionOwnR :: TermId -> Text -> Text -> Handler Html
getSubmissionOwnR tid csh shn = do
authId <- requireAuthId
sid <- runDB $ do
shid <- fetchSheetId tid csh shn
submissions <- E.select . E.from $ \(submission `E.InnerJoin` submissionUser) -> do
E.on (submission E.^. SubmissionId E.==. submissionUser E.^. SubmissionUserSubmission)
E.where_ $ submissionUser E.^. SubmissionUserUser E.==. E.val authId
E.&&. submission E.^. SubmissionSheet E.==. E.val shid
return $ submission E.^. SubmissionId
case submissions of
((E.Value sid):_) -> return sid
[] -> notFound
cID <- encrypt sid
redirect . CourseR tid csh . SheetR shn $ SubmissionR cID
submissionHelper :: TermId -> Text -> Text -> SubmissionMode -> Handler Html
submissionHelper tid csh shn (SubmissionMode mcid) = do
uid <- requireAuthId
msmid <- traverse decrypt mcid
(Entity shid Sheet{..}, buddies, lastEdits) <- runDB $ do
sheet@(Entity shid Sheet{..}) <- fetchSheet tid csh shn
case msmid of
Nothing -> do
submissions <- E.select . E.from $ \(submission `E.InnerJoin` submissionUser) -> do
E.on (submission E.^. SubmissionId E.==. submissionUser E.^. SubmissionUserSubmission)
E.where_ $ submissionUser E.^. SubmissionUserUser E.==. E.val uid
E.&&. submission E.^. SubmissionSheet E.==. E.val shid
return $ submission E.^. SubmissionId
-- $logDebugS "Submission.DUPLICATENEW" (tshow submissions)
case submissions of
[] -> do
-- fetch buddies from previous submission in this course
buddies <- E.select . E.from $ \(submissionUser `E.InnerJoin` user) -> do
E.on (submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId)
let oldids = E.subList_select . E.from $ \(sheet `E.InnerJoin` submission `E.InnerJoin` submissionUser `E.InnerJoin` submissionEdit) -> do
E.on (submissionEdit E.^. SubmissionEditSubmission E.==. submission E.^. SubmissionId)
E.on (submissionUser E.^. SubmissionUserSubmission E.==. submission E.^. SubmissionId)
E.on (sheet E.^. SheetId E.==. submission E.^. SubmissionSheet)
E.where_ $ submissionUser E.^. SubmissionUserUser E.==. E.val uid
E.&&. sheet E.^. SheetCourse E.==. E.val sheetCourse
E.orderBy [E.desc $ submissionEdit E.^. SubmissionEditTime]
E.limit 1
return $ submission E.^. SubmissionId
E.where_ $ submissionUser E.^. SubmissionUserSubmission `E.in_` oldids
E.&&. submissionUser E.^. SubmissionUserUser E.!=. E.val uid
E.orderBy [E.asc $ user E.^. UserEmail]
return $ user E.^. UserEmail
return (sheet,buddies,[])
(E.Value smid:_) -> do
cID <- encrypt smid
addMessageI "info" $ MsgSubmissionAlreadyExists
redirect $ CSheetR tid csh shn $ SubmissionR cID
(Just smid) -> do
shid' <- submissionSheet <$> get404 smid
when (shid /= shid') $ invalidArgsI [MsgSubmissionWrongSheet]
-- fetch buddies from current submission
buddies <- E.select . E.from $ \(submissionUser `E.InnerJoin` user) -> do
E.on (submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId)
E.where_ $ submissionUser E.^. SubmissionUserSubmission E.==. E.val smid
E.&&. submissionUser E.^. SubmissionUserUser E.!=. E.val uid
E.orderBy [E.asc $ user E.^. UserEmail]
return $ user E.^. UserEmail
-- mLastEdit <- selectFirst [SubmissionEditSubmission ==. smid] [Desc SubmissionEditTime]
lastEditValues <- E.select . E.from $ \(user `E.InnerJoin` submissionEdit) -> do
E.on (user E.^. UserId E.==. submissionEdit E.^. SubmissionEditUser)
E.where_ $ submissionEdit E.^. SubmissionEditSubmission E.==. E.val smid
E.orderBy [E.desc $ submissionEdit E.^. SubmissionEditTime]
E.limit numberOfSubmissionEditDates
return $ (user E.^. UserDisplayName, submissionEdit E.^. SubmissionEditTime)
let lastEdits = map (bimap E.unValue E.unValue) lastEditValues
return (sheet,buddies,lastEdits)
let unpackZips = True -- undefined -- TODO
((res,formWidget), formEnctype) <- runFormPost $ makeSubmissionForm msmid unpackZips sheetGrouping $ map E.unValue buddies
mCID <- runDB $ do
res' <- case res of
(FormMissing ) -> return $ FormMissing
(FormFailure failmsgs) -> return $ FormFailure failmsgs
(FormSuccess (mFiles,[])) -> return $ FormSuccess (mFiles,[]) -- Type change
(FormSuccess (mFiles, (map CI.mk -> gEMails@(_:_)))) -- Validate AdHoc Group Members
| (Arbitrary {..}) <- sheetGrouping
, length gEMails < maxParticipants -> do -- < since submitting user is already accounted for
let gemails = map CI.foldedCase gEMails
prep :: [(E.Value Text, (E.Value UserId, E.Value Bool, E.Value Bool))] -> Map (CI Text) (Maybe (UserId, Bool, Bool))
prep ps = Map.fromList $ map (, Nothing) gEMails ++ [(CI.mk 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
E.where_ $ (E.lower_ $ user E.^. UserEmail) `E.in_` E.valList gemails
let
isParticipant = E.sub_select . E.from $ \courseParticipant -> do
E.where_ $ user E.^. UserId E.==. courseParticipant E.^. CourseParticipantUser
E.&&. courseParticipant E.^. CourseParticipantCourse E.==. E.val sheetCourse
return $ E.countRows E.>. E.val (0 :: Int64)
hasSubmitted = E.sub_select . E.from $ \(submissionUser `E.InnerJoin` submission) -> do
E.on $ submissionUser E.^. SubmissionUserSubmission E.==. submission E.^. SubmissionId
E.where_ $ submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId
E.&&. submission E.^. SubmissionSheet E.==. E.val shid
return $ E.countRows E.>. E.val (0 :: Int64)
return (user E.^. UserEmail, (user E.^. UserId, isParticipant, hasSubmitted))
$logDebugS "SUBMISSION.AdHocGroupValidation" $ tshow participants
mr <- getMessageRender
let failmsgs = flip Map.foldMapWithKey participants $ \email -> \case
Nothing -> [mr $ MsgEMailUnknown $ CI.original email]
(Just (_,False,_)) -> [mr $ MsgNotAParticipant (CI.original email) (unTermKey tid) csh]
(Just (_,_, True)) -> [mr $ MsgSubmissionAlreadyExistsFor (CI.original email)]
_other -> mempty
return $ if null failmsgs
then FormSuccess (mFiles, foldMap (\(Just (i,_,_)) -> [i]) participants)
else FormFailure failmsgs
| otherwise -> return $ FormFailure ["Mismatching number of group participants"]
case res' of
(FormSuccess (mFiles,(setFromList -> adhocIds))) -> do
now <- liftIO $ getCurrentTime
smid <- do
smid <- case (mFiles, msmid) of
(Nothing, Just smid) -- no new files, existing submission partners updated
-> return smid
(Just files, _) -- new files
-> runConduit $ transPipe lift files .| extractRatings .| sinkSubmission shid uid ((,False) <$> msmid)
_ -> error "Impossible, because of definition of `makeSubmissionForm`"
-- Determine members of pre-registered group
groupUids <- fmap (setFromList . map E.unValue) . E.select . E.from $ \(submissionGroupUser `E.InnerJoin` submissionGroup `E.InnerJoin` submissionGroupUser') -> do
E.on $ submissionGroup E.^. SubmissionGroupId E.==. submissionGroupUser' E.^. SubmissionGroupUserSubmissionGroup
E.on $ submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup E.==. submissionGroup E.^. SubmissionGroupId
E.where_ $ submissionGroupUser E.^. SubmissionGroupUserUser E.==. E.val uid
E.&&. submissionGroup E.^. SubmissionGroupCourse E.==. E.val sheetCourse
return $ submissionGroupUser' E.^. SubmissionGroupUserUser
-- SubmissionUser for all group members (pre-registered & ad-hoc)
let subUsers = Set.insert uid $ groupUids `Set.union` adhocIds
-- remove obsolete old entries
deleteWhere [SubmissionUserSubmission ==. smid, SubmissionUserUser /<-. setToList subUsers]
-- maybe add current users
forM_ subUsers $ \uid' -> void . insertUnique $ SubmissionUser uid' smid
return smid
cID <- encrypt smid
return $ Just cID
(FormFailure msgs) -> Nothing <$ forM_ msgs (addMessage "warning" . toHtml)
_other -> return Nothing
case mCID of
Just cID -> redirect $ CSheetR tid csh shn $ SubmissionR cID
Nothing -> return ()
mArCid <- fmap ZIPArchiveName <$> traverse encrypt msmid
let pageTitle = MsgSubmissionTitle (unTermKey tid) csh shn
let formTitle = pageTitle
let formText = Nothing :: Maybe UniWorXMessage
actionUrl <- Data.Maybe.fromJust <$> getCurrentRoute
-- Maybe construct a table to display uploaded archive files
let colonnadeFiles cid = mconcat
-- [ sortable (Just "type") "Typ" $ \(_,_, E.Value ftype) -> textCell $ toPathPiece ftype
[ sortable (Just "path") "Dateiname" $ anchorCell (\(Entity _ File{..}) -> SubmissionDownloadSingleR cid fileTitle)
(\(Entity _ File{..}) -> str2widget fileTitle)
, sortable (Just "time") "Modifikation" $ \(Entity _ File{..}) -> stringCell $ formatTimeGerWDT fileModified
]
smid2ArchiveTable (smid,cid) = DBTable
{ dbtSQLQuery = submissionFileQuery smid
, dbtColonnade = colonnadeFiles cid
, dbtAttrs = tableDefault
, dbtIdent = "files" :: Text
, dbtSorting = [ ( "path"
, SortColumn $ \(sf `E.InnerJoin` f) -> f E.^. FileTitle
)
, ( "time"
, SortColumn $ \(sf `E.InnerJoin` f) -> f E.^. FileModified
)
]
, dbtFilter = []
}
mFileTable <- traverse (dbTable def) . fmap smid2ArchiveTable $ (,) <$> msmid <*> mcid
defaultLayout $ do
setTitleI pageTitle
$(widgetFile "formPageI18n")
[whamlet|
$maybe arCid <- mArCid
<hr>
<h2>
<a href=@{SubmissionDownloadArchiveR arCid}>Archiv
$forall (name,time) <- lastEdits
<div>last edited by #{name} at #{formatTimeGerDTlong time}
$maybe fileTable <- mFileTable
<h3>Enthaltene Dateien:
^{fileTable}
|]
submissionFileSource :: SubmissionId -> Source (YesodDB UniWorX) (Entity File)
submissionFileSource = E.selectSource . E.from . submissionFileQuery
submissionFileQuery :: SubmissionId -> E.SqlExpr (Entity SubmissionFile) `E.InnerJoin` E.SqlExpr (Entity File)
-> E.SqlQuery (E.SqlExpr (Entity File))
submissionFileQuery submissionID (sf `E.InnerJoin` f) = E.distinctOnOrderBy [E.asc $ f E.^. FileTitle] $ do
E.on (f E.^. FileId E.==. sf E.^. SubmissionFileFile)
E.where_ $ sf E.^. SubmissionFileSubmission E.==. E.val submissionID
E.where_ . E.not_ $ sf E.^. SubmissionFileIsDeletion -- TODO@gk: won't work as intended! Fix with refactor
E.orderBy [E.desc $ sf E.^. SubmissionFileIsUpdate] -- E.desc returns corrector updated data first
return f
getSubmissionDownloadSingleR :: CryptoUUIDSubmission -> FilePath -> Handler TypedContent
getSubmissionDownloadSingleR cID path = do
submissionID <- decrypt cID
cID' <- encrypt submissionID
runDB $ do
isRating <- maybe False (== submissionID) <$> isRatingFile path
case isRating of
True -> do
file <- runMaybeT $ lift . ratingFile cID' =<< MaybeT (getRating submissionID)
maybe notFound (return . toTypedContent . Text.decodeUtf8) $ fileContent =<< file
False -> do
results <- E.select . E.from $ \(sf `E.InnerJoin` f) -> E.distinctOnOrderBy [E.asc $ f E.^. FileTitle] $ do
E.on (f E.^. FileId E.==. sf E.^. SubmissionFileFile)
E.where_ (sf E.^. SubmissionFileSubmission E.==. E.val submissionID)
E.where_ (f E.^. FileTitle E.==. E.val path)
E.where_ . E.not_ . E.isNothing $ f E.^. FileContent
E.where_ . E.not_ $ sf E.^. SubmissionFileIsDeletion
E.orderBy [E.desc $ sf E.^. SubmissionFileIsUpdate]
return f
let fileName = Text.pack $ takeFileName path
case results of
[Entity _ File{ fileContent = Just c }] -> return $ TypedContent (defaultMimeLookup fileName) (toContent c)
_ -> notFound
getSubmissionDownloadArchiveR :: ZIPArchiveName SubmissionId -> Handler TypedContent
getSubmissionDownloadArchiveR (ZIPArchiveName cID) = do
submissionID <- decrypt cID
cUUID <- encrypt submissionID
respondSourceDB "application/zip" $ do
rating <- lift $ getRating submissionID
case rating of
Nothing -> lift notFound
Just rating' -> do
let fileEntitySource' :: Source (YesodDB UniWorX) File
fileEntitySource' = submissionFileSource submissionID =$= Conduit.map entityVal >> yieldM (ratingFile cID rating')
info = ZipInfo { zipComment = Text.encodeUtf8 . tshow $ ciphertext (cUUID :: CryptoUUIDSubmission) }
fileEntitySource' =$= produceZip info =$= Conduit.map toFlushBuilder
-----------------------------------------------------------------------------------------------
------------------------- DEMO BELOW
submissionTable :: MForm Handler (FormResult [SubmissionId], Widget)
submissionTable = do
subs <- lift . runDB $ E.select . E.from $ \(sub `E.InnerJoin` sheet `E.InnerJoin` course) -> do
E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourseId
E.on $ sheet E.^. SheetId E.==. sub E.^. SubmissionSheetId
E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse
E.on $ sheet E.^. SheetId E.==. sub E.^. SubmissionSheet
return (sub, sheet, course)
@ -54,9 +351,9 @@ submissionTable = do
(,,) <$> encrypt submissionId <*> encrypt submissionId <*> pure s
let
anchorCourse (_, _, (_, _, Entity _ Course{..})) = CourseR courseTermId courseShorthand CourseShowR
anchorCourse (_, _, (_, _, Entity _ Course{..})) = CourseR courseTerm courseShorthand CShowR
courseText (_, _, (_, _, Entity _ Course{..})) = toWidget courseName
anchorSubmission (_, cUUID, _) = SubmissionR cUUID
anchorSubmission (_, cUUID, _) = SubmissionDemoR cUUID
submissionText (cID, _, _) = toWidget . toPathPiece . CI.foldedCase $ ciphertext cID
colonnade = mconcat
[ headed "Abgabe-ID" $ anchorCell anchorSubmission submissionText
@ -91,7 +388,7 @@ postSubmissionListR = do
Just sink -> return sink
Nothing -> do
Submission{..} <- lift $ get404 sId
return . newResumableSink $ sinkSubmission submissionSheetId userId (Just (sId, isUpdate))
return . newResumableSink $ sinkSubmission submissionSheet userId (Just (sId, isUpdate))
sink' <- lift $ yield val ++$$ sink
case sink' of
Left _ -> error "sinkSubmission returned prematurely"
@ -115,39 +412,7 @@ postSubmissionListR = do
defaultLayout $(widgetFile "submission-list")
getSubmissionDownloadSingleR :: CryptoUUIDSubmission -> FilePath -> Handler TypedContent
getSubmissionDownloadSingleR cID path = do
submissionID <- decrypt cID
cID' <- encrypt submissionID
runDB $ do
isRating <- maybe False (== submissionID) <$> isRatingFile path
case isRating of
True -> do
file <- runMaybeT $ lift . ratingFile cID' =<< MaybeT (getRating submissionID)
maybe notFound (return . toTypedContent . Text.decodeUtf8) $ fileContent =<< file
False -> do
results <- E.select . E.from $ \(sf `E.InnerJoin` f) -> E.distinctOnOrderBy [E.asc $ f E.^. FileTitle] $ do
E.on (f E.^. FileId E.==. sf E.^. SubmissionFileFileId)
E.where_ (sf E.^. SubmissionFileSubmissionId E.==. E.val submissionID)
E.where_ (f E.^. FileTitle E.==. E.val path)
E.where_ . E.not_ . E.isNothing $ f E.^. FileContent
E.where_ . E.not_ $ sf E.^. SubmissionFileIsDeletion
E.orderBy [E.desc $ sf E.^. SubmissionFileIsUpdate]
return f
let fileName = Text.pack $ takeFileName path
case results of
[Entity _ File{ fileContent = Just c }] -> return $ TypedContent (defaultMimeLookup fileName) (toContent c)
_ -> notFound
submissionFileSource :: SubmissionId -> Source (YesodDB UniWorX) (Entity File)
submissionFileSource submissionID = E.selectSource . E.from $ \(sf `E.InnerJoin` f) -> E.distinctOnOrderBy [E.asc $ f E.^. FileTitle] $ do
E.on (f E.^. FileId E.==. sf E.^. SubmissionFileFileId)
E.where_ $ sf E.^. SubmissionFileSubmissionId E.==. E.val submissionID
E.where_ . E.not_ $ sf E.^. SubmissionFileIsDeletion
E.orderBy [E.desc $ sf E.^. SubmissionFileIsUpdate]
return f
postSubmissionDownloadMultiArchiveR :: Handler TypedContent
postSubmissionDownloadMultiArchiveR = do
@ -193,27 +458,12 @@ postSubmissionDownloadMultiArchiveR = do
mapM_ fileEntitySource' ratedSubmissions =$= produceZip def =$= Conduit.map toFlushBuilder
getSubmissionDownloadArchiveR :: FilePath -> Handler TypedContent
getSubmissionDownloadArchiveR path = do
let (baseName, ext) = splitExtension path
cID :: CryptoFileNameSubmission
cID = CryptoID $ CI.mk baseName
unless (ext == ".zip") notFound
submissionID <- decrypt cID
cUUID <- encrypt submissionID
respondSourceDB "application/zip" $ do
rating <- lift $ getRating submissionID
case rating of
Nothing -> lift notFound
Just rating' -> do
let fileEntitySource' :: Source (YesodDB UniWorX) File
fileEntitySource' = submissionFileSource submissionID =$= Conduit.map entityVal >> yieldM (ratingFile cID rating')
info = ZipInfo { zipComment = Text.encodeUtf8 . tshow $ ciphertext (cUUID :: CryptoUUIDSubmission) }
fileEntitySource' =$= produceZip info =$= Conduit.map toFlushBuilder
getSubmissionR, postSubmissionR :: CryptoUUIDSubmission -> Handler Html
getSubmissionR = postSubmissionR
postSubmissionR cID = do
getSubmissionDemoR, postSubmissionDemoR :: CryptoUUIDSubmission -> Handler Html
getSubmissionDemoR = postSubmissionDemoR
postSubmissionDemoR cID = do
submissionId <- decrypt cID
((uploadResult, uploadWidget), uploadEnctype) <- runFormPost . renderAForm FormStandard $ (,)
@ -238,12 +488,12 @@ postSubmissionR cID = do
yieldM $ do
fileContent <- Just <$> runConduit (fileSource fInfo =$= foldC)
return File{..}
submissionId' <- runConduit $ source =$= extractRatings =$= sinkSubmission submissionSheetId userId (Just (submissionId, isUpdate))
submissionId' <- runConduit $ source =$= extractRatings =$= sinkSubmission submissionSheet userId (Just (submissionId, isUpdate))
get404 submissionId'
files <- E.select . E.from $ \(sf `E.InnerJoin` f) -> E.distinctOnOrderBy [E.asc $ f E.^. FileTitle] $ do
E.on (f E.^. FileId E.==. sf E.^. SubmissionFileFileId)
E.where_ (sf E.^. SubmissionFileSubmissionId E.==. E.val submissionId)
E.on (f E.^. FileId E.==. sf E.^. SubmissionFileFile)
E.where_ (sf E.^. SubmissionFileSubmission E.==. E.val submissionId)
E.orderBy [E.desc $ sf E.^. SubmissionFileIsUpdate]
return (f, sf)
return (submission, files)

View File

@ -1,11 +1,13 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NoImplicitPrelude
, OverloadedStrings
, OverloadedLists
, RecordWildCards
, TemplateHaskell
, QuasiQuotes
, MultiParamTypeClasses
, TypeFamilies
, FlexibleContexts
#-}
module Handler.Term where
@ -29,18 +31,18 @@ getTermShowR = do
-- return term
--
let
termData = E.from $ \term -> do
termData :: E.SqlExpr (Entity Term) -> E.SqlQuery (E.SqlExpr (Entity Term), E.SqlExpr (E.Value Int64))
termData term = do
-- E.orderBy [E.desc $ term E.^. TermStart ]
let courseCount :: E.SqlExpr (E.Value Int)
courseCount = E.sub_select . E.from $ \course -> do
E.where_ $ term E.^. TermId E.==. course E.^. CourseTermId
let courseCount = E.sub_select . E.from $ \course -> do
E.where_ $ term E.^. TermId E.==. course E.^. CourseTerm
return E.countRows
return (term, courseCount)
selectRep $ do
provideRep $ toJSON . map fst <$> runDB (E.select termData)
provideRep $ toJSON . map fst <$> runDB (E.select $ E.from termData)
provideRep $ do
let colonnadeTerms = mconcat
[ headed "Kürzel" $ \(Entity tid Term{..},_) -> cell $ do
[ sortable Nothing "Kürzel" $ \(Entity tid Term{..},_) -> cell $ do
-- Scrap this if to slow, create term edit page instead
adminLink <- handlerToWidget $ isAuthorized (TermEditExistR tid) False
[whamlet|
@ -50,40 +52,46 @@ getTermShowR = do
$else
#{termToText termName}
|]
, headed "Beginn Vorlesungen" $ \(Entity _ Term{..},_) ->
, sortable (Just "lecture-start") "Beginn Vorlesungen" $ \(Entity _ Term{..},_) ->
stringCell $ formatTimeGerWD termLectureStart
, headed "Ende Vorlesungen" $ \(Entity _ Term{..},_) ->
, sortable (Just "lecture-end") "Ende Vorlesungen" $ \(Entity _ Term{..},_) ->
stringCell $ formatTimeGerWD termLectureEnd
, headed "Aktiv" $ \(Entity _ Term{..},_) ->
, sortable Nothing "Aktiv" $ \(Entity _ Term{..},_) ->
textCell $ bool "" tickmark termActive
, headed "Kursliste" $ \(Entity tid Term{..}, E.Value numCourses) ->
cell [whamlet|
<a href=@{CourseListTermR tid}>
#{show numCourses} Kurse
|]
, headed "Semesteranfang" $ \(Entity _ Term{..},_) ->
, sortable Nothing "Kursliste" $ anchorCell
(\(Entity tid _, _) -> TermCourseListR tid)
(\(_, E.Value numCourses) -> [whamlet|_{MsgNumCourses numCourses}|])
, sortable (Just "start") "Semesteranfang" $ \(Entity _ Term{..},_) ->
stringCell $ formatTimeGerWD termStart
, headed "Semesterende" $ \(Entity _ Term{..},_) ->
, sortable (Just "end") "Semesterende" $ \(Entity _ Term{..},_) ->
stringCell $ formatTimeGerWD termEnd
, headed "Feiertage im Semester" $ \(Entity _ Term{..},_) ->
, sortable Nothing "Feiertage im Semester" $ \(Entity _ Term{..},_) ->
stringCell $ (intercalate ", ") $ map formatTimeGerWD termHolidays
]
table <- dbTable def $ DBTable
{ dbtSQLQuery = termData
, dbtColonnade = colonnadeTerms
, dbtSorting = mempty
, dbtSorting = [ ( "start"
, SortColumn $ \term -> term E.^. TermStart
)
, ( "end"
, SortColumn $ \term -> term E.^. TermEnd
)
, ( "lecture-start"
, SortColumn $ \term -> term E.^. TermLectureStart
)
, ( "lecture-end"
, SortColumn $ \term -> term E.^. TermLectureEnd
)
]
, dbtFilter = [ ( "active"
, FilterColumn $ \term -> (term E.^. TermActive :: E.SqlExpr (E.Value Bool))
)
]
, dbtAttrs = tableDefault
, dbtIdent = "terms" :: Text
}
let pageActions =
[ PageActionPrime $ MenuItem
{ menuItemLabel = "Neues Semester"
, menuItemIcon = Nothing
, menuItemRoute = TermEditR
, menuItemAccessCallback = (== Authorized) <$> isAuthorized TermEditR True
}
]
defaultLinkLayout pageActions $ do
defaultLayout $ do
setTitle "Freigeschaltete Semester"
$(widgetFile "terms")

View File

@ -16,7 +16,8 @@ import Handler.Utils.Form as Handler.Utils
import Handler.Utils.Table as Handler.Utils
import Handler.Utils.Table.Pagination as Handler.Utils
import Handler.Utils.Zip as Handler.Utils
import Handler.Utils.Rating as Handler.Utils
import Handler.Utils.Zip as Handler.Utils
import Handler.Utils.Rating as Handler.Utils
import Handler.Utils.Submission as Handler.Utils
import Handler.Utils.Templates as Handler.Utils
import Handler.Utils.Sheet as Handler.Utils
import Handler.Utils.Templates as Handler.Utils

View File

@ -15,9 +15,11 @@ module Handler.Utils.Form where
import Handler.Utils.Form.Types
import Handler.Utils.DateTime
import Import
import qualified Data.Char as Char
import Handler.Utils.DateTime
import Data.String (IsString(..))
import qualified Data.Foldable as Foldable
@ -40,11 +42,13 @@ import qualified Database.Esqueleto.Internal.Sql as E
import qualified Data.Set as Set
import Control.Monad.Writer.Class
------------------------------------------------
-- Unique Form Identifiers to avoid accidents --
------------------------------------------------
data FormIdentifier = FIDcourse | FIDsheet
data FormIdentifier = FIDcourse | FIDsheet | FIDsubmission
deriving (Enum, Eq, Ord, Bounded, Read, Show)
@ -131,6 +135,8 @@ linkButton lbl cls url = [whamlet| <a href=@{url} .btn .#{bcc2txt cls} role=butt
-- |]
-- <input .btn .#{bcc2txt cls} type="submit" value=^{lbl}>
simpleLink :: Widget -> Route UniWorX -> Widget
simpleLink lbl url = [whamlet| <a href=@{url}>^{lbl} |]
buttonField :: Button a => a -> Field Handler a
buttonField btn = Field {fieldParse, fieldView, fieldEnctype}
@ -232,7 +238,6 @@ posIntField d = checkBool (>= 1) (T.append d " muss eine positive Zahl sein.")
minIntField :: (Monad m, Integral i, Show i, RenderMessage (HandlerSite m) FormMessage) => i -> Text -> Field m i
minIntField m d = checkBool (>= m) (T.concat [d," muss größer als ", T.pack $ show m, " sein."]) $ intField
--termField: see Utils.Term
schoolField :: Field Handler SchoolId
@ -327,6 +332,25 @@ sheetGroupAFormReq d _other = -- TODO
-- TODO, offer options to choose between Arbitrary/Registered/NoGroups
Arbitrary <$> areq (natField "Abgabegruppengröße") d (Just 1)
{-
dayTimeField :: FieldSettings UniWorX -> Maybe UTCTime -> Form Handler UTCTime
dayTimeField fs mutc = do
let (mbDay,mbTime) = case mutcs of
Nothing -> return (Nothing,Nothing)
(Just utc) ->
(dayResult, dayView) <- mreq dayField fs
(result, view) <- (,) <$> dayField <*> timeField
where
(mbDay,mbTime)
| (Just utc) <- mutc =
let lt = utcToLocalTime ??? utcs
in (Just $ localDay lt, Just $ localTimeOfDay lt)
| otherwise = (Nothing,Nothing)
-}
utcTimeField :: (Monad m, RenderMessage (HandlerSite m) FormMessage) => Field m UTCTime
-- StackOverflow: dayToUTC <$> (areq (jqueryDayField def {...}) settings Nothing)
-- TODO: Verify whether this is UTC or local time from Browser
@ -355,7 +379,7 @@ utcTimeField = Field
fsm :: RenderMessage UniWorX msg => msg -> FieldSettings UniWorX
fsm = bfs
fsm = bfs -- TODO: get rid of Bootstrap
fsb :: Text -> FieldSettings site
fsb = bfs -- Just to avoid annoying Ambiguous Type Errors
@ -426,3 +450,26 @@ optionsPersistCryptoId filts ords toDisplay = fmap mkOptionList $ do
, optionInternalValue = key
, optionExternalValue = toPathPiece (cId :: CryptoID UUID (Key a))
}) cPairs
mforced :: (site ~ HandlerSite m, MonadHandler m)
=> Field m a -> FieldSettings site -> a -> MForm m (FormResult a, FieldView site)
mforced Field{..} FieldSettings{..} val = do
tell fieldEnctype
name <- maybe newFormIdent return fsName
theId <- lift $ maybe newIdent return fsId
mr <- getMessageRender
let fsAttrs' = fsAttrs <> [("disabled", "")]
return ( FormSuccess val
, FieldView
{ fvLabel = toHtml $ mr fsLabel
, fvTooltip = toHtml <$> fmap mr fsTooltip
, fvId = theId
, fvInput = fieldView theId name fsAttrs' (Right val) False
, fvErrors = Nothing
, fvRequired = False
}
)
aforced :: (RenderMessage site FormMessage, HandlerSite m ~ site, MonadHandler m)
=> Field m a -> FieldSettings site -> a -> AForm m a
aforced field settings val = formToAForm $ second pure <$> mforced field settings val

View File

@ -90,8 +90,8 @@ 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.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse
E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheet
E.where_ $ submission E.^. SubmissionId E.==. E.val submissionId

View File

@ -0,0 +1,52 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
module Handler.Utils.Sheet where
import Import
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Internal.Sql as E
fetchSheetAux :: ( BaseBackend backend ~ SqlBackend
, E.SqlSelect b a
, Typeable a, MonadHandler m, IsPersistBackend backend
, PersistQueryRead backend, PersistUniqueRead backend
)
=> (E.SqlExpr (Entity Sheet) -> b)
-> TermId -> Text -> Text -> ReaderT backend m a
fetchSheetAux prj tid csh shn =
let cachId = encodeUtf8 $ tshow (tid,csh,shn)
in cachedBy cachId $ do
-- Mit Yesod:
-- cid <- getKeyBy404 $ CourseTermShort tid csh
-- getBy404 $ CourseSheet cid shn
-- Mit Esqueleto:
sheetList <- E.select . E.from $ \(course `E.InnerJoin` sheet) -> do
E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse
E.where_ $ course E.^. CourseTerm E.==. E.val tid
E.&&. course E.^. CourseShorthand E.==. E.val csh
E.&&. sheet E.^. SheetName E.==. E.val shn
return $ prj sheet
case sheetList of
[sheet] -> return sheet
_other -> notFound
fetchSheet :: TermId -> Text -> Text -> YesodDB UniWorX (Entity Sheet)
fetchSheet = fetchSheetAux id
fetchSheetId :: TermId -> Text -> Text -> YesodDB UniWorX (Key Sheet)
fetchSheetId tid cid shn = E.unValue <$> fetchSheetAux (E.^. SheetId) tid cid shn
fetchSheetIdCourseId :: TermId -> Text -> Text -> YesodDB UniWorX (Key Sheet, Key Course)
fetchSheetIdCourseId tid cid shn = bimap E.unValue E.unValue <$> fetchSheetAux ((,) <$> (E.^. SheetId) <*> (E.^. SheetCourse)) tid cid shn

View File

@ -15,13 +15,24 @@ module Handler.Utils.Submission
, sinkSubmission
) where
import Import
import Import hiding ((.=))
import Control.Lens
import Control.Lens.Extras (is)
import Utils.Lens
import Control.Monad.State hiding (forM_)
import qualified Control.Monad.Random as Rand
import Data.Maybe
import qualified Data.List as List
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Map (Map, (!?))
import qualified Data.Map as Map
import Data.Monoid (Monoid, Any(..))
import Generics.Deriving.Monoid (memptydefault, mappenddefault)
@ -32,6 +43,70 @@ import qualified Database.Esqueleto as E
import qualified Data.Conduit.List as Conduit
data AssignSubmissionException = NoCorrectorsByProportion
deriving (Typeable, Show)
instance Exception AssignSubmissionException
-- | Assigns all submissions according to sheet corrector loads
assignSubmissions ::
SheetId -- ^ Sheet do distribute to correction
-> YesodDB UniWorX (Set SubmissionId -- ^ assigned submissions
,Set SubmissionId -- ^ unassigend submissions (no tutors by load)
)
assignSubmissions sid = do
correctors <- selectList [SheetCorrectorSheet ==. sid] []
let corrsGroup = filter hasTutorialLoad correctors -- needed as List within Esqueleto
let corrsProp = filter hasPositiveLoad correctors
let countsToLoad' :: UserId -> Bool
countsToLoad' uid = -- refactor by simply using Map.(!)
fromMaybe (error "Called `countsToLoad'` on entity not element of `corrsGroup`") $
Map.lookup uid loadMap
loadMap :: Map UserId Bool
loadMap = Map.fromList [(sheetCorrectorUser,b) | Entity _ SheetCorrector{ sheetCorrectorLoad = (Load {byTutorial = Just b}), .. } <- corrsGroup]
subs <- E.select . E.from $ \(submission `E.LeftOuterJoin` user) -> do
let tutors = E.subList_select . E.from $ \(submissionUser `E.InnerJoin` tutorialUser `E.InnerJoin` tutorial) -> do
-- Uncomment next line for equal chance between tutors, irrespective of the number of students per tutor per submission group
-- E.distinctOn [E.don $ tutorial E.^. TutorialTutor] $ do
E.on (tutorial E.^. TutorialId E.==. tutorialUser E.^. TutorialUserTutorial)
E.on (submissionUser E.^. SubmissionUserUser E.==. tutorialUser E.^. TutorialUserUser)
E.where_ (tutorial E.^. TutorialTutor `E.in_` E.valList (map (sheetCorrectorUser . entityVal) corrsGroup))
return $ tutorial E.^. TutorialTutor
E.on $ user E.?. UserId `E.in_` E.justList tutors
E.where_ $ submission E.^. SubmissionSheet E.==. E.val sid
E.orderBy [E.rand] -- randomize for fair tutor distribution
return (submission E.^. SubmissionId, user) -- , listToMaybe tutors)
queue <- liftIO . Rand.evalRandIO . sequence . repeat $ Rand.weightedMay [ (sheetCorrectorUser, byProportion sheetCorrectorLoad) | Entity _ SheetCorrector{..} <- corrsProp]
let subTutor' :: Map SubmissionId (Maybe UserId)
subTutor' = Map.fromListWith (<|>) $ map (over (_2.traverse) entityKey . over _1 E.unValue) subs
subTutor <- fmap fst . flip execStateT (Map.empty, queue) . forM_ (Map.toList subTutor') $ \case
(smid, Just tutid) -> do
_1 %= Map.insert smid tutid
when (any ((== tutid) . sheetCorrectorUser . entityVal) corrsProp && countsToLoad' tutid) $
_2 %= List.delete (Just tutid)
(smid, Nothing) -> do
(q:qs) <- use _2
_2 .= qs
case q of
Just q -> _1 %= Map.insert smid q
Nothing -> return () -- NOTE: throwM NoCorrectorsByProportion
forM_ (Map.toList subTutor) $ \(smid, tutid) -> update smid [SubmissionRatingBy =. Just tutid]
let assignedSubmissions = Map.keysSet subTutor
unassigendSubmissions = Map.keysSet subTutor' \\ assignedSubmissions
return (assignedSubmissions, unassigendSubmissions)
where
hasPositiveLoad = (> 0) . byProportion . sheetCorrectorLoad . entityVal
hasTutorialLoad = isJust . byTutorial . sheetCorrectorLoad . entityVal
data SubmissionSinkState = SubmissionSinkState
{ sinkSeenRating :: Any
, sinkSubmissionTouched :: Any
@ -65,7 +140,7 @@ sinkSubmission :: SheetId
sinkSubmission sheetId userId mExists = do
now <- liftIO getCurrentTime
let
submissionSheetId = sheetId
submissionSheet = sheetId
submissionRatingPoints = Nothing
submissionRatingComment = Nothing
submissionRatingBy = Nothing
@ -90,8 +165,8 @@ sinkSubmission sheetId userId mExists = do
tell $ mempty{ sinkFilenames = Set.singleton fileTitle }
otherVersions <- 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.on $ sf E.^. SubmissionFileFile E.==. f E.^. FileId
E.where_ $ sf E.^. SubmissionFileSubmission 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)
@ -121,8 +196,8 @@ sinkSubmission sheetId userId mExists = do
_ -> do
fileId <- insert file
insert_ $ SubmissionFile
{ submissionFileSubmissionId = submissionId
, submissionFileFileId = fileId
{ submissionFileSubmission = submissionId
, submissionFileFile = fileId
, submissionFileIsUpdate = isUpdate
, submissionFileIsDeletion = False
}
@ -189,8 +264,8 @@ sinkSubmission sheetId userId mExists = do
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
E.on $ sf E.^. SubmissionFileFile E.==. f E.^. FileId
E.where_ $ sf E.^. SubmissionFileSubmission 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)
@ -202,8 +277,8 @@ sinkSubmission sheetId userId mExists = do
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.on $ sf E.^. SubmissionFileFile E.==. f E.^. FileId
E.where_ $ sf E.^. SubmissionFileSubmission 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
@ -212,13 +287,13 @@ sinkSubmission sheetId userId mExists = do
([], _) -> deleteCascade fileId
(E.Value f:_, False) -> do
insert_ $ SubmissionFile
{ submissionFileSubmissionId = submissionId
, submissionFileFileId = f
{ submissionFileSubmission = submissionId
, submissionFileFile = f
, submissionFileIsUpdate = True
, submissionFileIsDeletion = True
}
(E.Value f:_, True) -> do
update sfId [ SubmissionFileFileId =. f, SubmissionFileIsDeletion =. True ]
update sfId [ SubmissionFileFile =. f, SubmissionFileIsDeletion =. True ]
deleteCascade fileId
when (isUpdate && not (getAny sinkSeenRating)) $

View File

@ -6,30 +6,50 @@
, QuasiQuotes
, LambdaCase
, ViewPatterns
, FlexibleContexts
, FlexibleInstances
, MultiParamTypeClasses
, TypeFamilies
#-}
module Handler.Utils.Table.Pagination
( SortColumn(..), SortDirection(..)
, FilterColumn(..), IsFilterColumn
, DBRow(..), DBOutput
, DBTable(..)
, PaginationSettings(..)
, PSValidator(..)
, Sortable(..), sortable
, dbTable
) where
import Handler.Utils.Table.Pagination.Types
import Import
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Internal.Sql as E (SqlSelect)
import qualified Database.Esqueleto.Internal.Sql as E (SqlSelect,unsafeSqlValue)
import qualified Database.Esqueleto.Internal.Language as E (From)
import Text.Blaze (Attribute)
import qualified Text.Blaze.Html5.Attributes as Html5
import qualified Text.Blaze.Html5 as Html5
import Yesod.Core.Types (Body(..),GWData(..),WidgetT(..))
import qualified Data.Binary.Builder as Builder
import qualified Network.Wai as Wai
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
import Control.Monad.RWS hiding ((<>), Foldable(..), mapM_)
import Control.Monad.RWS hiding ((<>), Foldable(..), mapM_, forM_)
import Data.Map (Map, (!))
import qualified Data.Map as Map
import Colonnade hiding (bool, fromMaybe)
import Data.Profunctor (lmap)
import Colonnade hiding (bool, fromMaybe, singleton)
import Colonnade.Encode
import Yesod.Colonnade
import Text.Hamlet (hamletFile)
@ -37,7 +57,7 @@ import Text.Hamlet (hamletFile)
import Data.Ratio ((%))
data SortColumn = forall a. PersistField a => SortColumn { getSortColumn :: E.SqlExpr (E.Value a) }
data SortColumn t = forall a. PersistField a => SortColumn { getSortColumn :: t -> E.SqlExpr (E.Value a) }
data SortDirection = SortAsc | SortDesc
deriving (Eq, Ord, Enum, Show, Read)
@ -49,24 +69,68 @@ instance PathPiece SortDirection where
| t == "desc" = Just SortDesc
| otherwise = Nothing
sqlSortDirection :: (SortColumn, SortDirection) -> E.SqlExpr E.OrderBy
sqlSortDirection (SortColumn e, SortAsc ) = E.asc e
sqlSortDirection (SortColumn e, SortDesc) = E.desc e
data DBTable = forall a r h i.
( Headedness h
, E.SqlSelect a r
sqlSortDirection :: t -> (SortColumn t, SortDirection) -> E.SqlExpr E.OrderBy
sqlSortDirection t (SortColumn e, SortAsc ) = E.asc $ e t
sqlSortDirection t (SortColumn e, SortDesc) = E.desc $ e t
data FilterColumn t = forall a. IsFilterColumn t a => FilterColumn a
filterColumn :: FilterColumn t -> [Text] -> t -> E.SqlExpr (E.Value Bool)
filterColumn (FilterColumn f) = filterColumn' f
class IsFilterColumn t a where
filterColumn' :: a -> [Text] -> t -> E.SqlExpr (E.Value Bool)
instance IsFilterColumn t (E.SqlExpr (E.Value Bool)) where
filterColumn' fin _ _ = fin
instance IsFilterColumn t cont => IsFilterColumn t (t -> cont) where
filterColumn' cont is t = filterColumn' (cont t) is t
instance {-# OVERLAPPABLE #-} (PathPiece (Element l), IsFilterColumn t cont, MonoPointed l, Monoid l) => IsFilterColumn t (l -> cont) where
filterColumn' cont is t = filterColumn' (cont input) is' t
where
(input, ($ []) -> is') = go (mempty, id) is
go acc [] = acc
go (acc, is') (i:is)
| Just i' <- fromPathPiece i = go (acc `mappend` singleton i', is') is
| otherwise = go (acc, is' . (i:)) is
data DBRow r = DBRow
{ dbrIndex, dbrCount :: Int64
, dbrOutput :: r
}
class DBOutput r r' where
dbProj :: r -> r'
instance DBOutput r r where
dbProj = id
instance DBOutput (DBRow r) r where
dbProj = dbrOutput
instance DBOutput (DBRow r) (Int64, r) where
dbProj = (,) <$> dbrIndex <*> dbrOutput
data DBTable = forall a r r' h i t.
( ToSortable h, Functor h
, E.SqlSelect a r, DBOutput (DBRow r) r'
, PathPiece i
, E.From E.SqlQuery E.SqlExpr E.SqlBackend t
) => DBTable
{ dbtSQLQuery :: E.SqlQuery a
, dbtColonnade :: Colonnade h r (Cell UniWorX)
, dbtSorting :: Map Text SortColumn
{ dbtSQLQuery :: t -> E.SqlQuery a
, dbtColonnade :: Colonnade h r' (Cell UniWorX)
, dbtSorting :: Map Text (SortColumn t)
, dbtFilter :: Map Text (FilterColumn t)
, dbtAttrs :: Attribute
, dbtIdent :: i
}
data PaginationSettings = PaginationSettings
{ psSorting :: [(Text, SortDirection)]
, psFilter :: Map Text [Text]
, psLimit :: Int64
, psPage :: Int64
, psShortcircuit :: Bool
@ -75,15 +139,16 @@ data PaginationSettings = PaginationSettings
instance Default PaginationSettings where
def = PaginationSettings
{ psSorting = []
, psFilter = Map.empty
, psLimit = 50
, psPage = 0
, psShortcircuit = False
}
newtype PSValidator = PSValidator { runPSValidator :: Maybe PaginationSettings -> ([SomeMessage UniWorX], PaginationSettings) }
newtype PSValidator = PSValidator { runPSValidator :: DBTable -> Maybe PaginationSettings -> ([SomeMessage UniWorX], PaginationSettings) }
instance Default PSValidator where
def = PSValidator $ \case
def = PSValidator $ \DBTable{..} -> \case
Nothing -> def
Just ps -> swap . (\act -> execRWS act () ps) $ do
l <- gets psLimit
@ -91,8 +156,9 @@ instance Default PSValidator where
modify $ \ps -> ps { psLimit = psLimit def }
tell . pure $ SomeMessage MsgPSLimitNonPositive
dbTable :: PSValidator -> DBTable -> Handler Widget
dbTable PSValidator{..} DBTable{ dbtIdent = (toPathPiece -> dbtIdent), .. } = do
dbTable PSValidator{..} dbtable@(DBTable{ dbtIdent = (toPathPiece -> dbtIdent), dbtColonnade = (lmap dbProj -> dbtColonnade), .. }) = do
let
sortingOptions = mkOptionList
[ Option t' (t, d) t'
@ -100,46 +166,96 @@ dbTable PSValidator{..} DBTable{ dbtIdent = (toPathPiece -> dbtIdent), .. } = do
, d <- [SortAsc, SortDesc]
, let t' = t <> "-" <> toPathPiece d
]
(_, defPS) = runPSValidator Nothing
(_, defPS) = runPSValidator dbtable Nothing
wIdent n
| not $ null dbtIdent = dbtIdent <> "-" <> n
| otherwise = n
dbtAttrs'
| not $ null dbtIdent = Html5.id (fromString $ unpack dbtIdent) <> dbtAttrs
| otherwise = dbtAttrs
multiTextField = Field
{ fieldParse = \ts _ -> return . Right $ Just ts
, fieldView = undefined
, fieldEnctype = UrlEncoded
}
psResult <- runInputGetResult $ PaginationSettings
<$> ireq (multiSelectField $ return sortingOptions) (wIdent "sorting")
<$> (fromMaybe [] <$> iopt (multiSelectField $ return sortingOptions) (wIdent "sorting"))
<*> (Map.mapMaybe ((\args -> args <$ guard (not $ null args)) =<<) <$> Map.traverseWithKey (\k _ -> iopt multiTextField $ wIdent k) dbtFilter)
<*> (fromMaybe (psLimit defPS) <$> iopt intField (wIdent "pagesize"))
<*> (fromMaybe (psPage defPS) <$> iopt intField (wIdent "page"))
<*> ireq checkBoxField (wIdent "table-only")
$(logDebug) . tshow $ (,,,) <$> (length . psSorting <$> psResult)
<*> (psLimit <$> psResult)
<*> (psPage <$> psResult)
<*> (psShortcircuit <$> psResult)
$(logDebug) . tshow $ (,,,,) <$> (length . psSorting <$> psResult)
<*> (Map.keys . psFilter <$> psResult)
<*> (psLimit <$> psResult)
<*> (psPage <$> psResult)
<*> (psShortcircuit <$> psResult)
let
(errs, PaginationSettings{..}) = case psResult of
FormSuccess ps -> runPSValidator $ Just ps
FormFailure errs -> first (map SomeMessage errs <>) $ runPSValidator Nothing
FormMissing -> runPSValidator Nothing
FormSuccess ps -> runPSValidator dbtable $ Just ps
FormFailure errs -> first (map SomeMessage errs <>) $ runPSValidator dbtable Nothing
FormMissing -> runPSValidator dbtable Nothing
psSorting' = map (first (dbtSorting !)) psSorting
sqlQuery' = dbtSQLQuery
<* E.orderBy (map sqlSortDirection psSorting')
sqlQuery' = E.from $ \t -> dbtSQLQuery t
<* E.orderBy (map (sqlSortDirection t) psSorting')
<* E.limit psLimit
<* E.offset (psPage * psLimit)
<* Map.foldrWithKey (\key args expr -> E.where_ (filterColumn (dbtFilter ! key) args t) >> expr) (return ()) psFilter
mapM_ (addMessageI "warning") errs
(rows, [E.Value rowCount]) <- runDB $ (,) <$> E.select sqlQuery' <*> E.select (E.countRows <$ dbtSQLQuery :: E.SqlQuery (E.SqlExpr (E.Value Int64)))
rows' <- runDB . E.select $ (,) <$> pure (E.unsafeSqlValue "row_number() OVER ()" :: E.SqlExpr (E.Value Int64), E.unsafeSqlValue "count(*) OVER ()" :: E.SqlExpr (E.Value Int64)) <*> sqlQuery'
let
rowCount
| ((_, E.Value n), _):_ <- rows' = n
| otherwise = 0
rows = map (\((E.Value i, E.Value n), r) -> DBRow i n r) rows'
bool return (sendResponse <=< tblLayout) psShortcircuit $ do
let table = encodeCellTable dbtAttrs' dbtColonnade rows
getParams <- handlerToWidget $ queryToQueryText . Wai.queryString . reqWaiRequest <$> getRequest
let table = $(widgetFile "table/colonnade")
pageCount = max 1 . ceiling $ rowCount % psLimit
$(widgetFile "table-layout")
pageNumbers = [0..pred pageCount]
tblLink f = decodeUtf8 . Builder.toLazyByteString . renderQueryText True $ f getParams
withSortLinks Sortable{ sortableContent = Cell{..}, .. } = Cell
{ cellContents = $(widgetFile "table/sortable-header")
, cellAttrs = maybe mempty (const sortableAttr) sortableKey <> cellAttrs
}
where
directions = [dir | (k, dir) <- psSorting, Just k == sortableKey ]
sortableAttr = Html5.class_ . fromString . unwords $ "sortable" : foldMap toAttr directions
toAttr SortAsc = ["sorted-asc"]
toAttr SortDesc = ["sorted-desc"]
$(widgetFile "table/layout")
where
tblLayout :: Widget -> Handler Html
tblLayout tbl' = do
tbl <- widgetToPageContent tbl'
withUrlRenderer $(hamletFile "templates/table-layout-wrapper.hamlet")
withUrlRenderer $(hamletFile "templates/table/layout-wrapper.hamlet")
setParam :: Text -> Maybe Text -> QueryText -> QueryText
setParam key v qt = (key, v) : [ i | i@(key', _) <- qt, key' /= key ]
widgetFromCell ::
(Attribute -> WidgetT site IO () -> WidgetT site IO ())
-> Cell site
-> WidgetT site IO ()
widgetFromCell f (Cell attrs contents) =
f attrs contents
td,th ::
Attribute -> WidgetT site IO () -> WidgetT site IO ()
td = liftParent Html5.td
th = liftParent Html5.th
liftParent :: (Html -> Html) -> Attribute -> WidgetT site IO a -> WidgetT site IO a
liftParent el attrs (WidgetT f) = WidgetT $ \hdata -> do
(a,gwd) <- f hdata
let Body bodyFunc = gwdBody gwd
newBodyFunc render =
el Html5.! attrs $ (bodyFunc render)
return (a,gwd { gwdBody = Body newBodyFunc })

View File

@ -0,0 +1,43 @@
{-# LANGUAGE NoImplicitPrelude
, ExistentialQuantification
, RankNTypes
, RecordWildCards
#-}
module Handler.Utils.Table.Pagination.Types where
import Import hiding (singleton)
import Colonnade
import Colonnade.Encode
data Sortable a = Sortable
{ sortableKey :: Maybe Text
, sortableContent :: a
}
sortable :: Maybe Text -> c -> (a -> c) -> Colonnade Sortable a c
sortable k h = singleton (Sortable k h)
instance Headedness Sortable where
headednessPure = Sortable Nothing
headednessExtract = Just $ \(Sortable _ x) -> x
headednessExtractForall = Just $ ExtractForall (\(Sortable _ x) -> x)
instance Functor Sortable where
fmap f Sortable{..} = Sortable { sortableContent = f sortableContent, .. }
newtype SortableP s = SortableP { toSortable :: forall a. s a -> Sortable a}
class Headedness s => ToSortable s where
pSortable :: Maybe (SortableP s)
instance ToSortable Sortable where
pSortable = Just $ SortableP id
instance ToSortable Headed where
pSortable = Just $ SortableP (\(Headed x) -> Sortable Nothing x)
instance ToSortable Headless where
pSortable = Nothing

View File

@ -54,7 +54,7 @@ deriveJSON defaultOptions ''SheetType
derivePersistFieldJSON "SheetType"
data SheetGroup
= Arbitrary { maxParticipants :: Int } -- Distinguish Limited/Arbitrary
= Arbitrary { maxParticipants :: Int }
| RegisteredGroups
| NoGroups
deriving (Show, Read, Eq)
@ -77,10 +77,33 @@ data ExamStatus = Attended | NoShow | Voided
deriving (Show, Read, Eq, Ord, Enum, Bounded)
derivePersistField "ExamStatus"
data Load = ByTutorial | ByProportion Rational
-- | Specify a corrector's workload
data Load -- = ByTutorial { countsToLoad :: Bool } | ByProportion { load :: Rational }
= Load { byTutorial :: Maybe Bool -- ^ Just all from Tutorial, True if counting towards overall workload
, byProportion :: Rational -- ^ workload proportion of all submission not assigned to tutorial leaders
}
deriving (Show, Read, Eq)
derivePersistField "Load"
instance Semigroup Load where
(Load byTut prop) <> (Load byTut' prop') = Load byTut'' (prop + prop')
where
byTut''
| Nothing <- byTut = byTut'
| Nothing <- byTut' = byTut
| Just a <- byTut
, Just b <- byTut' = Just $ a || b
instance Monoid Load where
mempty = Load Nothing 0
mappend = (<>)
{- Use (is _ByTutorial) instead of this unneeded definition:
isByTutorial :: Load -> Bool
isByTutorial (ByTutorial {}) = True
isByTutorial _ = False
-}
data Season = Summer | Winter
deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic, Typeable)
@ -158,3 +181,6 @@ time `withinTerm` term = timeYear `mod` 100 == termYear `mod` 100
data StudyFieldType = FieldPrimary | FieldSecondary
deriving (Eq, Ord, Enum, Show, Read, Bounded)
derivePersistField "StudyFieldType"

View File

@ -1,5 +1,7 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeFamilies, FlexibleContexts, ConstraintKinds #-}
{-# LANGUAGE QuasiQuotes #-}
@ -14,10 +16,30 @@ import Utils.Common as Utils
import Text.Blaze (Markup, ToMarkup)
import Data.Map (Map)
import qualified Data.Map as Map
import qualified Data.List as List
-- import Data.Map (Map)
-- import qualified Data.Map as Map
-- import qualified Data.List as List
import Control.Monad.Trans.Except (ExceptT(..), throwE, runExceptT)
import Control.Monad.Trans.Maybe (MaybeT(..))
import Control.Monad.Catch
-----------
-- Yesod --
-----------
newtype MsgRendererS site = MsgRenderer { render :: (forall msg. RenderMessage site msg => msg -> Text) }
getMsgRenderer :: forall m site. (MonadHandler m, HandlerSite m ~ site) => m (MsgRendererS site)
getMsgRenderer = do
mr <- getMessageRender
return $ MsgRenderer (mr . SomeMessage :: forall msg. RenderMessage site msg => msg -> Text)
---------------------
-- Text and String --
---------------------
tickmark :: IsString a => a
tickmark = fromString ""
@ -42,6 +64,16 @@ withFragment :: ( Monad m
) => MForm m (a, WidgetT site IO ()) -> Markup -> MForm m (a, WidgetT site IO ())
withFragment form html = (flip fmap) form $ \(x, widget) -> (x, toWidget html >> widget)
------------
-- Tuples --
------------
----------
-- Maps --
----------
-----------
-- Maybe --
-----------
@ -49,8 +81,48 @@ whenIsJust :: Monad m => Maybe a -> (a -> m ()) -> m ()
whenIsJust (Just x) f = f x
whenIsJust Nothing _ = return ()
maybeT :: Monad m => m a -> MaybeT m a -> m a
maybeT x m = runMaybeT m >>= maybe x return
catchIfMaybeT :: (MonadCatch m, Exception e) => (e -> Bool) -> m a -> MaybeT m a
catchIfMaybeT p act = catchIf p (lift act) (const mzero)
---------------
-- Exception --
---------------
maybeExceptT :: Monad m => e -> m (Maybe b) -> ExceptT e m b
maybeExceptT err act = lift act >>= maybe (throwE err) return
maybeMExceptT :: Monad m => (m e) -> m (Maybe b) -> ExceptT e m b
maybeMExceptT err act = lift act >>= maybe (lift err >>= throwE) return
whenExceptT :: Monad m => Bool -> e -> ExceptT e m ()
whenExceptT b err = when b $ throwE err
whenMExceptT :: Monad m => Bool -> (m e) -> ExceptT e m ()
whenMExceptT b err = when b $ lift err >>= throwE
guardExceptT :: Monad m => Bool -> e -> ExceptT e m ()
guardExceptT b err = unless b $ throwE err
guardMExceptT :: Monad m => Bool -> (m e) -> ExceptT e m ()
guardMExceptT b err = unless b $ lift err >>= throwE
exceptT :: Monad m => (e -> m b) -> (a -> m b) -> ExceptT e m a -> m b
exceptT f g = either f g <=< runExceptT
catchIfMExceptT :: (MonadCatch m, Exception e) => (e -> m e') -> (e -> Bool) -> m a -> ExceptT e' m a
catchIfMExceptT err p act = catchIf p (lift act) (throwE <=< lift . err)
----------
-- Maps --
----------
------------
-- Monads --
------------
shortCircuitM :: Monad m => (a -> Bool) -> m a -> m a -> (a -> a -> a) -> m a
shortCircuitM sc mx my op = do
x <- mx
case sc x of
True -> return x
False -> op <$> pure x <*> my

View File

@ -5,7 +5,10 @@ module Utils.Common where
-- Common Utility Functions
import Language.Haskell.TH
-- import Control.Monad
-- import Control.Monad.Trans.Class
-- import Control.Monad.Trans.Maybe
-- import Control.Monad.Trans.Except
------------
-- Tuples --
@ -50,3 +53,4 @@ altFun perm = lamE pat rhs
ps = [ xs !! (j-1) | j <- perm ]
fn = mkName "fn"

15
src/Utils/Lens.hs Normal file
View File

@ -0,0 +1,15 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FunctionalDependencies #-}
module Utils.Lens where
import Import.NoFoundation
import Control.Lens
makeClassy_ ''Entity
makeClassy_ ''SheetCorrector
-- makeClassy_ ''Load

View File

@ -32,3 +32,6 @@
.glyphicon--logout::before {
content: '\e163';
}
.glyphicon--none::before {
content: '';
}

39
static/css/tabber.css Normal file
View File

@ -0,0 +1,39 @@
.tab-group {
border-top: 2px solid #dcdcdc;
padding-top: 30px;
}
.tab-group-openers {
display: flex;
justify-content: stretch;
line-height: 40px;
font-size: 14px;
margin-bottom: 40px;
}
.tab-opener {
display: inline-block;
flex: 1;
text-align: center;
padding: 0 13px;
margin: 0 2px;
background-color: #b3b7c1;
color: white;
font-size: 16px;
text-transform: uppercase;
font-weight: 600;
transition: all .1s ease;
border-bottom: 5px solid rgba(100, 100, 100, 0.2);
}
.tab-opener:not(.tab-visible):hover {
cursor: pointer;
background-color: transparent;
color: rgb(52, 48, 58);
border-bottom-color: grey;
}
.tab-opener.tab-visible {
background-color: transparent;
color: rgb(52, 48, 58);
border-bottom-color: #5F98C2;
}

View File

@ -1,7 +1,4 @@
window.addEventListener('touchstart', function onFirstTouch() {
// we could use a class
document.body.classList.add('touch-supported');
// we only need to know once that a human touched the screen, so we can stop listening now
window.removeEventListener('touchstart', onFirstTouch, false);
}, false);

466
static/js/fetchPolyfill.js Normal file
View File

@ -0,0 +1,466 @@
(function(self) {
'use strict';
if (self.fetch) {
return
}
var support = {
searchParams: 'URLSearchParams' in self,
iterable: 'Symbol' in self && 'iterator' in Symbol,
blob: 'FileReader' in self && 'Blob' in self && (function() {
try {
new Blob()
return true
} catch(e) {
return false
}
})(),
formData: 'FormData' in self,
arrayBuffer: 'ArrayBuffer' in self
}
if (support.arrayBuffer) {
var viewClasses = [
'[object Int8Array]',
'[object Uint8Array]',
'[object Uint8ClampedArray]',
'[object Int16Array]',
'[object Uint16Array]',
'[object Int32Array]',
'[object Uint32Array]',
'[object Float32Array]',
'[object Float64Array]'
]
var isDataView = function(obj) {
return obj && DataView.prototype.isPrototypeOf(obj)
}
var isArrayBufferView = ArrayBuffer.isView || function(obj) {
return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1
}
}
function normalizeName(name) {
if (typeof name !== 'string') {
name = String(name)
}
if (/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(name)) {
throw new TypeError('Invalid character in header field name')
}
return name.toLowerCase()
}
function normalizeValue(value) {
if (typeof value !== 'string') {
value = String(value)
}
return value
}
// Build a destructive iterator for the value list
function iteratorFor(items) {
var iterator = {
next: function() {
var value = items.shift()
return {done: value === undefined, value: value}
}
}
if (support.iterable) {
iterator[Symbol.iterator] = function() {
return iterator
}
}
return iterator
}
function Headers(headers) {
this.map = {}
if (headers instanceof Headers) {
headers.forEach(function(value, name) {
this.append(name, value)
}, this)
} else if (Array.isArray(headers)) {
headers.forEach(function(header) {
this.append(header[0], header[1])
}, this)
} else if (headers) {
Object.getOwnPropertyNames(headers).forEach(function(name) {
this.append(name, headers[name])
}, this)
}
}
Headers.prototype.append = function(name, value) {
name = normalizeName(name)
value = normalizeValue(value)
var oldValue = this.map[name]
this.map[name] = oldValue ? oldValue+','+value : value
}
Headers.prototype['delete'] = function(name) {
delete this.map[normalizeName(name)]
}
Headers.prototype.get = function(name) {
name = normalizeName(name)
return this.has(name) ? this.map[name] : null
}
Headers.prototype.has = function(name) {
return this.map.hasOwnProperty(normalizeName(name))
}
Headers.prototype.set = function(name, value) {
this.map[normalizeName(name)] = normalizeValue(value)
}
Headers.prototype.forEach = function(callback, thisArg) {
for (var name in this.map) {
if (this.map.hasOwnProperty(name)) {
callback.call(thisArg, this.map[name], name, this)
}
}
}
Headers.prototype.keys = function() {
var items = []
this.forEach(function(value, name) { items.push(name) })
return iteratorFor(items)
}
Headers.prototype.values = function() {
var items = []
this.forEach(function(value) { items.push(value) })
return iteratorFor(items)
}
Headers.prototype.entries = function() {
var items = []
this.forEach(function(value, name) { items.push([name, value]) })
return iteratorFor(items)
}
if (support.iterable) {
Headers.prototype[Symbol.iterator] = Headers.prototype.entries
}
function consumed(body) {
if (body.bodyUsed) {
return Promise.reject(new TypeError('Already read'))
}
body.bodyUsed = true
}
function fileReaderReady(reader) {
return new Promise(function(resolve, reject) {
reader.onload = function() {
resolve(reader.result)
}
reader.onerror = function() {
reject(reader.error)
}
})
}
function readBlobAsArrayBuffer(blob) {
var reader = new FileReader()
var promise = fileReaderReady(reader)
reader.readAsArrayBuffer(blob)
return promise
}
function readBlobAsText(blob) {
var reader = new FileReader()
var promise = fileReaderReady(reader)
reader.readAsText(blob)
return promise
}
function readArrayBufferAsText(buf) {
var view = new Uint8Array(buf)
var chars = new Array(view.length)
for (var i = 0; i < view.length; i++) {
chars[i] = String.fromCharCode(view[i])
}
return chars.join('')
}
function bufferClone(buf) {
if (buf.slice) {
return buf.slice(0)
} else {
var view = new Uint8Array(buf.byteLength)
view.set(new Uint8Array(buf))
return view.buffer
}
}
function Body() {
this.bodyUsed = false
this._initBody = function(body) {
this._bodyInit = body
if (!body) {
this._bodyText = ''
} else if (typeof body === 'string') {
this._bodyText = body
} else if (support.blob && Blob.prototype.isPrototypeOf(body)) {
this._bodyBlob = body
} else if (support.formData && FormData.prototype.isPrototypeOf(body)) {
this._bodyFormData = body
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
this._bodyText = body.toString()
} else if (support.arrayBuffer && support.blob && isDataView(body)) {
this._bodyArrayBuffer = bufferClone(body.buffer)
// IE 10-11 can't handle a DataView body.
this._bodyInit = new Blob([this._bodyArrayBuffer])
} else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) {
this._bodyArrayBuffer = bufferClone(body)
} else {
throw new Error('unsupported BodyInit type')
}
if (!this.headers.get('content-type')) {
if (typeof body === 'string') {
this.headers.set('content-type', 'text/plain;charset=UTF-8')
} else if (this._bodyBlob && this._bodyBlob.type) {
this.headers.set('content-type', this._bodyBlob.type)
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8')
}
}
}
if (support.blob) {
this.blob = function() {
var rejected = consumed(this)
if (rejected) {
return rejected
}
if (this._bodyBlob) {
return Promise.resolve(this._bodyBlob)
} else if (this._bodyArrayBuffer) {
return Promise.resolve(new Blob([this._bodyArrayBuffer]))
} else if (this._bodyFormData) {
throw new Error('could not read FormData body as blob')
} else {
return Promise.resolve(new Blob([this._bodyText]))
}
}
this.arrayBuffer = function() {
if (this._bodyArrayBuffer) {
return consumed(this) || Promise.resolve(this._bodyArrayBuffer)
} else {
return this.blob().then(readBlobAsArrayBuffer)
}
}
}
this.text = function() {
var rejected = consumed(this)
if (rejected) {
return rejected
}
if (this._bodyBlob) {
return readBlobAsText(this._bodyBlob)
} else if (this._bodyArrayBuffer) {
return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer))
} else if (this._bodyFormData) {
throw new Error('could not read FormData body as text')
} else {
return Promise.resolve(this._bodyText)
}
}
if (support.formData) {
this.formData = function() {
return this.text().then(decode)
}
}
this.json = function() {
return this.text().then(JSON.parse)
}
return this
}
// HTTP methods whose capitalization should be normalized
var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT']
function normalizeMethod(method) {
var upcased = method.toUpperCase()
return (methods.indexOf(upcased) > -1) ? upcased : method
}
function Request(input, options) {
options = options || {}
var body = options.body
if (input instanceof Request) {
if (input.bodyUsed) {
throw new TypeError('Already read')
}
this.url = input.url
this.credentials = input.credentials
if (!options.headers) {
this.headers = new Headers(input.headers)
}
this.method = input.method
this.mode = input.mode
if (!body && input._bodyInit != null) {
body = input._bodyInit
input.bodyUsed = true
}
} else {
this.url = String(input)
}
this.credentials = options.credentials || this.credentials || 'omit'
if (options.headers || !this.headers) {
this.headers = new Headers(options.headers)
}
this.method = normalizeMethod(options.method || this.method || 'GET')
this.mode = options.mode || this.mode || null
this.referrer = null
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
throw new TypeError('Body not allowed for GET or HEAD requests')
}
this._initBody(body)
}
Request.prototype.clone = function() {
return new Request(this, { body: this._bodyInit })
}
function decode(body) {
var form = new FormData()
body.trim().split('&').forEach(function(bytes) {
if (bytes) {
var split = bytes.split('=')
var name = split.shift().replace(/\+/g, ' ')
var value = split.join('=').replace(/\+/g, ' ')
form.append(decodeURIComponent(name), decodeURIComponent(value))
}
})
return form
}
function parseHeaders(rawHeaders) {
var headers = new Headers()
// Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
// https://tools.ietf.org/html/rfc7230#section-3.2
var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ')
preProcessedHeaders.split(/\r?\n/).forEach(function(line) {
var parts = line.split(':')
var key = parts.shift().trim()
if (key) {
var value = parts.join(':').trim()
headers.append(key, value)
}
})
return headers
}
Body.call(Request.prototype)
function Response(bodyInit, options) {
if (!options) {
options = {}
}
this.type = 'default'
this.status = options.status === undefined ? 200 : options.status
this.ok = this.status >= 200 && this.status < 300
this.statusText = 'statusText' in options ? options.statusText : 'OK'
this.headers = new Headers(options.headers)
this.url = options.url || ''
this._initBody(bodyInit)
}
Body.call(Response.prototype)
Response.prototype.clone = function() {
return new Response(this._bodyInit, {
status: this.status,
statusText: this.statusText,
headers: new Headers(this.headers),
url: this.url
})
}
Response.error = function() {
var response = new Response(null, {status: 0, statusText: ''})
response.type = 'error'
return response
}
var redirectStatuses = [301, 302, 303, 307, 308]
Response.redirect = function(url, status) {
if (redirectStatuses.indexOf(status) === -1) {
throw new RangeError('Invalid status code')
}
return new Response(null, {status: status, headers: {location: url}})
}
self.Headers = Headers
self.Request = Request
self.Response = Response
self.fetch = function(input, init) {
return new Promise(function(resolve, reject) {
var request = new Request(input, init)
var xhr = new XMLHttpRequest()
xhr.onload = function() {
var options = {
status: xhr.status,
statusText: xhr.statusText,
headers: parseHeaders(xhr.getAllResponseHeaders() || '')
}
options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL')
var body = 'response' in xhr ? xhr.response : xhr.responseText
resolve(new Response(body, options))
}
xhr.onerror = function() {
reject(new TypeError('Network request failed'))
}
xhr.ontimeout = function() {
reject(new TypeError('Network request failed'))
}
xhr.open(request.method, request.url, true)
if (request.credentials === 'include') {
xhr.withCredentials = true
} else if (request.credentials === 'omit') {
xhr.withCredentials = false
}
if ('responseType' in xhr && support.blob) {
xhr.responseType = 'blob'
}
request.headers.forEach(function(value, name) {
xhr.setRequestHeader(name, value)
})
xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit)
})
}
self.fetch.polyfill = true
})(typeof self !== 'undefined' ? self : this);

88
static/js/tabber.js Normal file
View File

@ -0,0 +1,88 @@
(function($) {
document.addEventListener('DOMContentLoaded', function() {
'use strict';
// define plugin
$.fn.tabgroup = function() {
var $this = $(this);
var $openers = $('<div class="tab-group-openers"></div>');
$this.prepend($openers);
var openedByDefault = $this.data('tab-open') || 0;
var tabs = [];
var currentTab = {};
$this.find('.tab').each(function(i, t) {
var tab = $(t);
tab.data('tab-index', i);
var tabName = tab.data('tab-name') || 'Tab '+i;
var tabFile = tab.data('tab-file') || false;
var $opener = makeOpener(tabName, i);
$openers.append($opener);
if (tab.find('.tab-title')) {
tab.find('.tab-title').remove();
}
tab.hide();
var loaded = false;
tabs.push({index: i, name: tabName, file: tabFile, dom: tab, opener: $opener, loaded: false});
});
$this.on('click', 'a[href^="#"]', function(event) {
var $target = $(event.currentTarget);
var tab = getTabByName($target.attr('href').replace('#', ''));
if ( tab ) {
showTab(tab.index);
}
event.preventDefault();
});
function getTabByName(name) {
var it = -1;
$.each(tabs, function(i, t) {
if ( t.name.toLowerCase() === name.toLowerCase() ) {
it = i;
}
});
if ( it >= 0 ) {
return tabs[it];
} else {
return false;
}
}
function makeOpener(tabName, i) {
return $('<span class="tab-opener">'+tabName+'</span>').
on('click', function() {
showTab(i);
});
}
function showTab(i) {
tabs.forEach(function(t) {
t.dom.hide();
t.opener.removeClass('tab-visible');
});
currentTab = tabs[i];
if ( !currentTab.loaded && currentTab.file ){
$.get(currentTab.file, function(res) {
currentTab.dom.html(res);
currentTab.loaded = true;
});
}
currentTab.opener.addClass('tab-visible');
currentTab.dom.show();
}
showTab(openedByDefault);
currentTab = tabs[openedByDefault];
};
// apply plugin to all available tab-groups
$('.tab-group').each(function(i, t) {
$(t).tabgroup();
})
});
})($);

348
static/js/urlPolyfill.js Normal file
View File

@ -0,0 +1,348 @@
(function(global) {
/**
* Polyfill URLSearchParams
*
* Inspired from : https://github.com/WebReflection/url-search-params/blob/master/src/url-search-params.js
*/
var checkIfIteratorIsSupported = function() {
try {
return !!Symbol.iterator;
} catch(error) {
return false;
}
};
var iteratorSupported = checkIfIteratorIsSupported();
var createIterator = function(items) {
var iterator = {
next: function() {
var value = items.shift();
return { done: value === void 0, value: value };
}
};
if(iteratorSupported) {
iterator[Symbol.iterator] = function() {
return iterator;
};
}
return iterator;
};
/**
* Search param name and values should be encoded according to https://url.spec.whatwg.org/#urlencoded-serializing
* encodeURIComponent() produces the same result except encoding spaces as `%20` instead of `+`.
*/
var serializeParam = function(value) {
return encodeURIComponent(value).replace(/%20/g, '+');
};
var deserializeParam = function(value) {
return decodeURIComponent(value).replace(/\+/g, ' ');
};
var polyfillURLSearchParams= function() {
var URLSearchParams = function(searchString) {
Object.defineProperty(this, '_entries', { value: {} });
if(typeof searchString === 'string') {
if(searchString !== '') {
searchString = searchString.replace(/^\?/, '');
var attributes = searchString.split('&');
var attribute;
for(var i = 0; i < attributes.length; i++) {
attribute = attributes[i].split('=');
this.append(
deserializeParam(attribute[0]),
(attribute.length > 1) ? deserializeParam(attribute[1]) : ''
);
}
}
} else if(searchString instanceof URLSearchParams) {
var _this = this;
searchString.forEach(function(value, name) {
_this.append(value, name);
});
}
};
var proto = URLSearchParams.prototype;
proto.append = function(name, value) {
if(name in this._entries) {
this._entries[name].push(value.toString());
} else {
this._entries[name] = [value.toString()];
}
};
proto.delete = function(name) {
delete this._entries[name];
};
proto.get = function(name) {
return (name in this._entries) ? this._entries[name][0] : null;
};
proto.getAll = function(name) {
return (name in this._entries) ? this._entries[name].slice(0) : [];
};
proto.has = function(name) {
return (name in this._entries);
};
proto.set = function(name, value) {
this._entries[name] = [value.toString()];
};
proto.forEach = function(callback, thisArg) {
var entries;
for(var name in this._entries) {
if(this._entries.hasOwnProperty(name)) {
entries = this._entries[name];
for(var i = 0; i < entries.length; i++) {
callback.call(thisArg, entries[i], name, this);
}
}
}
};
proto.keys = function() {
var items = [];
this.forEach(function(value, name) { items.push(name); });
return createIterator(items);
};
proto.values = function() {
var items = [];
this.forEach(function(value) { items.push(value); });
return createIterator(items);
};
proto.entries = function() {
var items = [];
this.forEach(function(value, name) { items.push([name, value]); });
return createIterator(items);
};
if(iteratorSupported) {
proto[Symbol.iterator] = proto.entries;
}
proto.toString = function() {
var searchString = '';
this.forEach(function(value, name) {
if(searchString.length > 0) searchString+= '&';
searchString += serializeParam(name) + '=' + serializeParam(value);
});
return searchString;
};
global.URLSearchParams = URLSearchParams;
};
if(!('URLSearchParams' in global) || (new URLSearchParams('?a=1').toString() !== 'a=1')) {
polyfillURLSearchParams();
}
// HTMLAnchorElement
})(
(typeof global !== 'undefined') ? global
: ((typeof window !== 'undefined') ? window
: ((typeof self !== 'undefined') ? self : this))
);
(function(global) {
/**
* Polyfill URL
*
* Inspired from : https://github.com/arv/DOM-URL-Polyfill/blob/master/src/url.js
*/
var checkIfURLIsSupported = function() {
try {
var u = new URL('b', 'http://a');
u.pathname = 'c%20d';
return (u.href === 'http://a/c%20d') && u.searchParams;
} catch(e) {
return false;
}
};
var polyfillURL = function() {
var _URL = global.URL;
var URL = function(url, base) {
if(typeof url !== 'string') url = String(url);
var doc = document.implementation.createHTMLDocument('');
window.doc = doc;
if(base) {
var baseElement = doc.createElement('base');
baseElement.href = base;
doc.head.appendChild(baseElement);
}
var anchorElement = doc.createElement('a');
anchorElement.href = url;
doc.body.appendChild(anchorElement);
anchorElement.href = anchorElement.href; // force href to refresh
if(anchorElement.protocol === ':' || !/:/.test(anchorElement.href)) {
throw new TypeError('Invalid URL');
}
Object.defineProperty(this, '_anchorElement', {
value: anchorElement
});
};
var proto = URL.prototype;
var linkURLWithAnchorAttribute = function(attributeName) {
Object.defineProperty(proto, attributeName, {
get: function() {
return this._anchorElement[attributeName];
},
set: function(value) {
this._anchorElement[attributeName] = value;
},
enumerable: true
});
};
['hash', 'host', 'hostname', 'port', 'protocol', 'search']
.forEach(function(attributeName) {
linkURLWithAnchorAttribute(attributeName);
});
Object.defineProperties(proto, {
'toString': {
get: function() {
var _this = this;
return function() {
return _this.href;
};
}
},
'href' : {
get: function() {
return this._anchorElement.href.replace(/\?$/,'');
},
set: function(value) {
this._anchorElement.href = value;
},
enumerable: true
},
'pathname' : {
get: function() {
return this._anchorElement.pathname.replace(/(^\/?)/,'/');
},
set: function(value) {
this._anchorElement.pathname = value;
},
enumerable: true
},
'origin': {
get: function() {
// get expected port from protocol
var expectedPort = {'http:': 80, 'https:': 443, 'ftp:': 21}[this._anchorElement.protocol];
// add port to origin if, expected port is different than actual port
// and it is not empty f.e http://foo:8080
// 8080 != 80 && 8080 != ''
var addPortToOrigin = this._anchorElement.port != expectedPort &&
this._anchorElement.port !== ''
return this._anchorElement.protocol +
'//' +
this._anchorElement.hostname +
(addPortToOrigin ? (':' + this._anchorElement.port) : '');
},
enumerable: true
},
'password': { // TODO
get: function() {
return '';
},
set: function(value) {
},
enumerable: true
},
'username': { // TODO
get: function() {
return '';
},
set: function(value) {
},
enumerable: true
},
'searchParams': {
get: function() {
var searchParams = new URLSearchParams(this.search);
var _this = this;
['append', 'delete', 'set'].forEach(function(methodName) {
var method = searchParams[methodName];
searchParams[methodName] = function() {
method.apply(searchParams, arguments);
_this.search = searchParams.toString();
};
});
return searchParams;
},
enumerable: true
}
});
URL.createObjectURL = function(blob) {
return _URL.createObjectURL.apply(_URL, arguments);
};
URL.revokeObjectURL = function(url) {
return _URL.revokeObjectURL.apply(_URL, arguments);
};
global.URL = URL;
};
if(!checkIfURLIsSupported()) {
polyfillURL();
}
if((global.location !== void 0) && !('origin' in global.location)) {
var getOrigin = function() {
return global.location.protocol + '//' + global.location.hostname + (global.location.port ? (':' + global.location.port) : '');
};
try {
Object.defineProperty(global.location, 'origin', {
get: getOrigin,
enumerable: true
});
} catch(e) {
setInterval(function() {
global.location.origin = getOrigin();
}, 100);
}
}
})(
(typeof global !== 'undefined') ? global
: ((typeof window !== 'undefined') ? window
: ((typeof self !== 'undefined') ? self : this))
);

1650
static/js/zepto.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,100 @@
<div .course-header>
<div .course-header__info>
<table>
<tr>
<th>Teilnehmer
<td>
#{participants}
$maybe capacity <- courseCapacity course
\ von #{capacity}
<tr>
<th>Anmeldezeitraum
<td>
$maybe regFrom <- courseRegisterFrom course
#{formatTimeGerWD regFrom}
$maybe regTo <- courseRegisterTo course
\ bis #{formatTimeGerWD regTo}
<div>
<form method=post action=@{CourseR tid csh CourseShowR} enctype=#{regEnctype}>
^{regWidget}
<div .course-header__title>
<h1>#{courseName course}
<div .container>
<h2>#{courseName course}
<table>
$maybe school <- schoolMB
<h4>#{schoolName school}
<tr>
<th #school>Fakultät/Institut
<td>
#{schoolName school}
$maybe descr <- courseDescription course
<tr>
<th #description>Beschreibung
<td>
<p>#{descr}
$maybe link <- courseLinkExternal course
<tr>
<th #website>Website
<td>
<a href=#{link}>#{link}
<tr>
<th #participants>Teilnehmer
<td>
#{participants}
$maybe capacity <- courseCapacity course
\ von #{capacity}
<tr>
<th #registration>Anmeldezeitraum
<td>
$maybe regFrom <- courseRegisterFrom course
#{formatTimeGerWD regFrom}
$maybe regTo <- courseRegisterTo course
\ bis #{formatTimeGerWD regTo}
<tr>
<th>
<td>
$# if allowed to register
<div .course__registration.container>
<button class="btn btn-primary">
<a href="#">Anmelden
$# <form method=post action=@{CourseR tid csh CShow} enctype=#{regEnctype}>
$# ^{regWidget}
<div .container>
$maybe descr <- courseDescription course
<h2 #description>Beschreibung
<p> #{descr}
$maybe link <- courseLinkExternal course
<h4 #linl>Homepage
<a href=#{link}>#{link}
<div .tab-group>
<div .tab data-tab-name="Übungsblätter">
^{modal "#modal-toggler__new-sheet" Nothing}
<h3 .tab-title>Übungsblätter
<table .table.table-striped.table-hover>
<thead>
<tr>
<th>Blatt
<th>Abgabe ab
<th>Abgabe bis
<th>Bewertung</th>
<tbody>
<tr>
<td>
<a href="http://localhost:3000/course/S2018/ffp/ex/Blatt%201/show" role="button">Blatt 1
<td>Do 08.04.18
<td>Do 11.04.18
<td>NotGraded
<tr>
<td>
<a href="http://localhost:3000/course/S2018/ffp/ex/Blatt%201/show" role="button">Blatt 2
<td>Do 15.04.18
<td>Do 18.04.18
<td>NotGraded
<tr .no-hover.no-stripe>
<td colspan="4">
<a href="/course/S2018/ffp/ex/new" #modal-toggler__new-sheet>Neues Übungsblatt anlegen
<div .tab data-tab-name="Übungsgruppen">
<h3 .tab-title>Übungsgruppen
<table .table.table-striped.table-hover>
<thead>
<tr>
<th>Name
<th>Termin
<th>Raum
<th>Studenten
<th>Tutor
<th>Anmeldung bis
<tbody>
<tr>
<td>Gruppe 1
<td>Montag 10:00 - 12:00
<td>N/A
<td>2/10
<td>Tutor1 Tutoren
<td>Do 21.02.2019, 19:00
<tr>
<td>Gruppe 2
<td>Montag 12:00 - 14:00
<td>N/A
<td>0/10
<td>Assistant1 Assistant
<td>Di 21.02.2017, 19:00
<div .tab data-tab-name="Klausuren">
<h3 .tab-title>Klausuren
<div>...

View File

@ -1,19 +1,3 @@
.course-header {
/*display: flex;
flex-direction: row;
justify-content: space-between;*/
}
.course-header__title {
align-self: baseline;
}
.course-header__info {
border: 1px solid var(--greybase);
padding: 13px;
align-self: center;
float: right;
}
.course-header__info table {
margin: 0;
.course__registration {
margin-top: 20px;
}

View File

@ -13,8 +13,17 @@
$with status2 <- bool status "info" (status == "")
<div class="alert alert-#{status2}">#{msg}
<!-- breadcrumbs -->
$if not $ Just HomeR == mcurrentRoute
^{breadcrumbs}
$maybe headline <- contentHeadline
<h1>
^{headline}
<!-- prime page actions -->
^{pageactionprime}
<!-- actual content -->
^{widget}

View File

@ -1,46 +1,18 @@
:root {
/* THEME 1 */
--base00: #72a85b;
--base-bg-color: #1d1c1d;
--base-font-color: #fff;
--sec-font-color: #fff;
--box-bg-color: #3c3c3c;
/* THEME 2 */
--base00: #38428a;
--base-bg-color: #ffffff;
--base-font-color: rgb(53, 53, 53);
--sec-font-color: #eaf2ff;
--box-bg-color: #dddddd;
/* THEME 3 */
--darkbase: #364B60;
--lightbase: #2490E8;
--lighterbase: #60C2FF;
--whitebase: #FCFFFA;
--greybase: #B1B5C0;
--fontbase: #34303a;
--fontsec: #5b5861;
/* THEME 4 */
--darkbase: #263C4C;
--lightbase: #598EB5;
--lighterbase: #5F98C2;
--whitebase: #FCFFFA;
--greybase: #B1B5C0;
--lightgreybase: #D9DEDB;
--blackbase: #1A2A36;
--fontbase: #34303a;
--fontsec: #5b5861;
--primarybase: #4C7A9C;
/* THEME INDEPENDENT COLORS */
--errorbase: red;
--warningbase: #fe7700;
--validbase: #2dcc35;
--infobase: var(--darkbase);
--color-error: red;
--color-warning: #fe7700;
--color-success: #2dcc35;
--color-info: #c4c4c4;
--color-lightblack: #1A2A36;
--color-lightwhite: #FCFFFA;
--color-grey: #B1B5C0;
--color-font: #34303a;
--color-fontsec: #5b5861;
/* FONTS */
--fontfamilybase: "Source Sans Pro", Helvetica, sans-serif;
--font-base: "Source Sans Pro", Helvetica, sans-serif;
/* DIMENSIONS */
--header-height: 80px;
@ -55,13 +27,64 @@
body {
background-color: white;
color: var(--fontbase);
font-family: var(--fontfamilybase);
color: var(--color-font);
font-family: var(--font-base);
font-weight: 400;
font-size: 16px;
overflow-y: scroll;
}
/* THEMES */
body {
/* DEFAULT THEME */
--color-primary: #4C7A9C;
--color-light: #598EB5;
--color-lighter: #5F98C2;
--color-dark: #425d79;
--color-darker: #274a65;
--color-link: var(--color-dark);
--color-link-hover: var(--color-darker);
&.theme--neutral-blue {
--color-primary: #3E606F;
--color-light: rgb(189, 201, 219);
--color-lighter: rgb(145, 159, 170);
--color-dark: #3E606F;
--color-darker: #193441;
}
&.theme--aberdeen-reds {
--color-primary: #820333;
--color-light: #C9283E;
--color-lighter: #F0433A;
--color-dark: #540032;
--color-darker: #2E112D;
}
&.theme--mint-green {
--color-primary: #5C996B;
--color-light: #7ACC8F;
--color-lighter: #99FFB2;
--color-dark: #3D6647;
--color-darker: #1F3324;
}
&.theme--sky-love {
--color-primary: #87ABE5;
--color-light: #A0C6F2;
--color-lighter: #BAE2FF;
--color-dark: #7A95DE;
--color-darker: #6B7BC9;
--color-link: var(--color-lightblack);
--color-link-hover: var(--color-darker);
}
}
/* END THEMES */
a,
a:visited {
text-decoration: none;
@ -69,6 +92,15 @@ a:visited {
transition: color .2s ease, background-color .2s ease;
}
a {
color: var(--color-link);
}
a:hover {
color: var(--color-link-hover);
}
ul {
list-style-type: none;
}
@ -105,18 +137,35 @@ table {
overflow: auto;
}
.table-striped {
tbody {
tr:not(.no-stripe):nth-child(even) {
background-color: #e8e8e8;
}
}
}
.table-hover {
tbody {
tr:not(.no-hover):hover {
background-color: #d8d8d8;
}
}
}
th, td {
text-align: left;
padding: 0 13px 0 7px;
padding: 7px;
vertical-align: baseline;
}
th:first-child,
td:first-child {
padding-left: 0;
border-left: 0;
}
th {
border-left: 2px solid var(--greybase);
border-left: 2px solid var(--color-grey);
}
/* LAYOUT */
.main {
@ -140,18 +189,10 @@ th {
p {
margin: 10px 0;
}
a {
color: var(--darkbase);
}
a:hover {
color: var(--lightbase);
}
}
.pseudo-focus {
outline: 5px auto var(--lightbase);
outline: 5px auto var(--color-light);
outline: 5px auto -webkit-focus-ring-color;
}
@ -163,28 +204,22 @@ button,
outline: 0;
border: 0;
box-shadow: 0;
background-color: var(--lightbase);
background-color: var(--color-dark);
color: white;
padding: 10px 17px;
min-width: 100px;
transition: all .1s;
font-size: 16px;
cursor: pointer;
border-radius: 4px;
display: inline-block;
}
input.btn-primary,
button.btn-primary,
a.btn.btn-primary,
.btn.btn-primary {
background-color: var(--primarybase);
}
input.btn-info,
button.btn-info,
a.btn.btn-info,
.btn.btn-info {
background-color: var(--infobase)
a {
color: white;
}
a:hover {
color: white;
}
}
input[type="submit"][disabled],
@ -193,7 +228,7 @@ button[disabled],
a.btn[disabled],
.btn[disabled] {
opacity: 0.3;
background-color: var(--greybase);
background-color: var(--color-grey);
cursor: default;
}
@ -202,20 +237,33 @@ input[type="button"]:not([disabled]):hover,
button:not([disabled]):hover,
a.btn:not([disabled]):hover,
.btn:not([disabled]):hover {
background-color: var(--lighterbase);
background-color: var(--color-light);
text-decoration: underline;
color: white;
}
input.btn-primary,
button.btn-primary,
a.btn.btn-primary,
.btn.btn-primary {
background-color: var(--color-primary);
}
input.btn-info,
button.btn-info,
a.btn.btn-info,
.btn.btn-info {
background-color: var(--color-info)
}
input[type="submit"].btn-info:hover,
input[type="button"].btn-info:hover,
button.btn-info:hover,
a.btn.btn-info:hover,
.btn.btn-info:hover {
background-color: var(--greybase)
background-color: var(--color-grey)
}
.alert-debug {
background-color: rgb(240, 30, 240);
}

View File

@ -1,5 +1,5 @@
<div .container>
<h1>UniworkY - Demo
<h1>Uniworky - Demo
<h3>
Testumgebung für die Re-Implementierung von <a href="https://uniworx.ifi.lmu.de/">UniWorX</a>
<p>
@ -49,24 +49,6 @@
^{modal ".toggler1" Nothing}
<a href="/" .btn.toggler1>Klick mich für Ajax-Test
<noscript>(Für Modals bitte JS aktivieren)</noscript>
^{modal ".toggler2" (Just "Test wegen Modal")}
^{modal ".toggler2" (Just "Test Inhalt für Modal")}
<div .btn.toggler2>Klick mich für Content-Test
<noscript>(Für Modals bitte JS aktivieren)</noscript>
<li><br>
Multi-File-Input für bereits hochgeladene Dateien:
<form>
<div .form-group>
<label .form-group__label>Datei(en)
$# file 1
<div .file-checkbox__container>
<label .file-checkbox__label.reactive-label.btn for="f2-1">Datenschutz.txt
<div .checkbox>
<input .file-checkbox id="f2-1" name="f2" value="Datenschutz.txt" type="checkbox">
<label for="f2-1">
$# file 2
<div .file-checkbox__container>
<label .file-checkbox__label.reactive-label.btn for="f2-2">fill-db.hs
<div .checkbox>
<input .file-checkbox id="f2-2" name="f2" value="fill-db.hs" type="checkbox">
<label for="f2-2">

View File

@ -6,13 +6,6 @@ $forall FileUploadInfo{..} <- fileInfos
<input .file-checkbox.js-file-checkbox id=#{fuiHtmlId} name=#{fieldName} :fuiChecked:checked value=#{toPathPiece fuiId} type="checkbox">
<label for=#{fuiHtmlId}>
<div .file-checkbox__container.file-checkbox__container--checked>
<label .file-checkbox__label.reactive-label.btn for=fi1>file1.txt
<div .checkbox>
<input .file-checkbox.js-file-checkbox id=fi1 name=file checked value="file1.txt" type="checkbox">
<label for=fi1>
$# new files
<input type="file" name=#{fieldName} multiple>

View File

@ -25,15 +25,6 @@
<div .row>
<div .col-lg-12>
<h2>Dateien
<ul>
$forall fileLink <- fileLinks
<li>
$case fileLink
$of CourseR _ _ (SheetR (SheetFileR _ typ name))
#{toPathPiece typ}
<a href=@{fileLink}>#{name}
$of other
<a href=@{fileLink}>@{fileLink}
^{fileTable}
<hr>

View File

@ -63,7 +63,6 @@
if (formGroup.classList.contains('form-group')) {
formGroup.classList.add('form-group--valid')
}
submitBtn.removeAttribute('disabled');
if (isMulti) {
addNextInput();
}
@ -71,7 +70,6 @@
if (formGroup.classList.contains('form-group')) {
formGroup.classList.remove('form-group--valid')
}
submitBtn.setAttribute('disabled', 'disabled');
}
}
// addseventlistener destInput

View File

@ -13,21 +13,9 @@ form {
grid-gap: 5px;
justify-content: flex-start;
align-items: center;
margin: 10px 0;
margin: 17px 0;
padding-left: 10px;
border-left: 8px solid transparent;
}
.form-group--required {
border-left: 8px solid var(--lighterbase);
}
.form-group--valid {
border-left: 8px solid var(--validbase);
}
.form-group--has-error {
border-left: 8px solid var(--errorbase) !important;
border-left: 2px solid transparent;
}
.form-group__label {
@ -56,16 +44,54 @@ input[type="password"],
input[type="url"],
input[type="number"],
input[type="email"] {
background-color: rgba(0, 0, 0, 0.05);
padding: 7px 3px 7px;
outline: 0;
border: 0;
border-bottom: 2px solid var(--darkbase);
box-shadow: 0 2px 13px rgba(0, 0, 0, 0.05);
color: var(--fontbase);
transition: all .1s;
font-size: 16px;
/* from bulma.css */
background-color: #fff;
border-color: #dbdbdb;
color: #363636;
box-shadow: inset 0 2px 3px 1px rgba(50,50,50,.1);
min-width: 400px;
-webkit-appearance: none;
align-items: center;
border: 1px solid transparent;
border-radius: 4px;
display: inline-flex;
font-size: 1rem;
height: 2.25em;
justify-content: flex-start;
line-height: 1.5;
padding-bottom: calc(.375em - 1px);
padding-left: calc(.625em - 1px);
padding-right: calc(.625em - 1px);
padding-top: calc(.375em - 1px);
position: relative;
vertical-align: top;
}
.form-group--required {
.form-group__label::before {
content: '*';
position: absolute;
left: -14px;
}
input, textarea {
border-bottom-color: var(--color-lighter);
}
}
.form-group--valid {
input, textarea {
border-bottom-color: var(--color-success);
}
}
.form-group--has-error {
input, textarea {
border-bottom-color: var(--color-error);
}
}
input[type="text"]:focus,
@ -73,30 +99,42 @@ input[type="password"]:focus,
input[type="url"]:focus,
input[type="number"]:focus,
input[type="email"]:focus {
border-bottom-color: var(--lightbase);
/* border-bottom-color: var(--color-light);
background-color: transparent;
box-shadow: 0 0 13px var(--color-lighter); */
border-color: #3273dc;
box-shadow: 0 0 0 0.125em rgba(50,115,220,.25);
outline: 0;
}
/* BUTTON STYLE SEE default-layout.lucius */
/* TEXTAREAS */
textarea {
outline: 0;
border: 0;
padding: 7px 4px;
min-width: 400px;
min-height: 100px;
font-family: var(--fontfamilybase);
font-size: 16px;
color: var(--fontbase);
background-color: rgba(0, 0, 0, 0.05);
box-shadow: 0 2px 13px rgba(0, 0, 0, 0.05);
border-bottom: 2px solid var(--darkbase);
-webkit-appearance: none;
border: 1px solid transparent;
border-radius: 2px;
display: inline-flex;
font-size: 1rem;
height: 170px;
width: 400px;
line-height: 1.5;
padding-bottom: calc(.375em - 1px);
padding-left: calc(.625em - 1px);
padding-right: calc(.625em - 1px);
padding-top: calc(.375em - 1px);
position: relative;
vertical-align: top;
background-color: #fff;
border-color: #dbdbdb;
color: #363636;
box-shadow: inset 0 1px 2px rgba(10,10,10,.1);
}
textarea:focus {
background-color: transparent;
border-bottom-color: var(--lightbase);
border-color: #3273dc;
box-shadow: 0 0 0 0.125em rgba(50,115,220,.25);
outline: 0;
}
/* CUSTOM LEGACY CHECKBOX AND RADIO BOXES */
@ -112,14 +150,14 @@ input[type="checkbox"]::before {
position: absolute;
width: 20px;
height: 20px;
background-color: var(--lighterbase);
background-color: var(--color-lighter);
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
}
input[type="checkbox"]:checked::before {
background-color: var(--lightbase);
background-color: var(--color-light);
}
input[type="checkbox"]:checked::after {
content: '✓';
@ -148,7 +186,7 @@ input[type="checkbox"]:checked::after {
display: block;
height: 30px;
width: 30px;
background-color: var(--greybase);
background-color: var(--color-grey);
border-radius: 4px;
color: white;
cursor: pointer;
@ -180,12 +218,12 @@ input[type="checkbox"]:checked::after {
}
> :checked + label {
background-color: var(--lightbase);
background-color: var(--color-light);
text-decoration: underline;
}
&:hover > label {
background-color: var(--lighterbase);
background-color: var(--color-lighter);
}
&:hover > label::before {
@ -231,13 +269,13 @@ input[type="checkbox"]:checked::after {
/* REACTIVE LABELS */
.reactive-label {
cursor: text;
color: var(--fontsec);
color: var(--color-fontsec);
transform: translate(0, 0);
transition: all .1s;
}
.reactive-label--small {
cursor: default;
color: var(--fontbase);
color: var(--color-font);
}
@media (max-width: 999px) {
.reactive-label {
@ -246,7 +284,7 @@ input[type="checkbox"]:checked::after {
}
.reactive-label--small {
transform: translate(2px, 0px);
color: var(--fontsec);
color: var(--color-fontsec);
/*font-size: 14px;*/
}
}
@ -281,7 +319,7 @@ input[type="file"].js-file-input {
display: block;
border-radius: 2px;
padding: 5px 13px;
color: var(--whitebase);
color: var(--color-lightwhite);
cursor: pointer;
}
.file-input__label,
@ -291,7 +329,7 @@ input[type="file"].js-file-input {
height: 30px;
}
.file-checkbox__label {
background-color: var(--greybase);
background-color: var(--color-grey);
text-decoration: line-through;
}
.file-input__label.btn,
@ -325,7 +363,7 @@ input[type="file"].js-file-input {
width: 40px;
height: 30px;
text-align: center;
background-color: var(--warningbase);
background-color: var(--color-warning);
position: relative;
margin-left: 10px;
}
@ -339,14 +377,14 @@ input[type="file"].js-file-input {
background-color: white;
}
.file-input__container--valid > .file-input__label {
background-color: var(--lightbase);
background-color: var(--color-light);
}
.file-checkbox__container--checked > .file-checkbox__label {
text-decoration: none;
background-color: var(--lighterbase);
background-color: var(--color-lighter);
&.btn:hover {
background-color: var(--lighterbase);
background-color: var(--color-lighter);
text-decoration: line-through;
}
}

View File

@ -10,6 +10,8 @@
var origParent = modal.parentNode;
function open(event) {
// disable modals for narrow screens
if (window.innerWidth < 768) return true;
if (event) {
event.preventDefault();
}
@ -64,7 +66,9 @@
replaceMe.classList.remove('replace-me');
replaceMe.innerText = '...loading';
if (replaceWith.length > 0) {
fetch(replaceWith).then(function(response) {
fetch(replaceWith, {
credentials: 'same-origin'
}).then(function(response) {
return response.text();
}).then(function(body) {
var modalContent = document.createElement('div');

View File

@ -10,7 +10,7 @@
max-height: calc(100vh - 30px);
border-radius: 7px;
z-index: -1;
color: var(--fontbase);
color: var(--color-font);
padding: 20px;
overflow: auto;
opacity: 0;
@ -70,7 +70,7 @@
justify-content: center;
width: 30px;
height: 30px;
background-color: var(--darkbase);
background-color: var(--color-darker);
border-radius: 2px;
cursor: pointer;
z-index: 20;

View File

@ -24,12 +24,12 @@
left: -28px;
top: 10px;
border-left: 8px solid transparent;
border-top: 8px solid var(--lightbase);
border-top: 8px solid var(--color-light);
}
.js-show-hide__toggle:hover::before,
.js-show-hide--collapsed .js-show-hide__toggle::before {
border-left: 8px solid var(--lightbase);
border-left: 8px solid var(--color-light);
border-top: 8px solid transparent;
top: 5px;
left: -22px;

View File

@ -1 +0,0 @@
<!-- only here to be able to include sortable using `toWidget` -->

View File

@ -1,107 +0,0 @@
/**
* delcare a table as sortable by adding class 'js-sortable'
*/
(function() {
'use strict';
window.utils = window.utils || {};
window.utils.sortable = function(table) {
var ASC = 1;
var DESC = -1;
var trs, ths, sortBy, sortDir, trContents;
function setup() {
trs = table.querySelectorAll('tr');
ths = table.querySelectorAll('th');
sortBy = 0;
sortDir = ASC;
trContents = [];
Array.from(trs).forEach(function(tr, rowIndex) {
if (rowIndex === 0) {
// register table headers as sort-listener
Array.from(tr.querySelectorAll('th')).forEach(function(th, thIndex) {
th.addEventListener('click', function(el) {
sortTableBy(thIndex);
});
});
} else {
// register table rows
trContents.push(Array.from(tr.querySelectorAll('td')).map(function(td) {
return td.innerHTML;
}));
}
});
}
setup();
function updateThs(thIndex, sortOrder) {
Array.from(ths).forEach(function (th) {
th.classList.remove('sorted-asc', 'sorted-desc');
});
var suffix = sortOrder > 0 ? 'asc' : 'desc';
ths[thIndex].classList.add('sorted-' + suffix);
}
function sortTableBy(thIndex) {
var sortKey = thIndex;
var sortOrder = ASC;
if (sortBy === sortKey) {
sortOrder = sortDir === ASC ? DESC : ASC;
}
trContents.sort(dynamicSortByType(sortKey, sortOrder));
trContents.sort(dynamicSortByKey(sortKey, sortOrder));
sortBy = thIndex;
sortDir = sortOrder;
updateThs(thIndex, sortOrder);
Array.from(trs).forEach(function(tr, trIndex) {
if (trIndex > 0) {
Array.from(tr.querySelectorAll('td')).forEach(function (td, tdIndex) {
td.innerHTML = trContents[trIndex - 1][tdIndex];
});
}
});
}
function dynamicSortByKey(key, order) {
return function (a,b) {
var aVal = parseInt(a[key]);
var bVal = parseInt(b[key]);
if ((isNaN(aVal) && !isNaN(bVal)) || (!isNaN(aVal) && isNaN(bVal))) {
return 1;
}
aVal = isNaN(aVal) ? a[key] : aVal;
bVal = isNaN(bVal) ? b[key] : bVal;
var result = (aVal < bVal) ? -1 : (aVal > bVal) ? 1 : 0;
return result * order;
}
}
function dynamicSortByType(key, order) {
return function (a,b) {
var aVal = parseInt(a[key]);
var bVal = parseInt(b[key]);
aVal = isNaN(aVal) ? a[key] : aVal;
bVal = isNaN(bVal) ? b[key] : bVal;
var res = (aVal < bVal ? -1 : aVal > bVal ? 1 : 0);
if (isNaN(aVal) && !isNaN(bVal)) {
res = -1;
}
if (!isNaN(aVal) && isNaN(bVal)) {
res = 1;
}
return res * order;
}
}
};
})();
document.addEventListener('DOMContentLoaded', function() {
Array.from(document.querySelectorAll('.js-sortable')).forEach(function(table) {
utils.sortable(table);
});
});

View File

@ -1,31 +0,0 @@
table.js-sortable th {
cursor: pointer;
position: relative;
padding-right: 20px;
}
table.js-sortable th.sorted-asc,
table.js-sortable th.sorted-desc {
color: var(--darkbase);
}
table.js-sortable th.sorted-asc::after,
table.js-sortable th.sorted-desc::after {
content: '';
position: absolute;
right: 0;
top: 15px;
width: 0;
height: 0;
transform: translateY(-100%);
border-left: 8px solid transparent;
border-right: 8px solid transparent;
}
table.js-sortable th.sorted-asc::after {
border-top: 8px solid var(--lightbase);
}
table.js-sortable th.sorted-desc::after {
border-bottom: 8px solid var(--lightbase);
}

View File

@ -0,0 +1 @@
<!-- only here to be able to include tabber using `toWidget` -->

View File

@ -0,0 +1,7 @@
.tab-opener {
background-color: var(--color-dark);
&.tab-visible {
border-bottom-color: var(--color-primary);
}
}

View File

@ -27,13 +27,13 @@
<div .panel-heading>
Abgabe herunterladen
<div .panel-body .text-center>
<a href=@{SubmissionDownloadArchiveR archiveName} download .btn .btn-lg .btn-default>
$#<a href=@{SubmissionDownloadArchiveR archiveName} download .btn .btn-lg .btn-default>
<span .glyphicon .glyphicon-cloud-download aria-hidden="true"> ZIP-Archive
<div .col-md-6>
<div .panel .panel-default>
<div .panel-heading>
Abgabe ersetzen
<form role=form method=post action=@{SubmissionR cID} enctype=#{uploadEnctype} .panel-body>
<form role=form method=post action=@{SubmissionDemoR cID} enctype=#{uploadEnctype} .panel-body>
^{uploadWidget}
<div .panel .panel-default>

View File

@ -1,4 +0,0 @@
<div .table>
^{table}
<p style="text-align:center">
_{MsgPage (succ psPage) pageCount}

View File

@ -0,0 +1,12 @@
<table id="#{dbtIdent}">
$maybe sortableP <- pSortable
$with toSortable <- toSortable sortableP
<thead>
$forall OneColonnade{..} <- getColonnade dbtColonnade
^{widgetFromCell th $ withSortLinks $ toSortable oneColonnadeHead}
$nothing
<tbody>
$forall row <- rows
<tr>
$forall OneColonnade{..} <- getColonnade dbtColonnade
^{widgetFromCell td $ oneColonnadeEncode row}

View File

@ -0,0 +1,45 @@
table th {
position: relative;
padding-right: 20px;
&.sortable {
cursor: pointer;
}
a {
font-weight: 800;
}
}
table th.sorted-asc,
table th.sorted-desc {
color: var(--color-light);
}
table th.sortable::after,
table th.sortable::before {
content: '';
position: absolute;
right: 0;
width: 0;
height: 0;
transform: translateY(-100%);
border-left: 8px solid transparent;
border-right: 8px solid transparent;
}
table th.sortable::before {
top: 21px;
border-top: 8px solid rgba(0, 0, 0, 0.1);
}
table th.sortable::after {
top: 9px;
border-bottom: 8px solid rgba(0, 0, 0, 0.1);
}
table th.sorted-asc::before {
border-top: 8px solid var(--color-light);
}
table th.sorted-desc::after {
border-bottom: 8px solid var(--color-light);
}

View File

@ -0,0 +1,10 @@
$newline never
<div ##{dbtIdent}-table-wrapper>
<div .scrolltable>
^{table}
$if pageCount > 1
<ul ##{dbtIdent}-pagination .pagination>
$forall p <- pageNumbers
<li .pagination-link :p == psPage:.current>
<a href=#{tblLink $ setParam (wIdent "page") (Just $ tshow p)}>
_{MsgPage (succ p)}

View File

@ -0,0 +1,70 @@
(function collonadeClosure() {
'use strict';
document.addEventListener('DOMContentLoaded', function DOMContentLoaded() {
var ASC = 'asc';
var DESC = 'desc';
function setupAsync(wrapper) {
var table = wrapper.querySelector('#' + #{String $ dbtIdent});
var ths = Array.from(table.querySelectorAll('th.sortable'));
if (ths) {
// attach click handler to each sortable column if any
ths.forEach(function(th) {
th.addEventListener('click', clickHandler);
});
}
var pagination = wrapper.querySelector('#' + #{String $ dbtIdent} + '-pagination');
if (pagination) {
var paginationLinks = Array.from(pagination.querySelectorAll('.pagination-link'));
// attach click handler to pagination links if any
paginationLinks.forEach(function(p) {
p.addEventListener('click', clickHandler);
});
}
function clickHandler(event) {
event.preventDefault();
var url = new URL(window.location.origin + window.location.pathname + getClickDestination(this));
url.searchParams.set(#{String $ wIdent "table-only"}, 'yes');
updateTableFrom(url);
}
function getClickDestination(el) {
var link = el.querySelector('a');
if (!link) { return false; }
return link.getAttribute('href');
}
// fetches new sorted table from url with params and replaces contents of current table
function updateTableFrom(url) {
fetch(url, {
credentials: 'same-origin',
headers: {
'Accept': 'text/html'
}
}).then(function(response) {
var contentType = response.headers.get("content-type");
if (!response.ok) {
throw ('Looks like there was a problem fetching ' + url.toString() + '. Status Code: ' + response.status);
}
return response.text();
}).then(function(data) {
// replace contents of table body
wrapper.innerHTML = data;
// set up async functionality again
setupAsync(wrapper);
table.querySelector('tbody').innerHTML = data;
}).catch(function(err) {
console.error(err);
});
}
}
var selector = '#' + #{String $ dbtIdent} + '-table-wrapper';
setupAsync(document.querySelector(selector));
});
})();

View File

@ -0,0 +1,38 @@
.pagination {
margin-top: 20px;
text-align: center;
.pagination-link {
margin: 0 7px;
display: inline-block;
background-color: var(--color-grey);
a {
color: var(--color-lightwhite);
padding: 7px 13px;
display: inline-block;
}
&:not(.current):hover {
background-color: var(--color-lighter);
a {
color: var(--color-lightwhite);
}
}
&.current {
pointer-events: none;
background-color: var(--color-light);
a {
text-decoration: underline;
pointer-events: none;
}
}
&:last-child {
margin-right: 0;
}
}
}

View File

@ -0,0 +1,10 @@
$maybe flag <- sortableKey
$case directions
$of [SortAsc]
<a href=#{tblLink $ setParam (wIdent "sorting") (Just $ flag <> "-desc")}>
^{cellContents}
$of _
<a href=#{tblLink $ setParam (wIdent "sorting") (Just $ flag <> "-asc")}>
^{cellContents}
$nothing
^{cellContents}

View File

@ -1,5 +1,2 @@
<div .container>
<h1>Semesterübersicht
<div .scrolltable>
^{table}
^{table}

View File

@ -8,8 +8,7 @@ $newline never
$of NavbarAside (MenuItem label mIcon route _)
<li .asidenav__list-item :Just route == mcurrentRoute:.asidenav__list-item--active>
<a .asidenav__link-wrapper href=@{route}>
$if isJust mIcon
<div .glyphicon.glyphicon--#{fromMaybe "" mIcon}>
<div .glyphicon.glyphicon--#{fromMaybe "none" mIcon}>
<div .asidenav__link-label>#{label}
$of _
@ -17,22 +16,28 @@ $newline never
<h3 .asidenav__box-title>
WiSe 17/18
<ul .asidenav__list>
$forall (Entity _ Course{..}) <- favourites
$forall (Course{..}, courseRoute, pageActions) <- favourites
<li .asidenav__list-item>
<a .asidenav__link-wrapper href=@{CourseR courseTermId courseShorthand CourseShowR}>
<a .asidenav__link-wrapper href=@{courseRoute}>
<div .asidenav__link-shorthand>#{courseShorthand}
<div .asidenav__link-label>#{courseName}
<ul .asidenav__nested-list>
$forall action <- pageActions
$case action
$of PageActionPrime (MenuItem{..})
<li .asidenav__nested-list-item>
<a .asidenav__link-wrapper href=@{menuItemRoute}>#{menuItemLabel}
$of _
<div .asidenav__box>
<h3 .asidenav__box-title>
Themes (dev)
<select #theme-selector>
<option value="default">Default Blue
<option value="neutral-blue">Neutral Blue
<option value="aberdeen-reds">Aberdeen Reds
<option value="mint-green">Mint Green
<option value="sky-love">Sky Love
<li .asidenav__list-item>
<a .asidenav__link-wrapper href="/course/S2018/ixd/show">
<div .asidenav__link-shorthand>EXAMPLE
<div .asidenav__link-label>Beispiel-Kurs
<ul .asidenav__nested-list>
<li .asidenav__list-item>
<a .asidenav__link-wrapper href="/course/S2018/ixd/ex">Übungsblätter
<li .asidenav__list-item>
<a .asidenav__link-wrapper href="/course/S2018/ixd/show">Klausuren
<li .asidenav__list-item>
<a .asidenav__link-wrapper href="/course/S2018/ixd/show">Übungsgruppen
<div .asidenav__toggler>

View File

@ -4,7 +4,7 @@
window.utils = window.utils || {};
// Defines a function to turn an element into an interactive aside-navigation.
// If the small is smaller than 999px the navigation is automatically
// If the screen is smaller than 999px the navigation is automatically
// collapsed - even when dynamically resized (e.g. switching from portatit
// to landscape).
// The can user may also manually collapse and expand the navigation by
@ -16,7 +16,7 @@
// (potentially happening) initial collapse of the asidenav
// goes unnoticed by the user.
var animClass = 'main__aside--transitioning';
var aboveCollapsedNav = false;
var hoveringAboveCollapsedNav = false;
init();
function init() {
@ -62,17 +62,21 @@
if (!hasCollapsedClass()) {
return false;
}
aboveCollapsedNav = true;
hoveringAboveCollapsedNav = true;
window.setTimeout(function() {
if (aboveCollapsedNav && !document.body.classList.contains('touch-supported')) {
if (hoveringAboveCollapsedNav && !document.body.classList.contains('touch-supported')) {
asideEl.classList.add('pseudo-hover');
}
}, 800);
}, 200);
}, false);
asideEl.addEventListener('mouseleave', function(event) {
aboveCollapsedNav = false;
asideEl.classList.remove('pseudo-hover');
hoveringAboveCollapsedNav = false;
window.setTimeout(function() {
if (!hoveringAboveCollapsedNav) {
asideEl.classList.remove('pseudo-hover');
}
}, 200);
}, false);
}
};
@ -82,4 +86,36 @@ document.addEventListener('DOMContentLoaded', function() {
utils.aside(document.querySelector('.main__aside'));
// remove me before flight:
// EXPERIMENTAL
var selector = document.querySelector('#theme-selector');
var options = Array.from(selector.querySelectorAll('option'))
.reduce(function(acc, optEl) {
if (!acc.includes(optEl.value)) {
acc.push(optEl.value);
}
return acc;
},
[]);
selector.addEventListener('change', function(event) {
setTheme(event.target.value);
});
function setTheme(theme) {
document.body.className = 'theme--' + theme;
}
// random theme on loading and again every 20 seconds
// setInterval(function() {
// setTheme(randomOption());
// }, 20000);
// function randomOption() {
// return options[Math.floor(Math.random() * options.length)];
// }
// // initial theme
// setTheme(randomOption());
});

View File

@ -1,6 +1,6 @@
.main__aside {
position: relative;
background-color: var(--darkbase);
background-color: var(--color-dark);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
z-index: 1;
flex: 0 0 300px;
@ -12,48 +12,8 @@
transition: opacity .2s ease;
}
.main__aside--collapsed.pseudo-hover {
overflow: visible;
}
.main__aside--collapsed {
width: 50px;
flex-basis: 50px;
overflow: hidden;
.asidenav__box-title {
width: 50px;
padding: 0;
}
.asidenav__link-wrapper {
.asidenav__link-shorthand {
display: flex;
position: static;
background-color: var(--darkbase);
color: var(--whitebase);
height: 50px;
width: 50px;
text-align: center;
opacity: 1;
font-size: 16px;
line-height: 1em;
margin-right: 13px;
flex-shrink: 0;
outline: 1px solid white;
text-transform: uppercase;
word-break: break-all;
align-items: center;
justify-content: center;
}
.asidenav__link-label {
padding-left: 0;
}
}
}
.asidenav {
width: 300px;
margin-top: 20px;
color: white;
.js-show-hide__target {
@ -78,82 +38,50 @@
.asidenav__box-title {
padding: 7px 13px;
a {
color: white;
}
}
/* hover sub-menus */
.asidenav__nested-list {
position: absolute;
top: 0;
right: 0;
color: var(--fontbase);
transform: translateX(0);
opacity: 0;
transition: all .2s ease-out;
width: 0;
overflow: hidden;
z-index: -1;
.asidenav__list-item {
background-color: var(--darkbase);
color: white;
&:first-child {
margin-top: 0;
}
}
.asidenav__link-wrapper {
padding-left: 13px;
padding-right: 13px;
border-left: 20px solid white;
transition: all .2s ease;
&:hover {
background-color: white;
color: var(--darkbase) !important;
border-left: 20px solid var(--darkbase);
}
}
margin-top: 13px;
background-color: transparent;
transition: all .2s ease;
}
.asidenav__list-item {
position: relative;
background-color: white;
color: var(--darkbase);
color: var(--color-lightwhite);
margin: 4px 0;
&:not(.asidenav__list-item--active):hover {
color: white;
background-color: var(--darkbase);
&:hover {
color: var(--color-link);
background-color: var(--color-lightwhite);
.asidenav__link-shorthand {
transform: scale(1.05, 1.0);
transform-origin: right;
text-shadow: none;
}
> .asidenav__link-wrapper {
color: var(--color-link);
}
.asidenav__nested-list {
transform: translateX(100%);
opacity: 1;
width: 200px;
}
.asidenav__link-wrapper,
.asidenav__link-label {
color: white;
}
.asidenav__link-shorthand {
transform: scale(1.05, 1.0);
transform-origin: right;
}
}
}
.asidenav__list-item--active {
background-color: var(--darkbase);
color: white;
background-color: var(--color-lightwhite);
.asidenav__link-wrapper {
pointer-events: none;
color: white;
color: var(--color-link);
}
.asidenav__link-shorthand {
transform: scale(1.05, 1.0);
transform-origin: right;
text-shadow: none;
}
}
@ -163,35 +91,81 @@
height: 50px;
align-items: center;
justify-content: flex-start;
color: var(--darkbase);
color: var(--color-lightwhite);
z-index: 1;
.glyphicon {
width: 50px;
}
.asidenav__link-shorthand {
display: block;
position: absolute;
color: var(--greybase);
line-height: 50px;
opacity: 0.3;
right: 10px;
top: 0;
font-size: 40px;
text-transform: uppercase;
transition: transform .2s ease;
}
.asidenav__link-label {
padding-left: 13px;
}
.glyphicon + .asidenav__link-label {
padding-left: 0;
}
}
.asidenav__link-shorthand {
display: block;
position: absolute;
color: var(--color-grey);
line-height: 50px;
opacity: 0.2;
right: 10px;
top: 0;
font-size: 40px;
text-transform: uppercase;
transition: transform .2s ease;
text-shadow: 1px 1px 4px rgba(30, 30, 30, 0.8);
}
.asidenav__link-label {
padding-left: 13px;
}
/* hover sub-menus */
.asidenav__nested-list {
position: absolute;
top: 0;
right: 0;
color: var(--color-font);
transform: translateX(0);
opacity: 0;
transition: all .2s ease-out;
width: 0;
overflow: hidden;
z-index: -1;
}
.asidenav__nested-list-item {
position: relative;
color: var(--color-lightwhite);
background-color: var(--color-dark);
&:hover {
color: var(--color-link);
background-color: var(--color-lightwhite);
.asidenav__link-wrapper {
background-color: white;
color: var(--color-link);
}
.asidenav__nested-list {
transform: translateX(100%);
opacity: 1;
width: 200px;
}
}
.asidenav__link-wrapper {
padding-left: 13px;
padding-right: 13px;
border-left: 20px solid white;
transition: all .2s ease;
color: var(--color-lightwhite);
}
}
.asidenav__toggler {
position: absolute;
bottom: 20px;
@ -201,22 +175,78 @@
align-items: center;
justify-content: center;
transition: background-color .2s ease;
border-top: 1px solid var(--whitebase);
border-bottom: 1px solid var(--whitebase);
border-top: 1px solid var(--color-lightwhite);
border-bottom: 1px solid var(--color-lightwhite);
cursor: pointer;
&::before {
content: '\e079';
display: block;
font-family: 'Glyphicons Halflings';
color: var(--whitebase);
color: var(--color-lightwhite);
}
&:hover {
background-color: var(--lightbase);
background-color: var(--color-light);
}
}
.main__aside--collapsed .asidenav__toggler::before {
content: '\e080';
.main__aside--collapsed {
width: 50px;
flex-basis: 50px;
overflow: hidden;
&.pseudo-hover {
overflow: visible;
}
.asidenav__toggler::before {
content: '\e080';
}
.asidenav__box-title {
width: 50px;
padding: 1px;
font-size: 18px;
text-align: center;
margin-bottom: 0;
}
.asidenav__link-shorthand {
display: flex;
position: static;
background-color: var(--color-dark);
color: var(--color-lightwhite);
height: 50px;
width: 50px;
text-align: center;
opacity: 1;
font-size: 15px;
line-height: 1em;
margin-right: 13px;
flex-shrink: 0;
padding: 1px;
outline: 1px solid white;
text-transform: uppercase;
word-break: break-all;
align-items: center;
justify-content: center;
}
.asidenav__list-item:hover {
> .asidenav__link-wrapper {
color: var(--color-dark);
background-color: var(--color-lightwhite);
}
}
.asidenav__link-wrapper {
color: var(--color-lightwhite);
background-color: var(--color-dark);
}
.asidenav__link-label {
padding-left: 0;
}
}

View File

@ -1,7 +1,7 @@
$newline never
<div .breadcrumbs__container>
<ul .breadcrumbs__list.list--inline>
$forall bc <- parents
<li .breadcrumbs__item>
<a .breadcrumbs__link href="@{fst bc}">#{snd bc}
&gt;
<li .breadcrumbs__item--active>#{title}
<li .breadcrumbs__item.breadcrumbs__item--active>#{title}

View File

@ -3,12 +3,82 @@
color: white;
z-index: 10;
align-self: flex-end;
margin-bottom: 20px;
margin-bottom: 10px;
transition: margin-bottom .2s ease;
margin-left: -40px;
margin-right: -40px;
background-color: var(--color-dark);
}
.breadcrumbs__container--animated {
transition: left .2s ease;
}
.breadcrumbs__container .breadcrumbs__link {
color: white;
.breadcrumbs__link {
color: var(--color-lightwhite);
z-index: 2;
}
.breadcrumbs__item {
padding-left: 10px;
padding-right: 4px;
position: relative;
line-height: 28px;
&:nth-child(even) {
background-color: var(--color-light);
}
&:first-child {
padding-left: 20px;
}
&:nth-child(even) {
background-color: var(--color-light);
&::before {
border-left-color: var(--color-primary) !important;
}
&::after {
border-left-color: var(--color-light) !important;
}
}
&:nth-child(odd) {
background-color: var(--color-primary);
&::before {
border-left-color: var(--color-light) !important;
}
&::after {
border-left-color: var(--color-primary) !important;
}
}
&:not(:first-child) {
padding-left: 25px;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
border-top: 14px solid transparent;
border-bottom: 14px solid transparent;
border-left: 14px solid var(--color-light);
}
}
&:last-child {
&::after {
content: '';
position: absolute;
top: 0;
right: -14px;
border-top: 14px solid transparent;
border-bottom: 14px solid transparent;
border-left: 14px solid var(--color-light);
}
}
}

View File

@ -4,8 +4,8 @@
window.utils = window.utils || {};
// registers input-listener for each element in <elements> (array) and
// enables <button> if <fn> for these elements returns true
window.utils.reactiveButton = function(elements, button, fn) {
// enables <button> if <validation> for these elements returns true
window.utils.reactiveButton = function(elements, button, validation) {
if (elements.length == 0) {
return false;
}
@ -19,7 +19,7 @@
});
function updateButtonState() {
if (fn.call(null, elements)) {
if (validation.call(null, elements) === true) {
button.removeAttribute('disabled');
} else {
button.setAttribute('disabled', 'true');
@ -36,7 +36,7 @@ document.addEventListener('DOMContentLoaded', function() {
var requireds = form.querySelectorAll('[required]');
var submitBtn = form.querySelector('[type=submit]');
if (submitBtn && requireds) {
window.utils.reactiveButton(Array.from(requireds), submitBtn, function(inputs) {
window.utils.reactiveButton(Array.from(requireds), submitBtn, function validateForm(inputs) {
var done = true;
inputs.forEach(function(inp) {
var len = inp.value.trim().length;

View File

@ -1,18 +1,8 @@
<div .modal.js-modal #modal-#{modalId} data-trigger=#{modalTrigger} data-closeable=true>
$# primitive way of checking if this is supposed to be add a placeholder for async data.
$# modalContent is 'placeholder' if there should be a placeholder only.
$# 'placeholder' has length 11.
$if 11 == length modalContent
<div .replace-me>
$else
<h2>Neue Veranstaltung
#{modalContent}
<form>
<div .form-group>
<label .reactive-label for="inp1">Name
<input type="text" id="inp1">
<div .form-group>
<label .reactive-label for="inp2">Kürzel
<input type="text" id="inp2">
<div .form-group>
<label .reactive-label for="inp3">Semester
<input type="text" id="inp3">
<div .form-group>
<input type="submit" value="Submit">

View File

@ -2,10 +2,6 @@ $newline never
<div .navbar-container>
<nav .navbar.js-sticky-navbar>
<!-- breadcrumbs -->
$if not $ Just HomeR == mcurrentRoute
^{breadcrumbs}
<ul .navbar__list.list--inline>
$forall menuType <- menuTypes
$case menuType

View File

@ -7,18 +7,17 @@
width: 100%;
height: var(--header-height);
padding-right: 5vw;
padding-left: 340px;
background: var(--darkbase); /* Old browsers */
background: -moz-linear-gradient(bottom, var(--darkbase) 0%, #425d79 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(bottom, var(--darkbase) 0%,#425d79 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to top, var(--darkbase) 0%,#425d79 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
background: var(--color-darker); /* Old browsers */
background: -moz-linear-gradient(bottom, var(--color-dark) 0%, var(--color-darker) 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(bottom, var(--color-dark) 0%,var(--color-darker) 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to top, var(--color-dark) 0%,var(--color-darker) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
color: white;
box-shadow: 0 1px 10px rgba(0, 0, 0, 0.1);
z-index: 10;
top: 0;
left: 0;
overflow: hidden;
transition: height 0.2s ease;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
}
.navbar__list {
@ -50,7 +49,7 @@
justify-content: center;
align-items: center;
height: 80px;
color: var(--whitebase);
color: var(--color-lightwhite);
transition: height .2s ease;
}
@ -63,7 +62,7 @@
.navbar__list-item--secondary {
margin-left: 20px;
color: var(--greybase);
color: var(--color-grey);
}
.navbar__list-item--secondary + .navbar__list-item--secondary {
margin-left: 0;
@ -72,32 +71,31 @@
.navbar__list-item--active {
background-color: white;
color: var(--darkbase);
color: var(--color-dark);
.navbar__link-wrapper {
color: var(--darkbase);
color: var(--color-dark);
}
}
.navbar__list-item--active .navbar__link-wrapper {
pointer-events: none;
}
.navbar__list-item--active .navbar__link-label {
color: var(--darkbase);
color: var(--color-dark);
}
.navbar .navbar__list-item:not(.navbar__list-item--active):hover {
background-color: var(--darkbase);
color: var(--whitebase);
background-color: var(--color-darker);
}
.navbar .navbar__list-item:not(.navbar__list-item--active):hover .navbar__link-wrapper {
color: var(--whitebase);
color: var(--color-lightwhite);
}
.navbar .navbar__list-item:not(.navbar__list-item--active):hover .navbar__link-label {
color: var(--whitebase);
color: var(--color-lightwhite);
}
.navbar__list-item--secondary .navbar__link-wrapper,
.navbar__list-item--secondary .navbar__link-label {
color: var(--greybase);
color: var(--color-grey);
}
.navbar--sticky {

View File

@ -1,7 +1,6 @@
$newline never
$if hasPageActions
<div .page-nav-prime>
<h3>Aktionen:
<ul .pagenav__list>
$forall menuType <- menuTypes
$case menuType

View File

@ -1,21 +1,13 @@
.page-nav-prime {
background-color: var(--lightgreybase);
box-shadow: -20px -20px 0 20px var(--lightgreybase),
20px -20px 0 20px var(--lightgreybase);
padding: 13px 0;
background-color: rgba(200, 200, 200, 0.2);
padding: 13px;
margin-top: 30px;
}
.page-nav-prime .pagenav__list {
margin: 7px 0 0;
display: block;
}
.page-nav-prime .pagenav__list-item {
display: inline-block;
border-bottom: 2px solid var(--lightbase);
margin-right: 7px;
transition: border-bottom-color .2s ease;
&:hover {
border-bottom-color: var(--lighterbase);
}
}