diff --git a/.hlint.yaml b/.hlint.yaml index f6a6cd81c..24e2d327e 100644 --- a/.hlint.yaml +++ b/.hlint.yaml @@ -194,3 +194,8 @@ - warn: { lhs: n >= length xs, rhs: maxLength n xs, note: IncreasesLaziness } - warn: { lhs: length xs < n, rhs: maxLength (n - 1) xs, note: IncreasesLaziness } - warn: { lhs: n > length xs, rhs: maxLength (n - 1) xs, note: IncreasesLaziness } + + - error: {lhs: v Database.Esqueleto.==. Database.Esqueleto.nothing, rhs: Database.Esqueleto.isNothing v, name: Use Esqueleto's isNothing} + - error: {lhs: v Database.Esqueleto.==. Database.Esqueleto.val Nothing, rhs: Database.Esqueleto.isNothing v, name: Use Esqueleto's isNothing} + - error: {lhs: v Database.Esqueleto.!=. Database.Esqueleto.nothing, rhs: Database.Esqueleto.Utils.isJust v, name: Use Esqueleto's not isNothing} + - error: {lhs: v Database.Esqueleto.!=. Database.Esqueleto.val Nothing, rhs: Database.Esqueleto.Utils.isJust v, name: Use Esqueleto's not isNothing} diff --git a/CHANGELOG.md b/CHANGELOG.md index aacb10dfa..6df8c4fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [25.9.1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.9.0...v25.9.1) (2021-04-14) + +## [25.9.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.8.1...v25.9.0) (2021-04-13) + + +### Features + +* partial support for lsf import ([37cdc77](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/37cdc775b5b2d3e4cd1cc22858b2c05e75de8a3c)), closes [#686](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/686) + + +### Bug Fixes + +* build ([5c709f1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5c709f1bbb077d981fbd5d59e9c0f30cddbb468d)) +* prevent deleting sheet-referenced exam parts ([9859c2e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9859c2e99c1e0c7531ee38864a24ff279a8e6a7c)), closes [#681](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/681) + ## [25.8.1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.8.0...v25.8.1) (2021-04-09) ## [25.8.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.7.0...v25.8.0) (2021-04-08) diff --git a/config/settings.yml b/config/settings.yml index 024a72945..a0bf49f47 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -221,7 +221,7 @@ cookies: secure: "_env:COOKIES_SECURE:true" user-defaults: - max-favourites: 12 + max-favourites: 0 max-favourite-terms: 2 theme: Default date-time-format: "%a %d %b %Y %R" diff --git a/frontend/src/app.sass b/frontend/src/app.sass index 2de117489..f6bd8a949 100644 --- a/frontend/src/app.sass +++ b/frontend/src/app.sass @@ -165,7 +165,7 @@ h4 margin-top: var(--current-header-height) margin-left: 0 - :target:not(table :target)::before + :target:not(table, .show-hide__toggle)::before content: "" display: block height: var(--current-header-height) @@ -275,6 +275,9 @@ button:not(.btn-link), display: grid grid: min-content / auto-flow max-content + .buttongroup--inline + display: inline-grid + input[type="submit"][disabled]:not(.btn-link), input[type="button"][disabled]:not(.btn-link), button[disabled]:not(.btn-link), @@ -328,6 +331,10 @@ input[type="button"].btn-info:not(.btn-link):hover, &:not([disabled]):hover color: var(--color-link-hover) +// STACK ICON STYLE +.icon--stacked + font-size: 0.5rem + // GENERAL TABLE STYLES .table margin: 21px 0 diff --git a/frontend/src/utils/asidenav/asidenav.sass b/frontend/src/utils/asidenav/asidenav.sass index c6d6b070e..5bca8b347 100644 --- a/frontend/src/utils/asidenav/asidenav.sass +++ b/frontend/src/utils/asidenav/asidenav.sass @@ -1,3 +1,6 @@ +@use "../../common" as * +@use "../../app" as * + .main__aside position: fixed box-shadow: 0 0 10px rgba(0, 0, 0, 0.3) @@ -90,6 +93,18 @@ padding: 0 13px margin: 3px 0 +.asidenav__box-explanation + @extend .explanation + padding: 0 13px + margin: 3px 0 + opacity: .66 + font-size: .7rem + + /* transition: opacity .2s ease, font-size .2s ease + /* &:hover + /* font-size: .9rem + /* opacity: 1 + // LOGO .asidenav__logo @@ -217,9 +232,30 @@ .asidenav__link-shorthand display: none +.asidenav__link-favourite-toggle + opacity: .33 + + &:hover + opacity: 1 + + button + display: flex + text-decoration: none + .asidenav__link-label + display: flex + justify-content: space-between + align-items: center line-height: 1 + & > .asidenav__link-label-text + word-break: break-word + flex: 1 1 auto + + & > .asidenav__link-favourite-toggle + flex: 0 0 $fa-fw-width + margin: 0 5px + // hover sub-menus .asidenav__nested-list-wrapper position: absolute diff --git a/messages/uniworx/misc/de-de-formal.msg b/messages/uniworx/misc/de-de-formal.msg index 23f75ff2d..53999e05c 100644 --- a/messages/uniworx/misc/de-de-formal.msg +++ b/messages/uniworx/misc/de-de-formal.msg @@ -83,7 +83,6 @@ CosubmittorTip: Einladungen per E-Mail erhalten genau jene Adressen, für die ni CorrectionsTitle: Zugewiesene Korrekturen CorrectorsHead sheetName@SheetName: Korrektoren für #{sheetName} -<<<<<<< Updated upstream MaterialName: Name MaterialType: Art MaterialTypePlaceholder: Folien, Code, Beispiel, ... @@ -180,8 +179,6 @@ MaterialFree: Kursmaterialien ohne Anmeldung zugänglich UnauthorizedWrite: Sie haben hierfür keine Schreibberechtigung UnauthorizedSystemMessageTime: Diese Systemnachricht ist noch nicht oder nicht mehr einsehbar. UnauthorizedSystemMessageAuth: Diese Systemnachricht ist nur für angemeldete Benutzer einsehbar. -======= ->>>>>>> Stashed changes UnsupportedAuthPredicate authTagT@Text shownRoute@Text: „#{authTagT}“ wurde auf eine Route angewandt, die dies nicht unterstützt: „#{shownRoute}“ WorkflowRoleUserMismatch: Sie sind nicht einer der vom Workflow geforderten Benutzer @@ -491,12 +488,9 @@ CsvDeleteMissing: Fehlende Einträge entfernen TableProportion c@Text of'@Text prop@Rational: #{c}/#{of'} (#{rationalToFixed2 (100 * prop)}%) TableProportionNoRatio c@Text of'@Text: #{c}/#{of'} -<<<<<<< Updated upstream ParticipantsCsvName tid@TermId ssh@SchoolId: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-kursteilnehmer ParticipantsCsvSheetName tid@TermId ssh@SchoolId: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)} Kursteilnehmer -======= ->>>>>>> Stashed changes CsvColumnUserField: Studienfach, mit dem der Teilnehmer seine Kursanmeldung assoziiert hat CsvColumnUserDegree: Abschluss, den der Teilnehmer im assoziierten Studienfach anstrebt CsvColumnUserSemester: Fachsemester des Teilnehmers im assoziierten Studienfach @@ -581,8 +575,13 @@ FavouriteParticipant: Ihre Kurse FavouriteManual: Favoriten FavouriteCurrent: Aktueller Kurs +<<<<<<< HEAD ======= >>>>>>> Stashed changes +======= +FavouritesEmptyTip: Hier werden Ihre Kurse, sowie zuletzt besuchte Kurse angezeigt. +FavouritesToggleTip: Der Anzeigemodus für den aktuellen Kurs kann über einen Klick auf das Stern-Symbol zwischen automatisch, permanent und nie gewechselt werden. +>>>>>>> master FavouritesUnavailableTip: Das Schnellzugriffsmenü für diesen Kurs ist aktuell nicht verfügbar. @@ -669,3 +668,15 @@ UrlFieldCouldNotParseAbsolute: Konnte nicht als absolute URL interpretiert werde WGFTextInput: Textfeld WGFFileUpload: Dateifeld +<<<<<<< HEAD +======= +WorkflowGraphFormUploadIsDirectory: Upload ist Verzeichnis +WorkflowGraphFormInvalidNumberOfFiles: Es muss genau eine Datei hochgeladen werden + +CourseSortingOnlyLoggedIn: Das Benutzerinterface zur Sortierung dieser Tabelle ist nur für eingeloggte Benutzer aktiv + +CorrectionInvisibleExamUnfinished: Die Frist „_{MsgExamFinished}“ für die relevante Prüfung ist noch nicht verstrichen +CorrectionInvisibleRatingNotDone: Die Bewertung ist nicht als „Abgeschlossen“ markiert +CorrectionInvisibleWarning: Die Bewertung dieser Abgabe ist aktuell für mindestens eine an der Abgabe beteiligte Person nicht sichtbar! +CorrectionInvisibleReasons: Mögliche Gründe hierfür: +>>>>>>> master diff --git a/messages/uniworx/misc/en-eu.msg b/messages/uniworx/misc/en-eu.msg index b997c327a..d7007012c 100644 --- a/messages/uniworx/misc/en-eu.msg +++ b/messages/uniworx/misc/en-eu.msg @@ -86,7 +86,6 @@ CosubmittorTip: Invitations are sent via email to exactly those addresses for wh CorrectionsTitle: Assigned corrections CorrectorsHead sheetName: Correctors for #{sheetName} -<<<<<<< Updated upstream MaterialName: Name MaterialType: Type MaterialTypePlaceholder: Slides, Code, Example, ... @@ -182,8 +181,6 @@ MaterialFree: Course material is publicly available. UnauthorizedWrite: You do not have the write permission necessary to perform this action UnauthorizedSystemMessageTime: This system-message is not currently available. UnauthorizedSystemMessageAuth: This system-message is only available to logged in users. -======= ->>>>>>> Stashed changes UnsupportedAuthPredicate authTagT shownRoute: “#{authTagT}” was applied to a route which does not support it: “#{shownRoute}” WorkflowRoleUserMismatch: You aren't any of the users authorized by the workflow @@ -493,12 +490,9 @@ CsvDeleteMissing: Delete missing entries TableProportion c of' prop: #{c}/#{of'} (#{rationalToFixed2 (100 * prop)}%) TableProportionNoRatio c of': #{c}/#{of'} -<<<<<<< Updated upstream ParticipantsCsvName tid ssh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-participants ParticipantsCsvSheetName tid ssh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)} Participants -======= ->>>>>>> Stashed changes CsvColumnUserField: Field of study the participant specified when enrolling for the course CsvColumnUserDegree: Degree the participant pursues in their associated field of study CsvColumnUserSemester: Semester the participant is in wrt. to their associated field of study @@ -583,8 +577,13 @@ FavouriteParticipant: Your courses FavouriteManual: Favourites FavouriteCurrent: Current course +<<<<<<< HEAD ======= >>>>>>> Stashed changes +======= +FavouritesEmptyTip: Your courses and recently visited courses are shown here. +FavouritesToggleTip: The display mode for the current course can be changed between automatic, permanent and never with a click on the star symbol. +>>>>>>> master FavouritesUnavailableTip: Quick Actions for this course are currently not available. UserSimplifiedFeaturesOfStudyCsv: Simplified features of study diff --git a/package-lock.json b/package-lock.json index 5adae3a78..1666220d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "uni2work", - "version": "25.8.1", + "version": "25.9.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d808691cf..e81fa6dd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uni2work", - "version": "25.8.1", + "version": "25.9.1", "description": "", "keywords": [], "author": "", diff --git a/package.yaml b/package.yaml index 3f101e185..6f48c0774 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: uniworx -version: 25.8.1 +version: 25.9.1 dependencies: - base - yesod diff --git a/routes b/routes index 14332ce5d..223c0114e 100644 --- a/routes +++ b/routes @@ -174,7 +174,7 @@ !/course/new CourseNewR GET POST !lecturer /course/#TermId/#SchoolId/#CourseShorthand CourseR !lecturer: / CShowR GET !tutor !corrector !exam-corrector !course-registered !course-time !evaluation !exam-office !allocation-admin - /favourite CFavouriteR POST + /favourite CFavouriteR GET POST !free /register CRegisterR GET POST !timeANDcapacityANDallocation-timeAND¬course-registeredANDcourse-time !timeANDallocation-timeAND¬exam-resultANDcourse-registered !lecturerANDallocation-time /register-template CRegisterTemplateR GET !course-time /edit CEditR GET POST diff --git a/src/Database/Esqueleto/Utils.hs b/src/Database/Esqueleto/Utils.hs index a17b30cf1..cab53a24c 100644 --- a/src/Database/Esqueleto/Utils.hs +++ b/src/Database/Esqueleto/Utils.hs @@ -32,6 +32,7 @@ module Database.Esqueleto.Utils , selectMaybe , day, diffDays, diffTimes , exprLift + , explicitUnsafeCoerceSqlExprValue , module Database.Esqueleto.Utils.TH ) where @@ -55,6 +56,8 @@ import Data.Coerce (Coercible) import Data.Time.Clock (NominalDiffTime) +import qualified Data.Text.Lazy.Builder as Text.Builder + {-# ANN any ("HLint: ignore Use any" :: String) #-} {-# ANN all ("HLint: ignore Use all" :: String) #-} @@ -130,6 +133,17 @@ substring (E.ERaw p1 f1) (E.ERaw p2 f2) (E.ERaw p3 f3) ) substring a b c = substring (construct a) (construct b) (construct c) +explicitUnsafeCoerceSqlExprValue :: forall b a. + Text + -> E.SqlExpr (E.Value a) + -> E.SqlExpr (E.Value b) +explicitUnsafeCoerceSqlExprValue typ (E.ERaw p1 f1) = E.ERaw E.Parens $ \info -> + let (valTLB, valVals) = f1 info + in ( E.parensM p1 valTLB <> " :: " <> Text.Builder.fromText typ + , valVals + ) +explicitUnsafeCoerceSqlExprValue typ val = explicitUnsafeCoerceSqlExprValue typ $ construct val + construct :: E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value a) construct (E.ERaw p f) = E.ERaw E.Parens $ \info -> let (b1, vals) = f info diff --git a/src/Foundation/Instances.hs b/src/Foundation/Instances.hs index 6c43332ee..730d0ad29 100644 --- a/src/Foundation/Instances.hs +++ b/src/Foundation/Instances.hs @@ -10,7 +10,6 @@ module Foundation.Instances import Import.NoFoundation import qualified Data.Text as Text -import qualified Data.List as List import Data.List (inits) import qualified Yesod.Core.Unsafe as Unsafe @@ -28,6 +27,7 @@ import qualified Foundation.Yesod.StaticContent as UniWorX import qualified Foundation.Yesod.Persist as UniWorX import qualified Foundation.Yesod.Auth as UniWorX +import Foundation.Instances.ButtonClass import Foundation.SiteLayout import Foundation.Type import Foundation.I18n @@ -51,29 +51,6 @@ import qualified Data.CaseInsensitive as CI import qualified Database.Esqueleto as E -data instance ButtonClass UniWorX - = BCIsButton - | BCDefault - | BCPrimary - | BCSuccess - | BCInfo - | BCWarning - | BCDanger - | BCLink - | BCMassInputAdd | BCMassInputDelete - deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) - deriving anyclass (Universe, Finite) - -instance PathPiece (ButtonClass UniWorX) where - toPathPiece BCIsButton = "btn" - toPathPiece bClass = ("btn-" <>) . camelToPathPiece' 1 $ tshow bClass - fromPathPiece = flip List.lookup $ map (toPathPiece &&& id) universeF - -instance Button UniWorX ButtonSubmit where - btnClasses BtnSubmit = [BCIsButton, BCPrimary] - - - -- 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 diff --git a/src/Foundation/Instances/ButtonClass.hs b/src/Foundation/Instances/ButtonClass.hs new file mode 100644 index 000000000..2a6dfcb78 --- /dev/null +++ b/src/Foundation/Instances/ButtonClass.hs @@ -0,0 +1,33 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} +module Foundation.Instances.ButtonClass (ButtonClass(..)) where + +import Import.NoFoundation + +import Utils.Form +import Foundation.Type +import qualified Data.List as List + +-- instance RenderMessage UniWorX ButtonSubmit +import Foundation.I18n () + + +data instance ButtonClass UniWorX + = BCIsButton + | BCDefault + | BCPrimary + | BCSuccess + | BCInfo + | BCWarning + | BCDanger + | BCLink + | BCMassInputAdd | BCMassInputDelete + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) + deriving anyclass (Universe, Finite) + +instance PathPiece (ButtonClass UniWorX) where + toPathPiece BCIsButton = "btn" + toPathPiece bClass = ("btn-" <>) . camelToPathPiece' 1 $ tshow bClass + fromPathPiece = flip List.lookup $ map (toPathPiece &&& id) universeF + +instance Button UniWorX ButtonSubmit where + btnClasses BtnSubmit = [BCIsButton, BCPrimary] diff --git a/src/Foundation/SiteLayout.hs b/src/Foundation/SiteLayout.hs index 68941ea31..42c06dca4 100644 --- a/src/Foundation/SiteLayout.hs +++ b/src/Foundation/SiteLayout.hs @@ -5,6 +5,7 @@ module Foundation.SiteLayout ( siteLayout', siteLayout , siteLayoutMsg', siteLayoutMsg , getSystemMessageState + , storedFavouriteReason ) where import Import.NoFoundation hiding (embedFile, runDB) @@ -15,6 +16,7 @@ import Foundation.Routes import Foundation.Navigation import Foundation.I18n import Foundation.Yesod.Persist +import Foundation.Instances.ButtonClass import Utils.SystemMessage import Utils.Form @@ -37,6 +39,61 @@ import Text.Cassius (cassiusFile) import Text.Hamlet (hamletFile) import Data.FileEmbed (embedFile) +data CourseFavouriteToggleButton + = BtnCourseFavouriteToggleManual + | BtnCourseFavouriteToggleAutomatic + | BtnCourseFavouriteToggleOff + deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) + deriving anyclass (Universe, Finite) + +nullaryPathPiece ''CourseFavouriteToggleButton $ camelToPathPiece' 4 + +instance Button UniWorX CourseFavouriteToggleButton where + btnLabel BtnCourseFavouriteToggleManual + = toWidget $ iconFixed IconCourseFavouriteManual + btnLabel BtnCourseFavouriteToggleAutomatic + = toWidget $ iconFixed IconCourseFavouriteAutomatic + btnLabel BtnCourseFavouriteToggleOff + = toWidget $ iconStacked IconCourseFavouriteManual IconCourseFavouriteOff + + btnClasses _ = [BCIsButton, BCLink] + +-- inspired by examAutoOccurrenceIgnoreRoomsForm +courseFavouriteToggleForm :: Maybe FavouriteReason -> Form () +courseFavouriteToggleForm currentReason html + = over _1 void <$> identifyForm FIDCourseFavouriteToggle (buttonForm' [btn]) html + where + btn :: CourseFavouriteToggleButton + btn = case currentReason of + Nothing -> BtnCourseFavouriteToggleOff + (Just FavouriteVisited) -> BtnCourseFavouriteToggleAutomatic + (Just FavouriteParticipant) -> BtnCourseFavouriteToggleAutomatic + (Just FavouriteManual) -> BtnCourseFavouriteToggleManual + (Just FavouriteCurrent) -> BtnCourseFavouriteToggleAutomatic + +-- (storedReason, isBlacklist) +-- Will never return FavouriteCurrent +-- Nothing if no entry for current user (e.g. not logged in) +storedFavouriteReason :: (MonadIO m, BearerAuthSite UniWorX) => TermId -> SchoolId -> CourseShorthand -> Maybe (AuthId UniWorX, AuthEntity UniWorX) + -> ReaderT SqlBackend m (Maybe (Maybe FavouriteReason, Bool)) +storedFavouriteReason tid ssh csh muid = fmap unValueFirst . E.select . E.from $ \(course `E.LeftOuterJoin` courseFavourite) -> do + E.on $ E.just (course E.^. CourseId) E.==. courseFavourite E.?. CourseFavouriteCourse + E.&&. courseFavourite E.?. CourseFavouriteUser E.==. E.val (view _1 <$> muid) + E.where_ $ course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + E.limit 1 -- we know that there is at most one match, but we tell the DB this info too + let isBlacklist = E.exists . E.from $ \courseNoFavourite -> + E.where_ $ E.just (courseNoFavourite E.^. CourseNoFavouriteUser) E.==. E.val (view _1 <$> muid) + E.&&. courseNoFavourite E.^. CourseNoFavouriteCourse E.==. course E.^. CourseId + reason :: (E.SqlExpr (E.Value (Maybe FavouriteReason)), E.SqlExpr (E.Value Bool)) + reason = (courseFavourite E.?. CourseFavouriteReason, isBlacklist) + pure reason + where + unValueFirst :: [(E.Value (Maybe a), E.Value Bool)] -> Maybe (Maybe a, Bool) + -- `over each E.unValue` doesn't work here, since E.unValue is monomorphised + unValueFirst = fmap (bimap E.unValue E.unValue) . listToMaybe + data MemcachedKeyFavourites = MemcachedKeyFavouriteQuickActions (TermId, SchoolId, CourseShorthand) AuthContext (NonEmpty Lang) @@ -54,16 +111,15 @@ data MemcachedLimitKeyFavourites deriving anyclass (Hashable, Binary) -siteLayoutMsg :: (RenderMessage site msg, site ~ UniWorX, BearerAuthSite UniWorX, YesodPersistBackend UniWorX ~ SqlBackend, Button UniWorX ButtonSubmit) => msg -> WidgetFor UniWorX () -> HandlerFor UniWorX Html +siteLayoutMsg :: (RenderMessage site msg, site ~ UniWorX, BearerAuthSite UniWorX, YesodPersistBackend UniWorX ~ SqlBackend) => msg -> WidgetFor UniWorX () -> HandlerFor UniWorX Html siteLayoutMsg = siteLayout . i18n {-# DEPRECATED siteLayoutMsg' "Use siteLayoutMsg" #-} -siteLayoutMsg' :: (RenderMessage site msg, site ~ UniWorX, BearerAuthSite UniWorX, YesodPersistBackend UniWorX ~ SqlBackend, Button UniWorX ButtonSubmit) => msg -> WidgetFor UniWorX () -> HandlerFor UniWorX Html +siteLayoutMsg' :: (RenderMessage site msg, site ~ UniWorX, BearerAuthSite UniWorX, YesodPersistBackend UniWorX ~ SqlBackend) => msg -> WidgetFor UniWorX () -> HandlerFor UniWorX Html siteLayoutMsg' = siteLayoutMsg siteLayout :: ( BearerAuthSite UniWorX , YesodPersistBackend UniWorX ~ SqlBackend - , Button UniWorX ButtonSubmit ) => WidgetFor UniWorX () -- ^ `pageHeading` -> WidgetFor UniWorX () -> HandlerFor UniWorX Html @@ -71,7 +127,6 @@ siteLayout = siteLayout' . Just siteLayout' :: ( BearerAuthSite UniWorX , YesodPersistBackend UniWorX ~ SqlBackend - , Button UniWorX ButtonSubmit ) => Maybe (WidgetFor UniWorX ()) -- ^ `pageHeading` -> WidgetFor UniWorX () -> HandlerFor UniWorX Html @@ -94,11 +149,11 @@ siteLayout' overrideHeading widget = do now <- liftIO getCurrentTime + muid <- maybeAuthPair -- Lookup Favourites, Breadcrumbs, Headline, & Theme if possible - (favourites', (title, parents), nav', contentHeadline, mmsgs, maxFavouriteTerms, currentTheme) <- do - muid <- maybeAuthPair + (favourites', (title, parents), nav', contentHeadline, mmsgs, maxFavouriteTerms, currentTheme, storedReasonAndToggleRoute) <- do - (favCourses, breadcrumbs'', nav', contentHeadline, mmsgs) <- runDB $ do + (favCourses, breadcrumbs'', nav', contentHeadline, mmsgs, storedReasonAndToggleRoute) <- runDB $ do favCourses'' <- withReaderT (projectBackend @SqlReadBackend) . E.select . E.from $ \(course `E.LeftOuterJoin` courseFavourite) -> do E.on $ E.just (course E.^. CourseId) E.==. courseFavourite E.?. CourseFavouriteCourse E.&&. courseFavourite E.?. CourseFavouriteUser E.==. E.val (view _1 <$> muid) @@ -189,8 +244,12 @@ siteLayout' overrideHeading widget = do forM_ authTagPivots $ \authTag -> addMessageWidget Info $ msgModal [whamlet|_{MsgUnauthorizedDisabledTag authTag}|] (Left $ SomeRoute (AuthPredsR, catMaybes [(toPathPiece GetReferer, ) . toPathPiece <$> mcurrentRoute])) getMessages + + storedReasonAndToggleRoute <- case mcurrentRoute of + (Just (CourseR tid ssh csh _)) -> (, Just . SomeRoute $ CourseR tid ssh csh CFavouriteR) <$> storedFavouriteReason tid ssh csh muid + _otherwise -> pure (Nothing, Nothing) - return (favCourses, breadcrumbs'', nav', contentHeadline, mmsgs) + return (favCourses, breadcrumbs'', nav', contentHeadline, mmsgs, storedReasonAndToggleRoute) return ( favCourses , breadcrumbs'' @@ -199,10 +258,37 @@ siteLayout' overrideHeading widget = do , mmsgs , maybe userDefaultMaxFavouriteTerms userMaxFavouriteTerms $ view _2 <$> muid , maybe userDefaultTheme userTheme $ view _2 <$> muid + , storedReasonAndToggleRoute ) + let (currentReason', maybeRoute) = storedReasonAndToggleRoute + currentReason = case currentReason' of + -- (reason, blacklist) + (Just (_reason, True)) -> Nothing + (Just (Just reason, False)) -> Just reason + (Just (Nothing, False)) -> Just FavouriteCurrent + Nothing -> Just FavouriteCurrent + showFavToggle :: FavouriteReason -> Bool + showFavToggle FavouriteCurrent = isJust muid + showFavToggle _favouriteReason = False + favouriteToggleRes <- runFormPost $ courseFavouriteToggleForm currentReason + let favouriteToggleWgt = favouriteToggleRes & \((_, favouriteToggleView), favouriteToggleEncoding) -> + wrapForm favouriteToggleView def + { formAction = maybeRoute + , formEncoding = favouriteToggleEncoding + , formSubmit = FormNoSubmit + , formAttrs = [("class", "buttongroup buttongroup--inline")] + } + let favouriteTerms :: [TermIdentifier] - favouriteTerms = take maxFavouriteTerms . Set.toDescList $ foldMap (\((_, tid, _, _), _, _, _, _) -> Set.singleton $ unTermKey tid) favourites' + favouriteTerms = Set.toDescList . prune $ toTermKeySet favourites' + where + prune ts = currentTerms `Set.union` setTakeEnd (maxFavouriteTerms - Set.size currentTerms) (ts `Set.difference` currentTerms) + setTakeEnd n ts + | n <= 0 = Set.empty + | otherwise = Set.drop (Set.size ts - n) ts + currentTerms = toTermKeySet $ filter (views (_2 . _Value) . maybe True $ is _FavouriteCurrent) favourites' + toTermKeySet = setOf $ folded . _1 . _2 . to unTermKey favourites <- fmap catMaybes . forM favourites' $ \(c@(_, tid, ssh, csh), E.Value mFavourite, courseVisible, mayView, mayEdit) -> let courseRoute = CourseR tid ssh csh CShowR @@ -251,6 +337,8 @@ siteLayout' overrideHeading widget = do favouriteTermReason tid favReason' = favourites & filter (\((_, tid', _, _), _, _, favReason, _, _, _) -> unTermKey tid' == tid && favReason == favReason') & sortOn (\((cName, _, _, _), _, _, _, _, _, _) -> cName) + anyFavToggle = flip any ((,) <$> universeF <*> favouriteTerms) $ \(reason, term) -> + showFavToggle reason && not (null $ favouriteTermReason term reason) -- We break up the default layout into two components: -- default-layout is the contents of the body tag, and diff --git a/src/Foundation/Yesod/Middleware.hs b/src/Foundation/Yesod/Middleware.hs index 3aa73ab4f..683bb8ab6 100644 --- a/src/Foundation/Yesod/Middleware.hs +++ b/src/Foundation/Yesod/Middleware.hs @@ -185,7 +185,8 @@ updateFavourites cData = void . withReaderT projectBackend . runMaybeT $ do User{userMaxFavourites} <- MaybeT $ get uid -- update Favourites - for_ mcid $ \cid -> + -- no need to store them with userMaxFavourites==0, since they will be removed in the pruning step anyway! + when (userMaxFavourites > 0) $ for_ mcid $ \cid -> void . lift $ upsertBy (UniqueCourseFavourite uid cid) (CourseFavourite uid cid FavouriteVisited now) diff --git a/src/Handler/Course.hs b/src/Handler/Course.hs index b9186f509..0247f13b8 100644 --- a/src/Handler/Course.hs +++ b/src/Handler/Course.hs @@ -4,6 +4,8 @@ module Handler.Course import Import +import qualified Database.Esqueleto as E +import qualified Database.Persist as P import Handler.Course.Communication as Handler.Course import Handler.Course.Delete as Handler.Course @@ -32,5 +34,35 @@ getCNotesR, postCNotesR :: TermId -> SchoolId -> CourseShorthand -> Handler Html getCNotesR = postCNotesR postCNotesR _ _ _ = defaultLayout [whamlet|You have corrector access to this course.|] +-- simple redirect for now to avoid running into HTTP method not supported. +getCFavouriteR :: TermId -> SchoolId -> CourseShorthand -> Handler () +getCFavouriteR tid ssh csh = redirect $ CourseR tid ssh csh CShowR postCFavouriteR :: TermId -> SchoolId -> CourseShorthand -> Handler () -postCFavouriteR _ _ _ = error "not implemented" +postCFavouriteR tid ssh csh = void $ do + authPair@(uid, _) <- requireAuthPair + runDB $ void $ do + cid <- getKeyBy404 $ TermSchoolCourseShort tid ssh csh + now <- liftIO getCurrentTime + -- should never return FavouriteCurrent + newReason <- storedFavouriteReason tid ssh csh (Just authPair) <&> (\case + -- Maybe (Maybe reason, blacklist) + Nothing -> Just FavouriteManual + Just (_reason, True) -> Just FavouriteVisited + Just (Just FavouriteManual, False) -> Nothing + Just (_reason, False) -> Just FavouriteManual) + -- change stored reason in DB + case newReason of + (Just reason) -> do + void $ E.upsertBy + (UniqueCourseFavourite uid cid) + (CourseFavourite uid cid reason now) + [P.Update CourseFavouriteReason reason P.Assign] + E.deleteBy $ UniqueCourseNoFavourite uid cid + Nothing -> do + E.deleteBy $ UniqueCourseFavourite uid cid + void $ E.upsertBy + (UniqueCourseNoFavourite uid cid) + (CourseNoFavourite uid cid) + [] -- entry shouldn't exists, but keep it unchanged anyway + -- show course page again + redirect $ CourseR tid ssh csh CShowR diff --git a/src/Handler/Exam/Edit.hs b/src/Handler/Exam/Edit.hs index 959b38580..909e571f7 100644 --- a/src/Handler/Exam/Edit.hs +++ b/src/Handler/Exam/Edit.hs @@ -14,6 +14,18 @@ import Handler.Utils.Invitations import Jobs.Queue +import qualified Database.Esqueleto as E +import qualified Database.Esqueleto.Utils as E + + +data ExamEditException + = ExamEditExamNameTaken ExamName + | ExamEditWouldBreakSheetTypeReference + deriving (Eq, Ord, Read, Show, Generic, Typeable) + deriving anyclass (Exception) + +embedRenderMessage ''UniWorX ''ExamEditException id + getEEditR, postEEditR :: TermId -> SchoolId -> CourseShorthand -> ExamName -> Handler Html getEEditR = postEEditR @@ -27,31 +39,34 @@ postEEditR tid ssh csh examn = do ((editExamResult, editExamWidget), editExamEnctype) <- runFormPost . validateForm (validateExam cid $ Just exam) . examForm course $ Just template editExamAct <- formResultMaybe editExamResult $ \ExamForm{..} -> do - insertRes <- myReplaceUnique eId Exam - { examCourse = cid - , examName = efName - , examGradingRule = efGradingRule - , examBonusRule = efBonusRule - , examOccurrenceRule = efOccurrenceRule - , examExamOccurrenceMapping = examExamOccurrenceMapping oldExam - , examVisibleFrom = efVisibleFrom - , examRegisterFrom = efRegisterFrom - , examRegisterTo = efRegisterTo - , examDeregisterUntil = efDeregisterUntil - , examPublishOccurrenceAssignments = efPublishOccurrenceAssignments - , examStart = efStart - , examEnd = efEnd - , examFinished = efFinished - , examClosed = examClosed oldExam - , examPublicStatistics = efPublicStatistics - , examGradingMode = efGradingMode - , examDescription = efDescription - , examExamMode = efExamMode - , examStaff = efStaff - , examPartsFrom = efPartsFrom - } + res <- trySql @ExamEditException $ do + insertRes <- myReplaceUnique eId Exam + { examCourse = cid + , examName = efName + , examGradingRule = efGradingRule + , examBonusRule = efBonusRule + , examOccurrenceRule = efOccurrenceRule + , examExamOccurrenceMapping = examExamOccurrenceMapping oldExam + , examVisibleFrom = efVisibleFrom + , examRegisterFrom = efRegisterFrom + , examRegisterTo = efRegisterTo + , examDeregisterUntil = efDeregisterUntil + , examPublishOccurrenceAssignments = efPublishOccurrenceAssignments + , examStart = efStart + , examEnd = efEnd + , examFinished = efFinished + , examClosed = examClosed oldExam + , examPublicStatistics = efPublicStatistics + , examGradingMode = efGradingMode + , examDescription = efDescription + , examExamMode = efExamMode + , examStaff = efStaff + , examPartsFrom = efPartsFrom + } + + when (is _Just insertRes) $ + throwM $ ExamEditExamNameTaken efName - when (is _Nothing insertRes) $ do occIds <- fmap catMaybes . forM (Set.toList efOccurrences) $ traverse decrypt . eofId deleteWhere [ ExamOccurrenceExam ==. eId, ExamOccurrenceId /<-. occIds ] forM_ (Set.toList efOccurrences) $ \case @@ -83,6 +98,21 @@ postEEditR tid ssh csh examn = do } pIds <- fmap catMaybes . forM (Set.toList efExamParts) $ traverse decrypt . epfId + + brokenRefs <- E.selectExists . E.from $ \examPart -> do + E.where_ $ examPart E.^. ExamPartExam E.==. E.val eId + E.&&. examPart E.^. ExamPartId `E.notIn` E.valList pIds + E.where_ . E.exists . E.from $ \sheet -> do + let + sheetTypeExamPart :: E.SqlExpr (E.Value (Maybe Value)) + sheetTypeExamPart = sheet E.^. SheetType E.->. "exam-part" + examPartId' :: E.SqlExpr (E.Value Value) + examPartId' = E.explicitUnsafeCoerceSqlExprValue @Value "jsonb" . E.explicitUnsafeCoerceSqlExprValue @Text "text" $ examPart E.^. ExamPartId + E.where_ $ E.maybe E.false (E.==. examPartId') sheetTypeExamPart + + when brokenRefs $ + throwM ExamEditWouldBreakSheetTypeReference + deleteWhere [ ExamPartExam ==. eId, ExamPartId /<-. pIds ] forM_ (Set.toList efExamParts) $ \case ExamPartForm{ epfId = Nothing, .. } -> insert_ @@ -118,9 +148,11 @@ postEEditR tid ssh csh examn = do deleteWhere [ InvitationFor ==. invRef @ExamCorrector eId, InvitationEmail /<-. invites ] sinkInvitationsF examCorrectorInvitationConfig $ map (, eId, (InvDBDataExamCorrector, InvTokenDataExamCorrector)) invites - return . Just $ case insertRes of - Just _ -> addMessageI Error $ MsgExamNameTaken efName - Nothing -> do + return insertRes + + return . Just $ case res of + Left exc -> addMessageI Error exc + Right _ -> do addMessageI Success $ MsgExamEdited efName redirect $ CExamR tid ssh csh efName EShowR diff --git a/src/Handler/Exam/New.hs b/src/Handler/Exam/New.hs index 90d80c17d..4c20fe692 100644 --- a/src/Handler/Exam/New.hs +++ b/src/Handler/Exam/New.hs @@ -98,7 +98,7 @@ postCExamNewR tid ssh csh = do runConduit $ selectSource [ CourseParticipantCourse ==. cid, CourseParticipantState ==. CourseParticipantInactive True ] [] .| C.mapM_ recordNoShow return . Just $ case insertRes of - Nothing -> addMessageI Error $ MsgExamNameTaken efName + Nothing -> addMessageI Error $ MsgExamEditExamNameTaken efName Just _ -> do addMessageI Success $ MsgExamCreated efName redirect $ CourseR tid ssh csh CExamListR diff --git a/src/Handler/Info.hs b/src/Handler/Info.hs index d76a83d7f..8dbd009b9 100644 --- a/src/Handler/Info.hs +++ b/src/Handler/Info.hs @@ -14,7 +14,10 @@ import qualified Database.Esqueleto.Utils as E import Development.GitRev -import Auth.LDAP (ADError(..), ADInvalidCredentials(..)) +import Auth.LDAP (ADError(..), ADInvalidCredentials(..), CampusMessage(..)) + +import Yesod.Auth.Message(AuthMessage(..)) + -- | Versionsgeschichte getVersionR :: Handler TypedContent diff --git a/src/Model/Types/Misc.hs b/src/Model/Types/Misc.hs index 6bfae3fc6..2369dff95 100644 --- a/src/Model/Types/Misc.hs +++ b/src/Model/Types/Misc.hs @@ -54,8 +54,6 @@ $(deriveSimpleWith ''ToMessage 'toMessage (over Text.packed $ Text.intercalate " derivePersistField "Theme" - - data FavouriteReason = FavouriteVisited | FavouriteParticipant @@ -68,6 +66,9 @@ deriveJSON defaultOptions } ''FavouriteReason derivePersistFieldJSON ''FavouriteReason +makePrisms ''FavouriteReason + + data Sex = SexNotKnown | SexMale diff --git a/src/Utils/Form.hs b/src/Utils/Form.hs index 4f28d482d..2b5bdd4c6 100644 --- a/src/Utils/Form.hs +++ b/src/Utils/Form.hs @@ -198,6 +198,7 @@ data FormIdentifier | FIDmaterial | FIDCourseNews | FIDCourseEvent + | FIDCourseFavouriteToggle | FIDsubmission | FIDsettings | FIDcorrectors diff --git a/src/Utils/Icon.hs b/src/Utils/Icon.hs index cf553465f..be30d24d8 100644 --- a/src/Utils/Icon.hs +++ b/src/Utils/Icon.hs @@ -37,6 +37,7 @@ data Icon | IconVisible | IconInvisible | IconCourse + | IconCourseFavouriteManual | IconCourseFavouriteAutomatic | IconCourseFavouriteOff | IconEnrolTrue | IconEnrolFalse | IconPlanned @@ -110,6 +111,9 @@ iconText = \case IconVisible -> "eye" IconInvisible -> "eye-slash" IconCourse -> "graduation-cap" + IconCourseFavouriteManual -> "star" + IconCourseFavouriteAutomatic -> "star-half-alt" + IconCourseFavouriteOff -> "slash" -- TODO use FA regular style star for stacked icon IconEnrolTrue -> "user-plus" IconEnrolFalse -> "user-slash" IconPlanned -> "cog" @@ -189,6 +193,23 @@ icon ic = [shamlet| $newline never |] + +-- Create an icon from font-awesome with fixed width +iconFixed :: Icon -> Markup +iconFixed ic = [shamlet| + $newline never + + |] + +-- Stack two icons from font-awesome without additional space +iconStacked :: Icon -> Icon -> Markup +iconStacked ic0 ic1 + = [shamlet| + $newline never + + + + |] -- Create an icon (defaults to "?") with a specified tooltip iconTooltip :: forall site. WidgetFor site () -> Maybe Icon -> Bool -> WidgetFor site () diff --git a/src/Utils/Sql.hs b/src/Utils/Sql.hs index dfc09d8ac..b00dcb87d 100644 --- a/src/Utils/Sql.hs +++ b/src/Utils/Sql.hs @@ -5,6 +5,7 @@ module Utils.Sql , catchSql, handleSql , isUniqueConstraintViolation , catchIfSql, handleIfSql + , trySql ) where import ClassyPrelude.Yesod hiding (handle) @@ -125,5 +126,8 @@ catchIfSql p = flip $ handleIfSql p handleIfSql :: forall e m a. (MonadCatch m, MonadIO m, Exception e) => (e -> Bool) -> (e -> SqlPersistT m a) -> SqlPersistT m a -> SqlPersistT m a handleIfSql p recover = handleSql (\err -> bool throwM recover (p err) err) +trySql :: forall e m a. (MonadCatch m, MonadIO m, Exception e) => SqlPersistT m a -> SqlPersistT m (Either e a) +trySql = handleSql (return . Left) . fmap Right + isUniqueConstraintViolation :: SqlError -> Bool isUniqueConstraintViolation SqlError{..} = "duplicate key value violates unique constraint" `ByteString.isPrefixOf` sqlErrorMsg diff --git a/templates/i18n/faq/campus-cant-login.de-de-formal.hamlet b/templates/i18n/faq/campus-cant-login.de-de-formal.hamlet index 17d7e6768..9ee438b02 100644 --- a/templates/i18n/faq/campus-cant-login.de-de-formal.hamlet +++ b/templates/i18n/faq/campus-cant-login.de-de-formal.hamlet @@ -4,32 +4,66 @@ $newline never Können sie sich mit exakt identischen (idealerweise # copy&paste) Daten # im Campus-Portal # - anmelden?
+ anmelden? - Falls nicht, ist davon auszugehen, dass Sie Ihre Anmeldedaten falsch # - eingeben oder keine # - LMU-Benutzerkennung (ehem. Campus-Kennung) besitzen. +
+ + Falls nicht („_{InvalidLogin}“), ist davon auszugehen, dass Sie # + Ihre Anmeldedaten falsch eingeben oder # + keine LMU-Benutzerkennung # + (ehem. Campus-Kennung) besitzen.

Beachten Sie dabei auch, dass Uni2work Leerzeichen sowohl im # - Passwort als auch bei der Kennung berücksichtigt.
+ Passwort als auch bei der Kennung berücksichtigt. + +
Beim Passwort ist zudem Groß- und Kleinschreibung relevant. +
+ + Prüfen Sie bitte (auch wenn Sie einen Passwortmanager verwenden) mit # + dem Auge-Symbol rechts neben dem Passwort-Feld (nur verfügbar mit # + aktiviertem JavaScript), dass Sie Ihr Passwort korrekt eingeben. +

Uni2work bietet zwei Login-Formulare. +
+ Für die Anmeldung mit der LMU-Benutzerkennung (ehem. Campus-Kennung) # - müssen Sie das Formular „Campus-Login“ verwenden. + müssen Sie das Formular „_{MsgLDAPLoginTitle}“ verwenden. +
- Geben Sie unter „Campus-Kennung“ ihre vollständige # - LMU-Benutzerkennung an. + + Geben Sie unter „_{MsgCampusIdent}“ ihre vollständige # + LMU-Benutzerkennung an. # + Diese ist identisch mit ihrer @campus.lmu.de E-Mail # Adresse.

- Falls Sie sich # - im Campus-Portal # - anmelden können, aber nicht in Uni2work, wenden Sie sich bitte über # - das Hilfe-Formular, oben rechts auf jeder # - Seite, an die Uni2work-Administration. + Falls Sie seit Ihrem letzten Login in Uni2work ihr Passwort geändert # + haben, kann es sein, dass die Änderung des Passworts (noch) nicht # + korrekt propagiert wurde. + +
+ + In diesem Fall können Sie versuchen Ihr Passwort erneut zu ändern. + +

+ Sobald Sie die obigen Hinweise befolgt haben, wenden Sie sich bitte # + (erneut) über das Hilfe-Formular, oben rechts # + auf jeder Seite, an die Uni2work-Administration. + +
+ + Erwähnen Sie dabei bitte, dass Sie die in diesem FAQ-Punkt # + aufgeführten Hinweise beachtet haben und schildern Sie, welche # + Schritte Sie bereits ergriffen haben. + +
+ + Teilen Sie Ihr Passwort niemals mit Dritten (auch nicht der # + Uni2work-Administration oder dem IT-Servicedesk der LMU)! diff --git a/templates/i18n/faq/campus-cant-login.en-eu.hamlet b/templates/i18n/faq/campus-cant-login.en-eu.hamlet index 67c2fe89e..98a167538 100644 --- a/templates/i18n/faq/campus-cant-login.en-eu.hamlet +++ b/templates/i18n/faq/campus-cant-login.en-eu.hamlet @@ -4,31 +4,68 @@ $newline never Can you log in to # the Campus-Portal # using the exact same (ideally copied & pasted) login data? +
- If you cannot (“Invalid Login”), this means that you are entering # - your login data wrong or that you # + If you cannot (“_{InvalidLogin}”), this means that you are # + entering your login data wrong or that you # do not have a LMU user ID # (formerly Campus-ID).

Please consider that for Uni2work both your user ID and password are # sensitive to whitespace characters. +
+ Your password is also case-sensitive. +
+ + Please also use the eye-symbol next to the password field to check # + that you have entered your password correctly. # + + The eye-symbol is only visible if JavaScript is activated in your # + browser. # + + Please check your password in this way, even if you are using a # + password manager instead of typing it manually. +

Uni2work offers two login forms. +
+ To log in using your LMU user ID (formerly Campus-ID) you need to # - use the form titled “Campus login”. + use the form titled “_{MsgLDAPLoginTitle}”. +
- Under “Campus account” please enter either your entire LMU user ID, # - which is identical to your @campus.lmu.de email address. + + Under “_{MsgCampusIdent}” please enter your entire LMU user ID, # + which is identical to your @campus.lmu.de email # + address.

- If you can log in to # - the Campus-Portal # - but can't log in to Uni2work, please contact a # - Uni2work-Administrator using the Support form # + If you have changed your password since last you logged into # + Uni2work, it may be the case that your password change was not # + propagated properly. + +
+ + If so, please try changing your password again. + +

+ Once you have followed the suggestions above, please contact a # + Uni2work-administrator using the Support form # (at the top right of every page). + +
+ + Please include that you have read this faq-entry and which steps you # + have already taken. + +
+ + Never disclose your password to third parties! # + + Not even to an Uni2work-administrator or the IT-Servicedesk! diff --git a/templates/widgets/asidenav/asidenav.hamlet b/templates/widgets/asidenav/asidenav.hamlet index da4b8a010..67f6d890e 100644 --- a/templates/widgets/asidenav/asidenav.hamlet +++ b/templates/widgets/asidenav/asidenav.hamlet @@ -9,35 +9,46 @@ $newline never _{MsgLogo}

- $forall tid <- favouriteTerms -
-

-
- _{ShortTermIdentifier tid} -
- #{toPathPiece tid} - $forall favReason <- sortOn Down universeF - $if not (null $ favouriteTermReason tid favReason) -

- _{favReason} -