Merge branch 'master' into stundenplan

This commit is contained in:
Sarah Vaupel 2021-05-04 18:30:59 +02:00
commit f46f23785d
440 changed files with 25859 additions and 9706 deletions

5
.gitignore vendored
View File

@ -41,4 +41,7 @@ tunnel.log
/.well-known-cache
/**/tmp-*
/testdata/bigAlloc_*.csv
/sessions
/sessions
/changelog.json
/.current-version
/.current-changelog.md

View File

@ -1,8 +1,16 @@
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
when: always
- when: never
default:
image:
name: fpco/stack-build:lts-16.11
name: fpco/stack-build:lts-16.31
cache: &global_cache
key: default
paths:
- .npm
- node_modules
- .stack
- .stack-work
@ -21,6 +29,7 @@ variables:
UPLOAD_S3_KEY_ID: gOel7KvadwNKgjjy
UPLOAD_S3_KEY: ugO5pkEla7F0JW9MdPwLi4MWLT5ZbqAL
N_PREFIX: "${HOME}/.n"
PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/uni2work"
stages:
- setup
@ -28,13 +37,16 @@ stages:
- yesod:build
- lint
- test
- deploy
- prepare release
- upload packages
- release
# - deploy
npm install:
stage: setup
script:
- ./.npmrc.gup
- npm install
- npm ci --cache .npm --prefer-offline
before_script: &npm
- rm -rvf /etc/apt/sources.list /etc/apt/sources.list.d
- install -v -T -m 0644 ${APT_SOURCES_LIST} /etc/apt/sources.list
@ -51,30 +63,26 @@ npm install:
artifacts:
paths:
- node_modules/
name: "${CI_JOB_NAME}"
name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}"
expire_in: "1 day"
retry: 2
interruptible: true
frontend:build:
cache:
<<: *global_cache
policy: pull
stage: frontend:build
script:
- npm run frontend:build
before_script: *npm
needs:
- npm install
- job: npm install
artifacts: true
artifacts:
paths:
- static
- well-known
- config/webpack.yml
name: "${CI_JOB_NAME}"
name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}"
expire_in: "1 day"
dependencies:
- npm install
retry: 2
interruptible: true
@ -84,69 +92,235 @@ frontend:lint:
- npm run frontend:lint
before_script: *npm
needs:
- npm install
dependencies:
- npm install
- job: npm install
artifacts: true
retry: 2
interruptible: true
yesod:build:dev:
services: &build-services
stage: yesod:build
script:
- stack build --test --copy-bins --local-bin-path $(pwd)/bin --fast --flag uniworx:-library-only --flag uniworx:dev --flag uniworx:pedantic --no-strip --no-run-tests
- cp $(stack path --dist-dir)/build/hlint/hlint bin/test-hlint
- cp $(stack path --dist-dir)/build/yesod/yesod bin/test-yesod
needs:
- job: npm install # transitive
artifacts: false
- job: frontend:build
artifacts: true
before_script: &haskell
- rm -rvf /etc/apt/sources.list /etc/apt/sources.list.d
- install -v -T -m 0644 ${APT_SOURCES_LIST} /etc/apt/sources.list
- apt-get update -y
- apt-get install -y --no-install-recommends locales-all openssh-client git-restore-mtime
- install -v -m 0700 -d ~/.ssh
- install -v -T -m 0644 ${SSH_KNOWN_HOSTS} ~/.ssh/known_hosts
- install -v -T -m 0400 ${SSH_DEPLOY_KEY} ~/.ssh/deploy && echo "IdentityFile ~/.ssh/deploy" >> ~/.ssh/config;
- stack install happy
- export PATH="${HOME}/.local/bin:$PATH"
- hash -r
- git restore-mtime
artifacts:
paths:
- bin/
name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}"
expire_in: "1 week"
rules:
- if: $CI_COMMIT_REF_NAME =~ /(^v[0-9].*)|((^|\/)profile($|\/))/
when: manual
allow_failure: true
- when: on_success
retry: 2
interruptible: true
yesod:build:
stage: yesod:build
script:
- stack build --test --copy-bins --local-bin-path $(pwd)/bin --flag uniworx:-library-only --flag uniworx:-dev --flag uniworx:pedantic --no-strip --no-run-tests
- cp $(stack path --dist-dir)/build/hlint/hlint bin/test-hlint
- cp $(stack path --dist-dir)/build/yesod/yesod bin/test-yesod
needs:
- job: npm install # transitive
artifacts: false
- job: frontend:build
artifacts: true
before_script: *haskell
artifacts:
paths:
- bin/
name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}"
expire_in: "1 week"
rules:
- if: $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
when: on_success
- when: manual
allow_failure: true
retry: 2
interruptible: true
resource_group: ram
yesod:build:profile:
cache:
<<: *global_cache
policy: pull
stage: yesod:build
script:
- stack build --profile --copy-bins --local-bin-path $(pwd)/bin --flag uniworx:-library-only --flag uniworx:-dev --flag uniworx:pedantic --no-strip
needs:
- job: npm install # transitive
artifacts: false
- job: frontend:build
artifacts: true
before_script: *haskell
artifacts:
paths:
- bin/
name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}"
expire_in: "1 week"
rules:
- if: $CI_COMMIT_REF_NAME =~ /(^|\/)profile($|\/)/
when: on_success
- when: manual
allow_failure: true
retry: 2
interruptible: true
resource_group: ram
yesod:test:yesod:
stage: test
cache: {}
services: &test-services
- name: postgres:10.10
alias: postgres
- name: minio/minio:RELEASE.2020-08-27T05-16-20Z
alias: minio
command: ["minio", "server", "/data"]
stage: yesod:build
script:
- stack build --test --copy-bins --local-bin-path $(pwd)/bin --fast --flag uniworx:-library-only --flag uniworx:dev --flag uniworx:pedantic --no-strip
needs:
- frontend:build
before_script: &haskell
- rm -rvf /etc/apt/sources.list /etc/apt/sources.list.d
- install -v -T -m 0644 ${APT_SOURCES_LIST} /etc/apt/sources.list
- apt-get update -y
- apt-get install -y --no-install-recommends locales-all openssh-client
- install -v -m 0700 -d ~/.ssh
- install -v -T -m 0644 ${SSH_KNOWN_HOSTS} ~/.ssh/known_hosts
- install -v -T -m 0400 ${SSH_DEPLOY_KEY} ~/.ssh/deploy && echo "IdentityFile ~/.ssh/deploy" >> ~/.ssh/config;
artifacts:
paths:
- bin/
name: "${CI_JOB_NAME}"
expire_in: "1 week"
dependencies:
- frontend:build
- job: npm install # transitive
artifacts: false
- job: frontend:build #transitive
artifacts: false
- job: yesod:build
artifacts: true
rules:
- if: $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
when: on_success
script:
- bin/test-yesod
only:
variables:
- $CI_COMMIT_REF_NAME !~ /^v[0-9].*/
retry: 2
interruptible: true
yesod:build:
services: *build-services
stage: yesod:build
script:
- stack build --test --copy-bins --local-bin-path $(pwd)/bin --flag uniworx:-library-only --flag uniworx:-dev --flag uniworx:pedantic --no-strip
yesod:test:yesod:dev:
stage: test
cache: {}
services: *test-services
needs:
- frontend:build
- job: npm install # transitive
artifacts: false
- job: frontend:build #transitive
artifacts: false
- job: yesod:build:dev
artifacts: true
rules:
- if: $CI_COMMIT_REF_NAME =~ /(^v[0-9].*)|((^|\/)profile($|\/))/
when: manual
allow_failure: true
- when: on_success
script:
- bin/test-yesod
retry: 2
interruptible: true
yesod:test:hlint:
stage: lint
cache: &hlint_cache
key: hlint
paths:
- .stack
- .stack-work
needs:
- job: npm install # transitive
artifacts: false
- job: frontend:build #transitive
artifacts: false
- job: yesod:build
artifacts: false
rules:
- if: $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
when: on_success
before_script: *haskell
script:
- stack install hlint
- stack exec -- hlint --cc src > gl-code-quality-report.json
- jq . gl-code-quality-report.json
artifacts:
paths:
- bin/
name: "${CI_JOB_NAME}"
dependencies:
- frontend:build
only:
variables:
- $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
- gl-code-quality-report.json
name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}"
expire_in: "1 week"
reports:
codequality: gl-code-quality-report.json
retry: 2
interruptible: true
yesod:test:hlint:dev:
stage: lint
cache: *hlint_cache
needs:
- job: npm install # transitive
artifacts: false
- job: frontend:build #transitive
artifacts: false
- job: yesod:build:dev
artifacts: false
rules:
- if: $CI_COMMIT_REF_NAME =~ /(^v[0-9].*)|((^|\/)profile($|\/))/
when: manual
allow_failure: true
- when: on_success
before_script: *haskell
script:
- stack install hlint
- stack exec -- hlint --cc src > gl-code-quality-report.json
- jq . gl-code-quality-report.json
artifacts:
paths:
- gl-code-quality-report.json
name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}"
expire_in: "1 week"
reports:
codequality: gl-code-quality-report.json
retry: 2
interruptible: true
resource_group: ram
frontend:test:
cache:
@ -156,7 +330,8 @@ frontend:test:
script:
- npm run frontend:test
needs:
- npm install
- job: npm install
artifacts: true
before_script:
- rm -rvf /etc/apt/sources.list /etc/apt/sources.list.d
- install -v -T -m 0644 ${APT_SOURCES_LIST} /etc/apt/sources.list
@ -167,31 +342,137 @@ frontend:test:
- npm install -g npm
- hash -r
- apt-get install -y --no-install-recommends chromium-browser
dependencies:
- npm install
retry: 2
interruptible: true
deploy:uniworx3:
parse-changelog:
cache: {}
stage: deploy
script:
- zip -qj - bin/uniworx bin/uniworxdb | ssh root@uniworx3.ifi.lmu.de /root/bin/accept_uni2work
stage: prepare release
needs:
- yesod:build
- frontend:test # For sanity
- job: npm install
artifacts: true
rules:
- if: $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
when: on_success
before_script:
- rm -rvf /etc/apt/sources.list /etc/apt/sources.list.d
- install -v -T -m 0644 ${APT_SOURCES_LIST} /etc/apt/sources.list
- apt-get update -y
- apt-get install -y --no-install-recommends openssh-client
- install -v -m 0700 -d ~/.ssh
- install -v -T -m 0644 ${SSH_KNOWN_HOSTS} ~/.ssh/known_hosts
- install -v -T -m 0400 ${SSH_PRIVATE_KEY_UNIWORX3} ~/.ssh/uniworx3; echo "IdentityFile ~/.ssh/uniworx3" >> ~/.ssh/config;
dependencies:
- yesod:build
- apt-get install -y --no-install-recommends jq
script:
- npm run parse-changelog
- |
jq -r '.versions[0].version' changelog.json > .current-version
- |
jq -r '.versions[0].body' changelog.json > .current-changelog.md
artifacts:
paths:
- .current-version
- .current-changelog.md
name: "changelog-${CI_COMMIT_SHORT_SHA}"
expire_in: "1 day"
retry: 2
interruptible: true
upload:
cache: {}
variables:
GIT_STRATEGY: none
stage: upload packages
image: curlimages/curl:latest
needs:
- job: npm install # transitive
artifacts: false
- job: frontend:build # transitive
artifacts: false
- job: yesod:build
artifacts: true
- job: parse-changelog
artifacts: true
- job: frontend:lint # validation
artifacts: false
- job: frontend:test # validation
artifacts: false
- job: yesod:test:hlint # validation
artifacts: false
- job: yesod:test:yesod # validation
artifacts: false
rules:
- if: $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
when: on_success
before_script:
- export VERSION="$(cat .current-version)"
script:
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file bin/uniworx ${PACKAGE_REGISTRY_URL}/${VERSION}/uniworx
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file bin/uniworxdb ${PACKAGE_REGISTRY_URL}/${VERSION}/uniworxdb
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file bin/uniworxload ${PACKAGE_REGISTRY_URL}/${VERSION}/uniworxload
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file bin/uniworx-wflint ${PACKAGE_REGISTRY_URL}/${VERSION}/uniworx-wflint
release:
cache: {}
variables:
GIT_STRATEGY: none
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
needs:
- job: upload
artifacts: false
- job: npm install # transitive
artifacts: false
- job: frontend:build # transitive
artifacts: false
- job: yesod:build # transitive
artifacts: false
- job: parse-changelog
artifacts: true
- job: frontend:lint # validation
artifacts: false
- job: frontend:test # validation
artifacts: false
- job: yesod:test:hlint # validation
artifacts: false
- job: yesod:test:yesod # validation
artifacts: false
rules:
- if: $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
when: on_success
before_script:
- export VERSION="$(cat .current-version)"
script:
- |
release-cli create --name "${VERSION}" --tag-name $CI_COMMIT_TAG --description .current-changelog.md \
--assets-link "{\"name\":\"uniworx\",\"url\":\"${PACKAGE_REGISTRY_URL}/${VERSION}/uniworx\",\"filepath\":\"/uniworx\"}" \
--assets-link "{\"name\":\"uniworxdb\",\"url\":\"${PACKAGE_REGISTRY_URL}/${VERSION}/uniworxdb\",\"filepath\":\"/uniworxdb\"}" \
--assets-link "{\"name\":\"uniworxload\",\"url\":\"${PACKAGE_REGISTRY_URL}/${VERSION}/uniworxload\",\"filepath\":\"/uniworxload\"}" \
--assets-link "{\"name\":\"uniworx-wflint\",\"url\":\"${PACKAGE_REGISTRY_URL}/${VERSION}/uniworx-wflint\",\"filepath\":\"/uniworx-wflint\"}"
# deploy:uniworx3:
# cache: {}
# stage: deploy
# variables:
# GIT_STRATEGY: none
# script:
# - zip -qj - bin/uniworx bin/uniworxdb | ssh root@uniworx3.ifi.lmu.de /root/bin/accept_uni2work
# needs:
# - yesod:build
# - frontend:test # For sanity
# before_script:
# - rm -rvf /etc/apt/sources.list /etc/apt/sources.list.d
# - install -v -T -m 0644 ${APT_SOURCES_LIST} /etc/apt/sources.list
# - apt-get update -y
# - apt-get install -y --no-install-recommends openssh-client
# - install -v -m 0700 -d ~/.ssh
# - install -v -T -m 0644 ${SSH_KNOWN_HOSTS} ~/.ssh/known_hosts
# - install -v -T -m 0400 ${SSH_PRIVATE_KEY_UNIWORX3} ~/.ssh/uniworx3; echo "IdentityFile ~/.ssh/uniworx3" >> ~/.ssh/config;
# dependencies:
# - yesod:build
only:
variables:
- $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
resource_group: uniworx3
# only:
# variables:
# - $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
# resource_group: uniworx3

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "testdata/workflows"]
path = testdata/workflows
url = gitlab2.rz.ifi.lmu.de:uni2work/workflows

View File

@ -18,3 +18,184 @@
- -XQuasiQuotes
- -XTemplateHaskell
- -j
- fixity: "infix 4 `isInfixOf`"
- fixity: "infix 4 `hasInfix`"
- fixity: "infixl 6 `strConcat`"
- fixity: "infix 4 `ciEq`"
- fixity: "infix 4 `maybeEq`"
- fixity: "infixl 8 ->."
- fixity: "infixl 8 #>>."
- fixity: "infixl 6 `diffDays`"
- fixity: "infixr 3 `predDNFAnd`"
- fixity: "infixr 2 `predDNFOr`"
- fixity: "infixl 6 |-"
- fixity: "infixr 5 <|"
- fixity: "infixr 5 `cons`"
- fixity: "infixl 5 |>"
- fixity: "infixl 5 `snoc`"
- fixity: "infixl 8 ^.."
- fixity: "infixl 8 ^?"
- fixity: "infixl 8 ^?!"
- fixity: "infixl 8 ^@.."
- fixity: "infixl 8 ^@?"
- fixity: "infixl 8 ^@?!"
- fixity: "infixl 8 ^."
- fixity: "infixl 8 ^@."
- fixity: "infixr 9 <.>"
- fixity: "infixr 9 <."
- fixity: "infixr 9 .>"
- fixity: "infixl 8 ^#"
- fixity: "infixr 4 %%@~"
- fixity: "infixr 4 <%@~"
- fixity: "infixr 4 <<%@~"
- fixity: "infixr 4 %%~"
- fixity: "infixr 4 <+~"
- fixity: "infixr 4 <*~"
- fixity: "infixr 4 <-~"
- fixity: "infixr 4 <//~"
- fixity: "infixr 4 <^~"
- fixity: "infixr 4 <^^~"
- fixity: "infixr 4 <**~"
- fixity: "infixr 4 <&&~"
- fixity: "infixr 4 <||~"
- fixity: "infixr 4 <<>~"
- fixity: "infixr 4 <%~"
- fixity: "infixr 4 <<%~"
- fixity: "infixr 4 <<.~"
- fixity: "infixr 4 <<?~"
- fixity: "infixr 4 <#~"
- fixity: "infixr 4 #~"
- fixity: "infixr 4 #%~"
- fixity: "infixr 4 <#%~"
- fixity: "infixr 4 #%%~"
- fixity: "infixr 4 <<+~"
- fixity: "infixr 4 <<-~"
- fixity: "infixr 4 <<*~"
- fixity: "infixr 4 <<//~"
- fixity: "infixr 4 <<^~"
- fixity: "infixr 4 <<^^~"
- fixity: "infixr 4 <<**~"
- fixity: "infixr 4 <<||~"
- fixity: "infixr 4 <<&&~"
- fixity: "infixr 4 <<<>~"
- fixity: "infix 4 %%@="
- fixity: "infix 4 <%@="
- fixity: "infix 4 <<%@="
- fixity: "infix 4 %%="
- fixity: "infix 4 <+="
- fixity: "infix 4 <*="
- fixity: "infix 4 <-="
- fixity: "infix 4 <//="
- fixity: "infix 4 <^="
- fixity: "infix 4 <^^="
- fixity: "infix 4 <**="
- fixity: "infix 4 <&&="
- fixity: "infix 4 <||="
- fixity: "infix 4 <<>="
- fixity: "infix 4 <%="
- fixity: "infix 4 <<%="
- fixity: "infix 4 <<.="
- fixity: "infix 4 <<?="
- fixity: "infix 4 <#="
- fixity: "infix 4 #="
- fixity: "infix 4 #%="
- fixity: "infix 4 <#%="
- fixity: "infix 4 #%%="
- fixity: "infix 4 <<+="
- fixity: "infix 4 <<-="
- fixity: "infix 4 <<*="
- fixity: "infix 4 <<//="
- fixity: "infix 4 <<^="
- fixity: "infix 4 <<^^="
- fixity: "infix 4 <<**="
- fixity: "infix 4 <<||="
- fixity: "infix 4 <<&&="
- fixity: "infix 4 <<<>="
- fixity: "infixr 2 <<~"
- fixity: "infixl 1 ??"
- fixity: "infixl 1 &~"
- fixity: "infixr 9 ..."
- fixity: "infixr 8 #"
- fixity: "infixr 4 %@~"
- fixity: "infixr 4 .@~"
- fixity: "infixr 4 .~"
- fixity: "infixr 4 +~"
- fixity: "infixr 4 *~"
- fixity: "infixr 4 -~"
- fixity: "infixr 4 //~"
- fixity: "infixr 4 ^~"
- fixity: "infixr 4 ^^~"
- fixity: "infixr 4 **~"
- fixity: "infixr 4 &&~"
- fixity: "infixr 4 <>~"
- fixity: "infixr 4 ||~"
- fixity: "infixr 4 %~"
- fixity: "infixr 4 <.~"
- fixity: "infixr 4 ?~"
- fixity: "infixr 4 <?~"
- fixity: "infix 4 %@="
- fixity: "infix 4 .@="
- fixity: "infix 4 .="
- fixity: "infix 4 +="
- fixity: "infix 4 *="
- fixity: "infix 4 -="
- fixity: "infix 4 //="
- fixity: "infix 4 ^="
- fixity: "infix 4 ^^="
- fixity: "infix 4 **="
- fixity: "infix 4 &&="
- fixity: "infix 4 <>="
- fixity: "infix 4 ||="
- fixity: "infix 4 %="
- fixity: "infix 4 <.="
- fixity: "infix 4 ?="
- fixity: "infix 4 <?="
- fixity: "infixr 2 <~"
- fixity: "infixl 5 `failing`"
- fixity: "infixr 2 `zoom`"
- fixity: "infixr 2 `magnify`"
- fixity: "infixl 1 &"
- fixity: "infixl 1 <&>"
- fixity: "infixr 4 .|.~"
- fixity: "infixr 4 .&.~"
- fixity: "infixr 4 <.|.~"
- fixity: "infixr 4 <.&.~"
- fixity: "infixr 4 <<.|.~"
- fixity: "infixr 4 <<.&.~"
- fixity: "infix 4 .|.="
- fixity: "infix 4 .&.="
- fixity: "infix 4 <.|.="
- fixity: "infix 4 <.&.="
- fixity: "infix 4 <<.|.="
- fixity: "infix 4 <<.&.="
- fixity: "infixr 4 </>~"
- fixity: "infixr 4 <</>~"
- fixity: "infixr 4 <<</>~"
- fixity: "infixr 4 <.>~"
- fixity: "infixr 4 <<.>~"
- fixity: "infixr 4 <<<.>~"
- fixity: "infix 4 </>="
- fixity: "infix 4 <</>="
- fixity: "infix 4 <<</>="
- fixity: "infix 4 <.>="
- fixity: "infix 4 <<.>="
- fixity: "infix 4 <<<.>="
- suggest: { lhs: maybeT (return ()), rhs: maybeT_ }
- suggest: { lhs: fromMaybe (return ()), rhs: maybeVoid }
- suggest: { lhs: maybe (return ()) void, rhs: maybeVoid }
- warn: { lhs: length xs >= n, rhs: minLength n xs, note: IncreasesLaziness }
- warn: { lhs: n <= length xs, rhs: minLength n xs, note: IncreasesLaziness }
- warn: { lhs: length xs > n, rhs: minLength (n + 1) xs, note: IncreasesLaziness }
- warn: { lhs: n < length xs, rhs: minLength (n + 1) xs, note: IncreasesLaziness }
- warn: { lhs: length xs <= n, rhs: maxLength n xs, note: IncreasesLaziness }
- 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}

6
.mailmap Normal file
View File

@ -0,0 +1,6 @@
Gregor Kleen <gregor.kleen@ifi.lmu.de> <kleen@cip.ifi.lmu.de>
Gregor Kleen <gregor.kleen@ifi.lmu.de> <gkleen@yggdrasil.li>
Felix Hamann <felix.hamann@campus.lmu.de> <felix.hamann@satellytes.com>
Steffen Jost <jost@tcs.ifi.lmu.de> <jost@tcs.ifi.lmu.de>
Sarah Vaupel <vaupel.sarah@campus.lmu.de> <vaupel@cip.ifi.lmu.de>
Sarah Vaupel <vaupel.sarah@campus.lmu.de> Sarah Vaupel <>

27
.versionrc.js Normal file
View File

@ -0,0 +1,27 @@
const standardVersionUpdaterYaml = require.resolve('standard-version-updater-yaml');
module.exports = {
scripts: {
// postbump: './sync-versions.hs && git add -- package.yaml', // moved to bumpFiles
postchangelog: 'sed \'s/^### \\[/## [/g\' -i CHANGELOG.md'
},
packageFiles: ['package.json', 'package.yaml'],
bumpFiles: [
{
filename: 'package.json',
type: 'json'
},
{
filename: 'package-lock.json',
type: 'json'
},
{
filename: 'package.yaml',
updater: standardVersionUpdaterYaml
}
],
commitUrlFormat: 'https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/{{hash}}',
compareUrlFormat: 'https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/{{previousTag}}...{{currentTag}}',
issueUrlFormat: 'https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/{{id}}',
userUrlFormat: 'https://gitlab2.rz.ifi.lmu.de/{{user}}'
};

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,9 @@ host: "_env:HOST:*4" # any IPv4 host
port: "_env:PORT:3000"
ip-from-header: "_env:IP_FROM_HEADER:false"
approot: "_env:APPROOT:http://localhost:3000"
# approot:
# default: "http://localhost:3000"
# user-generated: "http://127.0.0.1:3000"
mail-from:
name: "_env:MAILFROM_NAME:Uni2work"
email: "_env:MAILFROM_EMAIL:uniworx@localhost"
@ -60,6 +63,9 @@ health-check-matching-cluster-config-timeout: "_env:HEALTHCHECK_MATCHING_CLUSTER
synchronise-ldap-users-within: "_env:SYNCHRONISE_LDAP_WITHIN:1209600"
synchronise-ldap-users-interval: "_env:SYNCHRONISE_LDAP_INTERVAL:3600"
study-features-recache-relevance-within: 172800
study-features-recache-relevance-interval: 293
log-settings:
detailed: "_env:DETAILED_LOGGING:false"
all: "_env:LOG_ALL:false"
@ -84,6 +90,7 @@ auth-pw-hash:
# reload-templates: false
# mutable-static: false
# skip-combining: false
# clear-cache: false
database:
user: "_env:PGUSER:uniworx"
@ -150,6 +157,10 @@ memcached:
limit: "_env:MEMCACHED_LIMIT:1024"
timeout: "_env:MEMCACHED_TIMEOUT:20"
expiration: "_env:MEMCACHED_EXPIRATION:300"
memcache-auth: true
memcached-local:
maximum-ghost: 512
maximum-weight: 104857600 # 100MiB
upload-cache:
host: "_env:UPLOAD_S3_HOST:"
@ -161,9 +172,11 @@ upload-cache:
auto-discover-region: "_env:UPLOAD_S3_AUTO_DISCOVER_REGION:true"
disable-cert-validation: "_env:UPLOAD_S3_DISABLE_CERT_VALIDATION:false"
upload-cache-bucket: "uni2work-uploads"
upload-tmp-bucket: "uni2work-tmp"
inject-files: 601
rechunk-files: 1201
check-missing-files: 7207
file-upload-db-chunksize: 4194304 # 4MiB
file-chunking-target-exponent: 21 # 2MiB
@ -174,9 +187,13 @@ server-sessions:
absolute-timeout: 604801
timeout-resolution: 601
persistent-cookies: true
session-token-start: null
session-token-expiration: 28807
session-token-encoding: HS256
session-token-clock-leniency-start: 5
bearer-token-clock-leniency-start: 5
cookies:
SESSION:
same-site: lax
@ -204,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"
@ -246,6 +263,10 @@ token-buckets:
depth: 20971520 # 20MiB
inv-rate: 9.5e-7 # 1MiB/s
initial-value: 0
inject-files-count:
depth: 100
inv-rate: 1
initial-value: 0
prune-files:
depth: 1572864000 # 1500MiB
inv-rate: 1.9e-6 # 2MiB/s
@ -256,3 +277,19 @@ token-buckets:
initial-value: 0
fallback-personalised-sheet-files-keys-expire: 2419200
download-token-expire: 604801
file-source-arc:
maximum-ghost: 512
maximum-weight: 1073741824 # 1GiB
file-source-prewarm:
maximum-weight: 1073741824 # 1GiB
start: 1800 # 30m
end: 600 # 10m
inhibit: 3600 # 60m
steps: 20
max-speedup: 3
bot-mitigations:
- only-logged-in-table-sorting

View File

@ -1,6 +1,7 @@
database:
database: "_env:PGDATABASE_TEST:uniworx_test"
upload-cache-bucket: "uni2work-test-uploads"
upload-tmp-bucket: "uni2work-test-tmp"
log-settings:
detailed: true

View File

@ -4,6 +4,8 @@ import { I18n } from './services/i18n/i18n';
import { UtilRegistry } from './services/util-registry/util-registry';
import { isValidUtility } from './core/utility';
import 'css.escape';
import './app.sass';
export class App {

View File

@ -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)
@ -280,7 +280,10 @@ button:not(.btn-link),
.buttongroup
display: grid
grid: min-content / auto-flow 1fr
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),
@ -337,6 +340,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
@ -412,6 +419,9 @@ input[type="button"].btn-info:not(.btn-link):hover,
font-weight: 600
color: var(--color-fontsec)
.table__td--tooltip
width: 2em
.table__td
font-size: 16px
color: var(--color-font)
@ -470,7 +480,7 @@ input[type="button"].btn-info:not(.btn-link):hover,
overflow-y: auto
.table--vertical
th
th, .table__th
background-color: transparent
color: var(--color-font)
width: 170px
@ -478,7 +488,16 @@ input[type="button"].btn-info:not(.btn-link):hover,
padding-right: 15px
font-weight: 400
td
a
color: var(--color-lin)
&:hover
color: var(--color-link-hover)
&::before
display: none
td, .table__td
font-weight: 600
color: var(--color-font)
@ -588,13 +607,35 @@ section
padding-bottom: 30px
border-bottom: 1px solid #d3d3d3
+ section
& + section, & + .two-column-sections
margin-top: 20px
&:last-child
border-bottom: none
padding-bottom: 0px
.two-column-sections
padding-bottom: 30px
border-bottom: 1px solid #d3d3d3
& + section, & + .two-column-sections
margin-top: 20px
&:last-child
border-bottom: none
padding-bottom: 0px
@media (min-width: 768px)
display: flex
justify-content: space-between
& > section
padding: 0
border: none
margin: 0 auto
width: calc(50% - 7px)
.headline-one
margin-bottom: 10px
@ -692,7 +733,7 @@ section
color: var(--color-lightblack)
.notification-success
color: var(--color-warning)
color: var(--color-success-dark)
// "Heated" element.
// Set custom property "--hotness" to a value from 0 to 1 to turn
@ -729,7 +770,7 @@ section
background-color: hsla($hue, 75%, 50%, $opacity) !important
.uuid, .pseudonym, .ldap-primary-key, .email, .file-path, .metric-value, .metric-label
.uuid, .pseudonym, .ldap-primary-key, .email, .file-path, .metric-value, .metric-label, .workflow-payload--text, .cryptoid
font-family: var(--font-monospace)
.shown
@ -946,7 +987,7 @@ th, td
right: 5px
top: 5px
.occurrence--not-registered, .no-bonus, .allocation-course--excluded, .allocation-course--inactive
.occurrence--not-registered, .no-bonus, .allocation-course--excluded, .allocation-course--inactive, .occurrence--ignored
text-decoration: line-through
.result
@ -975,18 +1016,29 @@ th, td
dd, .dd
margin-left: 12px
.explanation
font-style: italic
.note
font-size: 0.9rem
font-weight: 600
color: var(--color-fontsec)
.explanation
font-style: italic
@extend .note
emph
font-style: normal
// SORTABLE TABLE-HEADERS
.table__th.sortable
position: relative
padding-right: 24px
cursor: pointer
.table__th.presorted,
.table__th.presorted
position: relative
padding-right: 24px
.table__th.sortable::after,
.table__th.sortable::before
content: ''
@ -1016,6 +1068,28 @@ th, td
.table__th.sorted-desc::after
border-bottom-color: white !important
.table__th.presorted::before,
.table__th.presorted::after
content: ''
position: absolute
top: 50%
right: 4px
width: 0
height: 0
border-left: 8px solid transparent
border-right: 8px solid transparent
border-bottom: 8px solid rgba(255, 255, 255, 0.2)
.table__th.presorted.sorted-asc::before,
.table__th.presorted.sorted-desc::after
border-bottom-color: white !important
.table__th.presorted::before
transform: translateY(150%) scale(1, -1)
transform-origin: top
.table__th.presorted::after
transform: translateY(-150%)
\:root
--color-grey-light: #efefef
--color-grey-lighter: #f5f5f5
@ -1037,6 +1111,22 @@ th, td
border-left: 8px solid transparent
border-right: 8px solid transparent
border-bottom: 8px solid rgba(255, 255, 255, 0.4)
.inactive-course-header::before,
.inactive-course-header::after
content: ''
position: absolute
right: 10px
top: 20px
width: 0
height: 0
border-left: 8px solid transparent
border-right: 8px solid transparent
border-bottom: 8px solid rgba(255,255,255, 0.2)
.inactive-course-header.sorted-asc::before,
.inactive-course-header.sorted-desc::after
border-bottom-color: white !important
.course-header::before
// magic numbers to move arrow back in the right position after flipping it.
@ -1047,6 +1137,12 @@ th, td
.course-header::after
transform: translateY(-150%)
.inactive-course-header::before
transform: translateY(150%) scale(1, -1)
transform-origin: top
.inactive-course-header::after
transform: translateY(-150%)
.course-header:hover::before,
.course-header:hover::after
border-bottom-color: rgba(255, 255, 255, 0.7)
@ -1408,6 +1504,114 @@ a.breadcrumbs__home
.multi-user-invitation-field__wrapper
max-width: 25rem
.json, .yaml
white-space: pre-wrap
font-family: var(--font-monospace)
pre, tt, code
font-family: var(--font-monospace)
.workflow-instances
margin: 0
list-style: none
& > li
margin: 0 0 0.5rem
padding: 0 10px 12px 7px
border-left: 1px solid var(--color-grey)
&:nth-child(2n)
background-color: rgba(0, 0, 0, 0.015)
.workflow-instance--name, .workflow-instance--title
font-size: 1.2rem
font-weight: 600
.workflow-instance--name
font-family: var(--font-monospace)
.workflow-instance--actions
margin: 0 0 0.5rem 11px
.workflow-history-labels
display: flex
flex-direction: column
&__own
align-self: flex-end
text-align: right
&__others
align-self: flex-start
text-align: left
.workflow-history
@extend .list--iconless
display: flex
flex-direction: column
position: relative
.workflow-history--item
border: 1px solid var(--color-grey)
align-self: flex-start
padding: 7px
margin: 12px 0
min-width: 45%
display: grid
grid-template-areas: 'user time' 'action-states action-states' 'payload payload'
margin-right: 10vw
&.workflow-history-item__self
align-self: flex-end
margin-left: 10vw
margin-right: 0
&:last-child
margin-bottom: 0
&:first-child
margin-bottom: 0
.workflow-history--item-user
grid-area: user
.workflow-history--item-time
grid-area: time
text-align: right
.workflow-history--item-action-states
grid-area: action-states
margin-top: 7px
.deflist__dt, .deflist__dd
padding-top: 0
padding-bottom: 0
.workflow-history--item-payload-changes
grid-area: payload
margin-top: 12px
border-top: 1px solid var(--color-grey)
padding-top: 12px
.workflow-history--item-payload-changes-label
font-size: 20px
font-weight: 600
.workflow-history--item-user-special, .workflow-history--item-action-special, .workflow-history--item-state-special
@extend .explanation
.workflow-state
margin-top: 7px
.deflist__dt, .deflist__dd
padding-top: 0
padding-bottom: 0
.workflow-payload
margin-top: 12px
.workflow-payload--label
font-size: 20px
font-weight: 600
video
max-width: 100%
max-height: calc(90vh - var(--current-header-height))

View File

@ -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

View File

@ -355,6 +355,9 @@ export class AsyncTable {
_linkClickHandler = (event) => {
event.preventDefault();
let url = this._getClickDestination(event.target);
if (!url)
return;
if (!url.match(/^http/)) {
url = window.location.origin + window.location.pathname + url;
}
@ -363,7 +366,7 @@ export class AsyncTable {
_getClickDestination(el) {
if (!el.matches('a') && !el.querySelector('a')) {
return '';
return null;
}
return el.getAttribute('href') || el.querySelector('a').getAttribute('href');
}

View File

@ -216,7 +216,10 @@
line-height: 1.4
max-width: 85vw
.course-header
.explanation
clear: both
.course-header, .inactive-course-header
float: left
background-color: var(--color-dark)
position: relative

View File

@ -3,6 +3,8 @@ import './datepicker.css';
import { Utility } from '../../core/utility';
import moment from 'moment';
import * as defer from 'lodash.defer';
const KEYCODE_ESCAPE = 27;
const Z_INDEX_MODAL = 9999;
@ -77,8 +79,11 @@ export class Datepicker {
datepickerInstance;
_element;
elementType;
initialValue;
_locale;
_unloadIsDueToSubmit = false;
constructor(element) {
if (!element) {
throw new Error('Datepicker utility needs to be passed an element!');
@ -100,6 +105,9 @@ export class Datepicker {
// store the previously set type to select the input format
this.elementType = this._element.getAttribute('type');
// store initial value prior to changing type
this.initialValue = this._element.value || this._element.getAttribute('value');
// manually set the type attribute to text because datepicker handles displaying the date
this._element.setAttribute('type', 'text');
@ -120,7 +128,7 @@ export class Datepicker {
// FIXME dirty hack below; fix tail.datetime instead
// get date object from internal format before datetime does nasty things with it
let parsedMomentDate = moment(this._element.value, [ FORM_DATE_FORMAT[this.elementType], FORM_DATE_FORMAT_MOMENT[this.elementType] ], true);
let parsedMomentDate = moment(this.initialValue, [ FORM_DATE_FORMAT[this.elementType], FORM_DATE_FORMAT_MOMENT[this.elementType] ], true);
if (parsedMomentDate && parsedMomentDate.isValid()) {
parsedMomentDate = parsedMomentDate.toDate();
} else {
@ -222,7 +230,7 @@ export class Datepicker {
});
// format the date value of the form input element of this datepicker before form submission
this._element.form.addEventListener('submit', () => this.formatElementValue());
this._element.form.addEventListener('submit', this._submitHandler.bind(this));
}
destroy() {
@ -257,6 +265,16 @@ export class Datepicker {
}
}
_submitHandler() {
this._unloadIsDueToSubmit = true;
this.formatElementValue(false);
defer(() => { // Restore state after event loop is settled
this._unloadIsDueToSubmit = false;
this.formatElementValue(true);
});
}
/**
* Returns a datestring in internal format from the current state of the input element value.
* @param {*} toFancy Format date from internal to fancy or vice versa. When omitted, toFancy is falsy and results in fancy -> internal

View File

@ -5,6 +5,8 @@ import { AUTO_SUBMIT_INPUT_UTIL_SELECTOR } from './auto-submit-input';
import { InteractiveFieldset } from './interactive-fieldset';
import { Datepicker } from './datepicker';
import * as defer from 'lodash.defer';
/**
* Key generator from an arbitrary number of FormData objects.
* @param {...any} formDatas FormData objects
@ -67,6 +69,7 @@ export class NavigateAwayPrompt {
this._element.addEventListener('submit', () => {
this._unloadDueToSubmit = true;
defer(() => { this._unloadDueToSubmit = false; } ); // Restore state after event loop is settled
});
}
@ -95,9 +98,8 @@ export class NavigateAwayPrompt {
// allow the event to happen if the form was not touched by the
// user (i.e. if the current FormData is equal to the initial FormData)
// or the unload event was initiated by a form submit
if (!formDataHasChanged || this._unloadDueToSubmit) {
return false;
}
if (!formDataHasChanged || this._unloadDueToSubmit)
return;
// cancel the unload event. This is the standard to force the prompt to appear.
event.preventDefault();

View File

@ -1,6 +1,7 @@
import { Checkbox } from './checkbox';
import { FileInput } from './file-input';
import { FileMaxSize } from './file-max-size';
import { Password } from './password';
import './inputs.sass';
import './radio-group.sass';
@ -9,4 +10,5 @@ export const InputUtils = [
Checkbox,
FileInput,
FileMaxSize,
Password,
];

View File

@ -49,12 +49,20 @@
color: var(--color-fontsec)
font-size: 0.9rem
.form-group--required .form-group-label__caption::after, .form-group__required-marker::before
.form-group--required > .form-group-label > .form-group-label__caption::after, .form-group__required-marker::before
content: ' *'
color: var(--color-error)
font-weight: 600
font-style: normal
.form-group--potentially-required > .form-group-label > .form-group-label__caption::after, .form-group__potentially-required-marker::before
content: ''
color: var(--color-warning)
font-weight: 600
font-style: normal
vertical-align: super
font-size: 80%
.form-group--submit .form-group__input
grid-column: 2
@ -225,11 +233,15 @@ option
margin: 10px 0
color: var(--color-fontsec)
.file-input__list-wrapper
overflow: auto
max-height: 75vh
max-width: 30vw
.file-input__list
margin-left: 40px
margin-top: 10px
font-weight: 600
max-width: 25vw
tr:last-child td
padding-bottom: 0
@ -237,6 +249,7 @@ option
.file-input__list-item
font-family: var(--font-monospace)
font-size: 15px
word-break: break-all
// PREVIOUSLY UPLOADED FILES
@ -250,3 +263,40 @@ option
.checkbox
margin-left: 12px
.form--vertical .form-group__input
grid-column: unset
grid-row: 2
.form-group.form--vertical
grid-template: auto auto / auto
.form--vertical__cell
vertical-align: top
// PASSWORD INPUT
.password-input__wrapper
display: grid
grid-template-areas: 'input toggle'
width: 100%
max-width: 600px
grid-template-rows: auto
grid-template-columns: 1fr auto
.password-input__input
grid-area: input
.password-input__toggle
grid-area: toggle
display: flex
justify-content: center
align-content: center
flex-direction: column
padding: 7px
cursor: pointer
color: var(--color-fontsec)
&:hover
color: var(--color-font)

View File

@ -0,0 +1,77 @@
import { Utility } from '../../core/utility';
const PASSWORD_INITIALIZED_CLASS = 'password-input--initialized';
@Utility({
selector: 'input[type="password"]:not([uw-no-password])',
})
export class Password {
_element;
_iconEl;
_toggleContainerEl;
constructor(element) {
if (!element)
throw new Error('Password utility cannot be setup without an element!');
if (element.classList.contains(PASSWORD_INITIALIZED_CLASS))
return false;
this._element = element;
this._element.classList.add('password-input__input');
const siblingEl = this._element.nextSibling;
const parentEl = this._element.parentElement;
const wrapperEl = document.createElement('div');
wrapperEl.classList.add('password-input__wrapper');
wrapperEl.appendChild(this._element);
this._toggleContainerEl = document.createElement('div');
this._toggleContainerEl.classList.add('password-input__toggle');
wrapperEl.appendChild(this._toggleContainerEl);
this._iconEl = document.createElement('i');
this._iconEl.classList.add('fas', 'fa-fw');
this._toggleContainerEl.appendChild(this._iconEl);
parentEl.insertBefore(wrapperEl, siblingEl);
this._element.classList.add(PASSWORD_INITIALIZED_CLASS);
}
start() {
this.updateVisibleIcon(this.isVisible());
this._toggleContainerEl.addEventListener('mouseover', () => {
this.updateVisibleIcon(!this.isVisible());
});
this._toggleContainerEl.addEventListener('mouseout', () => {
this.updateVisibleIcon(this.isVisible());
});
this._toggleContainerEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.setVisible(!this.isVisible());
});
}
isVisible() {
return this._element.type !== 'password';
}
setVisible(visible) {
this._element.type = visible ? 'text' : 'password';
this.updateVisibleIcon(visible);
}
updateVisibleIcon(visible) {
function visibleClass(visible) {
return 'fa-' + (visible ? 'eye' : 'eye-slash');
}
this._iconEl.classList.remove(visibleClass(!visible));
this._iconEl.classList.add(visibleClass(!!visible));
}
}

View File

@ -226,8 +226,21 @@ export class MassInput {
}
_serializeForm(submitButton, enctype) {
const rawFormData = new FormData(this._massInputForm);
const extraneousKeys = new Set();
for (const k of rawFormData.keys()) {
const n = k.replace(/\[\]$/, '');
const inputElements = Array.from(this._massInputForm.querySelectorAll(`[name="${CSS.escape(n)}"]`));
const isBelowMassinput = inputElements.some((elem) => this._element.contains(elem));
const isFile = inputElements.some((elem) => elem.type === 'file');
if (!isBelowMassinput && isFile)
extraneousKeys.add(k);
}
for (const k of extraneousKeys)
rawFormData.delete(k);
// create new FormData and format any date values
const formData = Datepicker.unformatAll(this._massInputForm, new FormData(this._massInputForm));
const formData = Datepicker.unformatAll(this._massInputForm, rawFormData);
// manually add name and value of submit button to formData
formData.append(submitButton.name, submitButton.value);

View File

@ -4,4 +4,15 @@ set -e
[ "${FLOCKER}" != "$0" ] && exec env FLOCKER="$0" flock -en .stack-work.lock "$0" "$@" || :
move-back() {
mv -v .stack-work .stack-work-test
[[ -d .stack-work-build ]] && mv -v .stack-work-build .stack-work
}
if [[ -d .stack-work-test ]]; then
[[ -d .stack-work ]] && mv -v .stack-work .stack-work-build
mv -v .stack-work-test .stack-work
trap move-back EXIT
fi
exec -- stack build --test --fast --flag uniworx:dev --flag uniworx:library-only uniworx:test:hlint

View File

@ -50,13 +50,6 @@ import Data.List (genericLength)
import qualified Control.Retry as Retry
instance (a ~ b, Monad m) => Monoid (Kleisli m a b) where
mempty = Kleisli return
instance (a ~ b, Monad m) => Semigroup (Kleisli m a b) where
Kleisli f <> Kleisli g = Kleisli $ f <=< g
data Normal k = Normal
{ dAvg :: k
, dRelDev :: Centi
@ -367,5 +360,5 @@ sheetZipURI :: ReaderT SimulationContext IO URI
sheetZipURI = do
LoadOptions{..} <- asks loadOptions
let zipURI = nullURI { uriPath = unpack . Text.intercalate "/" $ "." : zipPath }
where (zipPath, _) = renderRoute . CSheetR loadTerm loadSchool loadCourse loadSheet $ SZipR SheetExercise
where (zipPath, _) = renderRoute . CSheetR loadTerm loadSchool loadCourse loadSheet $ SZipR SheetExercise -- FIXME: Broken with ApprootUserGenerated
return $ zipURI `relativeTo` loadBaseURI

View File

@ -1,5 +1,3 @@
PrintDebugForStupid name@Text: Debug message "#{name}"
Logo: Uni2work
BtnSubmit: Senden
@ -17,7 +15,7 @@ BtnExamSwitchOccurrence: Zu Prüfungstermin/-raum wechseln
BtnExamDeregister: Von der Prüfung abmelden
BtnHijack: Sitzung übernehmen
BtnSave: Speichern
PressSaveToSave: Änderungen werden erst durch Drücken des Knopfes "Speichern" gespeichert.
PressSaveToSave: Änderungen werden erst durch Drücken des Knopfes „_{MsgBtnSave}“ gespeichert.
BtnHandIn: Abgeben
BtnNameCandidatesInfer: Studienfach-Namens-Zuordnung automatisch lernen
BtnNameCandidatesDeleteConflicts: Namenskonflikte löschen
@ -227,7 +225,7 @@ CourseMemberOf: Teilnehmer von
CourseAssociatedWith: assoziiert mit
CourseMembersCount n@Int: #{n}
CourseMembersCountLimited n@Int max@Int: #{n}/#{max}
CourseMembersCountOf n@Int mbNum@IntMaybe: #{n} Kursanmeldungen #{maybeToMessage " von " mbNum " möglichen"}
CourseMembersCountOf n@Int mbNum@(Maybe Int): #{n} Kursanmeldungen #{maybeToMessage " von " mbNum " möglichen"}
CourseName: Kurstitel
CourseDescription: Beschreibung
CourseHomepageExternal: Externe Homepage
@ -480,6 +478,10 @@ SubmissionUserAlreadyAdded: Dieser Nutzer ist bereits als Mitabgebende(r) einget
NoOpenSubmissions: Keine unkorrigierten Abgaben vorhanden
SubmissionFilesCorrected: Abgegebene & Korrigierte Dateien
RatingUpdatedFiles: Bei der Korrektur wurden Dateien angepasst oder hinzugefügt
SubmissionFilesUnchanged: Abgabedateien beibehalten
SubmissionFilesUnchangedTip: Sollen die bestehenden Abgabedateien beim Ersetzen der Abgabe unverändert übernommen werden?
SubmissionUserDuplicateWarning: Dieser Teilnehmende ist bereits an einer anderen Abgabe beteiligt
SubmissionSomeUsersDuplicateWarning: Manche Abgebende sind auch an einer anderen Abgabe beteiligt
SubmissionsDeleteQuestion n@Int: Wollen Sie #{pluralDE n "die unten aufgeführte Abgabe" "die unten aufgeführten Abgaben"} wirklich löschen?
SubmissionsDeleted n@Int: #{pluralDE n "Abgabe gelöscht" "Abgaben gelöscht"}
@ -532,7 +534,8 @@ MaterialVideoDownload: Herunterladen
Unauthorized: Sie haben hierfür keine explizite Berechtigung.
UnauthorizedAnd l@Text r@Text: (#{l} UND #{r})
UnauthorizedOr l@Text r@Text: (#{l} ODER #{r})
UnauthorizedNot r@Text: (NICHT #{r})
UnauthorizedNot r@Text: #{r}
UnauthorizedI18nMismatch: Es wurden unterschiedliche Authorisierungs-Ergebnisse für verschiedene Sprachen berechnet
UnauthorizedNoToken: Ihrer Anfrage war kein Authorisierungs-Token beigefügt.
UnauthorizedTokenExpired: Ihr Authorisierungs-Token ist abgelaufen.
UnauthorizedTokenNotStarted: Ihr Authorisierungs-Token ist noch nicht gültig.
@ -542,12 +545,14 @@ UnauthorizedTokenInvalidNoAuthority: Ihr Authorisierungs-Token nennt keine Nutze
UnauthorizedTokenInvalidAuthority: Ihr Authorisierungs-Token basiert auf den Rechten eines Nutzers, der nicht mehr existiert.
UnauthorizedTokenInvalidAuthorityGroup: Ihr Authorisierungs-Token basiert auf den Rechten einer Gruppe von Nutzern, die nicht mehr existiert.
UnauthorizedTokenInvalidAuthorityValue: Ihr Authorisierungs-Token basiert auf Rechten, deren Spezifikation nicht interpretiert werden konnte.
UnauthorizedTokenInvalidImpersonation: Ihr Authorisierungs-Token enthält die Anweisung sich als ein Nutzer auszugeben, dies ist jedoch nicht allen Benutzern, auf deren Rechten ihr Authorisierungs-Token basiert, erlaubt.
UnauthorizedToken404: Authorisierungs-Tokens können nicht auf Fehlerseiten ausgewertet werden.
UnauthorizedSiteAdmin: Sie sind kein System-weiter Administrator.
UnauthorizedSchoolAdmin: Sie sind nicht als Administrator für dieses Institut eingetragen.
UnauthorizedAdminEscalation: Sie sind nicht Administrator für alle Institute, für die dieser Nutzer Administrator oder Veranstalter ist.
UnauthorizedExamOffice: Sie sind nicht mit Prüfungsverwaltung beauftragt.
UnauthorizedExamExamOffice: Es existieren keine Prüfungsergebnisse für Nutzer, für die Sie mit der Prüfungsverwaltung beauftragt sind.
UnauthorizedSchoolExamOffice: Sie sind nicht mit Prüfungsverwaltung für dieses Institut beauftragt.
UnauthorizedSystemExamOffice: Sie sind nicht mit systemweiter Prüfungsverwaltung beauftragt.
UnauthorizedExternalExamExamOffice: Es existieren keine Prüfungsergebnisse für Nutzer, für die Sie mit der Prüfungsverwaltung beauftragt sind.
UnauthorizedEvaluation: Sie sind nicht mit der Kursumfragenverwaltung beauftragt.
@ -572,6 +577,7 @@ UnauthorizedParticipantSelf: Sie sind kein Teilnehmer dieser Veranstaltung.
UnauthorizedApplicant: Angegebener Benutzer hat sich nicht für diese Veranstaltung beworben.
UnauthorizedApplicantSelf: Sie sind kein Bewerber für diese Veranstaltung.
UnauthorizedCourseTime: Dieser Kurs ist momentan nicht freigegeben.
UnauthorizedCorrectionExamTime: Sichtbarkeitseinstellungen der relevanten Prüfung verhindern momentan die Freigabe.
UnauthorizedCourseRegistrationTime: Dieses Kurs erlaubt momentan keine Anmeldungen.
UnauthorizedAllocationRegisterTime: Diese Zentralanmeldung erlaubt momentan keine Bewerbungen.
UnauthorizedSheetTime: Dieses Übungsblatt ist momentan nicht freigegeben.
@ -616,6 +622,20 @@ UnauthorizedPasswordResetToken: Dieses Authorisierungs-Token kann nicht mehr zum
UnauthorizedAllocatedCourseRegister: Direkte Anmeldungen zum Kurs sind aufgrund einer Zentralanmeldung aktuell nicht gestattet
UnauthorizedAllocatedCourseDeregister: Abmeldungen vom Kurs sind aufgrund einer Zentralanmeldung aktuell nicht gestattet
UnauthorizedAllocatedCourseDelete: Kurse, die an einer Zentralanmeldung teilnehmen, dürfen nicht gelöscht werden
UnauthorizedWorkflowInitiate: Sie dürfen keinen neuen laufenden Workflow initiieren
UnauthorizedWorkflowWrite: Sie dürfen aktuell keinen Zustandsübergang im Workflow auslösen
UnauthorizedWorkflowRead: Der Workflow enthält aktuell keine Zustände oder Daten die Sie einsehen dürfen
UnauthorizedWorkflowInstancesNotEmpty: Es gibt Workflow Instanzen für die Sie einen neuen laufenden Workflow initiieren dürfen
UnauthorizedWorkflowWorkflowsNotEmpty: Es gibt laufende Workflows, die Sie einsehen dürfen
UnauthorizedWorkflowFiles: Sie dürfen die angegebenen Workflow-Dateien nicht im angegebenen historischen Zustand herunterladen
UnauthorizedNotAuthenticatedInDifferentApproot: Sie konnten im Kontext einer separierten Domain (z.B. zum sicheren Download von Dateien) nicht authentifiziert werden. Vermutlich haben Sie kein oder ein abgelaufenes Token verwendet. Sie können versuchen auf die gewünschte Resource mit einem neu generierten Download-Link zuzugreifen.
UnauthorizedCsrfDisabled: Ihre Anfrage hätte wmgl. Änderungen am Server-Zustand ausgelöst. Da die sog. CSRF-Protection für Ihre Anfrage deaktiviert ist, musste sie daher abgelehnt werden.
UnauthorizedStudent: Sie sind kein Student
WorkflowRoleUserMismatch: Sie sind nicht einer der vom Workflow geforderten Benutzer
WorkflowRoleAlreadyInitiated: Dieser Workflow wurde bereits initiiert
WorkflowRoleNoSuchWorkflowWorkflow: Der angegebene Workflow konnte nicht gefunden werden
WorkflowRoleNoPayload: Dieser Workflow enthält keine Daten
EMail: E-Mail
EMailUnknown email@UserEmail: E-Mail #{email} gehört zu keinem bekannten Benutzer.
@ -717,6 +737,7 @@ CorrAutoSetCorrector: Korrekturen verteilen
CorrDelete: Abgaben löschen
NatField name@Text: #{name} muss eine natürliche Zahl sein!
JSONFieldDecodeFailure aesonFailure@String: Konnte JSON nicht parsen: #{aesonFailure}
YAMLFieldDecodeFailure yamlFailure@String: Konnte YAML nicht parsen: #{yamlFailure}
SecretJSONFieldDecryptFailure: Konnte versteckte vertrauliche Daten nicht entschlüsseln
SubmissionsAlreadyAssigned num@Int64: #{num} #{pluralDE num "Abgabe" "Abgaben"} waren bereits einem Korrektor zugeteilt und wurden nicht verändert:
@ -778,6 +799,8 @@ RatingTime: Korrigiert
RatingComment: Kommentar
SubmissionUsers: Studenten
Rating: Korrektur
IsRated: Korrigiert
SheetTypeIsExam: Anrechnung „als Prüfungsaufgabe“
RatingPoints: Punkte
RatingDone: Bewertung abgeschlossen
RatingDoneTip: Das Korrekturergebnis ist nur dann für die Abgebenden sichtbar und kann gegen etwaige Prüfungs-Bonuspunkte verrechnet werden, wenn die Bewertung abgeschlossen ist.
@ -1185,9 +1208,10 @@ SheetGradingPassPoints': Bestehen nach Punkten
SheetGradingPassBinary': Bestanden/Nicht bestanden
SheetGradingPassAlways': Automatisch bestanden, sobald korrigiert
SheetTypeBonus grading@SheetGrading: Bonus
SheetTypeNormal grading@SheetGrading: Normal
SheetTypeInformational grading@SheetGrading: Ohne Anrechnung
SheetTypeBonus: Bonus
SheetTypeNormal: Normal
SheetTypeInformational: Ohne Anrechnung
SheetTypeExamPartPoints: Als Prüfungsaufgabe
SheetTypeNotGraded: Keine Korrektur
SheetTypeInfoNormalLecturer: Normale Blätter werden zur Berechnung eines etwaigen Prüfungsbonus herangezogen. Der Bonus kann sowohl anhand der zu bestehenden Blätter als auch der erreichbaren Maximalpunktzahl automatisch oder manuell berechnet werden.
SheetTypeInfoNotGraded: Keine Korrektur bedeutet, dass es gar kein Feedback gibt.
@ -1197,6 +1221,11 @@ SheetGradingBonusIncluded: Erzielte Bonuspunkte wurden hier bereits zu den errei
SummaryTitle: Zusammenfassung über
SheetGradingSummaryTitle intgr@Integer: #{intgr} #{pluralDE intgr "Blatt" "Blätter"}
SubmissionGradingSummaryTitle intgr@Integer: #{intgr} #{pluralDE intgr "Abgabe" "Abgaben"}
SheetTypeExamPartPointsWeightNegative: Gewichtung darf nicht negativ sein
SheetTypeExamPartPointsWeight: Gewichtung
SheetTypeExamPartPointsExamPartOption examn@ExamName examPartNumber@ExamPartNumber: #{examn} - Teil #{view _ExamPartNumber examPartNumber}
SheetTypeInfoExamPartPoints: Bei diesem Übungsblatt erreichte Punkte werden direkt auf die Punktezahl eines Prüfungsteils angerechnet. Wenn die Anzahl von über Übungsblättern erreichbaren Punkten nicht der Maximalpunktzahl des Prüfungsteils entspricht, werden die Übungsblattpunkte anhand der angegebenen Gewichtung skaliert. Korrekturen für dieses Übungsblatt werden den Teilnehmenden erst angezeigt sobald die Prüfungsfrist „_{MsgExamFinished}“ verstrichen ist.
SheetTypeExamPartPointsExamPart: Prüfungsteil
SheetTypeBonus': Bonus
SheetTypeNormal': Normal
@ -1484,6 +1513,23 @@ MenuFaq: FAQ
MenuSheetPersonalisedFiles: Personalisierte Dateien herunterladen
MenuCourseSheetPersonalisedFiles: Vorlage für personalisierte Übungsblatt-Dateien herunterladen
MenuAdminCrontab: Crontab
MenuAdminWorkflowDefinitionList: Workflows
MenuAdminWorkflowDefinitionNew: Neue Workflow-Definition
MenuAdminWorkflowDefinitionDelete: Löschen
MenuAdminWorkflowInstanceList: Workflow-Instanzen
MenuAdminWorkflowInstanceNew: Neue Workflow-Instanz
MenuAdminWorkflowDefinitionInstantiate: Instanziieren
MenuWorkflowInstanceDelete: Löschen
MenuWorkflowInstanceWorkflows: Laufende Workflows
MenuWorkflowInstanceInitiate: Workflow starten
MenuWorkflowInstanceEdit: Bearbeiten
MenuWorkflowWorkflowList: Laufende Workflows
MenuWorkflowWorkflowEdit: Editieren
MenuWorkflowWorkflowDelete: Löschen
MenuGlobalWorkflowInstanceList: Systemweite Workflows
MenuTopWorkflowInstanceList: Workflows
MenuTopWorkflowWorkflowList: Laufende Workflows
MenuTopWorkflowWorkflowListHeader: Workflows
BreadcrumbSubmissionFile: Datei
BreadcrumbSubmissionUserInvite: Einladung zur Abgabe
@ -1562,6 +1608,31 @@ BreadcrumbFaq: FAQ
BreadcrumbSheetPersonalisedFiles: Personalisierte Dateien herunterladen
BreadcrumbCourseSheetPersonalisedFiles: Vorlage für personalisierte Übungsblatt-Dateien herunterladen
BreadcrumbAdminCrontab: Crontab
BreadcrumbAdminWorkflowDefinitionList: Workflow-Definitionen
BreadcrumbAdminWorkflowDefinitionNew: Neue Workflow-Definition
BreadcrumbAdminWorkflowDefinitionEdit renderedWorkflowScope'@Text wfdn@WorkflowDefinitionName: #{wfdn} (#{renderedWorkflowScope'})
BreadcrumbAdminWorkflowDefinitionDelete: Löschen
BreadcrumbAdminWorkflowDefinitionInstantiate: Instanziieren
BreadcrumbAdminWorkflowInstanceList: Workflow-Instanzen
BreadcrumbAdminWorkflowInstanceNew: Neue Workflow-Instanz
BreadcrumbAdminWorkflowInstanceEdit: Instanz bearbeiten
BreadcrumbAdminWorkflowWorkflowList: Initiierte Workflows
BreadcrumbAdminWorkflowWorkflowNew: Workflow initiieren
BreadcrumbWorkflowInstanceEdit win@WorkflowInstanceName: #{win}
BreadcrumbWorkflowInstanceDelete: Löschen
BreadcrumbWorkflowInstanceWorkflowList: Laufende Workflows
BreadcrumbWorkflowInstanceInitiate: Workflow starten
BreadcrumbWorkflowInstanceList: Workflows
BreadcrumbWorkflowInstanceNew: Neuer Workflow
BreadcrumbWorkflowWorkflowList: Laufende Workflows
BreadcrumbWorkflowWorkflow workflow@CryptoFileNameWorkflowWorkflow: #{toPathPiece workflow}
BreadcrumbWorkflowWorkflowFiles: Dateien
BreadcrumbWorkflowWorkflowEdit: Editieren
BreadcrumbWorkflowWorkflowDelete: Löschen
BreadcrumbGlobalWorkflowInstanceList: Systemweite Workflows
BreadcrumbTopWorkflowInstanceList: Workflows
BreadcrumbTopWorkflowWorkflowList: Laufende Workflows
BreadcrumbError: Fehler
ExternalExamEdit coursen@CourseName examn@ExamName: Bearbeiten: #{coursen}, #{examn}
ExternalExamGrades coursen@CourseName examn@ExamName: Prüfungsleistungen: #{coursen}, #{examn}
@ -1592,6 +1663,7 @@ AuthTagTime: Zeitliche Einschränkungen sind erfüllt
AuthTagStaffTime: Zeitliche Einschränkungen für Lehrbeteiligte sind erfüllt
AuthTagAllocationTime: Zeitliche Einschränkungen durch Zentralanmeldung sind erfüllt
AuthTagCourseTime: Zeitliche Einschränkungen für Kurssichtbarkeit sind erfüllt
AuthTagExamTime: Zeitliche Einschränkungen durch relevante Prüfung sind erfüllt
AuthTagCourseRegistered: Nutzer ist Kursteilnehmer
AuthTagAllocationRegistered: Nutzer nimmt an der Zentralanmeldung teil
AuthTagTutorialRegistered: Nutzer ist Tutoriumsteilnehmer
@ -1603,7 +1675,7 @@ AuthTagParticipant: Nutzer ist mit Kurs assoziiert
AuthTagApplicant: Nutzer ist mit Bewerber zum Kurs
AuthTagRegisterGroup: Nutzer ist nicht Mitglied eines anderen Tutoriums mit der selben Registrierungs-Gruppe
AuthTagCapacity: Kapazität ist ausreichend
AuthTagEmpty: Kurs hat keine Teilnehmer
AuthTagEmpty: Ressource ist „leer“
AuthTagMaterials: Kursmaterialien sind freigegeben
AuthTagOwner: Nutzer ist Besitzer
AuthTagPersonalisedSheetFiles: Nutzer verfügt über personalisierte Übungsblatt-Dateien
@ -1617,6 +1689,8 @@ AuthTagAuthentication: Nutzer ist angemeldet, falls erforderlich
AuthTagRead: Zugriff ist nur lesend
AuthTagWrite: Zugriff ist i.A. schreibend
AuthTagSubmissionGroup: Nutzer ist Mitglied in registrierter Abgabegruppe
AuthTagWorkflow: Nutzer hat passende Workflow-Rolle
AuthTagStudent: Nutzer ist Student
DeleteCopyStringIfSure n@Int: Wenn Sie sich sicher sind, dass Sie #{pluralDE n "das obige Objekt" "obige Objekte"} unwiderbringlich löschen möchten, schreiben Sie bitte zunächst den angezeigten Text ab.
DeletePressButtonIfSure n@Int: Wenn Sie sich sicher sind, dass Sie #{pluralDE n "das obige Objekt" "obige Objekte"} unwiderbringlich löschen möchten, bestätigen Sie dies bitte durch Drücken des untigen Knopfes.
@ -1640,6 +1714,7 @@ CommSuccess n@Int: Nachricht wurde an #{n} Empfänger versandt
CommTestSuccess: Nachricht wurde zu Testzwecken nur an Sie selbst versandt
CommUndisclosedRecipients: Verborgene Empfänger
CommAllRecipients: alle-empfaenger
CommAllRecipientsSheet: Empfänger
CommCourseHeading: Kursmitteilung
CommTutorialHeading: Tutorium-Mitteilung
@ -1775,6 +1850,10 @@ TutorialParticipants: Teilnehmer
TutorialCapacity: Kapazität
TutorialFreeCapacity: Freie Plätze
TutorialRoom: Regulärer Raum
TutorialRoomHidden: Raum nur für Teilnehmer
TutorialRoomHiddenTip: Soll der Raum nur den Teilnehmern des Tutoriums angezeigt werden?
TutorialRoomIsUnset: —
TutorialRoomIsHidden: Raum wird nur Teilnehmern angezeigt
TutorialTime: Zeit
TutorialRegistered: Angemeldet
TutorialRegGroup: Registrierungs-Gruppe
@ -1876,6 +1955,7 @@ ExamFinished: Ergebnisse sichtbar ab
ExamFinishedOffice: Noten bekannt gegeben
ExamFinishedParticipant: Bewertung voraussichtlich abgeschlossen
ExamFinishedTip: Zeitpunkt zu dem Prüfungergebnisse den Teilnehmern gemeldet werden; ohne Datum werden die Prüfungsergebnisse zunächst nie gemeldet
ExamFinishedTipCloseOnFinished: Zeitpunkt zu dem Prüfungergebnisse den Teilnehmern und den Prüfungsverwaltungen gemeldet werden; ohne Datum werden die Prüfungsergebnisse zunächst nie gemeldet
ExamClosed: Noten gemeldet
ExamClosedTip: Prüfungsbeauftraget, die im System Noten einsehen, werden zu diesem Zeitpunkt benachrichtigt und danach bei Änderungen informiert
ExamGradingMode: Bewertungsmodus
@ -1899,6 +1979,7 @@ ExamBonusRule: Prüfungsbonus aus Übungsbetrieb
ExamNoBonus': Kein automatischer Bonus
ExamBonusPoints': Umrechnung von Übungspunkten
ExamBonusManual': Manuelle Berechnung
ExamBonusInfoPoints: Zur Berechnung von Bonuspunkten werden nur jene Blätter herangezogen, deren Aktivitätszeitraum vor Start des jeweiligen Termin/Prüfung begonnen hat
ExamRegisterForOccurrence: Anmeldung zur Prüfung erfolgt durch Anmeldung zu einem Termin/Raum
@ -1923,6 +2004,8 @@ ExamRoomSurname': Nach Nachname
ExamRoomMatriculation': Nach Matrikelnummer
ExamRoomRandom': Zufällig pro Teilnehmer
ExamRoomFifo': Auswahl durch Teilnehmer bei Anmeldung
ExamOccurrenceRoomIsUnset: —
ExamOccurrenceRoomIsHidden: Raum wird nur Teilnehmern angezeigt
ExamOccurrence: Termin/Raum
ExamNoOccurrence: Kein Termin/Raum
@ -1931,9 +2014,12 @@ ExamOccurrences: Termine
ExamRooms: Räume
ExamTimes: Termine
ExamRoomRoom: Raum
ExamRoomRoomHidden: Raum nur für Angemeldete
ExamRoomRoomHiddenTip: Soll der Raum nur zu diesem Termin/Raum angemeldeten Prüfungsteilnehmern angezeigt werden?
ExamRoomAlreadyExists: Prüfung ist bereits eingetragen
ExamRoomName: Interne Bezeichnung
ExamRoomCapacity: Kapazität
ExamRoomCapacityTip: Maximale Anzahl an Prüfungsteilnehmern für diesen Termin/Raum; leer lassen für unbeschränkte Teilnehmeranzahl
ExamRoomCapacityNegative: Kapazität darf nicht negativ sein
ExamRoomTime: Termin
ExamRoomStart: Beginn
@ -2008,10 +2094,15 @@ ExamPartMaxPoints: Maximalpunktzahl
ExamPartWeight: Gewichtung
ExamPartWeightTip: Wird vor Anzeige oder automatischen Notenberechnung mit der erreichten Punktzahl und der Maximalpunktzahl multipliziert; Änderungen hier passen also auch bestehende Korrekturergebnisse an (derart geänderte Noten müssen erneut manuell übernommen werden)
ExamPartResultPoints: Erreichte Punkte
ExamPartSheets: Übungsblätter
ExamNameTaken exam@ExamName: Es existiert bereits eine Prüfung mit Namen #{exam}
ExamPartsFrom: Teile anzeigen ab
ExamPartsFromTip: Ab dem gegebenen Zeitpunkt wird die Liste von Prüfungsteilen/Aufgaben veröffentlicht, nicht jedoch die jeweilige Maximalpunktzahl. Ohne Zeitpunkt wird die Liste ab "Ergebnisse sichtbar ab" angezeigt.
ExamEditExamNameTaken exam@ExamName: Es existiert bereits eine Prüfung mit Namen #{exam}
ExamCreated exam@ExamName: #{exam} erfolgreich angelegt
ExamEdited exam@ExamName: #{exam} erfolgreich bearbeitet
ExamEditWouldBreakSheetTypeReference: Durch Ihre Änderungen würde ein Prüfungsteil gelöscht, auf den durch ein Übungsblatt noch eine Referenz besteht.
ExamNoShow: Nicht erschienen
ExamVoided: Entwertet
@ -2045,6 +2136,7 @@ ExamRegistrationMustFollowSchoolSeparationFromStart dayCount@Int: Nach Regeln de
ExamRegistrationMustFollowSchoolDuration dayCount@Int: Nach Regeln des Instituts #{pluralDE dayCount "muss" "müssen"} zwischen "Anmeldung ab" und "Anmeldung bis" mindestens #{dayCount} #{pluralDE dayCount "Tag" "Tage"} liegen.
ExamModeRequiredForRegistration: Nach Regeln des Institus muss die "Ausgestaltung der Prüfung" vollständig angegeben sein, bevor "Anmeldung ab" festgelegt werden kann.
ExamModeSchoolDiscouraged: Nach Regeln des Instituts wird von der angegebenen "Ausgestaltung der Prüfung" abgeraten
ExamPartsFromMustBeBeforeFinished: "Teile anzeigen ab" muss vor "Ergebnisse sichtbar ab" liegen
ExamOccurrenceEndMustBeAfterStart eoName@ExamOccurrenceName: Beginn des Termins #{eoName} muss vor seinem Ende liegen
ExamOccurrenceStartMustBeAfterExamStart eoName@ExamOccurrenceName: Beginn des Termins #{eoName} muss nach Beginn der Prüfung liegen
@ -2053,6 +2145,7 @@ ExamOccurrenceDuplicate eoRoom@Text eoRange@Text: Raum #{eoRoom}, Termin #{eoRan
ExamOccurrenceDuplicateName eoName@ExamOccurrenceName: Interne Terminbezeichnung #{eoName} kommt mehrfach vor
ExamOccurrenceCannotBeDeletedDueToRegistrations eoName@ExamOccurrenceName: Termin #{eoName} kann nicht gelöscht werden, da noch Teilnehmer diesem Termin zugewiesen sind. Über die Liste von Prüfungsteilnehmern können Sie zunächst die entsprechenden Terminzuweisungen entfernen.
ExamPartCannotBeDeletedDueToResults exampartnum@ExamPartNumber: Teil #{exampartnum} kann nicht gelöscht werden, da bereits Prüfungsergebnisse für diesen Teil eingetragen wurden.
ExamPartCannotBeDeletedDueToSheetReference exampartnum@ExamPartNumber sheetName@SheetName: Teil #{exampartnum} kann nicht gelöscht werden, da Übungsblatt #{sheetName} den Bewertungsmodus „als Prüfungsaufgabe“ trägt.
VersionHistory: Versionsgeschichte
KnownBugs: Bekannte Bugs
@ -2066,6 +2159,7 @@ ExamUserAssignOccurrence: Termin/Raum zuweisen
ExamUserAcceptComputedResult: Berechnetes Prüfungsergebnis übernehmen
ExamUserResetToComputedResult: Prüfungsergebnis zurücksetzen
ExamUserResetBonus: Auch Bonuspunkte zurücksetzen
ExamUserResetParts: Auch Teilergebnisse zurücksetzen
ExamUserSetPartResult: Teilergebnis setzen
ExamUserSetBonus: Bonuspunkte setzen
ExamUserSetResult: Prüfungsergebnis setzen
@ -2141,14 +2235,19 @@ CsvImportExplanationLabel: Hinweise zum CSV-Import
CsvExampleData: Beispiel-Datei
CsvExportExample: Beispiel-CSV exportieren
Proportion c@Text of@Text prop@Rational: #{c}/#{of} (#{rationalToFixed2 (100 * prop)}%)
ProportionNoRatio c@Text of@Text: #{c}/#{of}
Proportion c@Text of'@Text prop@Rational: #{c}/#{of'} (#{rationalToFixed2 (100 * prop)}%)
ProportionNoRatio c@Text of'@Text: #{c}/#{of'}
CourseUserCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-teilnehmer
CourseUserCsvSheetName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh} Teilnehmer
ExamUserCsvName tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-teilnehmer
ExamUserCsvSheetName tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn} Teilnehmer
ExternalExamUserCsvName tid@TermId ssh@SchoolId coursen@CourseName examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase coursen}-#{foldedCase examn}-teilnehmer
ExternalExamUserCsvSheetName tid@TermId ssh@SchoolId coursen@CourseName examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase coursen}-#{foldedCase examn} Teilnehmer
CourseApplicationsTableCsvName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-bewerbungen
CourseApplicationsTableCsvSheetName tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh} Bewerbungen
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
CourseUserCsvIncludeSheets: Übungsblätter
CourseUserCsvIncludeSheetsTip: Soll die exportierte CSV-Datei zusätzlich eine Spalte pro Übungsblatt enthalten?
@ -2172,6 +2271,8 @@ CsvColumnExamUserParts: Erreichte Punktezahlen in den Teilprüfungen, sofern vor
CsvColumnExamUserResult: Erreichte Prüfungsleistung; "passed", "failed", "no-show", "voided", oder eine Note ("1.0", "1.3", "1.7", ..., "4.0", "5.0")
CsvColumnExamUserCourseNote: Notizen zum Teilnehmer
CsvColumnUserSurname: Nachname(n) des Teilnehmers
CsvColumnUserFirstName: Vorname(n) des Teilnehmers
CsvColumnUserName: Voller Name des Teilnehmers
CsvColumnUserMatriculation: Matrikelnummer des Teilnehmers
CsvColumnUserSex: Geschlecht
@ -2538,8 +2639,14 @@ BtnCloseExam: Prüfung abschließen
ExamCloseTip: Wenn eine Prüfung abgeschlossen wird, werden Prüfungsbeauftragte, die im System Noten einsehen, benachrichtigt und danach bei Änderungen informiert.
ExamCloseReminder: Bitte schließen Sie die Prüfung frühstmöglich, sobald die Prüfungsleistungen sich voraussichtlich nicht mehr ändern werden. Z.B. direkt nach der Klausureinsicht.
ExamDidClose: Prüfung erfolgreich abgeschlossen
ExamCloseTipOnFinished: Die Prüfung wird automatisch abgeschlossen, also Prüfungsbeauftragte, die im System Note einsehen, benachrichtigt und danach bei Änderungen informiert, sobald die Noten für die Prüfungsteilnehmer veröffentlicht werden.
ExamFinishHeading: Prüfungsergebnisse sichtbar schalten
BtnFinishExam: Prüfungsergebnisse sichtbar schalten
ExamFinishTip: Wenn die Prufungsergebnisse sichtbar geschaltet sind, können Teilnehmende ihre Ergebnisse im System einsehen und werden über diesen Umstand informiert. Es wird die Prüfungfrist „_{MsgExamFinished}“ auf den aktuellen Zeitpunkt gesetzt.
ExamDidFinish: Prüfungsergbnisse sichtbar geschaltet
ExamClosedSince time@Text: Prüfung abgeschlossen seit #{time}
ExamFinishedSince time@Text: Prüfungsergebnisse sichtbar seit #{time}
LecturerInfoTooltipNew: Neues Feature
LecturerInfoTooltipProblem: Feature mit bekannten Problemen
@ -2562,8 +2669,9 @@ CsvOptionsTip: Diese Einstellungen betreffen primär den CSV-Export; beim Import
CsvFormatOptions: Dateiformat
CsvTimestamp: Zeitstempel
CsvTimestampTip: Soll an den Namen jeder exportierten CSV-Datei ein Zeitstempel vorne angehängt werden?
CsvPresetRFC: Standard-Konform (RFC 4180)
CsvPresetExcel: Excel-Kompatibel
CsvPresetRFC: Standard-Konforme .csv Dateien (RFC 4180)
CsvPresetExcel: Excel-Kompatible .csv Dateien (Excel <2010)
CsvPresetXlsx: .xlsx Dateien (ECMA-376; Excel ≥2010)
CsvCustom: Benutzerdefiniert
CsvDelimiter: Trennzeichen
CsvUseCrLf: Zeilenumbrüche
@ -2588,6 +2696,9 @@ CsvQuoteMinimal: Nur wenn nötig
CsvQuoteAll: Immer
CsvOptionsUpdated: CSV-Optionen erfolgreich angepasst
CsvChangeOptionsLabel: Export-Optionen
CsvFormatField: Dateiformat
CsvFormatCsv: .csv (Comma-Separated Values)
CsvFormatXlsx: .xlsx (Office Open XML)
CourseNews: Aktuelles
CourseNewsArchiveName tid@TermId ssh@SchoolId csh@CourseShorthand newsTitle@Text: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldCase newsTitle}
@ -2645,12 +2756,15 @@ AllocationResultsTip: Die folgenden Informationen entsprechen dem aktuellen Stan
AllocationResultsStudentTip: Unten aufgeführt sind alle Plätze, die Sie im Rahmen der genannten Zentralanmeldung erhalten haben und von denen Sie seit dem weder abgemeldet wurden, noch sich selbst abgemeldet haben. Plätze, über die Sie ggf. bereits informiert wurden, können also erneut aufgeführt sein.
AllocationResultStudentRegistrationTip: Sie sind zu oben genanntem Kurs in Uni2work angemeldet.
AllocationResultsStudentRegistrationTip: Sie sind zu den oben genannten Kursen in Uni2work angemeldet.
AllocationResultsStudentConsultFaq n@Int: Falls Sie Fragen oder Anmerkungen haben, beachten Sie bitte auch die Informationen auf #{pluralDE n "der" "den"} folgenden #{pluralDE n "Seite" "Seiten"}:
FavouriteVisited: Kürzlich besucht
FavouriteParticipant: Ihre Kurse
FavouriteManual: Favoriten
FavouriteCurrent: Aktueller Kurs
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.
FavouritesUnavailableTip: Das Schnellzugriffsmenü für diesen Kurs ist aktuell nicht verfügbar.
CourseEvents: Termine
@ -2658,6 +2772,10 @@ CourseEventType: Art
CourseEventTypePlaceholder: Vorlesung, Zentralübung, ...
CourseEventTime: Zeit
CourseEventRoom: Regulärer Raum
CourseEventRoomHidden: Raum nur für Teilnehmer
CourseEventRoomHiddenTip: Soll der Raum nur angemeldeten Kursteilnehmern angezeigt werden?
CourseEventRoomIsUnset: —
CourseEventRoomIsHidden: Raum wird nur Kurs-assoziierten Personen (Teilnehmer, Tutoren, Korrektoren, etc.) angezeigt
CourseEventNote: Notiz
CourseEventActions: Aktionen
CourseEventsActionEdit: Bearbeiten
@ -2788,13 +2906,17 @@ ExamAutoOccurrenceParticipantsAssigned num@Int64: Verteilungstabelle erfolgreich
TitleExamAutoOccurrence tid@TermId ssh@SchoolId csh@CourseShorthand examn@ExamName: #{tid} - #{ssh} - #{csh} #{examn}: Automatische Raum-/Terminverteilung
BtnExamAutoOccurrenceCalculate: Verteilungstabelle berechnen
BtnExamAutoOccurrenceAccept: Verteilung akzeptieren
BtnExamAutoOccurrenceNudgeUp: +
BtnExamAutoOccurrenceNudgeDown: -
ExamRoomMappingSurname: Nachnamen beginnend mit
ExamRoomMappingMatriculation: Matrikelnummern endend in
ExamRoomMappingRandom: Verteilung
ExamRoomMappingRandomHere: Zufällig
ExamRoomLoad: Auslastung
ExamRegisteredCount: Anmeldungen
ExamRegisteredCountOf num@Int64 count@Int64: #{num}/#{count}
ExamAutoOccurrenceExceptionRuleNoOp: Kein Verfahren zur automatischen Verteilung gewählt
ExamAutoOccurrenceExceptionNotEnoughSpace: Mehr Teilnehmende als verfügbare Plätze
ExamAutoOccurrenceExceptionNoUsers: Nach dem gewähltem Verfahren können keine Teilnehmenden verteilt werden
ExamAutoOccurrenceExceptionRoomTooSmall: Automatische Verteilung gescheitert. Ein anderes Verteil-Verfahren kann erfolgreich sein. Alternativ kann es helfen Räume zu minimieren oder kleine Räume zu entfernen.
NoFilter: Keine Einschränkung
@ -2808,7 +2930,7 @@ InfoLecturerTutorials: Tutorien
InfoLecturerExams: Prüfungen
InfoLecturerAllocations: Zentralanmeldungen
ParticipantsIntersectCourseOption tid@TermId ssh@SchoolId coursen@CourseName: #{tid} - #{ssh} - #{coursen}
ParticipantsIntersectCourseOption tid@TermId ssh@SchoolId csh@CourseShorthand coursen@CourseName: #{tid} - #{ssh} - #{csh}: #{coursen}
ParticipantsIntersectCourses: Kurse
AllocationUsersTitle tid@TermId ssh@SchoolId ash@AllocationShorthand: #{tid}-#{ssh}-#{ash}: Bewerber
@ -2830,6 +2952,7 @@ CsvColumnAllocationUserAssigned: Anzahl von Plätzen, die der Bewerber durch die
CsvColumnAllocationUserNewAssigned: Anzahl von Plätzen, die der Bewerber, nach Akzeptieren der berechneten Verteilung, zusätzlich erhalten würde
CsvColumnAllocationUserPriority: Zentrale Dringlichkeit des Bewerbers; entweder einzelne Zahl für Sortierungsbasierte Dringlichkeiten (höhere Dringlichkeit entspricht größerer Zahl) oder Komma-separierte Liste von numerischen Dringlichkeiten in eckigen Klammern (z.B. [1, 2, 3])
AllocationUsersCsvName tid@TermId ssh@SchoolId ash@AllocationShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase ash}-bewerber
AllocationUsersCsvSheetName tid@TermId ssh@SchoolId ash@AllocationShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase ash} Bewerber
AllocationPrioritiesMode: Modus
AllocationPrioritiesNumeric: Numerische Dringlichkeiten
@ -2899,7 +3022,7 @@ AllocationUsersCount: Teilnehmer
AllocationCoursesCount: Kurse
AllocationCourseEligible: Berücksichtigt
CourseOption tid@TermId ssh@SchoolId coursen@CourseName: #{tid} - #{ssh} - #{coursen}
CourseOption tid@TermId ssh@SchoolId csh@CourseShorthand coursen@CourseName: #{tid} - #{ssh} - #{csh}: #{coursen}
BearerTokenUsageWarning: Mit diesem Interface können quesi beliebige Rechte als Tokens kodiert und somit ohne wesentliche weitere Beschränkung frei übertragen werden. Benutzen Sie dieses Interface nur, wenn Sie von einem erfahrenen Entwickler über die Auswirkungen des konkreten Tokens, dass sie ausstellen möchten, beraten wurden!
BearerTokenAuthorityGroups: Token-Authorität (Gruppen)
@ -2922,6 +3045,16 @@ BearerTokenExpires: Ablaufzeitpunkt
BearerTokenExpiresTip: Wird der Ablaufzeitpunkt überschrieben und kein Ablaufzeitpunkt angegeben, ist das Token für immer gültig.
BearerTokenOverrideStart: Startzeitpunkt
BearerTokenOverrideStartTip: Wird kein Startzeitpunkt angegeben, wird bei Verwendung des Tokens nur der Ablaufzeitpunkt überprüft.
BearerTokenImpersonate: Auftreten als
BearerTokenImpersonateNone: Keine Änderung
BearerTokenImpersonateSingle: Einzelner Benutzer
BearerTokenImpersonateRandom: Zufälliger Benutzer
BearerTokenImpersonateSingleUser: Benutzer
BearerTokenImpersonateRandomNegative: Anzahl muss positiv sein
BearerTokenImpersonateRandomCount: Anzahl
BearerTokenImpersonateUnknownUser email@UserEmail: Ein Nutzer mit E-Mail #{email} ist dem System nicht bekannt
BearerTokenImpersonateRandomWeightActivity: Nach Aktivität gewichten
BearerTokenArchiveName: tokens.zip
FaqTitle: Häufig gestellte Fragen
AdditionalFaqs: Weitere häufig gestellte Fragen
@ -2987,6 +3120,9 @@ PersonalisedSheetFilesIgnored count@Int64: Es #{pluralDE count "wurde" "wurden"}
PersonalisedSheetFilesIgnoredIntro: Es wurden die folgenden Dateien ignoriert:
CourseUserHasPersonalisedSheetFilesFilter: Teilnehmer hat personalisierte Übungsblatt-Dateien für
SheetPersonalisedFilesUsersList: Liste von Teilnehmern mit personalisierten Übungsblatt-Dateien
PersonalisedSheetFilesDownloadRestrictByExamNone: Keine Einschränkung
PersonalisedSheetFilesDownloadRestrictByExam: Nur Prüfungsteilnehmer
PersonalisedSheetFilesDownloadRestrictByExamTip: Sollen nur personalisierte Übungsblatt-Dateien exportiert werden, für jene Kursteilnehmer, die auch Teilnehmer einer bestimmten Prüfung sind?
AdminCrontabNotGenerated: (Noch) keine Crontab generiert
CronMatchAsap: ASAP
@ -2994,6 +3130,144 @@ CronMatchNone: Nie
SystemExamOffice: Prüfungsverwaltung
SystemFaculty: Fakultätsmitglied
SystemStudent: Student
WorkflowScopeKindGlobal: Systemweit
WorkflowScopeKindTerm: Pro Semester
WorkflowScopeKindSchool: Pro Institut
WorkflowScopeKindTermSchool: Pro Institut & Semester
WorkflowScopeKindCourse: Pro Veranstaltung
WorkflowScopeGlobal: Systemweit
WorkflowScopeTermSchool tid@TermId ssh@SchoolId: #{tid} #{ssh}
WorkflowScopeCourse tid@TermId ssh@SchoolId csh@CourseShorthand: #{tid} #{ssh} #{csh}
WorkflowDefinitionScope: Bereich
WorkflowDefinitionName: Name
WorkflowDefinitionDescriptions: Beschreibung
WorkflowDefinitionDescriptionsLanguageExists: Eine Beschreibung in dieser Sprache existiert bereits
WorkflowDefinitionGraph: Spezifikation
WorkflowDefinitionKeyDoesNotExist renderedCryptoID@Text: Referenziert ID existiert nicht: #{renderedCryptoID}
WorkflowDefinitionFiles: Dateien
WorkflowFileIdentDoesNotExist fileIdent@Text: Referenzierte Datei existiert nicht: #{fileIdent}
WorkflowUserDoesNotExist userIdent@Text: Referenzierter Benutzer existiert nicht: #{userIdent}
WorkflowDefinitionFileIdentExists: Eine Datei mit dieser ID existiert bereits
WorkflowDefinitionFileIdent: Dateireferenz
WorkflowDefinitionFile: Datei
WorkflowDefinitionCreated: Workflow-Definition angelegt
WorkflowDefinitionEdited: Workflow-Definition ersetzt
WorkflowDefinitionCollision: Es existiert bereits eine Workflow-Definition mit diesem Namen und Bereich
WorkflowDefinitionNewTitle: Workflow-Definition anlegen
WorkflowDefinitionEditTitle: Workflow-Definition Bearbeiten
WorkflowDefinitionInstanceCategory: Kategorie
WorkflowDefinitionWarningLinterIssuesMessage: Es sind Linter issues aufgetreten
WorkflowDefinitionWarningLinterIssues: Es sind folgende Linter issues aufgetreten:
WorkflowDefinitionListTitle: Workflow-Definitionen
WorkflowDefinitionInstanceCount: Instanzen
WorkflowDefinitionWorkflowCount: Workflows
WorkflowDefinitionConcreteInstanceCount num@Int64: #{num} Instanzen
WorkflowDefinitionConcreteWorkflowCount num@Int64: #{num} Workflows
WorkflowDefinitionDeleteQuestion: Wollen Sie die unten aufgeführte Workflow-Definition wirklich löschen?
WorkflowDefinitionDeleted: Workflow-Definition gelöscht
WorkflowDefinitionInstantiateTitle: Workflow-Definition instanziieren
WorkflowDefinitionInstantiated: Instanz angelegt
WorkflowScope: Bereich
WorkflowInstanceName: Name
WorkflowInstanceCategory: Kategorie
WorkflowInstanceCollision: Es existiert bereits eine Workflow-Instanz mit diesem Namen und Bereich
WorkflowInstanceListTitle: Workflow-Instanzen
WorkflowInstanceDescription: Instanz-Beschreibung
WorkflowInstanceDescriptions: Instanz-Beschreibung
WorkflowInstanceDescriptionsLanguageExists: Eine Instanz-Beschreibung in dieser Sprache existiert bereits
WorkflowInstanceCreated: Instanz angelegt
WorkflowInstanceDescriptionTitle: Instanz-Titel
WorkflowInstanceWorkflowCount: Workflows
WorkflowInstanceInitiateSuccess: Workflow erfolgreich initiiert
WorkflowDescriptionLanguage: Sprach-Code (RFC1766)
WorkflowDescriptionTitle: Titel
WorkflowDescription: Beschreibung
GlobalWorkflowInstancesHeading: Workflows (Systemweit)
GlobalWorkflowInstancesTitle: Workflows (Systemweit)
GlobalWorkflowInstanceInitiateHeading workflowInstanceTitle@Text: Workflow initiieren: #{workflowInstanceTitle}
GlobalWorkflowInstanceInitiateTitle: Workflow initiieren
SchoolWorkflowInstancesHeading ssh@SchoolId: Workflows (#{ssh})
SchoolWorkflowInstancesTitle ssh@SchoolId: Workflows (#{ssh})
SchoolWorkflowInstanceInitiateHeading ssh@SchoolId workflowInstanceTitle@Text: Workflow initiieren: #{ssh}, #{workflowInstanceTitle}
SchoolWorkflowInstanceInitiateTitle ssh@SchoolId: Workflow initiieren: #{ssh}
WorkflowEdgeNumberedVariant edgeLabel@Text i@Natural: #{edgeLabel} (Variante #{i})
WorkflowEdgeFormEdge: Aktion
WorkflowEdgeFormHiddenPayload i@Natural: Versteckter Datensatz #{i}
WorkflowEdgeFormPayloadOneFieldRequired: Es muss mindestens ein Feld pro Datensatz ausgefüllt werden
WorkflowEdgeFormPayloadOneFieldRequiredFor payloadDisplayLabel@Text: Es muss mindestens ein Feld für “#{payloadDisplayLabel}” ausgefüllt werden
WorkflowEdgeFormFieldNumberTooSmall minSci@Scientific: Zahl muss mindestens #{formatScientific Scientific.Generic Nothing minSci} sein
WorkflowEdgeFormFieldNumberTooLarge maxSci@Scientific: Zahl muss höchstens #{formatScientific Scientific.Generic Nothing maxSci} sein
WorkflowEdgeFormFieldUserNotFound: E-Mail Adresse konnte keinem Benutzer zugeordnet werden
WorkflowEdgeFormFieldMultipleNoneAdded: (Noch) keine Einträge
WorkflowEdgeFormFieldCaptureUserLabel: Aktueller Benutzer
WorkflowWorkflowWorkflowHistoryHeading: Verlauf
WorkflowWorkflowWorkflowEdgeFormHeading: Aktion im Workflow auslösen
WorkflowWorkflowWorkflowEdgeSuccess: Aktion erfolgreich ausgelöst
WorkflowWorkflowWorkflowHistoryUserSelf: Sie selbst
WorkflowWorkflowWorkflowHistoryUserNotLoggedIn: Nicht eingeloggter Benutzer
WorkflowWorkflowWorkflowHistoryUserGone: Gelöschter Benutzer
WorkflowWorkflowWorkflowHistoryUserHidden: Versteckter Benutzer
WorkflowWorkflowWorkflowHistoryUserAutomatic: Automatisch
WorkflowWorkflowWorkflowHistoryActionAutomatic: Automatisch
WorkflowWorkflowWorkflowHistoryStateHidden: Versteckter Zustand
WorkflowWorkflowWorkflowHistoryActionLabel: Aktion
WorkflowWorkflowWorkflowHistoryFromLabel: Vorheriger Zustand
WorkflowWorkflowWorkflowHistoryToLabel: Neuer Zustand
WorkflowWorkflowWorkflowHistoryPayloadLabel: Datensatz-Änderungen
WorkflowWorkflowFilesArchiveName wwCID@CryptoFileNameWorkflowWorkflow wpl@WorkflowPayloadLabel stCID@CryptoUUIDWorkflowStateIndex: #{foldCase (toPathPiece wwCID)}-#{foldCase (toPathPiece stCID)}-#{foldCase (foldMap unidecode (toPathPiece wpl))}.zip
WorkflowWorkflowWorkflowStateHeading: Zustand/Daten
WorkflowWorkflowWorkflowPayloadHeading: Aktueller Datensatz
WorkflowWorkflowWorkflowStateStateLabel: Aktueller Zustand
WorkflowWorkflowWorkflowStateStateHidden: Versteckter Zustand
WorkflowWorkflowWorkflowHistoryLabelOthers: Aktionen Anderer
WorkflowWorkflowWorkflowHistoryLabelOwn: Eigene Aktionen
WorkflowPayloadFiles: Datei(en)
WorkflowPayloadBoolTrue: Ja
WorkflowPayloadBoolFalse: Nein
WorkflowPayloadUserGone: Gelöschter Benutzer
TopWorkflowInstancesHeading: Workflows
TopWorkflowInstancesTitle: Workflows
GlobalWorkflowWorkflowWorkflowHeading workflowWorkflowId@CryptoFileNameWorkflowWorkflow: Workflow #{toPathPiece workflowWorkflowId}
GlobalWorkflowWorkflowWorkflowTitle workflowWorkflowId@CryptoFileNameWorkflowWorkflow: Workflow #{toPathPiece workflowWorkflowId}
SchoolWorkflowWorkflowWorkflowHeading ssh@SchoolId workflowWorkflowId@CryptoFileNameWorkflowWorkflow: Workflow #{ssh}, #{toPathPiece workflowWorkflowId}
SchoolWorkflowWorkflowWorkflowTitle ssh@SchoolId workflowWorkflowId@CryptoFileNameWorkflowWorkflow: Workflow #{ssh}, #{toPathPiece workflowWorkflowId}
WorkflowWorkflowListScopeTitle rScope@Text: Laufende Workflows - #{rScope}
WorkflowWorkflowListScopeHeading rScope@Text: Laufende Workflows (#{rScope})
WorkflowWorkflowListInstanceTitle: Laufende Workflows für Instanz
WorkflowWorkflowListInstanceHeading: Laufende Workflows für Instanz
WorkflowWorkflowListNamedInstanceTitle rScope@Text wiTitle@Text: Laufende Workflows - #{rScope}, #{wiTitle}
WorkflowWorkflowListNamedInstanceHeading rScope@Text wiTitle@Text: Laufende Workflows (#{rScope}, #{wiTitle})
WorkflowWorkflowListTopTitle: Laufende Workflows
WorkflowWorkflowListTopHeading: Laufende Workflows
AdminWorkflowWorkflowListTitle: Laufende Workflows
AdminWorkflowWorkflowListHeading: Laufende Workflows
WorkflowWorkflowListNumber: Nummer
WorkflowWorkflowListScope: Bereich
WorkflowWorkflowListInstance: Instanz
WorkflowWorkflowListCurrentState: Aktueller Zustand
WorkflowWorkflowListLastActionTime: Zeitpunkt, letzte Aktion
WorkflowWorkflowListLastActionUser: Benutzer, letzte Aktion
WorkflowWorkflowListIsFinal: Abgeschlossen?
FormFieldWorkflowDatasetTip: Mindestens ein gekennzeichnetes Feld pro Datensatz muss ausgefüllt werden
ChangelogItemFeature: Feature
ChangelogItemBugfix: Bugfix
@ -3009,3 +3283,35 @@ InvalidCredentialsADTooManyContextIds: Benutzereintrag trägt zu viele Sicherhei
InvalidCredentialsADAccountExpired: Benutzereintrag abgelaufen
InvalidCredentialsADPasswordMustChange: Passwort muss geändert werden
InvalidCredentialsADAccountLockedOut: Benutzereintrag wurde durch Eindringlingserkennung gesperrt
ExamCloseModeSeparate: Separat
ExamCloseModeOnFinished: Mit Veröffentlichung
ExamCloseModeOnFinishedHidden: Mit Veröffentlichung (versteckt)
ExamCloseMode: Prüfungs-Abschluss
RoomReferenceSimple: Text
RoomReferenceLink: Link & Anweisungen
RoomReferenceSimpleText: Raum
RoomReferenceSimpleTextPlaceholder: Raum
RoomReferenceLinkLink: Link
RoomReferenceLinkLinkPlaceholder: URL
RoomReferenceLinkInstructions: Anweisungen
RoomReferenceLinkInstructionsPlaceholder: Anweisungen
RoomReferenceNone: —
UrlFieldCouldNotParseAbsolute: Konnte nicht als absolute URL interpretiert werden
WGFTextInput: Textfeld
WGFFileUpload: Dateifeld
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:
WorkflowWorkflowListPersons: Beteiligte Benutzer

View File

@ -1,5 +1,3 @@
PrintDebugForStupid name: Debug message "#{name}"
Logo: Uni2work
BtnSubmit: Submit
@ -142,6 +140,7 @@ ScheduleOffsetWeekForwardDays n: #{n} #{pluralEN n "day" "days"} forward
ScheduleOffsetWeekForwardWeek: 1 week forward
ScheduleWeekSlotIsCont: Cont.
ScheduleWeekHoliday: Holiday
ScheduleOptActions: Schedule
ScheduleOptOut: Unsubscribe
@ -478,6 +477,10 @@ SubmissionUserAlreadyAdded: This user is already configured as a submittor
NoOpenSubmissions: No open submissions exist
SubmissionFilesCorrected: Submitted & Corrected files
RatingUpdatedFiles: During correction files were added or changed
SubmissionFilesUnchanged: Keep submission files
SubmissionFilesUnchangedTip: Should the existing submission files be retained unchanged while replacing the submission?
SubmissionUserDuplicateWarning: This participant is already a submittor for a different submission
SubmissionSomeUsersDuplicateWarning: Some submittors are also submittors for a different submission
SubmissionsDeleteQuestion n: Do you really want to delete the #{pluralEN n "submission" "submissions"} mentioned below?
SubmissionsDeleted n: #{pluralEN n "Submission" "Submissions"} deleted
@ -530,6 +533,7 @@ Unauthorized: You do not have explicit authorisation.
UnauthorizedAnd l r: (#{l} AND #{r})
UnauthorizedOr l r: (#{l} OR #{r})
UnauthorizedNot r: (NOT #{r})
UnauthorizedI18nMismatch: Different authentication results were calculated for different languages
UnauthorizedNoToken: No authorisation-token was provided with your request.
UnauthorizedTokenExpired: Your authorisation-token is expired.
UnauthorizedTokenNotStarted: Your authorisation-token is not yet valid.
@ -539,6 +543,7 @@ UnauthorizedTokenInvalidNoAuthority: Your authorisation-token does not list any
UnauthorizedTokenInvalidAuthority: Your authorisation-token is based in an user's rights who does not exist anymore.
UnauthorizedTokenInvalidAuthorityGroup: Your authorisation-token is based in an user groups rights which does not exist anymore.
UnauthorizedTokenInvalidAuthorityValue: The specification of the rights in which your authorisation-token is based, could not be interpreted.
UnauthorizedTokenInvalidImpersonation: Your authorisation-token contains an instruction to impersonate an user. Not all users on whose rights your token is based however are permitted to do so.
UnauthorizedToken404: Authorisation-tokens cannot be processed on error pages.
UnauthorizedSiteAdmin: You are no system-wide administrator.
UnauthorizedSchoolAdmin: You are no administrator for this department.
@ -547,6 +552,7 @@ UnauthorizedExamOffice: You are not part of an exam office.
UnauthorizedEvaluation: You are not charged with course evaluation.
UnauthorizedAllocationAdmin: You are not charged with the administration of central allocations.
UnauthorizedExamExamOffice: You are not part of the appropriate exam office for any of the participants of this exam.
UnauthorizedSchoolExamOffice: You are not part of an exam office for this school.
UnauthorizedSystemExamOffice: You are not charged with system wide exam administration
UnauthorizedExternalExamExamOffice: You are not part of the appropriate exam office for any of the participants of this exam.
UnauthorizedSchoolLecturer: You are no lecturer for this department.
@ -569,6 +575,7 @@ UnauthorizedParticipantSelf: You are no participant of this course.
UnauthorizedApplicant: The specified user is no applicant for this course.
UnauthorizedApplicantSelf: You are no applicant for this course.
UnauthorizedCourseTime: This course is not currently available.
UnauthorizedCorrectionExamTime: Visibility restrictions of the relevant exam are restricting access.
UnauthorizedCourseRegistrationTime: This course does not currently allow enrollment.
UnauthorizedAllocationRegisterTime: This central allocation does not currently allow applications.
UnauthorizedSheetTime: This sheet is not currently available.
@ -613,6 +620,20 @@ UnauthorizedPasswordResetToken: This authorisation-token may no longer be used t
UnauthorizedAllocatedCourseRegister: Direct enrollment to this course is currently not allowed due to participation in a central allocation
UnauthorizedAllocatedCourseDeregister: Deregistration from this course is currently not allowed due to participation in a central allocation
UnauthorizedAllocatedCourseDelete: Courses that participate in a central allocation may not be deleted
UnauthorizedWorkflowInitiate: You currently may not initiate a new running workflow
UnauthorizedWorkflowWrite: You are currently not allowed to initiate any state transition within the workflow
UnauthorizedWorkflowRead: The workflow currently contains no states or data you are permitted to view
UnauthorizedWorkflowInstancesNotEmpty: There are workflow instances for which you are allowed to initiate a new running workflow
UnauthorizedWorkflowWorkflowsNotEmpty: There are running workflows, which you may view
UnauthorizedWorkflowFiles: You are not allowed to download the given workflow files in the given historical state
UnauthorizedNotAuthenticatedInDifferentApproot: You could not be authenticated in the context of a separate domain (e.g. for secure downloading of files). You probably used no or an expired token. You can try to access the resource with a newly generated download link.
UnauthorizedCsrfDisabled: Your request might have triggered a state change on the server. Since CSRF-protection was disabled for your request, it had to be rejected.
UnauthorizedStudent: You are not a student.
WorkflowRoleUserMismatch: You aren't any of the users authorized by the workflow
WorkflowRoleAlreadyInitiated: This workflow was already initiated
WorkflowRoleNoSuchWorkflowWorkflow: The given workflow could not be found
WorkflowRoleNoPayload: This workflow does not contain any data
EMail: Email
EMailUnknown email: Email #{email} does not belong to any known user.
@ -714,6 +735,7 @@ CorrAutoSetCorrector: Distribute corrections
CorrDelete: Delete submissions
NatField name: #{name} must be a natural number!
JSONFieldDecodeFailure aesonFailure: Could not parse JSON: #{aesonFailure}
YAMLFieldDecodeFailure yamlFailure: Could not parse YAML: #{yamlFailure}
SecretJSONFieldDecryptFailure: Could not decrypt hidden data
SubmissionsAlreadyAssigned num: #{num} #{pluralEN num "correction" "corrections"} were already assigned to a corrector and were left unchanged:
@ -775,6 +797,8 @@ RatingTime: Marked
RatingComment: Comment
SubmissionUsers: Submittors
Rating: Marking
IsRated: Marked
SheetTypeIsExam: Rating „as an exam part“
RatingPoints: Points
RatingDone: Rating finished
RatingDoneTip: The rating is only visible to the submittors and considered for any exam bonuses if it is finished.
@ -1186,9 +1210,10 @@ SheetGradingPassPoints': Passing by points
SheetGradingPassBinary': Pass/Fail
SheetGradingPassAlways': Automatically passed when corrected
SheetTypeBonus grading: Bonus
SheetTypeNormal grading: Normal
SheetTypeInformational grading: Informational
SheetTypeBonus: Bonus
SheetTypeNormal: Normal
SheetTypeInformational: Informational
SheetTypeExamPartPoints: As an exam part
SheetTypeNotGraded: Not marked
SheetTypeInfoNormalLecturer: Normal sheets are used to calculate exam bonuses. Bonuses may be calculated from the number of sheets that can be passed or the maximum number of points achievable either manually or automatically.
SheetTypeInfoNotGraded: "Not marked" means that there will be no feedback at all.
@ -1198,6 +1223,11 @@ SheetGradingBonusIncluded: Achieved bonus points are already counted among the a
SummaryTitle: Summary of
SheetGradingSummaryTitle intgr: #{intgr} #{pluralEN intgr "sheet" "sheets"}
SubmissionGradingSummaryTitle intgr: #{intgr} #{pluralEN intgr "submission" "submissions"}
SheetTypeExamPartPointsWeightNegative: Weight may not be negative
SheetTypeExamPartPointsWeight: Weight
SheetTypeExamPartPointsExamPartOption examn examPartNumber: #{examn} - Part #{view _ExamPartNumber examPartNumber}
SheetTypeInfoExamPartPoints: Points achieved in this exercise sheet will be directly applied to the result of an exam part. If the number of points achievable via exercise sheets for an exam part does not match the maximum number of points of that exam part, the points achieved via exercise sheets will be scaled according to their weight. Corrections for this sheet will only be displayed to participants once the exam timestamp “_{MsgExamFinished}” has passed.
SheetTypeExamPartPointsExamPart: Exam part
SheetTypeBonus': Bonus
SheetTypeNormal': Normal
@ -1484,6 +1514,23 @@ MenuFaq: FAQ
MenuSheetPersonalisedFiles: Download personalised sheet files
MenuCourseSheetPersonalisedFiles: Download template for personalised sheet files
MenuAdminCrontab: Crontab
MenuAdminWorkflowDefinitionList: Workflows
MenuAdminWorkflowDefinitionNew: New workflow definition
MenuAdminWorkflowDefinitionDelete: Delete
MenuAdminWorkflowInstanceList: Workflow instances
MenuAdminWorkflowInstanceNew: New workflow instance
MenuAdminWorkflowDefinitionInstantiate: Instantiate
MenuWorkflowInstanceDelete: Delete
MenuWorkflowInstanceWorkflows: Running workflows
MenuWorkflowInstanceInitiate: Start workflow
MenuWorkflowInstanceEdit: Edit
MenuWorkflowWorkflowList: Running workflows
MenuWorkflowWorkflowEdit: Edit
MenuWorkflowWorkflowDelete: Delete
MenuGlobalWorkflowInstanceList: System-wide workflows
MenuTopWorkflowInstanceList: Workflows
MenuTopWorkflowWorkflowList: Running workflows
MenuTopWorkflowWorkflowListHeader: Workflows
BreadcrumbSubmissionFile: File
BreadcrumbSubmissionUserInvite: Invitation to participate in a submission
@ -1562,6 +1609,31 @@ BreadcrumbFaq: FAQ
BreadcrumbSheetPersonalisedFiles: Download personalised sheet files
BreadcrumbCourseSheetPersonalisedFiles: Download template for personalised sheet files
BreadcrumbAdminCrontab: Crontab
BreadcrumbAdminWorkflowDefinitionList: Workflow definitions
BreadcrumbAdminWorkflowDefinitionNew: New workflow definition
BreadcrumbAdminWorkflowDefinitionEdit renderedWorkflowScope' wfdn: #{wfdn} (#{renderedWorkflowScope'})
BreadcrumbAdminWorkflowDefinitionDelete: Delete
BreadcrumbAdminWorkflowDefinitionInstantiate: Instantiate
BreadcrumbAdminWorkflowInstanceList: Workflow instances
BreadcrumbAdminWorkflowInstanceNew: New workflow-instance
BreadcrumbAdminWorkflowInstanceEdit: Edit instance
BreadcrumbAdminWorkflowWorkflowList: Initiated workflows
BreadcrumbAdminWorkflowWorkflowNew: Initiate workflow
BreadcrumbWorkflowInstanceEdit win: #{win}
BreadcrumbWorkflowInstanceDelete: Delete
BreadcrumbWorkflowInstanceWorkflowList: Running workflows
BreadcrumbWorkflowInstanceInitiate: Start workflow
BreadcrumbWorkflowInstanceList: Workflows
BreadcrumbWorkflowInstanceNew: New workflow
BreadcrumbWorkflowWorkflowList: Running workflows
BreadcrumbWorkflowWorkflow workflow: #{toPathPiece workflow}
BreadcrumbWorkflowWorkflowFiles: Files
BreadcrumbWorkflowWorkflowEdit: Edit
BreadcrumbWorkflowWorkflowDelete: Delete
BreadcrumbGlobalWorkflowInstanceList: System-wide workflows
BreadcrumbTopWorkflowInstanceList: Workflows
BreadcrumbTopWorkflowWorkflowList: Running workflows
BreadcrumbError: Error
ExternalExamEdit coursen examn: Edit: #{coursen}, #{examn}
ExternalExamGrades coursen examn: Exam achievements: #{coursen}, #{examn}
@ -1592,6 +1664,7 @@ AuthTagTime: Time restrictions are fulfilled
AuthTagStaffTime: Time restrictions wrt. staff are fulfilled
AuthTagAllocationTime: Time restrictions due to a central allocation are fulfilled
AuthTagCourseTime: Time restrictions wrt. course visibility are fulfilled
AuthTagExamTime: Exam time restrictions are satisfied
AuthTagCourseRegistered: User is enrolled in course
AuthTagAllocationRegistered: User participates in central allocation
AuthTagTutorialRegistered: User is tutorial participant
@ -1603,7 +1676,7 @@ AuthTagParticipant: User participates in course
AuthTagApplicant: User is applicant for course
AuthTagRegisterGroup: User is not participant in any tutorial of the same registration group
AuthTagCapacity: Capacity is sufficient
AuthTagEmpty: Course is empty
AuthTagEmpty: Resource is “empty”
AuthTagMaterials: Course material is publicly accessable
AuthTagOwner: User is owner
AuthTagPersonalisedSheetFiles: User has been assigned personalised sheet files
@ -1617,6 +1690,8 @@ AuthTagAuthentication: User is authenticated
AuthTagRead: Access is read only
AuthTagWrite: Access might write
AuthTagSubmissionGroup: User is part of a submission group
AuthTagWorkflow: User has matching workflow role
AuthTagStudent: User is a student
DeleteCopyStringIfSure n: If you are sure that you want to permanently delete the #{pluralEN n "object" "objects"} listed below, please copy the shown text.
DeletePressButtonIfSure n: If you are sure that you want to permanently delete the #{pluralEN n "object" "objects"} listed below, please confirm the action by pressing the button.
@ -1640,6 +1715,7 @@ CommSuccess n: Message was sent to #{n} #{pluralEN n "recipient" "recipients"}
CommTestSuccess: Message was sent only to yourself for testing purposes
CommUndisclosedRecipients: Undisclosed recipients
CommAllRecipients: all-recipients
CommAllRecipientsSheet: Recipients
CommCourseHeading: Course message
CommTutorialHeading: Tutorial message
@ -1773,6 +1849,10 @@ TutorialParticipants: Participants
TutorialCapacity: Capacity
TutorialFreeCapacity: Free capacity
TutorialRoom: Regular room
TutorialRoomHidden: Room only for participants
TutorialRoomHiddenTip: Should the room only be displayed to tutorial participants?
TutorialRoomIsUnset: —
TutorialRoomIsHidden: Room is only displayed to participants
TutorialTime: Time
TutorialRegistered: Registered
TutorialRegGroup: Registration group
@ -1874,6 +1954,7 @@ ExamFinished: Results visible from
ExamFinishedOffice: Exam achievements published
ExamFinishedParticipant: Marking expected to be finished
ExamFinishedTip: At this participants are informed of their exam achievements. If left empty participants are never informed of their exam achievements.
ExamFinishedTipCloseOnFinished: At this time participants and exam offices are informed of the exam achievements. If left empty participants and exam offices are never informed of the exam achievements.
ExamClosed: Exam achievements registered
ExamClosedTip: At this time exam offices, which pull exam achievements from Uni2work, are informed. Changes to exam achievements trigger further notifications
ExamGradingMode: Grading mode
@ -1897,6 +1978,7 @@ ExamBonusRule: Bonus points from exercises
ExamNoBonus': No automatic exam bonus
ExamBonusPoints': Compute from exercise achievements
ExamBonusManual': Manual computation
ExamBonusInfoPoints: When calculating an exam bonus only those sheets will be considered, for which the submission period started before the start of the relevant occurrence/room
ExamRegisterForOccurrence: Registration for this exam is done by registering for an occurrence/room
@ -1921,6 +2003,8 @@ ExamRoomSurname': By surname
ExamRoomMatriculation': By matriculation
ExamRoomRandom': Randomly
ExamRoomFifo': Selected by the participants when registering
ExamOccurrenceRoomIsUnset: —
ExamOccurrenceRoomIsHidden: Room is only displayed to participants registered for this occurrence/room
ExamOccurrence: Occurrence/room
ExamNoOccurrence: No occurrence/room
@ -1929,9 +2013,12 @@ ExamOccurrences: Exams
ExamRooms: Rooms
ExamTimes: Times
ExamRoomRoom: Room
ExamRoomRoomHidden: Room only for participants
ExamRoomRoomHiddenTip: Should the room only be displayed to participants registered for this occurrence/room?
ExamRoomAlreadyExists: Occurrence already configured
ExamRoomName: Internal name
ExamRoomCapacity: Capacity
ExamRoomCapacityTip: Maximum number of participants for this occurrence/room; leave empty for unlimited capacity
ExamRoomCapacityNegative: Capacity may not be negative
ExamRoomTime: Time
ExamRoomStart: Start
@ -2006,10 +2093,15 @@ ExamPartMaxPoints: Maximum points
ExamPartWeight: Weight
ExamPartWeightTip: Will be multiplied with the achieved number of points before they are shown to the participant or used in automatic grade computation. Thus this also affects existing exam results (changed exam achievements have to be accepted manually again)
ExamPartResultPoints: Achieved points
ExamPartSheets: Exercise sheets
ExamNameTaken exam: There already is an exam named #{exam}
ExamPartsFrom: Parts visible from
ExamPartsFromTip: At this time the list of exam parts/questions will be published, but without their respective maximum number of points. If left empty the list will be published with “Results visible from”
ExamEditExamNameTaken exam: There already is an exam named #{exam}
ExamCreated exam: Successfully created #{exam}
ExamEdited exam: Successfully edited #{exam}
ExamEditWouldBreakSheetTypeReference: Your changes include deleting an exam part to which a reference still exists through an exercise sheet.
ExamNoShow: Not present
ExamVoided: Voided
@ -2043,6 +2135,7 @@ ExamRegistrationMustFollowSchoolSeparationFromStart dayCount: As per school rule
ExamRegistrationMustFollowSchoolDuration dayCount: As per school rules there #{pluralEN dayCount "needs" "need"} to be at least #{dayCount} #{pluralEN dayCount "day" "days"} between "Register from" and "Register to".
ExamModeRequiredForRegistration: As per school rules "Exam design" needs to be fully specified before "Register from" may be set.
ExamModeSchoolDiscouraged: As per school rules the specified "Exam design" is discouraged
ExamPartsFromMustBeBeforeFinished: “Parts visible from” must be before “Results visible from”
ExamOccurrenceEndMustBeAfterStart eoName: End of the occurrence #{eoName} must be after it's start
ExamOccurrenceStartMustBeAfterExamStart eoName: Start of the occurrence #{eoName} must be after the exam start
@ -2051,6 +2144,7 @@ ExamOccurrenceDuplicate eoRoom eoRange: Combination of room #{eoRoom} and occurr
ExamOccurrenceDuplicateName eoName: Internal name #{eoName} occurs multiple times
ExamOccurrenceCannotBeDeletedDueToRegistrations eoName: Occurrence #{eoName} cannot be deleted because participants are registered for it. You can remove the offending registrations via the list of exam participants.
ExamPartCannotBeDeletedDueToResults exampartnum: Part #{exampartnum} cannot be deleted because some exam part results were already entered for it.
ExamPartCannotBeDeletedDueToSheetReference exampartnum sheetName: Part #{exampartnum} cannot be deleted, since exercise sheet #{sheetName} is configured “as an exam part”.
VersionHistory: Version history
KnownBugs: Known bugs
@ -2064,6 +2158,7 @@ ExamUserAssignOccurrence: Assign occurrence/room
ExamUserAcceptComputedResult: Accept computed result
ExamUserResetToComputedResult: Reset result
ExamUserResetBonus: Also reset exam bonus
ExamUserResetParts: Also reset exam part results
ExamUserSetPartResult: Set exam part result
ExamUserSetBonus: Set exam bonus
ExamUserSetResult: Set exam result
@ -2139,14 +2234,19 @@ CsvImportExplanationLabel: Informating regarding CSV import
CsvExampleData: Example data
CsvExportExample: Export example CSV
Proportion c of prop: #{c}/#{of} (#{rationalToFixed2 (100 * prop)}%)
ProportionNoRatio c of: #{c}/#{of}
Proportion c of' prop: #{c}/#{of'} (#{rationalToFixed2 (100 * prop)}%)
ProportionNoRatio c of': #{c}/#{of'}
CourseUserCsvName tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-participants
CourseUserCsvSheetName tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh} Participants
ExamUserCsvName tid ssh csh examn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn}-participants
ExamUserCsvSheetName tid ssh csh examn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase examn} Participants
ExternalExamUserCsvName tid@TermId ssh@SchoolId coursen@CourseName examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase coursen}-#{foldedCase examn}-participants
ExternalExamUserCsvSheetName tid@TermId ssh@SchoolId coursen@CourseName examn@ExamName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase coursen}-#{foldedCase examn} Participants
CourseApplicationsTableCsvName tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-applications
CourseApplicationsTableCsvSheetName tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh} Applications
ParticipantsCsvName tid ssh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-participants
ParticipantsCsvSheetName tid ssh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)} Participants
CourseUserCsvIncludeSheets: Exercise sheets
CourseUserCsvIncludeSheetsTip: Should the exportet CSV-file additionally contain one column per exercise sheet?
@ -2170,6 +2270,8 @@ CsvColumnExamUserParts: Number of points the participant achieved per exam part.
CsvColumnExamUserResult: Exam achievement; "passed", "failed", "no-show", "voided", or any number grade ("1.0", "1.3", "1.7", ..., "4.0", "5.0")
CsvColumnExamUserCourseNote: Course notes for the participant
CsvColumnUserSurname: Participant's surname
CsvColumnUserFirstName: Participant's given name
CsvColumnUserName: Participant's full name
CsvColumnUserMatriculation: Participant's matriculation
CsvColumnUserSex: Participant's sex
@ -2537,8 +2639,14 @@ BtnCloseExam: Close exam
ExamCloseTip: When an exam is closed all relevant exam offices, which pull exam achievements from Uni2work, are informed and kept up to date with changes.
ExamCloseReminder: Please close the exam as soon as possible, when exam achievements are no longer expected to change e.g. after inspection of the exam has concluced.
ExamDidClose: Successfully closed exam
ExamCloseTipOnFinished: The exam will be closed automatically as soon as exam participants are informed of their exam achievements. That means exam offices will be able notified once and after that each time a grade changes.
ExamFinishHeading: Make results visible
BtnFinishExam: Make results visible
ExamFinishTip: After results are made visible participants are notified and can view their result in Uni2work. The exam timestamp “_{MsgExamFinished}” will be set to the current time.
ExamDidFinish: Successfully made results visible
ExamClosedSince time: Exam closed since #{time}
ExamFinishedSince time: Exam results visible since #{time}
LecturerInfoTooltipNew: New feature
LecturerInfoTooltipProblem: Feature with known issues
@ -2561,8 +2669,9 @@ CsvOptionsTip: These settings primarily affect CSV export. During import most se
CsvFormatOptions: File format
CsvTimestamp: Timestamp
CsvTimestampTip: Should the name of every exported csv file contain a timestamp?
CsvPresetRFC: Standards-compliant (RFC 4180)
CsvPresetExcel: Excel compatible
CsvPresetRFC: Standards-compliant .csv files (RFC 4180)
CsvPresetExcel: Excel compatible .csv files (Excel <2010)
CsvPresetXlsx: .xlsx files (ECMA-376; Excel ≥2010)
CsvCustom: User defined
CsvDelimiter: Separator character
CsvUseCrLf: Linebreaks
@ -2587,6 +2696,9 @@ CsvQuoteMinimal: Only when necessary
CsvQuoteAll: Always
CsvOptionsUpdated: Successfully changed CSV options
CsvChangeOptionsLabel: Export options
CsvFormatField: File format
CsvFormatCsv: .csv (comma-separated values)
CsvFormatXlsx: .xlsx (Office Open XML)
CourseNews: News
CourseNewsArchiveName tid ssh csh newsTitle: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldCase newsTitle}
@ -2644,12 +2756,15 @@ AllocationResultsTip: The following information reflect the current state of the
AllocationResultsStudentTip: Listed below are placements in courses which you have received due to the mentioned central allocation and for which you have not left the respective course or have been deregistered. Thus placements you have been informed of already may be listed again.
AllocationResultStudentRegistrationTip: You were enrolled in the course mentioned above in Uni2work.
AllocationResultsStudentRegistrationTip: You were enrolled in the courses mentioned above in Uni2work.
AllocationResultsStudentConsultFaq n@Int: If you have questions or remarks, please also take into account the information on the following #{pluralEN n "page" "pages"}:
FavouriteVisited: Visited
FavouriteParticipant: Your courses
FavouriteManual: Favourites
FavouriteCurrent: Current course
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.
FavouritesUnavailableTip: Quick Actions for this course are currently not available.
CourseEvents: Occurrences
@ -2657,6 +2772,10 @@ CourseEventType: Type
CourseEventTypePlaceholder: Lecture, Exercise discussion, ...
CourseEventTime: Time
CourseEventRoom: Regular room
CourseEventRoomHidden: Room only for participants
CourseEventRoomHiddenTip: Should the room only be displayde to course participants?
CourseEventRoomIsUnset: —
CourseEventRoomIsHidden: Room is only displayed to course associated persons (participants, tutor, correctors, etc.)
CourseEventNote: Note
CourseEventActions: Actions
CourseEventsActionEdit: Edit
@ -2783,17 +2902,21 @@ ExamAutoOccurrenceHeading: Automatic occurrence/room distribution
ExamAutoOccurrenceMinimizeRooms: Minimize number of occurrences used
ExamAutoOccurrenceMinimizeRoomsTip: Should the list of occurrences/rooms be reduced prior to distribution? Only as many occurrence/rooms as necessary would be used (starting with the biggest).
ExamAutoOccurrenceOccurrencesChangedInFlight: Occurrences/rooms changed
ExamAutoOccurrenceParticipantsAssigned num: Occurrence/room assignment rule saved successfully. Also assigned occurence/room to #{num} #{pluralEN num "participant" "participants"}
ExamAutoOccurrenceParticipantsAssigned num: Occurrence/room assignment rule saved successfully. Also assigned occurrence/room to #{num} #{pluralEN num "participant" "participants"}
TitleExamAutoOccurrence tid ssh csh examn: #{tid} - #{ssh} - #{csh} #{examn}: Automatic occurrence/room distribution
BtnExamAutoOccurrenceCalculate: Calculate assignment rules
BtnExamAutoOccurrenceAccept: Accept assignments
BtnExamAutoOccurrenceNudgeUp: +
BtnExamAutoOccurrenceNudgeDown: -
ExamRoomMappingSurname: Surnames starting with
ExamRoomMappingMatriculation: Matriculation numbers ending in
ExamRoomMappingRandom: Distribution
ExamRoomMappingRandomHere: Random
ExamRoomLoad: Utilisation
ExamRegisteredCount: Registrations
ExamRegisteredCountOf num count: #{num}/#{count}
ExamAutoOccurrenceExceptionRuleNoOp: Didn't chose an automatic distribution procedure
ExamAutoOccurrenceExceptionNotEnoughSpace: More participants than available space
ExamAutoOccurrenceExceptionNoUsers: No participants can be distributed with the chosen procedure
ExamAutoOccurrenceExceptionRoomTooSmall: Automatic distribution failed. A different distribution procedure might succeed. Alternatively, minimizing rooms or removing small rooms might help.
NoFilter: No restriction
@ -2807,7 +2930,7 @@ InfoLecturerTutorials: Tutorials
InfoLecturerExams: Exams
InfoLecturerAllocations: Central allocations
ParticipantsIntersectCourseOption tid@TermId ssh@SchoolId coursen@CourseName: #{tid} - #{ssh} - #{coursen}
ParticipantsIntersectCourseOption tid ssh csh coursen: #{tid} - #{ssh} - #{csh}: #{coursen}
ParticipantsIntersectCourses: Courses
AllocationUsersTitle tid ssh ash: #{tid}-#{ssh}-#{ash}: Applicants
@ -2829,6 +2952,7 @@ CsvColumnAllocationUserAssigned: Number of assignments the applicant has already
CsvColumnAllocationUserNewAssigned: Number of assignments the applicant would receive, if the calculated matching is accepted
CsvColumnAllocationUserPriority: Central priority of this applicant; either a number based on the applicants position in the list sorted by priority (higher numbers mean a higher priority) or a comma-separated list of numerical priorities in square brackets (e.g. [1, 2, 3])
AllocationUsersCsvName tid ssh ash: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase ash}-applicants
AllocationUsersCsvSheetName tid ssh ash: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase ash} Applicants
AllocationPrioritiesMode: Mode
AllocationPrioritiesNumeric: Numeric priorities
@ -2898,7 +3022,7 @@ AllocationUsersCount: Participants
AllocationCoursesCount: Courses
AllocationCourseEligible: Considered
CourseOption tid ssh coursen: #{tid} - #{ssh} - #{coursen}
CourseOption tid ssh csh coursen: #{tid} - #{ssh} - #{csh}: #{coursen}
BearerTokenUsageWarning: Using this interface you are able to encode essentially arbitrary permissions inte bearer tokens. This allows you to freely hand permissions off arbitrarily and without relevant restrictions. Only use this interface if you have discussed the consequences of the specific token, that you want to issue, with an experienced developer!
BearerTokenAuthorityGroups: Authority (groups)
@ -2921,6 +3045,16 @@ BearerTokenExpires: Expiration time
BearerTokenExpiresTip: If no expiration time is given, the token will not expire. It will be valid forever.
BearerTokenOverrideStart: Start time
BearerTokenOverrideStartTip: If no start time is given, only the expiration time will be checked when the token is used.
BearerTokenImpersonate: Impersonate
BearerTokenImpersonateNone: No one
BearerTokenImpersonateSingle: A specific user
BearerTokenImpersonateRandom: Random users
BearerTokenImpersonateSingleUser: User
BearerTokenImpersonateRandomNegative: Count must be positive
BearerTokenImpersonateRandomCount: Count
BearerTokenImpersonateUnknownUser email: Could not find any user with email #{email}
BearerTokenImpersonateRandomWeightActivity: Weight by activity
BearerTokenArchiveName: tokens.zip
FaqTitle: Frequently asked questions
AdditionalFaqs: More frequently asked questions
@ -2987,6 +3121,9 @@ PersonalisedSheetFilesIgnored count: #{count} uploaded #{pluralEN count "file wa
PersonalisedSheetFilesIgnoredIntro: The following files were ignored:
CourseUserHasPersonalisedSheetFilesFilter: Participant has personalised sheet files for
SheetPersonalisedFilesUsersList: List of course participants who have personalised sheet files
PersonalisedSheetFilesDownloadRestrictByExamNone: No restriction
PersonalisedSheetFilesDownloadRestrictByExam: Restrict to exam participants
PersonalisedSheetFilesDownloadRestrictByExamTip: Only download personalised sheet files for participants also registered to a certain exam?
AdminCrontabNotGenerated: Crontab not (yet) generated
CronMatchAsap: ASAP
@ -2994,6 +3131,143 @@ CronMatchNone: Never
SystemExamOffice: Exam office
SystemFaculty: Faculty member
SystemStudent: Student
WorkflowScopeKindGlobal: Global
WorkflowScopeKindTerm: Per term
WorkflowScopeKindSchool: Per school
WorkflowScopeKindTermSchool: Per school & term
WorkflowScopeKindCourse: Per course
WorkflowScopeGlobal: System-wide
WorkflowScopeTermSchool tid ssh: #{tid} #{ssh}
WorkflowScopeCourse tid ssh csh: #{tid} #{ssh} #{csh}
WorkflowDefinitionScope: Scope
WorkflowDefinitionName: Name
WorkflowDefinitionDescriptions: Description
WorkflowDefinitionDescriptionsLanguageExists: A description in this language already exists
WorkflowDescriptionLanguage: Language code (RFC1766)
WorkflowDescriptionTitle: Title
WorkflowDescription: Description
GlobalWorkflowInstancesHeading: Workflows (system-wide)
GlobalWorkflowInstancesTitle: Workflows (system-wide)
GlobalWorkflowInstanceInitiateHeading workflowInstanceTitle: Initiate workflow: #{workflowInstanceTitle}
GlobalWorkflowInstanceInitiateTitle: Initiate workflow
SchoolWorkflowInstancesHeading ssh: Workflows (#{ssh})
SchoolWorkflowInstancesTitle ssh: Workflows (#{ssh})
SchoolWorkflowInstanceInitiateHeading ssh workflowInstanceTitle: Initiate workflow: #{ssh}, #{workflowInstanceTitle}
SchoolWorkflowInstanceInitiateTitle ssh: Initiate workflow: #{ssh}
WorkflowEdgeNumberedVariant edgeLabel i: #{edgeLabel} (variant #{i})
WorkflowEdgeFormEdge: Action
WorkflowEdgeFormHiddenPayload i: Hidden dataset #{i}
WorkflowEdgeFormPayloadOneFieldRequired: At least one field per dataset needs to be filled
WorkflowEdgeFormPayloadOneFieldRequiredFor payloadDisplayLabel: At least one field for “#{payloadDisplayLabel}” needs to be filled
WorkflowEdgeFormFieldNumberTooSmall minSci: Number must be at least #{formatScientific Scientific.Generic Nothing minSci}
WorkflowEdgeFormFieldNumberTooLarge maxSci: Number must be at most #{formatScientific Scientific.Generic Nothing maxSci}
WorkflowEdgeFormFieldUserNotFound: Email could not be resolved to an user
WorkflowEdgeFormFieldMultipleNoneAdded: No entries (yet)
WorkflowEdgeFormFieldCaptureUserLabel: Current user
WorkflowWorkflowWorkflowHistoryHeading: History
WorkflowWorkflowWorkflowEdgeFormHeading: Trigger action within workflow
WorkflowWorkflowWorkflowEdgeSuccess: Successfully triggered action
WorkflowWorkflowWorkflowHistoryUserSelf: You
WorkflowWorkflowWorkflowHistoryUserNotLoggedIn: Not-logged in user
WorkflowWorkflowWorkflowHistoryUserGone: Deleted user
WorkflowWorkflowWorkflowHistoryUserHidden: Hidden user
WorkflowWorkflowWorkflowHistoryUserAutomatic: Automatic
WorkflowWorkflowWorkflowHistoryActionAutomatic: Automatic
WorkflowWorkflowWorkflowHistoryStateHidden: Hidden state
WorkflowWorkflowWorkflowHistoryActionLabel: Action
WorkflowWorkflowWorkflowHistoryFromLabel: Previous state
WorkflowWorkflowWorkflowHistoryToLabel: New state
WorkflowWorkflowWorkflowHistoryPayloadLabel: Data changes
WorkflowWorkflowFilesArchiveName wwCID wpl stCID: #{foldCase (toPathPiece wwCID)}-#{foldCase (toPathPiece stCID)}-#{foldCase (foldMap unidecode (toPathPiece wpl))}.zip
WorkflowWorkflowWorkflowStateHeading: State/Data
WorkflowWorkflowWorkflowPayloadHeading: Current data
WorkflowWorkflowWorkflowStateStateLabel: Current state
WorkflowWorkflowWorkflowStateStateHidden: Hidden state
WorkflowWorkflowWorkflowHistoryLabelOthers: Other users' actions
WorkflowWorkflowWorkflowHistoryLabelOwn: Your actions
WorkflowPayloadFiles: File(s)
WorkflowPayloadBoolTrue: Yes
WorkflowPayloadBoolFalse: No
WorkflowPayloadUserGone: Deleted user
TopWorkflowInstancesHeading: Workflows
TopWorkflowInstancesTitle: Workflows
GlobalWorkflowWorkflowWorkflowHeading workflowWorkflowId: Workflow #{toPathPiece workflowWorkflowId}
GlobalWorkflowWorkflowWorkflowTitle workflowWorkflowId: Workflow #{toPathPiece workflowWorkflowId}
SchoolWorkflowWorkflowWorkflowHeading ssh workflowWorkflowId: Workflow #{ssh}, #{toPathPiece workflowWorkflowId}
SchoolWorkflowWorkflowWorkflowTitle ssh workflowWorkflowId: Workflow #{ssh}, #{toPathPiece workflowWorkflowId}
WorkflowWorkflowListScopeTitle rScope: Running workflows - #{rScope}
WorkflowWorkflowListScopeHeading rScope: Running workflows (#{rScope})
WorkflowWorkflowListInstanceTitle: Running workflows for an instance
WorkflowWorkflowListInstanceHeading: Running workflows for an instance
WorkflowWorkflowListNamedInstanceTitle rScope wiTitle: Running workflows - #{rScope}, #{wiTitle}
WorkflowWorkflowListNamedInstanceHeading rScope wiTitle: Running workflows (#{rScope}, #{wiTitle})
WorkflowWorkflowListTopTitle: Running workflows
WorkflowWorkflowListTopHeading: Running workflows
AdminWorkflowWorkflowListTitle: Running workflows
AdminWorkflowWorkflowListHeading: Running workflows
WorkflowWorkflowListNumber: Number
WorkflowWorkflowListScope: Scope
WorkflowWorkflowListInstance: Instance
WorkflowWorkflowListCurrentState: Current state
WorkflowWorkflowListLastActionTime: Timestamp of last action
WorkflowWorkflowListLastActionUser: User for last action
WorkflowWorkflowListIsFinal: Finalised?
FormFieldWorkflowDatasetTip: At least one of the marked fields must be filled
WorkflowDefinitionGraph: Specification
WorkflowDefinitionKeyDoesNotExist renderedCryptoID: Referenced id does not exist: #{renderedCryptoID}
WorkflowDefinitionFiles: Files
WorkflowFileIdentDoesNotExist fileIdent: Referenced file does not exist: #{fileIdent}
WorkflowUserDoesNotExist userIdent: Referenced user does not exist: #{userIdent}
WorkflowDefinitionFileIdentExists: A file with the given reference id already exists
WorkflowDefinitionFileIdent: File reference id
WorkflowDefinitionFile: File
WorkflowDefinitionCreated: Successfully created workflow definition
WorkflowDefinitionEdited: Successfully replaced workflow definition
WorkflowDefinitionCollision: A workflow definition with this name already exists
WorkflowDefinitionNewTitle: Create new workflow definition
WorkflowDefinitionEditTitle: Edit workflow definition
WorkflowDefinitionInstanceCategory: Category
WorkflowDefinitionWarningLinterIssuesMessage: There were linter issues
WorkflowDefinitionWarningLinterIssues: There are the following linter issues:
WorkflowDefinitionListTitle: Workflow definitions
WorkflowDefinitionInstanceCount: Instances
WorkflowDefinitionWorkflowCount: Workflows
WorkflowDefinitionConcreteInstanceCount num: #{num} instances
WorkflowDefinitionConcreteWorkflowCount num: #{num} workflows
WorkflowDefinitionDeleteQuestion: Do you really want to delete the workflow definition listed below?
WorkflowDefinitionDeleted: Successfully deleted workflow definition
WorkflowDefinitionInstantiateTitle: Instantiate workflow definition
WorkflowDefinitionInstantiated: Instance created
WorkflowScope: Scope
WorkflowInstanceName: Name
WorkflowInstanceCategory: Category
WorkflowInstanceCollision: There already exists a workflow instance with the given name and category
WorkflowInstanceListTitle: Workflow instances
WorkflowInstanceDescription: Instance description
WorkflowInstanceDescriptions: Instance description
WorkflowInstanceDescriptionsLanguageExists: A instance description in the given language already exists
WorkflowInstanceCreated: Instance created
WorkflowInstanceDescriptionTitle: Instance title
WorkflowInstanceWorkflowCount: Workflows
WorkflowInstanceInitiateSuccess: Successfully initiated workflow
ChangelogItemFeature: Feature
ChangelogItemBugfix: Bugfix
@ -3009,3 +3283,35 @@ InvalidCredentialsADTooManyContextIds: Account carries to many security identifi
InvalidCredentialsADAccountExpired: Account expired
InvalidCredentialsADPasswordMustChange: Password needs to be changed
InvalidCredentialsADAccountLockedOut: Account disabled by intruder detection
ExamCloseModeSeparate: Seperately
ExamCloseModeOnFinished: With publication of achievements
ExamCloseModeOnFinishedHidden: With publication of achievements (hidden)
ExamCloseMode: Exam closure
RoomReferenceSimple: Text
RoomReferenceLink: Link & Instructions
RoomReferenceSimpleText: Room
RoomReferenceSimpleTextPlaceholder: Room
RoomReferenceLinkLink: Link
RoomReferenceLinkLinkPlaceholder: URL
RoomReferenceLinkInstructions: Instructions
RoomReferenceLinkInstructionsPlaceholder: Instructions
RoomReferenceNone: —
UrlFieldCouldNotParseAbsolute: Could not parse as an absolute URL
WGFTextInput: Text field
WGFFileUpload: File field
WorkflowGraphFormUploadIsDirectory: Upload is a directory
WorkflowGraphFormInvalidNumberOfFiles: You need to upload exactly one file
CourseSortingOnlyLoggedIn: The user interface for sorting this table is only active for logged in users
CorrectionInvisibleExamUnfinished: The time configured in “_{MsgExamFinished}” of the relevant exam has not yet passed
CorrectionInvisibleRatingNotDone: The correction is not marked as “finished”
CorrectionInvisibleWarning: This correction is currently invisible for at least one of the submittors!
CorrectionInvisibleReasons: Possible reasons include:
WorkflowWorkflowListPersons: Involved users

View File

@ -0,0 +1 @@
PrintDebugForStupid name@Text: Debug message "#{name}"

View File

@ -0,0 +1 @@
PrintDebugForStupid name: Debug message "#{name}"

View File

@ -31,6 +31,7 @@ AllocationMatching
fingerprint AllocationFingerprint
time UTCTime
log FileContentReference
deriving Generic
AllocationCourse
allocation AllocationId
@ -38,24 +39,26 @@ AllocationCourse
minCapacity Int -- if the course would get assigned fewer than this many applicants, restart the assignment process without the course
acceptSubstitutes UTCTime Maybe
UniqueAllocationCourse course
deriving Generic
AllocationUser
allocation AllocationId
user UserId
totalCourses Natural -- number of total allocated courses for this user must be <= than this number
totalCourses Word64 -- number of total allocated courses for this user must be <= than this number
priority AllocationPriority Maybe
UniqueAllocationUser allocation user
deriving Eq Ord Show
deriving Eq Ord Show Generic
AllocationDeregister -- self-inflicted user-deregistrations from an allocated course
user UserId
course CourseId Maybe
time UTCTime
reason Text Maybe -- if this deregistration was done by proxy (e.g. the lecturer pressed the button)
deriving Eq Ord Show
deriving Eq Ord Show Generic
AllocationNotificationSetting
user UserId
allocation AllocationId
isOptOut Bool
UniqueAllocationNotificationSetting user allocation
UniqueAllocationNotificationSetting user allocation
deriving Generic

View File

@ -5,4 +5,4 @@ TransactionLog
initiator UserId Maybe -- User associated with performing this action
remote IP Maybe -- Remote party that triggered this action via HTTP
info Value -- JSON-encoded `Transaction`
deriving Eq Read Show Generic Typeable
deriving Eq Read Show Generic

View File

@ -2,3 +2,4 @@ ChangelogItemFirstSeen
item ChangelogItem
firstSeen Day
Primary item
deriving Generic

View File

@ -3,4 +3,5 @@
ClusterConfig
setting ClusterSettingsKey -- I.e. Symmetric key for encrypting database-ids for use in URLs, Symmetric key for encrypting user-sessions so they can be saved directly as a browser-cookie, Symmetric key for encrypting error messages which might contain secret information, ...
value Value -- JSON-encoded value
Primary setting
Primary setting
deriving Generic

View File

@ -3,10 +3,11 @@ DegreeCourse json -- for which degree programmes this course is appropriate fo
degree StudyDegreeId
terms StudyTermsId
UniqueDegreeCourse course degree terms
deriving Generic
Course -- Information about a single course; contained info is always visible to all users
name CourseName
description StoredMarkup Maybe -- user-defined large Html, ought to contain module description
linkExternal Text Maybe -- arbitrary user-defined url for external course page
linkExternal URI Maybe -- arbitrary user-defined url for external course page
shorthand (CI Text) -- practical shorthand of course name, used for identification
term TermId -- semester this course is taught
school SchoolId
@ -34,19 +35,23 @@ CourseScheduleOpt -- opt-in/-out for displaying occurrence related to this co
user UserId
opt Bool
UniqueCourseScheduleOpt course user
deriving Generic
CourseEvent
type CourseEventType
course CourseId
room CourseEventRoom
room RoomReference Maybe
roomHidden Bool default=false
time Occurrences
note StoredMarkup Maybe
lastChanged UTCTime default=now()
deriving Generic
CourseEventScheduleOpt -- opt-in/-out for course event display in a user's schedule (TODO: currently for all occurrences of a course event; separate opt-ins/-outs per occurrence in CourseEventTime instead?)
courseEvent CourseEventId
user UserId
opt Bool -- whether the course event should be displayed; False <=> opt-out, True <=> opt-in
UniqueCourseEventScheduleOpt courseEvent user
deriving Generic
CourseAppInstructionFile
course CourseId
@ -54,16 +59,19 @@ CourseAppInstructionFile
content FileContentReference Maybe
modified UTCTime
UniqueCourseAppInstructionFile course title
deriving Generic
CourseEdit -- who edited when a row in table "Course", kept indefinitely (might be replaced by generic Audit Table; like all ...-Edit tables)
user UserId
time UTCTime
course CourseId
deriving Generic
Lecturer -- course ownership
user UserId
course CourseId
type LecturerType default='"lecturer"'::jsonb
UniqueLecturer user course -- note: multiple lecturers per course are allowed, but no duplicated rows in this table
deriving Generic
CourseParticipant -- course enrolement
course CourseId
user UserId
@ -72,7 +80,7 @@ CourseParticipant -- course enrolement
allocated AllocationId Maybe -- participant was centrally allocated
state CourseParticipantState
UniqueParticipant user course
deriving Eq Ord Show
deriving Eq Ord Show Generic
-- Replace the last two by the following, once an audit log is available
-- CourseUserNote -- lecturers of a specific course may share a text note on each enrolled student
-- course CourseId
@ -86,13 +94,16 @@ CourseUserNote -- lecturers of a specific course may share a tex
user UserId
note StoredMarkup -- arbitrary user-defined text; visible only to lecturer of this course
UniqueCourseUserNote user course
deriving Generic
CourseUserNoteEdit -- who edited a participants course note when
user UserId
time UTCTime
note CourseUserNoteId -- PROBLEM: deleted notes have no modification date any more
deriving Generic
CourseUserExamOfficeOptOut
course CourseId
user UserId
school SchoolId
UniqueCourseUserExamOfficeOptOut course user school
deriving Generic

View File

@ -7,9 +7,10 @@ CourseApplication
ratingPoints ExamGrade Maybe
ratingComment Text Maybe
allocation AllocationId Maybe
allocationPriority Natural Maybe
allocationPriority Word64 Maybe
time UTCTime default=now()
ratingTime UTCTime Maybe
deriving Generic
CourseApplicationFile
application CourseApplicationId
@ -17,3 +18,4 @@ CourseApplicationFile
content FileContentReference Maybe
modified UTCTime
UniqueCourseApplicationFile application title
deriving Generic

View File

@ -4,7 +4,9 @@ CourseFavourite -- which user accessed which course when, only display
reason FavouriteReason
lastVisit UTCTime
UniqueCourseFavourite user course
deriving Generic
CourseNoFavourite
user UserId
course CourseId
UniqueCourseNoFavourite user course
UniqueCourseNoFavourite user course
deriving Generic

View File

@ -12,4 +12,5 @@ MaterialFile -- a file that is part of a material distribution
title FilePath
content FileContentReference Maybe
modified UTCTime
UniqueMaterialFile material title
UniqueMaterialFile material title
deriving Generic

View File

@ -6,9 +6,11 @@ CourseNews
content StoredMarkup
summary StoredMarkup Maybe
lastEdit UTCTime
deriving Generic
CourseNewsFile
news CourseNewsId
title FilePath
content FileContentReference Maybe
modified UTCTime
UniqueCourseNewsFile news title
UniqueCourseNewsFile news title
deriving Generic

View File

@ -3,17 +3,21 @@ ExamOfficeField
field StudyTermsId
forced Bool
UniqueExamOfficeField office field
deriving Generic
ExamOfficeUser
office UserId
user UserId
UniqueExamOfficeUser office user
deriving Generic
ExamOfficeResultSynced
school SchoolId Maybe
office UserId
result ExamResultId
time UTCTime
deriving Generic
ExamOfficeExternalResultSynced
school SchoolId Maybe
office UserId
result ExternalExamResultId
time UTCTime
time UTCTime
deriving Generic

View File

@ -19,7 +19,9 @@ Exam
description StoredMarkup Maybe
examMode ExamMode
staff Text Maybe
partsFrom UTCTime Maybe
UniqueExam course name
deriving Generic
ExamPart
exam ExamId
number ExamPartNumber
@ -28,57 +30,64 @@ ExamPart
weight Rational
UniqueExamPartNumber exam number
UniqueExamPartName exam name !force
deriving Read Show Eq Ord Generic
ExamOccurrence
exam ExamId
name ExamOccurrenceName
room ExamOccurrenceRoom
capacity Natural
room RoomReference Maybe
roomHidden Bool default=false
capacity Word64 Maybe
start UTCTime
end UTCTime Maybe
description StoredMarkup Maybe
UniqueExamOccurrence exam name
deriving Generic
ExamOccurrenceScheduleOpt
examOccurrence ExamOccurrenceId
user UserId
opt Bool
UniqueExamOccurrenceScheduleOpt examOccurrence user
deriving Generic
ExamRegistration
exam ExamId
user UserId
occurrence ExamOccurrenceId Maybe
time UTCTime default=now()
UniqueExamRegistration exam user
deriving Eq Ord Show
deriving Eq Ord Show Generic
ExamPartResult
examPart ExamPartId
user UserId
result ExamResultPoints
lastChanged UTCTime default=now()
UniqueExamPartResult examPart user
deriving Eq Ord Show
deriving Eq Ord Show Generic
ExamBonus
exam ExamId
user UserId
bonus Points
lastChanged UTCTime default=now()
UniqueExamBonus exam user
deriving Eq Ord Show
deriving Eq Ord Show Generic
ExamResult
exam ExamId
user UserId
result ExamResultPassedGrade
lastChanged UTCTime default=now()
UniqueExamResult exam user
deriving Eq Ord Show
deriving Eq Ord Show Generic
ExamCorrector
exam ExamId
user UserId
UniqueExamCorrector exam user
deriving Generic
ExamPartCorrector
part ExamPartId
corrector ExamCorrectorId
UniqueExamPartCorrector part corrector
deriving Generic
ExamOfficeSchool
school SchoolId
exam ExamId
UniqueExamOfficeSchool exam school
deriving Generic

View File

@ -6,6 +6,7 @@ ExternalExam
defaultTime UTCTime Maybe
gradingMode ExamGradingMode
UniqueExternalExam term school courseName examName
deriving Generic
ExternalExamResult
user UserId
exam ExternalExamId
@ -13,12 +14,14 @@ ExternalExamResult
time UTCTime
lastChanged UTCTime
UniqueExternalExamResult exam user
deriving Eq Ord Show
deriving Eq Ord Show Generic
ExternalExamStaff
user UserId
exam ExternalExamId
UniqueExternalExamStaff exam user
deriving Generic
ExternalExamOfficeSchool
school SchoolId
exam ExternalExamId
UniqueExternalExamOfficeSchool exam school
UniqueExternalExamOfficeSchool exam school
deriving Generic

View File

@ -1,30 +1,36 @@
FileContentEntry
hash FileContentReference
ix Natural
ix Word64
chunkHash FileContentChunkId
UniqueFileContentEntry hash ix
deriving Generic
FileContentChunk
hash FileContentChunkReference
content ByteString
contentBased Bool default=false -- For Migration
Primary hash
deriving Generic
FileContentChunkUnreferenced
hash FileContentChunkId
since UTCTime
UniqueFileContentChunkUnreferenced hash
deriving Generic
SessionFile
content FileContentReference Maybe
touched UTCTime
deriving Generic
FileLock
content FileContentReference
instance InstanceId
time UTCTime
deriving Generic
FileChunkLock
hash FileContentChunkReference
instance InstanceId
time UTCTime
time UTCTime
deriving Generic

View File

@ -3,4 +3,5 @@ Invitation
for Value
data Value
expiresAt UTCTime Maybe
UniqueInvitation email for
UniqueInvitation email for
deriving Generic

View File

@ -6,7 +6,7 @@ QueuedJob
lockInstance InstanceId Maybe -- instance that has started to execute this job
lockTime UTCTime Maybe -- time when execution had begun
writeLastExec Bool default=false -- record successful execution to CronLastExec
deriving Eq Read Show Generic Typeable
deriving Eq Read Show Generic
-- Jobs are deleted from @QueuedJob@ after they are executed successfully and recorded in @CronLastExec@
-- There is a Cron-system that, at set intervals, queries the database for work to be done in the background (i.e. if a lecturer has set a sheet's submissions to be automatically distributed and the submission deadline passed since the last check, then queue a new job to actually do the distribution)
@ -16,9 +16,11 @@ CronLastExec
time UTCTime -- When was the job executed
instance InstanceId -- Which uni2work-instance did the work
UniqueCronLastExec job
deriving Generic
TokenBucket
ident TokenBucketIdent
lastValue Int64
lastAccess UTCTime
Primary ident
Primary ident
deriving Generic

View File

@ -6,8 +6,10 @@ SentMail
recipient UserId Maybe
headers MailHeaders
contentRef SentMailContentId
deriving Generic
SentMailContent
hash MailContentReference
content MailContent
Primary hash
Primary hash
deriving Generic

View File

@ -7,6 +7,7 @@ School json
examMinimumRegisterDuration NominalDiffTime Maybe
examRequireModeForRegistration Bool default=false
examDiscouragedModes ExamModeDNF
examCloseMode ExamCloseMode default='separate'
UniqueSchool name
UniqueSchoolShorthand shorthand -- required for Normalisation of CI Text
Primary shorthand -- newtype Key School = SchoolKey { unSchoolKey :: SchoolShorthand }
@ -15,7 +16,9 @@ SchoolLdap
school SchoolId Maybe
orgUnit (CI Text)
UniqueOrgUnit orgUnit
deriving Generic
SchoolTerms
school SchoolId
terms StudyTermsId
UniqueSchoolTerms school terms
UniqueSchoolTerms school terms
deriving Generic

View File

@ -2,7 +2,7 @@ Sheet -- exercise sheet for a given course
course CourseId
name (CI Text)
description StoredMarkup Maybe
type SheetType -- Does it count towards overall course grade?
type (SheetType SqlBackendKey) -- ExamPartId; Does it count towards overall course grade?
grouping SheetGroup -- May participants submit in groups of certain sizes?
markingText StoredMarkup Maybe -- Instructons for correctors, included in marking templates
visibleFrom UTCTime Maybe -- Invisible to enrolled participants before
@ -21,6 +21,7 @@ SheetEdit -- who edited when a row in table "Course", kept i
user UserId
time UTCTime
sheet SheetId
deriving Generic
-- For anonoymous external submissions (i.e. paper submission tracked in uni2work)
-- Map pseudonyms to users injectively in the context of a single sheet; for the next sheet all-new pseudonyms need to be created
@ -31,13 +32,14 @@ SheetPseudonym
user UserId
UniqueSheetPseudonym sheet pseudonym
UniqueSheetPseudonymUser sheet user
deriving Generic
SheetCorrector -- grant corrector role to user for a sheet
user UserId
sheet SheetId
load Load -- portion of work that will be assigned to this corrector
state CorrectorState default='CorrectorNormal' -- whether a corrector is assigned his load this time (e.g. in case of sickness)
UniqueSheetCorrector user sheet
deriving Show Eq Ord
deriving Show Eq Ord Generic
SheetFile -- a file that is part of an exercise sheet
sheet SheetId
type SheetFileType -- excercise, marking, hint or solution
@ -45,6 +47,7 @@ SheetFile -- a file that is part of an exercise sheet
content FileContentReference Maybe
modified UTCTime
UniqueSheetFile sheet type title
deriving Generic
PersonalisedSheetFile
sheet SheetId
user UserId
@ -53,11 +56,12 @@ PersonalisedSheetFile
content FileContentReference Maybe
modified UTCTime
UniquePersonalisedSheetFile sheet user type title
deriving Eq Ord Read Show Generic Typeable
deriving Eq Ord Read Show Typeable Generic
FallbackPersonalisedSheetFilesKey
course CourseId
index Word24
secret ByteString
generated UTCTime
UniqueFallbackPersonalisedSheetFilesKey course index
UniqueFallbackPersonalisedSheetFilesKey course index
deriving Generic

View File

@ -8,15 +8,16 @@ StudyFeatures -- multiple entries possible for students pursuing several degree
firstObserved UTCTime Maybe
lastObserved UTCTime default=now() -- last update from LDAP
valid Bool default=true
relevanceCached Bool default=false
relevanceCached UUID Maybe
UniqueStudyFeatures user degree field type semester
deriving Eq Show
deriving Eq Show Generic
-- UniqueUserSubject ubuser degree field -- There exists a counterexample
RelevantStudyFeatures
term TermId
studyFeatures StudyFeaturesId
UniqueRelevantStudyFeatures term studyFeatures
deriving Generic
StudyDegree -- Studienabschluss
key Int -- LMU-internal key
@ -24,7 +25,7 @@ StudyDegree -- Studienabschluss
name Text Maybe -- description given by LDAP
Primary key -- column key is used as actual DB row key
-- newtype Key StudyDegree = StudyDegreeKey' { unStudyDegreeKey :: Int }
deriving Eq Show
deriving Eq Show Generic
StudyTerms -- Studiengang
key Int -- standardised key
shorthand Text Maybe -- admin determined shorthand
@ -33,11 +34,12 @@ StudyTerms -- Studiengang
defaultType StudyFieldType Maybe
Primary key -- column key is used as actual DB row key
-- newtype Key StudyTerms = StudyTermsKey' { unStudyTermsKey :: Int }
deriving Eq Ord Show
deriving Eq Ord Show Generic
StudySubTerms
child StudyTermsId
parent StudyTermsId
UniqueStudySubTerms child parent
deriving Generic
StudyTermNameCandidate -- No one at LMU is willing and able to tell us the meaning of the keys for StudyDegrees and StudyTerms.
-- Each LDAP login provides an unordered set of keys and an unordered set of plain text description with an unknown 1-1 correspondence.
-- This table helps us to infer which key belongs to which plain text by recording possible combinations at login.
@ -45,14 +47,14 @@ StudyTermNameCandidate -- No one at LMU is willing and able to tell us the me
incidence TermCandidateIncidence -- random id, generated once per login to associate matching pairs
key Int -- a possible key for the studyTermName or studySubTermName
name Text -- studyTermName as plain text from LDAP
deriving Show Eq Ord
deriving Show Eq Ord Generic
StudySubTermParentCandidate
incidence TermCandidateIncidence
key Int
parent Int
deriving Show Eq Ord
deriving Show Eq Ord Generic
StudyTermStandaloneCandidate
incidence TermCandidateIncidence
key Int
deriving Show Eq Ord
deriving Show Eq Ord Generic

View File

@ -10,7 +10,8 @@ SubmissionEdit -- user uploads new version of their submissio
user UserId Maybe -- track id, important for group submissions
time UTCTime
submission SubmissionId
SubmissionFile -- files that are part of a submission
deriving Generic
SubmissionFile json -- files that are part of a submission
submission SubmissionId
title FilePath
content FileContentReference Maybe
@ -18,17 +19,19 @@ SubmissionFile -- files that are part of a submission
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 submission title isUpdate
deriving Show
deriving Eq Ord Read Show Generic
SubmissionUser -- which submission belongs to whom
user UserId
submission SubmissionId
UniqueSubmissionUser user submission -- multiple users may share same submission, in case of (ad-hoc) submission groups
deriving Generic
SubmissionGroup -- pre-defined submission groups; some courses only allow pre-defined submission groups
course CourseId
name SubmissionGroupName
UniqueSubmissionGroup course name
deriving Generic
SubmissionGroupUser -- Registered submission groups, just for checking upon submission, but independent of actual SubmissionUser
submissionGroup SubmissionGroupId
user UserId
UniqueSubmissionGroupUser submissionGroup user
deriving Eq Ord Show
deriving Eq Ord Show Generic

View File

@ -6,13 +6,14 @@ SystemMessage
newsOnly Bool default=false
authenticatedOnly Bool -- Show message to all users upon visiting the site or only upon login?
severity MessageStatus -- Success, Warning, Error, Info, ...
manualPriority Natural Maybe
manualPriority Word64 Maybe
created UTCTime default=now()
lastChanged UTCTime default=now()
lastUnhide UTCTime default=now()
defaultLanguage Lang -- Language of @content@ and @summary@
content StoredMarkup -- Detailed message shown when clicking on the @summary@-popup or when no @summary@ is specified
summary StoredMarkup Maybe
deriving Generic
SystemMessageTranslation -- Translation of a @SystemMessage@ into another language; which language to choose is determined by user-sent HTTP-headers
message SystemMessageId
@ -20,9 +21,11 @@ SystemMessageTranslation -- Translation of a @SystemMessage@ into another langua
content StoredMarkup
summary StoredMarkup Maybe
UniqueSystemMessageTranslation message language
deriving Generic
SystemMessageHidden
message SystemMessageId
user UserId
time UTCTime
UniqueSystemMessageHidden user message
UniqueSystemMessageHidden user message
deriving Generic

View File

@ -3,7 +3,8 @@ Tutorial json
course CourseId
type TutorialType -- "Tutorium", "Zentralübung", ...
capacity Int Maybe -- limit for enrolment in this tutorial
room Text Maybe
room RoomReference Maybe
roomHidden Bool default=false
time Occurrences
regGroup (CI Text) Maybe -- each participant may register for one tutorial per regGroup
registerFrom UTCTime Maybe
@ -17,13 +18,16 @@ Tutor
tutorial TutorialId
user UserId
UniqueTutor tutorial user
deriving Generic
TutorialParticipant
tutorial TutorialId
user UserId
UniqueTutorialParticipant tutorial user
deriving Eq Ord Show
deriving Generic
TutorialScheduleOpt
tutorial TutorialId
user UserId
opt Bool
UniqueTutorialScheduleOpt tutorial user
deriving Generic

View File

@ -51,21 +51,25 @@ UserFunction -- Administratively assigned functions (lecturer, admin, evaluation
school SchoolId
function SchoolFunction
UniqueUserFunction user school function
deriving Generic
UserSystemFunction
user UserId
function SystemFunction
manual Bool
isOptOut Bool
UniqueUserSystemFunction user function
deriving Generic
UserExamOffice
user UserId
field StudyTermsId
UniqueUserExamOffice user field
deriving Generic
UserSchool -- Managed by users themselves, encodes "schools of interest"
user UserId
school SchoolId
isOptOut Bool -- true if this a marker, that the user manually deleted this entry; it should not be recreated automatically
UniqueUserSchool user school
deriving Generic
UserGroupMember
group UserGroupName
@ -74,4 +78,6 @@ UserGroupMember
UniquePrimaryUserGroupMember group primary !force
UniqueUserGroupMember group user
deriving Generic

53
models/workflows.model Normal file
View File

@ -0,0 +1,53 @@
SharedWorkflowGraph
hash WorkflowGraphReference
graph (WorkflowGraph FileReference SqlBackendKey) -- UserId
Primary hash
deriving Generic
WorkflowDefinition
graph SharedWorkflowGraphId
scope WorkflowScope'
name WorkflowDefinitionName
instanceCategory WorkflowInstanceCategory Maybe
UniqueWorkflowDefinition name scope
deriving Generic
WorkflowDefinitionDescription
definition WorkflowDefinitionId
language Lang
title Text
description StoredMarkup Maybe
UniqueWorkflowDefinitionDescription definition language
deriving Generic
WorkflowDefinitionInstanceDescription
definition WorkflowDefinitionId
language Lang
title Text
description StoredMarkup Maybe
UniqueWorkflowDefinitionInstanceDescription definition language
deriving Generic
WorkflowInstance
definition WorkflowDefinitionId Maybe
graph SharedWorkflowGraphId
scope (WorkflowScope TermIdentifier SchoolShorthand SqlBackendKey) -- TermId, SchoolId, CourseId
name WorkflowInstanceName
category WorkflowInstanceCategory Maybe
UniqueWorkflowInstance name scope
deriving Generic
WorkflowInstanceDescription
instance WorkflowInstanceId
language Lang
title Text
description StoredMarkup Maybe
UniqueWorkflowInstanceDescription instance language
deriving Generic
WorkflowWorkflow
instance WorkflowInstanceId Maybe
scope (WorkflowScope TermIdentifier SchoolShorthand SqlBackendKey) -- TermId, SchoolId, CourseId
graph SharedWorkflowGraphId
state (WorkflowState FileReference SqlBackendKey) -- UserId
deriving Generic

7749
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "uni2work",
"version": "22.1.0",
"version": "25.10.0",
"description": "",
"keywords": [],
"author": "",
@ -26,7 +26,8 @@
"i18n:test": "./missing-translations.sh",
"prerelease": "./is-clean.sh && npm run test",
"release": "standard-version -a",
"postrelease": "git push --follow-tags origin master"
"postrelease": "git push --follow-tags origin master",
"parse-changelog": "changelog-parser ./CHANGELOG.md > changelog.json"
},
"husky": {
"hooks": {
@ -40,15 +41,6 @@
"git add"
]
},
"standard-version": {
"scripts": {
"postbump": "./sync-versions.hs && git add -- package.yaml"
},
"commitUrlFormat": "https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/{{hash}}",
"compareUrlFormat": "https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/{{previousTag}}...{{currentTag}}",
"issueUrlFormat": "https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/{{id}}",
"userUrlFormat": "https://gitlab2.rz.ifi.lmu.de/{{user}}"
},
"browserslist": [
"defaults"
],
@ -70,6 +62,7 @@
"babel-plugin-transform-decorators-legacy": "^1.3.5",
"babel-preset-es2015": "^6.24.1",
"cbt_tunnels": "^1.2.2",
"changelog-parser": "^2.8.0",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^6.0.3",
"css-loader": "^2.1.1",
@ -89,6 +82,7 @@
"karma-mocha-reporter": "^2.2.5",
"karma-webpack": "^3.0.5",
"lint-staged": "^8.2.1",
"lodash.debounce": "^4.0.8",
"mini-css-extract-plugin": "^0.8.2",
"npm-run-all": "^4.1.5",
"null-loader": "^2.0.0",
@ -103,13 +97,14 @@
"sass": "^1.26.10",
"sass-loader": "^7.3.1",
"semver": "^6.3.0",
"standard-version": "^9.0.0",
"standard-version": "^9.1.0",
"standard-version-updater-yaml": "^1.0.2",
"style-loader": "^0.23.1",
"terser-webpack-plugin": "^2.3.8",
"tmp": "^0.1.0",
"typeface-roboto": "0.0.75",
"typeface-source-sans-pro": "0.0.75",
"typeface-source-code-pro": "^1.1.3",
"typeface-source-sans-pro": "0.0.75",
"webpack": "^4.44.1",
"webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^2.2.0",
@ -119,6 +114,7 @@
"@babel/runtime": "^7.11.2",
"@juggle/resize-observer": "^2.5.0",
"core-js": "^3.6.5",
"css.escape": "^1.5.1",
"js-cookie": "^2.2.1",
"lodash.debounce": "^4.0.8",
"lodash.defer": "^4.1.0",

View File

@ -1,6 +1,5 @@
name: uniworx
version: 22.1.0
name: uniworx
version: 25.10.0
dependencies:
- base
- yesod
@ -154,14 +153,23 @@ dependencies:
- network-ip
- data-textual
- fastcdc
- bimap
- list-t
- insert-ordered-containers
- topograph
- network-uri
- psqueues
- nonce
- IntervalMap
- haskell-src-meta
- either
- xlsx
other-extensions:
- GeneralizedNewtypeDeriving
- IncoherentInstances
- OverloadedLists
- UndecidableInstances
- ApplicativeDo
default-extensions:
- OverloadedStrings
- PartialTypeSignatures
@ -216,7 +224,6 @@ default-extensions:
- EmptyDataDeriving
- StandaloneKindSignatures
- NoStarIsType
ghc-options:
- -Wall
- -Wmissing-home-modules
@ -229,7 +236,6 @@ ghc-options:
- -fno-max-relevant-binds
- -j
- -freduction-depth=0
when:
- condition: flag(pedantic)
ghc-options:
@ -242,18 +248,11 @@ when:
- -ddump-splices
- -ddump-to-file
cpp-options: -DDEVELOPMENT
ghc-prof-options:
- -fprof-auto
else:
ghc-options:
- -O -fllvm
# The library contains all of our application code. The executable
# defined below is just a thin wrapper.
library:
source-dirs: src
# Runnable executable for our application
executables:
uniworx:
main: main.hs
@ -264,7 +263,7 @@ executables:
- condition: flag(library-only)
buildable: false
ghc-options:
- -threaded -rtsopts "-with-rtsopts=-N -T" # Nonblocking GC causes segfaults in production as of 2020-08-19
- -threaded -rtsopts "-with-rtsopts=-N -T"
uniworxdb:
main: Database.hs
ghc-options:
@ -296,8 +295,20 @@ executables:
when:
- condition: flag(library-only)
buildable: false
# Test suite
uniworx-wflint:
main: WFLint.hs
ghc-options:
- -main-is WFLint
dependencies:
- base
- uniworx
- bytestring
- yaml
other-modules: []
source-dirs: wflint
when:
- condition: flag(library-only)
buildable: false
tests:
yesod:
main: Main.hs
@ -315,6 +326,8 @@ tests:
- http-types
- yesod-persistent
- quickcheck-io
- network-arbitrary
- lens-properties
ghc-options:
- -fno-warn-orphans
- -threaded -rtsopts "-with-rtsopts=-N -T"
@ -327,8 +340,6 @@ tests:
when:
- condition: "!flag(pedantic)"
buildable: false
# Define flags used by "yesod devel" to make compilation faster
flags:
library-only:
description: Build for use with "yesod devel"

50
routes
View File

@ -41,6 +41,8 @@
/metrics MetricsR GET
/err ErrorR GET !free
/ NewsR GET !free
/users UsersR GET POST -- no tags, i.e. admins only
/users/#CryptoUUIDUser AdminUserR GET POST
@ -58,6 +60,36 @@
/admin/tokens AdminTokensR GET POST
/admin/crontab AdminCrontabR GET
/admin/workflows/definitions AdminWorkflowDefinitionListR GET
/admin/workflows/definitions/new AdminWorkflowDefinitionNewR GET POST
/admin/workflows/definitions/#WorkflowScope'/#WorkflowDefinitionName AdminWorkflowDefinitionR:
/edit AWDEditR GET POST
/delete AWDDeleteR GET POST
/instantiate AWDInstantiateR GET POST
/admin/workflows/instances AdminWorkflowInstanceListR GET
/admin/workflows/instances/new AdminWorkflowInstanceNewR GET POST
/admin/workflows/instances/#CryptoUUIDWorkflowInstance AdminWorkflowInstanceR:
/edit AWIEditR GET POST
/admin/workflows/workflows AdminWorkflowWorkflowListR GET
/admin/workflows/workflows/new AdminWorkflowWorkflowNewR GET POST
/global-workflows/instances GlobalWorkflowInstanceListR GET !free
/global-workflows/instances/new GlobalWorkflowInstanceNewR GET POST
/global-workflows/instances/#WorkflowInstanceName GlobalWorkflowInstanceR:
/edit GWIEditR GET POST
/delete GWIDeleteR GET POST
/workflows GWIWorkflowsR GET !¬empty
/initiate GWIInitiateR GET POST !workflow
/global-workflows GlobalWorkflowWorkflowListR GET !free
!/global-workflows/#CryptoFileNameWorkflowWorkflow GlobalWorkflowWorkflowR:
/ GWWWorkflowR GET POST !workflow
/files/#WorkflowPayloadLabel/#CryptoUUIDWorkflowStateIndex GWWFilesR GET !workflow
/edit GWWEditR GET POST
/delete GWWDeleteR GET POST
/workflow-instances TopWorkflowInstanceListR GET !free
/workflows TopWorkflowWorkflowListR GET !free
/health HealthR GET !free
/instance InstanceR GET !free
/info InfoR GET !free
@ -107,6 +139,20 @@
/school/#SchoolId SchoolR:
/ SchoolEditR GET POST
/workflows/instances SchoolWorkflowInstanceListR GET !free
/workflows/instances/new SchoolWorkflowInstanceNewR GET POST
/workflows/instances/#WorkflowInstanceName SchoolWorkflowInstanceR:
/edit SWIEditR GET POST
/delete SWIDeleteR GET POST
/workflows SWIWorkflowsR GET !¬empty
/initiate SWIInitiateR GET POST !workflow
/workflows SchoolWorkflowWorkflowListR GET !free
!/workflows/#CryptoFileNameWorkflowWorkflow SchoolWorkflowWorkflowR:
/ SWWWorkflowR GET POST !workflow
/files/#WorkflowPayloadLabel/#CryptoUUIDWorkflowStateIndex SWWFilesR GET !workflow
/edit SWWEditR GET POST
/delete SWWDeleteR GET POST
/allocation/ AllocationListR GET !free
/allocation/#TermId/#SchoolId/#AllocationShorthand AllocationR:
/ AShowR GET POST !free
@ -128,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
/schedule-opt/set/#Bool CScheduleOptSetR GET POST !free
/schedule-opt/del CScheduleOptDelR GET POST !free
/register CRegisterR GET POST !timeANDcapacityANDallocation-timeAND¬course-registeredANDcourse-time !timeANDallocation-timeAND¬exam-resultANDcourse-registered !lecturerANDallocation-time
@ -163,7 +209,7 @@
/ SubShowR GET POST !ownerANDtimeANDuser-submissionsANDsubmission-groupANDexam-registeredANDpersonalised-sheet-files !ownerANDread !correctorANDread
/delete SubDelR GET POST !ownerANDtimeANDuser-submissionsANDexam-registeredANDpersonalised-sheet-files
/assign SubAssignR GET POST !lecturerANDtime
/correction CorrectionR GET POST !corrector !ownerANDreadANDrated
/correction CorrectionR GET POST !corrector !ownerANDreadANDratedANDexam-time
/invite SInviteR GET POST !ownerANDtimeANDuser-submissionsANDsubmission-groupANDexam-registeredANDpersonalised-sheet-files
!/#SubmissionFileType SubArchiveR GET !owner !corrector
!/#SubmissionFileType/*FilePath SubDownloadR GET !owner !corrector

View File

@ -17,8 +17,10 @@ module Application
) where
import Control.Monad.Logger (liftLoc, LoggingT(..), MonadLoggerIO(..))
import Database.Persist.Postgresql (createPostgresqlPool, pgConnStr,
pgPoolSize, runSqlPool, ConnectionPool)
import Database.Persist.Postgresql ( openSimpleConn, pgConnStr, connClose, pgPoolIdleTimeout
, pgPoolSize
)
import qualified Database.PostgreSQL.Simple as PG
import Import hiding (cancel, respond)
import Language.Haskell.TH.Syntax (qLocation)
import Network.Wai (Middleware)
@ -89,6 +91,7 @@ import Control.Concurrent.STM.Delay
import Control.Monad.Trans.Cont (runContT, callCC)
import qualified Data.Set as Set
import qualified Data.Map as Map
import Handler.Utils.Routes (classifyHandler)
@ -103,6 +106,15 @@ import Web.ServerSession.Core (StorageException(..))
import GHC.RTS.Flags (getRTSFlags)
import qualified Prometheus
import qualified Data.IntervalMap.Strict as IntervalMap
import qualified Utils.Pool as Custom
import Utils.Postgresql
import Handler.Utils.Memcached (manageMemcachedLocalInvalidations)
-- Import all relevant handler modules here.
-- (HPack takes care to add new modules to our cabal file nowadays.)
import Handler.News
@ -129,6 +141,8 @@ import Handler.Metrics
import Handler.ExternalExam
import Handler.Participants
import Handler.StorageKey
import Handler.Workflow
import Handler.Error
-- This line actually creates our YesodDispatch instance. It is the second half
@ -141,8 +155,9 @@ mkYesodDispatch "UniWorX" resourcesUniWorX
-- the place to put your migrate statements to have automatic database
-- migrations handled by Yesod.
makeFoundation :: (MonadResource m, MonadUnliftIO m, MonadCatch m) => AppSettings -> m UniWorX
makeFoundation appSettings'@AppSettings{..} = do
makeFoundation appSettings''@AppSettings{..} = do
registerGHCMetrics
registerHealthCheckInterval appHealthCheckInterval
-- Some basic initializations: HTTP connection manager, logger, and static
-- subsite.
@ -178,32 +193,49 @@ makeFoundation appSettings'@AppSettings{..} = do
appJobState <- liftIO newEmptyTMVarIO
appHealthReport <- liftIO $ newTVarIO Set.empty
appFileSourceARC <- for appFileSourceARCConf $ \ARCConf{..} -> do
ah <- initARCHandle arccMaximumGhost arccMaximumWeight
void . Prometheus.register $ arcMetrics ARCFileSource ah
return ah
appFileSourcePrewarm <- for appFileSourcePrewarmConf $ \PrewarmCacheConf{..} -> do
lh <- initLRUHandle precMaximumWeight
void . Prometheus.register $ lruMetrics LRUFileSourcePrewarm lh
return lh
appFileInjectInhibit <- liftIO $ newTVarIO IntervalMap.empty
for_ (guardOnM (isn't _JobsOffload appJobMode) appInjectFiles) $ \_ ->
void . Prometheus.register $ injectInhibitMetrics appFileInjectInhibit
-- We need a log function to create a connection pool. We need a connection
-- pool to create our foundation. And we need our foundation to get a
-- logging function. To get out of this loop, we initially create a
-- temporary foundation without a real connection pool, get a log function
-- from there, and then create the real foundation.
let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache appVerpSecret = UniWorX {..}
-- The UniWorX {..} syntax is an example of record wild cards. For more
-- information, see:
-- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html
tempFoundation = mkFoundation
(error "connPool forced in tempFoundation")
(error "smtpPool forced in tempFoundation")
(error "ldapPool forced in tempFoundation")
(error "cryptoIDKey forced in tempFoundation")
(error "sessionStore forced in tempFoundation")
(error "secretBoxKey forced in tempFoundation")
(error "widgetMemcached forced in tempFoundation")
(error "JSONWebKeySet forced in tempFoundation")
(error "ClusterID forced in tempFoundation")
(error "memcached forced in tempFoundation")
(error "MinioConn forced in tempFoundation")
(error "VerpSecret forced in tempFoundation")
let
mkFoundation :: _ -> (forall m. MonadIO m => Custom.Pool' m DBConnLabel DBConnUseState SqlBackend) -> _
mkFoundation appSettings' appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appMemcachedLocal appUploadCache appVerpSecret appAuthKey = UniWorX {..}
-- The UniWorX {..} syntax is an example of record wild cards. For more
-- information, see:
-- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html
tempFoundation = mkFoundation
(error "appSettings' forced in tempFoundation")
(error "connPool forced in tempFoundation")
(error "smtpPool forced in tempFoundation")
(error "ldapPool forced in tempFoundation")
(error "cryptoIDKey forced in tempFoundation")
(error "sessionStore forced in tempFoundation")
(error "secretBoxKey forced in tempFoundation")
(error "widgetMemcached forced in tempFoundation")
(error "JSONWebKeySet forced in tempFoundation")
(error "ClusterID forced in tempFoundation")
(error "memcached forced in tempFoundation")
(error "memcachedLocal forced in tempFoundation")
(error "MinioConn forced in tempFoundation")
(error "VerpSecret forced in tempFoundation")
(error "AuthKey forced in tempFoundation")
runAppLoggingT tempFoundation $ do
$logInfoS "InstanceID" $ UUID.toText appInstanceID
$logDebugS "Configuration" $ tshow appSettings'
$logDebugS "Configuration" $ tshow appSettings''
$logDebugS "RTSFlags" . tshow =<< liftIO getRTSFlags
smtpPool <- for appSmtpConf $ \c -> do
@ -216,9 +248,24 @@ makeFoundation appSettings'@AppSettings{..} = do
-- Create the database connection pool
$logDebugS "setup" "PostgreSQL-Pool"
sqlPool <- createPostgresqlPool
(pgConnStr appDatabaseConf)
(pgPoolSize appDatabaseConf)
logFunc <- askLoggerIO
sqlPool' <-
let create = do
$logDebugS "SqlPool" "Opening connection..."
conn <- liftIO . PG.connectPostgreSQL $ pgConnStr appDatabaseConf
backend <- liftIO $ openSimpleConn logFunc conn
observeDatabaseConnectionOpened
$logDebugS "SqlPool" "Opened connection"
return backend
destroy conn = do
$logDebugS "SqlPool" "Closing connection..."
liftIO $ connClose conn
observeDatabaseConnectionClosed
$logDebugS "SqlPool" "Closed connection"
in Custom.createPool' (liftIO . flip runLoggingT logFunc) create destroy ((flip runLoggingT logFunc .) . onUseDBConn) onReleaseDBConn (Just . fromIntegral $ pgPoolIdleTimeout appDatabaseConf) (Just $ pgPoolSize appDatabaseConf)
let sqlPool :: forall m. MonadIO m => Custom.Pool' m DBConnLabel DBConnUseState SqlBackend
sqlPool = Custom.hoistPool (liftIO . flip runLoggingT logFunc) sqlPool'
void . Prometheus.register . poolMetrics PoolDatabaseConnections $ sqlPool @IO
ldapPool <- traverse mkFailoverLabeled <=< forOf (traverse . traverse) appLdapConf $ \conf@LdapConf{..} -> do
let ldapLabel = case ldapHost of
@ -233,35 +280,52 @@ makeFoundation appSettings'@AppSettings{..} = do
if
| appAutoDbMigrate -> do
$logDebugS "setup" "Migration"
migrateAll `runSqlPool` sqlPool
| otherwise -> whenM (requiresMigration `runSqlPool` sqlPool) $ do
migrateAll `customRunSqlPool` sqlPool
| otherwise -> whenM (requiresMigration `customRunSqlPool` sqlPool) $ do
$logErrorS "setup" "Migration required"
liftIO . exitWith $ ExitFailure 130
$logDebugS "setup" "Cluster-Config"
appCryptoIDKey <- clusterSetting (Proxy :: Proxy 'ClusterCryptoIDKey) `runSqlPool` sqlPool
appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `runSqlPool` sqlPool
appJSONWebKeySet <- clusterSetting (Proxy :: Proxy 'ClusterJSONWebKeySet) `runSqlPool` sqlPool
appClusterID <- clusterSetting (Proxy :: Proxy 'ClusterId) `runSqlPool` sqlPool
appVerpSecret <- clusterSetting (Proxy :: Proxy 'ClusterVerpSecret) `runSqlPool` sqlPool
appCryptoIDKey <- clusterSetting (Proxy :: Proxy 'ClusterCryptoIDKey) `customRunSqlPool` sqlPool
appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `customRunSqlPool` sqlPool
appJSONWebKeySet <- clusterSetting (Proxy :: Proxy 'ClusterJSONWebKeySet) `customRunSqlPool` sqlPool
appClusterID <- clusterSetting (Proxy :: Proxy 'ClusterId) `customRunSqlPool` sqlPool
appVerpSecret <- clusterSetting (Proxy :: Proxy 'ClusterVerpSecret) `customRunSqlPool` sqlPool
appAuthKey <- clusterSetting (Proxy :: Proxy 'ClusterAuthKey) `customRunSqlPool` sqlPool
needsRechunk <- exists [FileContentChunkContentBased !=. True] `customRunSqlPool` sqlPool
let appSettings' = appSettings''
& _appRechunkFiles %~ guardOnM needsRechunk
appMemcached <- for appMemcachedConf $ \memcachedConf -> do
$logDebugS "setup" "Memcached"
memcachedKey <- clusterSetting (Proxy :: Proxy 'ClusterMemcachedKey) `runSqlPool` sqlPool
memcached <- createMemcached memcachedConf
return (memcachedKey, memcached)
memcachedKey <- clusterSetting (Proxy :: Proxy 'ClusterMemcachedKey) `customRunSqlPool` sqlPool
memcachedConn <- createMemcached memcachedConf
when appClearCache $ do
$logWarnS "setup" "Clearing memcached"
liftIO $ Memcached.flushAll memcachedConn
return AppMemcached{..}
appMemcachedLocal <- for appMemcachedLocalConf $ \ARCConf{..} -> do
memcachedLocalARC <- initARCHandle arccMaximumGhost arccMaximumWeight
void . Prometheus.register $ arcMetrics ARCMemcachedLocal memcachedLocalARC
memcachedLocalInvalidationQueue <- newTVarIO mempty
memcachedLocalHandleInvalidations <- allocateLinkedAsync . managePostgresqlChannel appDatabaseConf ChannelMemcachedLocalInvalidation $ manageMemcachedLocalInvalidations memcachedLocalARC memcachedLocalInvalidationQueue
return AppMemcachedLocal{..}
appSessionStore <- mkSessionStore appSettings' sqlPool `runSqlPool` sqlPool
appSessionStore <- mkSessionStore appSettings'' sqlPool `customRunSqlPool` sqlPool
appUploadCache <- for appUploadCacheConf $ \minioConf -> liftIO $ do
conn <- Minio.connect minioConf
let isBucketExists Minio.BucketAlreadyOwnedByYou = True
isBucketExists _ = False
either throwM return <=< Minio.runMinioWith conn $
throwLeft <=< Minio.runMinioWith conn $ do
handleIf isBucketExists (const $ return ()) $ Minio.makeBucket appUploadCacheBucket Nothing
handleIf isBucketExists (const $ return ()) $ Minio.makeBucket appUploadTmpBucket Nothing
return conn
let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache appVerpSecret
$logDebugS "Runtime configuration" $ tshow appSettings'
let foundation = mkFoundation appSettings' sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appMemcachedLocal appUploadCache appVerpSecret appAuthKey
-- Return the foundation
$logDebugS "setup" "Done"
@ -278,7 +342,9 @@ mkSessionStore :: forall m.
, MonadThrow m
, MonadResource m
)
=> AppSettings -> ConnectionPool -> ReaderT SqlBackend m SomeSessionStorage
=> AppSettings
-> (forall m'. MonadIO m' => Custom.Pool' m' DBConnLabel DBConnUseState SqlBackend)
-> ReaderT SqlBackend m SomeSessionStorage
mkSessionStore AppSettings{..} mcdSqlConnPool
| Just mcdConf@MemcachedConf{..} <- appSessionMemcachedConf = do
mcdSqlMemcachedKey <- clusterSetting (Proxy :: Proxy 'ClusterServerSessionKey)
@ -536,41 +602,46 @@ appMain = runResourceT $ do
Just wInterval
| maybe True (== myProcessID) watchdogProcess
-> let notifyWatchdog :: forall a m'. ( MonadLogger m', MonadIO m') => m' a
notifyWatchdog = go Nothing
where
go :: Maybe (Set (UTCTime, HealthReport)) -> m' a
go pResults = do
let delay = floor $ wInterval % 4
d <- liftIO $ newDelay delay
notifyWatchdog = forever' Nothing $ \pResults -> do
let delay = floor $ wInterval % 4
d <- liftIO $ newDelay delay
$logDebugS "Notify" $ "Waiting up to " <> tshow delay <> "µs..."
mResults <- atomically $ asum
[ pResults <$ waitDelay d
, do
results <- readTVar $ foundation ^. _appHealthReport
guardOn (pResults /= Just results) $ Just results
]
$logDebugS "Notify" $ "Waiting up to " <> tshow delay <> "µs..."
mResults <- atomically $ asum
[ pResults <$ waitDelay d
, do
results <- readTVar $ foundation ^. _appHealthReport
guardOn (pResults /= Just results) $ Just results
]
$logDebugS "Notify" "Checking for status/watchdog..."
mResults <$ do
void . runMaybeT $ do
results <- hoistMaybe mResults
$logDebugS "Notify" "Checking for status/watchdog..."
(*> go mResults) . void . runMaybeT $ do
results <- hoistMaybe mResults
let latestResults = Map.fromListWith (\_ x -> x) $ Set.toAscList results
Min status <- hoistMaybe $ ofoldMap1 (Min . healthReportStatus) <$> fromNullable latestResults
$logInfoS "NotifyStatus" $ toPathPiece status
liftIO . void . Systemd.notifyStatus . unpack $ toPathPiece status
Min status <- hoistMaybe $ ofoldMap1 (Min . healthReportStatus . view _2) <$> fromNullable results
$logInfoS "NotifyStatus" $ toPathPiece status
liftIO . void . Systemd.notifyStatus . unpack $ toPathPiece status
now <- liftIO getCurrentTime
let missing = flip ifoldMap (foundation ^. _appHealthCheckInterval) $ \hc mInterval -> fromMaybe mempty $ do
interval <- mInterval
let lastSuccess = maybeMonoid mResults
& Set.filter (\(_, rep) -> classifyHealthReport rep == hc)
& Set.filter (\(_, rep) -> healthReportStatus rep >= HealthSuccess)
& Set.mapMonotonic (view _1)
& Set.lookupMax
now <- liftIO getCurrentTime
iforM_ (foundation ^. _appHealthCheckInterval) . curry $ \case
(_, Nothing) -> return ()
(hc, Just interval) -> do
lastSuccess <- hoistMaybe $ results
& Set.filter (\(_, rep) -> classifyHealthReport rep == hc)
& Set.filter (\(_, rep) -> healthReportStatus rep >= HealthSuccess)
& Set.mapMonotonic (view _1)
& Set.lookupMax
guard $ lastSuccess > addUTCTime (negate interval) now
$logInfoS "NotifyWatchdog" "Notify"
liftIO $ void Systemd.notifyWatchdog
successIsCurrent = lastSuccess > Just (negate interval `addUTCTime` now)
return . guardMonoid (not successIsCurrent) $ Set.singleton hc
if | Set.null missing -> do
$logInfoS "NotifyWatchdog" "Notify"
liftIO $ void Systemd.notifyWatchdog
| otherwise ->
$logWarnS "NotifyWatchdog" $ "No notify; missing \n\t " <> tshow (toList missing) <> "\n\tin " <> tshow (toList <$> mResults)
in do
$logDebugS "Notify" "Spawning notify thread..."
void $ allocateLinkedAsync notifyWatchdog
@ -607,11 +678,11 @@ shutdownApp :: (MonadIO m, MonadUnliftIO m) => UniWorX -> m ()
shutdownApp app = do
stopJobCtl app
liftIO $ do
destroyAllResources $ appConnPool app
Custom.purgePool $ appConnPool app
for_ (appSmtpPool app) destroyAllResources
for_ (appLdapPool app) . mapFailover $ views _2 destroyAllResources
for_ (appWidgetMemcached app) Memcached.close
for_ (appMemcached app) $ views _2 Memcached.close
for_ (appMemcached app) $ views _memcachedConn Memcached.close
release . fst $ appLogger app
liftIO $ threadDelay 1e6

View File

@ -90,12 +90,10 @@ data Transaction
}
| TransactionSubmissionFileEdit
{ transactionSubmissionFile :: SubmissionFileId
, transactionSubmission :: SubmissionId
{ transactionSubmissionFile :: Entity SubmissionFile
}
| TransactionSubmissionFileDelete
{ transactionSubmissionFile :: SubmissionFileId
, transactionSubmission :: SubmissionId
{ transactionSubmissionFile :: Entity SubmissionFile
}
| TransactionExamOfficeUserAdd

View File

@ -129,14 +129,14 @@ campusUserWith withLdap' pool mode Creds{..} = either (throwM . CampusUserLdapEr
_otherwise -> throwE CampusUserAmbiguous
campusUserReTest :: (MonadUnliftIO m, MonadMask m, MonadLogger m) => Failover (LdapConf, LdapPool) -> (Nano -> Bool) -> FailoverMode -> Creds site -> m (Ldap.AttrList [])
campusUserReTest pool doTest mode creds = either throwM return =<< campusUserWith (\l -> flip (withLdapFailoverReTest l) doTest) pool mode creds
campusUserReTest pool doTest mode creds = throwLeft =<< campusUserWith (\l -> flip (withLdapFailoverReTest l) doTest) pool mode creds
campusUserReTest' :: (MonadMask m, MonadLogger m, MonadUnliftIO m) => Failover (LdapConf, LdapPool) -> (Nano -> Bool) -> FailoverMode -> User -> m (Maybe (Ldap.AttrList []))
campusUserReTest' pool doTest mode User{userIdent}
= runMaybeT . catchIfMaybeT (is _CampusUserNoResult) $ campusUserReTest pool doTest mode (Creds apLdap (CI.original userIdent) [])
campusUser :: (MonadUnliftIO m, MonadMask m, MonadLogger m) => Failover (LdapConf, LdapPool) -> FailoverMode -> Creds site -> m (Ldap.AttrList [])
campusUser pool mode creds = either throwM return =<< campusUserWith withLdapFailover pool mode creds
campusUser pool mode creds = throwLeft =<< campusUserWith withLdapFailover pool mode creds
campusUser' :: (MonadMask m, MonadUnliftIO m, MonadLogger m) => Failover (LdapConf, LdapPool) -> FailoverMode -> User -> m (Maybe (Ldap.AttrList []))
campusUser' pool mode User{userIdent}

View File

@ -0,0 +1,15 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Control.Arrow.Instances
(
) where
import ClassyPrelude
import Control.Arrow
instance (a ~ b, Monad m) => Monoid (Kleisli m a b) where
mempty = Kleisli return
instance (a ~ b, Monad m) => Semigroup (Kleisli m a b) where
Kleisli f <> Kleisli g = Kleisli $ f <=< g

View File

@ -5,7 +5,7 @@ module Crypto.Hash.Instances
import ClassyPrelude
import Crypto.Hash
import Crypto.Hash hiding (hash)
import Database.Persist
import Database.Persist.Sql
@ -21,6 +21,15 @@ import Control.Monad.Fail
import Language.Haskell.TH.Syntax (Lift(liftTyped))
import Instances.TH.Lift ()
import Data.Binary
import qualified Data.Binary.Put as Binary
import qualified Data.Binary.Get as Binary
import qualified Data.ByteString.Base64.URL as Base64
import Type.Reflection (typeRep)
instance HashAlgorithm hash => PersistField (Digest hash) where
toPersistValue = PersistByteString . convert
fromPersistValue (PersistByteString bs) = maybe (Left "Could not convert Digest from ByteString") Right $ digestFromByteString bs
@ -31,14 +40,14 @@ instance HashAlgorithm hash => PersistFieldSql (Digest hash) where
sqlType _ = SqlBlob
instance HashAlgorithm hash => PathPiece (Digest hash) where
toPathPiece = showToPathPiece
fromPathPiece = readFromPathPiece
toPathPiece = decodeUtf8 . Base64.encodeUnpadded . convert
fromPathPiece = digestFromByteString <=< either (const Nothing) Just . Base64.decodeUnpadded . encodeUtf8
instance HashAlgorithm hash => ToHttpApiData (Digest hash) where
toUrlPiece = tshow
toUrlPiece = toPathPiece
instance HashAlgorithm hash => FromHttpApiData (Digest hash) where
parseUrlPiece = maybe (Left "Could not read Digest") Right . readMay
parseUrlPiece = maybe (Left "Could not read Digest") Right . fromPathPiece
instance HashAlgorithm hash => ToJSON (Digest hash) where
toJSON = Aeson.String . toUrlPiece
@ -46,8 +55,12 @@ instance HashAlgorithm hash => ToJSON (Digest hash) where
instance HashAlgorithm hash => FromJSON (Digest hash) where
parseJSON = withText "Digest" $ either (fail . unpack) return . parseUrlPiece
instance Hashable (Digest hash) where
hashWithSalt s = (hashWithSalt s :: ByteString -> Int) . convert
instance Typeable hash => Hashable (Digest hash) where
hashWithSalt s h = s `hashWithSalt` hash (typeRep @hash) `hashWithSalt` hash @ByteString (convert h)
instance HashAlgorithm hash => Lift (Digest hash) where
liftTyped dgst = [||fromMaybe (error "Lifted digest has wrong length") $ digestFromByteString $$(liftTyped (convert dgst :: ByteString))||]
instance HashAlgorithm hash => Binary (Digest hash) where
put = Binary.putByteString . convert
get = Binary.getByteString (hashDigestSize (error "hashDigestSize inspected value of type hash" :: hash)) >>= maybe (fail "Could not parse Digest") return . digestFromByteString

View File

@ -3,6 +3,7 @@
module CryptoID
( module CryptoID
, module CryptoID.Cached
, module Data.CryptoID.Poly.ImplicitNamespace
, module Data.UUID.Cryptographic.ImplicitNamespace
, module System.FilePath.Cryptographic.ImplicitNamespace
@ -18,6 +19,7 @@ import qualified Data.CryptoID as E
import Data.CryptoID.Poly.ImplicitNamespace hiding (decrypt, encrypt)
import Data.UUID.Cryptographic.ImplicitNamespace hiding (decrypt, encrypt)
import System.FilePath.Cryptographic.ImplicitNamespace hiding (decrypt, encrypt)
import CryptoID.Cached
import qualified Data.Text as Text
@ -28,28 +30,6 @@ import Data.Aeson.Encoding (text)
import Text.Blaze (ToMarkup(..))
import qualified Data.CryptoID.Class.ImplicitNamespace as I
encrypt :: forall plaintext ciphertext m.
( I.HasCryptoID ciphertext plaintext m
, KnownSymbol (CryptoIDNamespace ciphertext plaintext)
, MonadHandler m
, Typeable ciphertext
, PathPiece plaintext
)
=> plaintext -> m (I.CryptoID ciphertext plaintext)
encrypt plain = $cachedHereBinary (toPathPiece plain) $ I.encrypt plain
decrypt :: forall plaintext ciphertext m.
( I.HasCryptoID ciphertext plaintext m
, MonadHandler m
, Typeable plaintext
, PathPiece ciphertext
)
=> I.CryptoID ciphertext plaintext -> m plaintext
decrypt cipher = $cachedHereBinary (toPathPiece $ ciphertext cipher) $ I.decrypt cipher
instance {-# OVERLAPPING #-} MonadThrow m => MonadCrypto (ReaderT CryptoIDKey m) where
type MonadCryptoKey (ReaderT CryptoIDKey m) = CryptoIDKey
@ -73,9 +53,14 @@ decCryptoIDs [ ''SubmissionId
, ''CourseEventId
, ''TutorialId
, ''ExternalExamId
, ''WorkflowInstanceId
, ''WorkflowWorkflowId
, ''MaterialFileId
]
type instance CryptoIDNamespace a WorkflowStateIndex = "WorkflowStateIndex"
type CryptoUUIDWorkflowStateIndex = CryptoUUID WorkflowStateIndex
decCryptoIDKeySize
-- CryptoIDNamespace (CI FilePath) SubmissionId ~ "Submission"
@ -113,3 +98,22 @@ instance {-# OVERLAPS #-} FromJSONKey (E.CryptoID "User" (CI FilePath)) where
fromJSONKey = FromJSONKeyTextParser $ maybe (fail "Could not parse CryptoFileNameUser") return . fromPathPiece
instance {-# OVERLAPS #-} ToMarkup (E.CryptoID "User" (CI FilePath)) where
toMarkup = toMarkup . toPathPiece
-- CryptoIDNamespace (CI FilePath) WorkflowWorkflowId ~ "WorkflowWorkflow"
instance {-# OVERLAPS #-} PathPiece (E.CryptoID "WorkflowWorkflow" (CI FilePath)) where
fromPathPiece (Text.unpack -> piece) = do
piece' <- (stripPrefix `on` map CI.mk) "uww" piece
return . CryptoID . CI.mk $ map CI.original piece'
toPathPiece = Text.pack . ("uww" <>) . CI.foldedCase . ciphertext
instance {-# OVERLAPS #-} ToJSON (E.CryptoID "WorkflowWorkflow" (CI FilePath)) where
toJSON = String . toPathPiece
instance {-# OVERLAPS #-} ToJSONKey (E.CryptoID "WorkflowWorkflow" (CI FilePath)) where
toJSONKey = ToJSONKeyText toPathPiece (text . toPathPiece)
instance {-# OVERLAPS #-} FromJSON (E.CryptoID "WorkflowWorkflow" (CI FilePath)) where
parseJSON = withText "CryptoFileNameWorkflowWorkflow" $ maybe (fail "Could not parse CryptoFileNameWorkflowWorkflow") return . fromPathPiece
instance {-# OVERLAPS #-} FromJSONKey (E.CryptoID "WorkflowWorkflow" (CI FilePath)) where
fromJSONKey = FromJSONKeyTextParser $ maybe (fail "Could not parse CryptoFileNameWorkflowWorkflow") return . fromPathPiece
instance {-# OVERLAPS #-} ToMarkup (E.CryptoID "WorkflowWorkflow" (CI FilePath)) where
toMarkup = toMarkup . toPathPiece

51
src/CryptoID/Cached.hs Normal file
View File

@ -0,0 +1,51 @@
module CryptoID.Cached
( encrypt, decrypt
) where
import Import.NoModel
import qualified Data.Binary as Binary
import qualified Data.CryptoID.Class.ImplicitNamespace as I
newtype CryptoIDDecryption ciphertext plaintext = CryptoIDDecryption plaintext
deriving (Typeable)
newtype CryptoIDEncryption ciphertext plaintext = CryptoIDEncryption ciphertext
deriving (Typeable)
encrypt :: forall plaintext ciphertext m.
( I.HasCryptoID ciphertext plaintext (HandlerFor (HandlerSite m))
, Typeable plaintext, Typeable ciphertext
, Binary plaintext, Binary ciphertext
, MonadHandler m
)
=> plaintext -> m (I.CryptoID ciphertext plaintext)
encrypt plain = liftHandler $ do
(cachedEnc :: Maybe (CryptoIDEncryption ciphertext plaintext)) <- cacheByGet cacheKey
case cachedEnc of
Nothing -> do
cID@(I.CryptoID crypt) <- I.encrypt plain
cacheBySet cacheKey (CryptoIDEncryption crypt :: CryptoIDEncryption ciphertext plaintext)
cacheBySet (toStrict $ Binary.encode crypt) (CryptoIDDecryption plain :: CryptoIDDecryption ciphertext plaintext)
return cID
Just (CryptoIDEncryption crypt) -> return $ I.CryptoID crypt
where cacheKey = toStrict $ Binary.encode plain
decrypt :: forall plaintext ciphertext m.
( I.HasCryptoID ciphertext plaintext (HandlerFor (HandlerSite m))
, Typeable plaintext, Typeable ciphertext
, Binary plaintext, Binary ciphertext
, MonadHandler m
)
=> I.CryptoID ciphertext plaintext -> m plaintext
decrypt cID@(I.CryptoID crypt) = liftHandler $ do
(cachedDec :: Maybe (CryptoIDDecryption ciphertext plaintext)) <- cacheByGet cacheKey
case cachedDec of
Nothing -> do
plain <- I.decrypt cID
cacheBySet (toStrict $ Binary.encode plain) (CryptoIDEncryption crypt :: CryptoIDEncryption ciphertext plaintext)
cacheBySet cacheKey (CryptoIDDecryption plain :: CryptoIDDecryption ciphertext plaintext)
return plain
Just (CryptoIDDecryption plain) -> return plain
where cacheKey = toStrict $ Binary.encode crypt

View File

@ -6,7 +6,7 @@ module Data.CaseInsensitive.Instances
(
) where
import ClassyPrelude.Yesod hiding (lift)
import ClassyPrelude.Yesod hiding (lift, Proxy(..))
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
@ -28,16 +28,19 @@ import Web.HttpApiData
import qualified Data.Csv as Csv
import Utils.Persist
import Data.Proxy
instance PersistField (CI Text) where
toPersistValue ciText = PersistDbSpecific . Text.encodeUtf8 $ CI.original ciText
fromPersistValue (PersistDbSpecific bs) = Right . CI.mk $ Text.decodeUtf8 bs
fromPersistValue x = Left . pack $ "Expected PersistDbSpecific, received: " ++ show x
toPersistValue ciText = PersistLiteralEscaped . Text.encodeUtf8 $ CI.original ciText
fromPersistValue (PersistLiteralEscaped bs) = Right . CI.mk $ Text.decodeUtf8 bs
fromPersistValue x = Left $ fromPersistValueErrorSql (Proxy @(CI Text)) x
instance PersistField (CI String) where
toPersistValue ciText = PersistDbSpecific . Text.encodeUtf8 . pack $ CI.original ciText
fromPersistValue (PersistDbSpecific bs) = Right . CI.mk . unpack $ Text.decodeUtf8 bs
fromPersistValue x = Left . pack $ "Expected PersistDbSpecific, received: " ++ show x
toPersistValue ciText = PersistLiteralEscaped . Text.encodeUtf8 . pack $ CI.original ciText
fromPersistValue (PersistLiteralEscaped bs) = Right . CI.mk . unpack $ Text.decodeUtf8 bs
fromPersistValue x = Left $ fromPersistValueErrorSql (Proxy @(CI String)) x
instance PersistFieldSql (CI Text) where
sqlType _ = SqlOther "citext"
@ -85,6 +88,10 @@ instance (CI.FoldCase s, PathPiece s) => PathPiece (CI s) where
fromPathPiece = fmap CI.mk . fromPathPiece
toPathPiece = toPathPiece . CI.original
instance PathPiece [CI Char] where
fromPathPiece = fmap (map CI.mk . (unpack :: Text -> [Char])) . fromPathPiece
toPathPiece = toPathPiece . (pack :: [Char] -> Text) . map CI.original
instance ToHttpApiData s => ToHttpApiData (CI s) where
toUrlPiece = toUrlPiece . CI.original
toEncodedUrlPiece = toEncodedUrlPiece . CI.original

View File

@ -32,3 +32,10 @@ instance Read DynEncoding where
instance Ord DynEncoding where
compare = comparing show
instance Hashable DynEncoding where
hashWithSalt s = hashWithSalt s . show
instance NFData DynEncoding where
rnf enc = rnf $ show enc

View File

@ -0,0 +1,14 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Data.MultiSet.Instances
(
) where
import ClassyPrelude
import Data.MultiSet
type instance Element (MultiSet a) = a
instance MonoFoldable (MultiSet a)
instance GrowingAppend (MultiSet a)

View File

@ -18,7 +18,9 @@ deriving instance Lift Day
instance Hashable Day where
hashWithSalt s (ModifiedJulianDay jDay) = s `hashWithSalt` hash (typeRep @Day) `hashWithSalt` jDay
deriving instance Ord DayOfWeek
-- -- Available since time-1.11
-- deriving instance Ord DayOfWeek
instance Universe DayOfWeek where
universe = [Monday .. Sunday]
instance Finite DayOfWeek

View File

@ -3,11 +3,13 @@
module Data.UUID.Instances
() where
import ClassyPrelude.Yesod
import ClassyPrelude.Yesod hiding (Proxy(..))
import Data.UUID (UUID)
import qualified Data.UUID as UUID
import Database.Persist.Sql
import Utils.Persist
import Data.Proxy
import Text.Blaze (ToMarkup(..))
@ -17,12 +19,12 @@ instance PathPiece UUID where
toPathPiece = pack . UUID.toString
instance PersistField UUID where
toPersistValue = PersistDbSpecific . UUID.toASCIIBytes
toPersistValue = PersistLiteralEscaped . UUID.toASCIIBytes
fromPersistValue (PersistText t) = maybe (Left "Failed to parse UUID") Right $ UUID.fromText t
fromPersistValue (PersistByteString bs) = maybe (Left "Failed to parse UUID") Right $ UUID.fromASCIIBytes bs
fromPersistValue (PersistDbSpecific bs) = maybe (Left "Failed to parse UUID") Right $ UUID.fromASCIIBytes bs
fromPersistValue x = Left $ "Expected UUID, received: " <> tshow x
fromPersistValue (PersistText t) = maybe (Left "Failed to parse UUID") Right $ UUID.fromText t
fromPersistValue (PersistByteString bs) = maybe (Left "Failed to parse UUID") Right $ UUID.fromASCIIBytes bs
fromPersistValue (PersistLiteralEscaped bs) = maybe (Left "Failed to parse UUID") Right $ UUID.fromASCIIBytes bs
fromPersistValue x = Left $ fromPersistValueErrorSql (Proxy @UUID) x
instance PersistFieldSql UUID where
sqlType _ = SqlOther "uuid"

View File

@ -22,8 +22,11 @@ instance (Eq a, Hashable a, Finite a, ToJSON b, ToJSONKey a) => ToJSON (a -> b)
toJSON f = toJSON $ HashMap.fromList [(k, f k) | k <- universeF]
instance (Eq a, Hashable a, Finite a, FromJSON b, FromJSONKey a) => FromJSON (a -> b) where
parseJSON val = do
vMap <- parseJSON val :: Parser (HashMap a b)
unless (HashSet.fromMap (HashMap.map (const ()) vMap) == HashSet.fromList universeF) $
fail "Not all required keys found"
return (vMap !)
parseJSON val = asObject <|> asConst
where
asObject = do
vMap <- parseJSON val :: Parser (HashMap a b)
unless (HashSet.fromMap (HashMap.map (const ()) vMap) == HashSet.fromList universeF) $
fail "Not all required keys found"
return (vMap !)
asConst = const <$> parseJSON val

View File

@ -20,17 +20,19 @@ module Database.Esqueleto.Utils
, selectExists, selectNotExists
, SqlHashable
, sha256
, maybe, maybeEq, fromMaybe, unsafeCoalesce
, maybe, maybe2, maybeEq, fromMaybe, guardMaybe, unsafeCoalesce
, bool
, max, min
, abs
, SqlProject(..)
, (->.), (#>>.)
, fromSqlKey
, unKey
, selectCountRows
, selectMaybe
, day, diffDays
, day, diffDays, diffTimes
, exprLift
, explicitUnsafeCoerceSqlExprValue
, module Database.Esqueleto.Utils.TH
) where
@ -50,6 +52,12 @@ import qualified Data.ByteString.Lazy as Lazy (ByteString)
import Crypto.Hash (Digest, SHA256)
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) #-}
@ -124,19 +132,31 @@ substring (E.ERaw p1 f1) (E.ERaw p2 f2) (E.ERaw p3 f3)
, strVals <> fromiVals <> foriVals
)
substring a b c = substring (construct a) (construct b) (construct c)
where 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
build ("?", [E.PersistList vals']) =
(E.uncommas $ replicate (length vals') "?", vals')
build expr = expr
in build (E.parensM p b1, vals)
construct (E.ECompositeKey f) =
E.ERaw E.Parens $ \info -> (E.uncommas $ f info, mempty)
construct (E.EAliasedValue i _) =
E.ERaw E.Never $ E.aliasedValueIdentToRawSql i
construct (E.EValueReference i i') =
E.ERaw E.Never $ E.valueReferenceToRawSql i i'
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
build ("?", [E.PersistList vals']) =
(E.uncommas $ replicate (length vals') "?", vals')
build expr = expr
in build (E.parensM p b1, vals)
construct (E.ECompositeKey f) =
E.ERaw E.Parens $ \info -> (E.uncommas $ f info, mempty)
construct (E.EAliasedValue i _) =
E.ERaw E.Never $ E.aliasedValueIdentToRawSql i
construct (E.EValueReference i i') =
E.ERaw E.Never $ E.valueReferenceToRawSql i i'
and, or :: Foldable f => f (E.SqlExpr (E.Value Bool)) -> E.SqlExpr (E.Value Bool)
and = F.foldr (E.&&.) true
@ -302,6 +322,20 @@ maybe onNothing onJust val = E.case_
]
(E.else_ onNothing)
maybe2 :: (PersistField a, PersistField b, PersistField c)
=> E.SqlExpr (E.Value c)
-> (E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value b) -> E.SqlExpr (E.Value c))
-> E.SqlExpr (E.Value (Maybe a))
-> E.SqlExpr (E.Value (Maybe b))
-> E.SqlExpr (E.Value c)
maybe2 onNothing onJust val1 val2 = E.case_
[ E.when_
(isJust val1 E.&&. isJust val2)
E.then_
(onJust (E.veryUnsafeCoerceSqlExprValue val1) (E.veryUnsafeCoerceSqlExprValue val2))
]
(E.else_ onNothing)
infix 4 `maybeEq`
maybeEq :: PersistField a
@ -321,12 +355,20 @@ maybeEq a b = E.case_
]
(E.else_ $ a E.==. b)
-- TODO: replace with guardMaybe in Utils.Schedule
fromMaybe :: (PersistField a)
=> E.SqlExpr (E.Value a)
-> E.SqlExpr (E.Value (Maybe a))
-> E.SqlExpr (E.Value a)
fromMaybe onNothing = maybe onNothing id
guardMaybe :: PersistField a
=> E.SqlExpr (E.Value (Maybe a))
-> E.SqlQuery (E.SqlExpr (E.Value a))
guardMaybe mVal = do
E.where_ $ isJust mVal
return $ E.veryUnsafeCoerceSqlExprValue mVal
bool :: PersistField a
=> E.SqlExpr (E.Value a)
-> E.SqlExpr (E.Value a)
@ -356,17 +398,20 @@ unsafeCoalesce :: E.PersistField a => [E.SqlExpr (E.Value (Maybe a))] -> E.SqlEx
unsafeCoalesce = E.veryUnsafeCoerceSqlExprValue . E.coalesce
class (PersistEntity entity, PersistField value) => SqlProject entity value entity' value' | entity value entity' -> value', entity value value' -> entity' where
class (PersistEntity entity, PersistField value, PersistField value') => SqlProject entity value entity' value' | entity value entity' -> value', entity value value' -> entity' where
sqlProject :: E.SqlExpr entity' -> EntityField entity value -> E.SqlExpr (E.Value value')
unSqlProject :: forall p1 p2. p1 entity -> p2 entity' -> value -> value'
unSqlProjectExpr :: forall p1 p2. p1 entity -> p2 entity' -> E.SqlExpr (E.Value value) -> E.SqlExpr (E.Value value')
instance (PersistEntity val, PersistField typ) => SqlProject val typ (E.Entity val) typ where
sqlProject = (E.^.)
unSqlProject _ _ = id
unSqlProjectExpr _ _ = id
instance (PersistEntity val, PersistField typ) => SqlProject val typ (Maybe (E.Entity val)) (Maybe typ) where
sqlProject = (E.?.)
unSqlProject _ _ = Just
unSqlProjectExpr _ _ = E.just
infixl 8 ->.
@ -381,6 +426,12 @@ infixl 8 #>>.
fromSqlKey :: (ToBackendKey SqlBackend entity, PersistField (Key entity)) => E.SqlExpr (E.Value (Key entity)) -> E.SqlExpr (E.Value Int64)
fromSqlKey = E.veryUnsafeCoerceSqlExprValue
unKey :: ( Coercible (Key entity) a
, PersistField (Key entity), PersistField a
)
=> E.SqlExpr (E.Value (Key entity)) -> E.SqlExpr (E.Value a)
unKey = E.veryUnsafeCoerceSqlExprValue
selectCountRows :: (Num a, PersistField a, MonadIO m) => E.SqlQuery ignored -> E.SqlReadT m a
@ -399,11 +450,22 @@ selectMaybe = fmap listToMaybe . E.select . (<* E.limit 1)
day :: E.SqlExpr (E.Value UTCTime) -> E.SqlExpr (E.Value Day)
day = E.unsafeSqlCastAs "date"
infixl 6 `diffDays`
infixl 6 `diffDays`, `diffTimes`
diffDays :: E.SqlExpr (E.Value Day) -> E.SqlExpr (E.Value Day) -> E.SqlExpr (E.Value Int)
-- ^ PostgreSQL is weird.
diffDays a b = E.veryUnsafeCoerceSqlExprValue $ a E.-. b
diffTimes :: E.SqlExpr (E.Value UTCTime) -> E.SqlExpr (E.Value UTCTime) -> E.SqlExpr (E.Value NominalDiffTime)
diffTimes a b = unsafeExtract "EPOCH" $ a E.-. b
unsafeExtract :: String -> E.SqlExpr (E.Value a) -> E.SqlExpr (E.Value b)
unsafeExtract extr (E.ERaw vP vF) = E.ERaw E.Never $ \info ->
let (vTLB, vVals) = vF info
in ( "EXTRACT" <> E.parens (fromString extr <> " FROM " <> E.parensM vP vTLB)
, vVals
)
unsafeExtract extr v = unsafeExtract extr $ construct v
class ExprLift e a | e -> a where

View File

@ -10,7 +10,7 @@ module Database.Esqueleto.Utils.TH
import ClassyPrelude
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Internal.Sql as E (SqlSelect)
import qualified Database.Esqueleto.Internal.Internal as E (SqlSelect)
import Database.Persist (PersistField)

View File

@ -30,9 +30,6 @@ instance PersistEntity record => Binary (Key record) where
putList = Binary.putList . map toPersistValue
get = either (fail . unpack) return . fromPersistValue =<< Binary.get
instance PersistEntity record => NFData (Key record) where
rnf = rnf . keyToValues
uniqueToMap :: PersistEntity record => Unique record -> Map (HaskellName, DBName) PersistValue
uniqueToMap = fmap Map.fromList $ zip <$> persistUniqueToFieldNames <*> persistUniqueToValues

View File

@ -8,6 +8,8 @@ import ClassyPrelude
import Database.Persist.Sql
import Data.Binary (Binary)
instance BackendCompatible SqlWriteBackend SqlWriteBackend where
projectBackend = id
@ -20,3 +22,6 @@ instance BackendCompatible SqlReadBackend SqlBackend where
instance BackendCompatible SqlWriteBackend SqlBackend where
projectBackend = SqlWriteBackend
deriving newtype instance Binary (BackendKey SqlBackend)
deriving anyclass instance NFData (BackendKey SqlBackend)

View File

@ -1,3 +1,4 @@
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Database.Persist.Types.Instances
@ -22,3 +23,9 @@ deriving instance Typeable PersistValue
instance Hashable PersistValue
instance Binary PersistValue
instance NFData PersistValue
instance (NFData record, NFData (Key record)) => NFData (Entity record) where
rnf Entity{..} = rnf entityKey `seq` rnf entityVal
deriving instance Generic Checkmark
deriving anyclass instance NFData Checkmark

View File

@ -10,5 +10,5 @@ import Foundation.Instances as Foundation (ButtonClass(..), unsafeHandler)
import Foundation.Authorization as Foundation
import Foundation.SiteLayout as Foundation
import Foundation.DB as Foundation
import Foundation.Navigation as Foundation (evalAccessCorrector)
import Foundation.Navigation as Foundation (evalAccessCorrector, NavigationCacheKey(..))
import Foundation.Yesod.Middleware as Foundation (updateFavourites)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
module Foundation.DB
( runDBRead
, runSqlPoolRetry
( runDBRead, runDBRead'
, runSqlPoolRetry, runSqlPoolRetry'
, dbPoolPressured
) where
import Import.NoFoundation hiding (runDB, getDBRunner)
@ -10,17 +11,31 @@ import Foundation.Type
import qualified Control.Retry as Retry
import GHC.IO.Exception (IOErrorType(OtherError))
import Database.Persist.Sql (runSqlPool, SqlReadBackend(..))
import Database.Persist.Sql (SqlReadBackend(..))
import Database.Persist.Sql.Raw.QQ (executeQQ)
import qualified Utils.Pool as Custom
runSqlPoolRetry :: forall m a backend.
( MonadUnliftIO m, BackendCompatible SqlBackend backend
runSqlPoolRetry :: forall m a backend c.
( HasCallStack
, MonadUnliftIO m, BackendCompatible SqlBackend backend
, MonadLogger m, MonadMask m
)
=> ReaderT backend m a
-> Pool backend
-> Custom.Pool' m DBConnLabel c backend
-> m a
runSqlPoolRetry action pool = do
runSqlPoolRetry action pool = runSqlPoolRetry' action pool callStack
runSqlPoolRetry' :: forall m a backend c.
( MonadUnliftIO m, BackendCompatible SqlBackend backend
, MonadLogger m, MonadMask m
)
=> ReaderT backend m a
-> Custom.Pool' m DBConnLabel c backend
-> CallStack
-> m a
runSqlPoolRetry' action pool lbl = do
let policy = Retry.fullJitterBackoff 1e3 & Retry.limitRetriesByCumulativeDelay 10e6
handlers = Retry.skipAsyncExceptions `snoc` Retry.logRetries suggestRetry logRetry
where suggestRetry :: IOException -> m Bool
@ -38,9 +53,22 @@ runSqlPoolRetry action pool = do
Retry.recovering policy handlers $ \Retry.RetryStatus{..} -> do
$logDebugS "runSqlPoolRetry" $ "rsIterNumber = " <> tshow rsIterNumber
runSqlPool action pool
customRunSqlPool' action pool lbl
runDBRead :: ReaderT SqlReadBackend (HandlerFor UniWorX) a -> (HandlerFor UniWorX) a
runDBRead action = do
runDBRead :: HasCallStack => ReaderT SqlReadBackend (HandlerFor UniWorX) a -> (HandlerFor UniWorX) a
runDBRead = runDBRead' callStack
runDBRead' :: CallStack -> ReaderT SqlReadBackend (HandlerFor UniWorX) a -> (HandlerFor UniWorX) a
runDBRead' lbl action = do
$logDebugS "YesodPersist" "runDBRead"
runSqlPoolRetry (withReaderT SqlReadBackend action) . appConnPool =<< getYesod
flip (runSqlPoolRetry' . withReaderT SqlReadBackend $ [executeQQ|SET TRANSACTION READ ONLY|] *> action) lbl . appConnPool =<< getYesod
dbPoolPressured :: ( MonadHandler m
, HandlerSite m ~ UniWorX
)
=> m Bool
dbPoolPressured = do
connPool <- getsYesod @_ @(Custom.Pool' IO _ _ _) appConnPool
case Custom.getPoolMaxAvailable connPool of
Nothing -> return False
Just lim -> atomically $ (>= lim) <$> Custom.getPoolInUseCount connPool

View File

@ -1,13 +1,14 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Foundation.I18n
( appLanguages, appLanguagesOpts
, UniWorXMessage(..)
, UniWorXMessage(..), UniWorXTestMessage(..)
, ShortTermIdentifier(..)
, MsgLanguage(..)
, ShortSex(..)
, ShortWeekDay(..)
, SheetTypeHeader(..)
, SheetType'(..), classifySheetType
, SheetArchiveFileTypeDirectory(..)
, ShortStudyDegree(..)
, ShortStudyTerms(..)
@ -15,14 +16,15 @@ module Foundation.I18n
, ShortStudyFieldType(..)
, StudyDegreeTermType(..)
, ErrorResponseTitle(..)
, WorkflowPayloadBool(..)
, UniWorXMessages(..)
, uniworxMessages
, unRenderMessage, unRenderMessage', unRenderMessageLenient
, module Foundation.I18n.TH
) where
import Foundation.Type
import Import.NoFoundation
import Auth.LDAP
@ -47,6 +49,12 @@ import Data.Text.Lens (packed)
import Data.List ((!!))
import qualified Data.Scientific as Scientific
import Utils.Workflow (RouteWorkflowScope)
import Foundation.I18n.TH
pluralDE :: (Eq a, Num a)
=> a -- ^ Count
@ -118,21 +126,19 @@ ordinalEN (toMessage -> numStr) = case lastChar of
lastChar = last <$> fromNullable numStr
-- Convenience Type for Messages, since Yesod messages cannot deal with compound type identifiers
type IntMaybe = Maybe Int
-- | Convenience function for i18n messages definitions
maybeToMessage :: ToMessage m => Text -> Maybe m -> Text -> Text
maybeToMessage _ Nothing _ = mempty
maybeToMessage before (Just x) after = before <> toMessage x <> after
-- Messages creates type UniWorXMessage and RenderMessage UniWorX instance
mkMessage "UniWorX" "messages/uniworx" "de-de-formal"
mkMessageVariant "UniWorX" "Campus" "messages/campus" "de"
mkMessageVariant "UniWorX" "Dummy" "messages/dummy" "de"
mkMessageVariant "UniWorX" "PWHash" "messages/pw-hash" "de"
mkMessageVariant "UniWorX" "Button" "messages/button" "de"
mkMessageVariant "UniWorX" "Frontend" "messages/frontend" "de-de-formal"
mkMessage ''UniWorX "messages/uniworx/misc" "de-de-formal"
mkMessageAddition ''UniWorX "Test" "messages/uniworx/test" "de-de-formal"
mkMessageVariant ''UniWorX ''CampusMessage "messages/campus" "de"
mkMessageVariant ''UniWorX ''DummyMessage "messages/dummy" "de"
mkMessageVariant ''UniWorX ''PWHashMessage "messages/pw-hash" "de"
mkMessageVariant ''UniWorX ''ButtonMessage "messages/button" "de"
mkMessageVariant ''UniWorX ''FrontendMessage "messages/frontend" "de-de-formal"
instance RenderMessage UniWorX TermIdentifier where
renderMessage foundation ls TermIdentifier{..} = case season of
@ -160,6 +166,8 @@ instance RenderMessage UniWorX Integer where
renderMessage f ls = renderMessage f ls . tshow
instance RenderMessage UniWorX Natural where
renderMessage f ls = renderMessage f ls . tshow
instance RenderMessage UniWorX Word64 where
renderMessage f ls = renderMessage f ls . tshow
instance HasResolution a => RenderMessage UniWorX (Fixed a) where
renderMessage f ls = renderMessage f ls . showFixed True
@ -219,6 +227,7 @@ embedRenderMessage ''UniWorX ''SchoolFunction id
embedRenderMessage ''UniWorX ''SystemFunction id
embedRenderMessage ''UniWorX ''CsvPreset id
embedRenderMessage ''UniWorX ''Quoting ("Csv" <>)
embedRenderMessage ''UniWorX ''CsvFormat ("Csv" <>)
embedRenderMessage ''UniWorX ''FavouriteReason id
embedRenderMessage ''UniWorX ''Sex id
embedRenderMessage ''UniWorX ''ExamGradingMode id
@ -227,32 +236,41 @@ embedRenderMessage ''UniWorX ''ExamOnlinePreset id
embedRenderMessage ''UniWorX ''ExamSynchronicityPreset id
embedRenderMessage ''UniWorX ''ExamRequiredEquipmentPreset id
embedRenderMessage ''UniWorX ''ChangelogItemKind id
embedRenderMessage ''UniWorX ''WorkflowScope' $ ("WorkflowScopeKind" <>) . concat . drop 1 . splitCamel . fromMaybe (error "Expected WorkflowScope' to have '") . stripSuffix "'"
embedRenderMessage ''UniWorX ''RoomReference' $ dropSuffix "'"
embedRenderMessage ''UniWorX ''AuthenticationMode id
embedRenderMessage ''UniWorX ''RatingValidityException id
embedRenderMessage ''UniWorX ''UrlFieldMessage id
embedRenderMessageVariant ''UniWorX ''ADInvalidCredentials ("InvalidCredentials" <>)
newtype ShortSex = ShortSex Sex
embedRenderMessageVariant ''UniWorX ''ShortSex ("Short" <>)
newtype SheetTypeHeader = SheetTypeHeader SheetType
embedRenderMessageVariant ''UniWorX ''SheetTypeHeader ("SheetType" <>)
data SheetType'
= NotGraded' | Normal' | Bonus' | Informational' | ExamPartPoints'
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
deriving (Universe, Finite)
classifySheetType :: SheetType a -> SheetType'
classifySheetType = \case
NotGraded -> NotGraded'
Normal{} -> Normal'
Bonus{} -> Bonus'
Informational{} -> Informational'
ExamPartPoints{} -> ExamPartPoints'
nullaryPathPiece ''SheetType' (camelToPathPiece . dropSuffix "'")
embedRenderMessage ''UniWorX ''SheetType' $ ("SheetType" <>) . fromMaybe (error "Expected SheetType' to have '") . stripSuffix "'"
newtype SheetArchiveFileTypeDirectory = SheetArchiveFileTypeDirectory SheetFileType
deriving (Eq, Ord, Read, Show, Generic, Typeable)
deriving newtype (Enum, Bounded, Universe, Finite)
embedRenderMessageVariant ''UniWorX ''SheetArchiveFileTypeDirectory $ ("SheetArchiveFileTypeDirectory" <>) . concat . drop 1 . splitCamel
instance RenderMessage UniWorX SheetType where
renderMessage foundation ls sheetType = case sheetType of
NotGraded -> mr $ SheetTypeHeader NotGraded
other -> mr (grading other) <> ", " <> mr (SheetTypeHeader other)
where
mr :: RenderMessage UniWorX msg => msg -> Text
mr = renderMessage foundation ls
instance RenderMessage UniWorX StudyDegree where
renderMessage _found _ls StudyDegree{..} = fromMaybe (tshow studyDegreeKey) (studyDegreeName <|> studyDegreeShorthand)
@ -330,7 +348,17 @@ instance RenderMessage UniWorX ScheduleView where
mr :: RenderMessage UniWorX msg => msg -> Text
mr = renderMessage foundation ls
instance RenderMessage UniWorX ExamCloseMode where
renderMessage foundation ls = \case
ExamCloseSeparate -> mr MsgExamCloseModeSeparate
ExamCloseOnFinished False -> mr MsgExamCloseModeOnFinished
ExamCloseOnFinished True -> mr MsgExamCloseModeOnFinishedHidden
where
mr :: RenderMessage UniWorX msg => msg -> Text
mr = renderMessage foundation ls
-- ToMessage instances for converting raw numbers to Text (no internationalization)
-- FIXME: Use RenderMessage always
instance ToMessage Int where
toMessage = tshow
@ -340,6 +368,8 @@ instance ToMessage Integer where
toMessage = tshow
instance ToMessage Natural where
toMessage = tshow
instance ToMessage Word64 where
toMessage = tshow
instance HasResolution a => ToMessage (Fixed a) where
toMessage = toMessage . showFixed True
@ -353,6 +383,10 @@ instance HasResolution a => ToMessage (Fixed a) where
newtype ErrorResponseTitle = ErrorResponseTitle ErrorResponse
embedRenderMessageVariant ''UniWorX ''ErrorResponseTitle ("ErrorResponseTitle" <>)
newtype WorkflowPayloadBool = WorkflowPayloadBool { unWorkflowPayloadBool :: Bool }
embedRenderMessageVariant ''UniWorX ''WorkflowPayloadBool ("WorkflowPayloadBool" <>)
newtype UniWorXMessages = UniWorXMessages [SomeMessage UniWorX]
deriving stock (Generic, Typeable)
deriving newtype (Semigroup, Monoid)
@ -409,6 +443,17 @@ instance RenderMessage UniWorX ShortWeekDay where
embedRenderMessage ''UniWorX ''ButtonSubmit id
instance RenderMessage UniWorX RouteWorkflowScope where
renderMessage foundation ls = \case
WSGlobal -> mr MsgWorkflowScopeGlobal
WSTerm{..} -> mr . ShortTermIdentifier $ unTermKey wisTerm
WSSchool{..} -> mr $ unSchoolKey wisSchool
WSTermSchool{..} -> mr $ MsgWorkflowScopeTermSchool wisTerm wisSchool
WSCourse{ wisCourse = (tid, ssh, csh) } -> mr $ MsgWorkflowScopeCourse tid ssh csh
where
mr :: forall msg. RenderMessage UniWorX msg => msg -> Text
mr = renderMessage foundation ls
unRenderMessage' :: (Eq a, Finite a, RenderMessage master a) => (Text -> Text -> Bool) -> master -> Text -> [a]
unRenderMessage' cmp foundation inp = nub $ do

324
src/Foundation/I18n/TH.hs Normal file
View File

@ -0,0 +1,324 @@
{-# LANGUAGE UndecidableInstances #-}
module Foundation.I18n.TH
( mkMessage, mkMessageFor, mkMessageVariant, mkMessageAddition
) where
import Import.NoFoundation
import qualified Language.Haskell.TH.Lib as TH
import qualified Language.Haskell.TH.Syntax as TH
import qualified System.Directory.Tree as DirTree
import Data.HashMap.Strict.InsOrd (InsOrdHashMap)
import qualified Data.HashMap.Strict.InsOrd as InsOrdHashMap
import Data.HashSet.InsOrd (InsOrdHashSet)
import qualified Data.HashSet.InsOrd as InsOrdHashSet
import qualified Data.Foldable as F
import qualified Data.HashMap.Strict as HashMap
import qualified Data.HashSet as HashSet
import qualified Data.Map.Strict as Map
import qualified Text.Parsec as P
import qualified Text.Parsec.Text.Lazy as P
import qualified Language.Haskell.Meta.Parse as Meta
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Text as Text
import Utils.TH.AlphaConversion (alphaConvE)
newtype MsgFile f g = MsgFile
{ msgFileContent :: InsOrdHashMap String (f (MsgDef f g))
} deriving (Generic, Typeable)
deriving stock instance Eq (f (MsgDef f g)) => Eq (MsgFile f g)
deriving stock instance Show (f (MsgDef f g)) => Show (MsgFile f g)
instance Semigroup (f (MsgDef f g)) => Monoid (MsgFile f g) where
mempty = MsgFile InsOrdHashMap.empty
instance Semigroup (f (MsgDef f g)) => Semigroup (MsgFile f g) where
MsgFile a <> MsgFile b = MsgFile $ InsOrdHashMap.unionWith (<>) a b
data MsgDef f g = MsgDef
{ msgDefVars :: InsOrdHashMap String (f (g TH.Type))
, msgDefContent :: [MsgDefContent]
} deriving (Generic, Typeable)
deriving stock instance Eq (f (g TH.Type)) => Eq (MsgDef f g)
deriving stock instance Show (f (g TH.Type)) => Show (MsgDef f g)
data MsgDefContent = MsgDefContentLiteral String
| MsgDefContentSplice Bool {- Recurse? -} TH.Exp
deriving (Eq, Ord, Show, Generic, Typeable)
disambiguateMsgFile :: MsgFile NonEmpty Maybe -> Either (InsOrdHashSet String, InsOrdHashMap String (InsOrdHashSet String)) (MsgFile Identity Maybe)
disambiguateMsgFile MsgFile{..}
| not (InsOrdHashSet.null duplicateDefs) || not (InsOrdHashMap.null duplicateVars)
= Left (duplicateDefs, duplicateVars)
| otherwise
= Right $ MsgFile{ msgFileContent = fmap msgDefToSingletons <$> toSingletons msgFileContent, .. }
where
toDuplicates :: forall k v. (Eq k, Hashable k) => InsOrdHashMap k (NonEmpty v) -> InsOrdHashSet k
toDuplicates = InsOrdHashSet.fromList . InsOrdHashMap.keys . InsOrdHashMap.filter (minLength 2)
duplicateDefs = toDuplicates msgFileContent
duplicateVars = InsOrdHashMap.mapMaybe (assertM' (not . InsOrdHashSet.null) . toDuplicates . msgDefVars . NonEmpty.head) msgFileContent
toSingletons :: forall k v. InsOrdHashMap k (NonEmpty v) -> InsOrdHashMap k (Identity v)
toSingletons = InsOrdHashMap.map $ \case
x NonEmpty.:| [] -> Identity x
xs -> error $ "toSingletons: Expected length 1, but got: " <> show (NonEmpty.length xs)
msgDefToSingletons :: MsgDef NonEmpty Maybe -> MsgDef Identity Maybe
msgDefToSingletons MsgDef{..} = MsgDef
{ msgDefVars = toSingletons msgDefVars
, ..
}
ensureTypesMsgFile :: MsgFile Identity Maybe -> Either (InsOrdHashMap String (InsOrdHashSet String)) (MsgFile Identity Identity)
ensureTypesMsgFile MsgFile{..}
| not $ InsOrdHashMap.null untyped
= Left untyped
| otherwise
= Right $ MsgFile{ msgFileContent = over _Wrapped msgDefToTyped <$> msgFileContent, .. }
where
untyped = InsOrdHashMap.mapMaybe (assertM' (not . InsOrdHashSet.null) . InsOrdHashSet.fromList . InsOrdHashMap.keys . InsOrdHashMap.filter (is _Nothing . runIdentity) . msgDefVars . runIdentity) msgFileContent
msgDefToTyped MsgDef{..} = MsgDef
{ msgDefVars = flip InsOrdHashMap.map msgDefVars $ \case
Identity (Just x) -> Identity $ Identity x
_other -> error "msgDefToTyped got Nothing"
, ..
}
accumInsOrdHashMap :: (Foldable f, Eq k, Hashable k)
=> f (k, v)
-> InsOrdHashMap k (NonEmpty v)
accumInsOrdHashMap = F.foldl' (\acc (k, v) -> InsOrdHashMap.insertWith (<>) k (pure v) acc) InsOrdHashMap.empty
unionsInsOrdHashMap :: (Foldable f, Eq k, Hashable k)
=> f (InsOrdHashMap k (NonEmpty v))
-> InsOrdHashMap k (NonEmpty v)
unionsInsOrdHashMap = F.foldl' (InsOrdHashMap.unionWith (<>)) InsOrdHashMap.empty
insOrdHashMapKeysSet :: InsOrdHashMap k v -> HashSet k
insOrdHashMapKeysSet = HashMap.keysSet . InsOrdHashMap.toHashMap
mkMessage :: TH.Name -- ^ Foundation type
-> FilePath -- ^ Base directory of translation files
-> Lang -- ^ Default translation language
-> TH.DecsQ
mkMessage dt = mkMessageCommon True "Msg" dt . TH.mkName $ TH.nameBase dt <> "Message"
mkMessageFor :: TH.Name -- ^ Foundation type
-> TH.Name -- ^ Existing type to add translations for
-> FilePath -- ^ Base directory of translation files
-> Lang -- ^ Default translation language
-> TH.DecsQ
mkMessageFor = mkMessageCommon False ""
mkMessageVariant :: TH.Name -- ^ Foundation type
-> TH.Name -- ^ Existing type to add translations for
-> FilePath -- ^ Base directory of translation files
-> Lang -- ^ Default translation language
-> TH.DecsQ
mkMessageVariant = mkMessageCommon False "Msg"
mkMessageAddition :: TH.Name -- ^ Foundation type
-> String -- ^ Qualifier to insert into name of message type
-> FilePath -- ^ Base directory of translation files
-> Lang -- ^ Default translation language
-> TH.DecsQ
mkMessageAddition master qual = mkMessageCommon True "Msg" master . TH.mkName $ TH.nameBase master <> qual <> "Message"
mkMessageCommon :: Bool -- ^ Generate new datatype
-> String -- ^ String to prepend to constructor names
-> TH.Name -- ^ Name of master datatype
-> TH.Name -- ^ Name of translation datatype
-> FilePath -- ^ Base directory of translation files
-> Lang -- ^ Default translation language
-> TH.DecsQ
mkMessageCommon genType prefix master datName folder defLang = do
files <- fmap DirTree.zipPaths . liftIO $ DirTree.readDirectoryWith (runExceptT . parseMsgFile) folder
forMOf_ (folded . _1) files TH.addDependentFile
let (errors, successes) = flip (foldMapOf $ folded . _2) files $ \case
Left err -> ([err], mempty)
Right (lang, x) -> (mempty, MergeHashMap $ HashMap.singleton lang x)
unless (null errors) . fail $ "Errors occurred while parsing message files:\n" <> indent 2 (unlines $ map show errors)
let (ambiguous, disambiguated) = flip ifoldMap successes $ \lang x -> case disambiguateMsgFile x of
Left errs -> ([(lang, errs)], mempty)
Right x' -> (mempty, HashMap.singleton lang x')
ambiguousError (lang, (ambigDefs, ambigVars)) = "Language " <> unpack lang <> ":\n" <> unlines errs
where
errs = bool (pure $ "Duplicate message definitions:\n" <> indent 1 (unlines ambigDefsErrs)) [] (InsOrdHashSet.null ambigDefs)
++ bool (pure $ "Duplicate variable names:\n" <> indent 1 (unlines ambigVarsErrs)) [] (InsOrdHashMap.null ambigVars)
ambigDefsErrs = InsOrdHashSet.toList ambigDefs
ambigVarsErrs = map (\(defn, InsOrdHashSet.toList -> vars) -> defn <> ": " <> intercalate ", " vars) $ InsOrdHashMap.toList ambigVars
unless (null ambiguous) . fail . indent' 2 . unlines $ map ambiguousError ambiguous
defMsgFile <- case HashMap.lookup defLang disambiguated of
Nothing -> fail $ "Default language (" <> unpack defLang <> ") not found; found instead: " <> intercalate ", " (unpack <$> HashMap.keys disambiguated)
Just x -> return x
let allDefns = insOrdHashMapKeysSet $ msgFileContent defMsgFile
extraDefns = flip HashMap.mapMaybe disambiguated $ \MsgFile{..} -> assertM' (not . HashSet.null) $ insOrdHashMapKeysSet msgFileContent `HashSet.difference` allDefns
extraDefnsErrs = flip map (HashMap.toList extraDefns) $ \(lang, extra) -> "Language " <> unpack lang <> ":\n" <> indent 1 (intercalate ", " $ HashSet.toList extra)
unless (null extraDefns) . fail $ "Extraneous message definitions:\n" <> indent 2 (unlines extraDefnsErrs)
let defnName defn = TH.mkName $ prefix <> defn
execWriterT @_ @[TH.Dec] $ do
when genType $ do
typedDefMsgFile <- case ensureTypesMsgFile defMsgFile of
Left untypedVars -> fail $ "Default language (" <> unpack defLang <> ") contains untyped variables:\n" <> indent 2 (unlines . flip map (InsOrdHashMap.toList $ InsOrdHashSet.toList <$> untypedVars) $ \(defn, vs) -> defn <> ": " <> intercalate ", " vs)
Right x -> return x
let
datCons :: [TH.ConQ]
datCons = flip foldMap (InsOrdHashMap.toList $ msgFileContent typedDefMsgFile) $ \(defn, Identity MsgDef{ msgDefVars }) ->
pure . TH.normalC (defnName defn) . flip foldMap (InsOrdHashMap.toList msgDefVars) $ \(_, Identity (Identity varT)) ->
pure . TH.bangType (TH.bang TH.noSourceUnpackedness TH.sourceStrict) $ return varT
tellMPoint $ TH.dataD (TH.cxt []) datName [] Nothing datCons []
renderLangs <- iforM disambiguated $ \lang MsgFile{..} -> do
let missing = allDefns `HashSet.difference` insOrdHashMapKeysSet msgFileContent
complete = HashSet.null missing
unless complete $
lift . TH.reportWarning $ "Language " <> unpack lang <> " is not complete, missing:\n" <> indent 2 (unlines $ HashSet.toList missing)
funName <- lift $ newUniqueName "renderLang"
tellMPoint $ TH.sigD funName [t| $(TH.conT master) -> [Lang] -> $(TH.conT datName) -> $(bool [t|Maybe Text|] [t|Text|] complete) |]
masterN <- lift $ TH.newName "_master"
langsN <- lift $ TH.newName "_langs"
let
lamExp :: TH.ExpQ
lamExp = TH.lamCaseE $ bool (++ [TH.match TH.wildP (TH.normalB [e|Nothing|]) []]) id complete matches
where matches :: [TH.MatchQ]
matches = flip map (InsOrdHashMap.toList msgFileContent) $ \(defn, Identity MsgDef{..}) -> do
varns <- flip foldMapM (InsOrdHashMap.toList msgDefVars) $ \(varn, Identity mType) -> InsOrdHashMap.singleton varn . (, mType) <$> TH.newName ("_" <> varn)
let transE :: TH.ExpQ
transE
| Just (x NonEmpty.:| xs) <- NonEmpty.nonEmpty msgDefContent = go x xs
| otherwise = [e|Text.empty|]
where
go' (MsgDefContentLiteral (pack -> t)) = TH.lift (t :: Text)
go' (MsgDefContentSplice isRec spliceE)
| isRec = [e|renderMessage $(TH.varE masterN) $(TH.varE langsN) $(return $ alphaConv spliceE)|]
| otherwise = [e|toMessage $(return $ alphaConv spliceE)|]
go c [] = go' c
go (MsgDefContentLiteral t1) (MsgDefContentLiteral t2 : cs) = go (MsgDefContentLiteral $ t1 <> t2) cs
go c1 (c2:cs) = [e|$(go' c1) `Text.append` $(go c2 cs)|]
alphaConv = alphaConvE . Map.fromList . map ((,) <$> views _1 TH.mkName <*> view (_2 . _1)) $ InsOrdHashMap.toList varns
defnP :: TH.PatQ
defnP = TH.conP (defnName defn) . map varP $ F.toList varns
where varP (varn, Nothing) = TH.varP varn
varP (varn, Just typ) = TH.sigP (varP (varn, Nothing)) $ return typ
TH.match defnP (TH.normalB $ bool [e|Just $(transE)|] transE complete) []
tellMPoint . TH.funD funName . pure $ TH.clause [TH.varP masterN, TH.varP langsN] (TH.normalB lamExp) []
tellMPoint $ TH.pragInlD funName TH.Inlinable TH.FunLike TH.AllPhases
return (complete, funName)
allRenderers <- lift $ newUniqueName "langRendereres"
tellMPoint $ TH.sigD allRenderers [t|HashMap Lang (Either ($(TH.conT master) -> [Lang] -> $(TH.conT datName) -> Maybe Text) ($(TH.conT master) -> [Lang] -> $(TH.conT datName) -> Text))|]
let allRenderers' = TH.listE . flip map (HashMap.toList renderLangs) $ \(lang, (complete, funName)) -> [e|($(TH.lift lang), $(bool [e|Left|] [e|Right|] complete) $(TH.varE funName))|]
in tellMPoint . TH.funD allRenderers . pure $ TH.clause [] (TH.normalB [e|HashMap.fromList $(allRenderers')|]) []
tellMPoint $ TH.pragInlD allRenderers TH.NoInline TH.FunLike TH.AllPhases
let defRender = views _2 TH.varE $ HashMap.findWithDefault (error "could not find default language in renderLangs") defLang renderLangs
in tellMPoint . TH.instanceD (TH.cxt []) [t|RenderMessage $(TH.conT master) $(TH.conT datName)|] . pure $
TH.funD 'renderMessage . pure $
TH.clause [] (TH.normalB [e|renderMessageDispatch $(TH.lift defLang) $(defRender) $(TH.varE allRenderers)|]) []
where
indent, indent' :: Int -> String -> String
indent n = unlines . map (replicate (4 * n) ' ' <>) . lines
indent' n = unlines . over (_tail . traverse) (replicate (4 * n) ' ' <>) . lines
parseMsgFile :: FilePath -> ExceptT P.ParseError IO (Lang, MsgFile NonEmpty Maybe)
parseMsgFile fPath = do
let msgFileLang = pack $ takeBaseName fPath
msgFileContent <- either throwE return <=< liftIO $ P.parseFromFile (pMsgFile <* P.eof) fPath
return (msgFileLang, MsgFile{..})
pMsgFile, pMsgLine :: P.Parser (InsOrdHashMap String (NonEmpty (MsgDef NonEmpty Maybe)))
pMsgFile = flip P.label ".msg file" . fmap unionsInsOrdHashMap $ pMsgLine `P.sepEndBy` P.try (P.many1 P.endOfLine)
pMsgLine = flip P.label ".msg line" $ do
spaces
P.choice
[ flip P.label "comment line" $ InsOrdHashMap.empty <$ P.char '#' <* P.manyTill P.anyChar (P.lookAhead . P.try $ void P.endOfLine <|> P.eof)
, flip P.label "empty line" $ InsOrdHashMap.empty <$ P.try P.endOfLine
, flip P.label "message line" $ do
constrBase <- (:) <$> P.upper <*> P.many (P.upper <|> P.lower <|> P.digit <|> P.char '\'')
msgDefVars <- P.option InsOrdHashMap.empty $ do
P.skipMany1 P.space
accumInsOrdHashMap <$> P.sepEndBy pMsgDefVar (P.many1 P.space)
spaces
void $ P.char ':'
spaces
msgDefContent <- P.manyTill pMsgDefContent . P.lookAhead . P.try $ void P.endOfLine <|> P.eof
return . InsOrdHashMap.singleton constrBase $ (NonEmpty.:| []) MsgDef{..}
]
pMsgDefVar :: P.Parser (String, Maybe TH.Type)
pMsgDefVar = do
varBase <- (:) <$> P.lower <*> P.many (P.upper <|> P.lower <|> P.digit <|> P.char '\'')
varTyp <- P.optionMaybe $
P.char '@'
*> parseToSeparator Meta.parseType (fmap pure $ P.char ':' <|> P.space)
return (varBase, varTyp)
pMsgDefContent :: P.Parser MsgDefContent
pMsgDefContent = flip P.label "message definition content" $ choice
[ MsgDefContentLiteral . pure <$ P.char '\\' <*> (P.char '_' <|> P.char '#' <|> P.char '\\')
, do
isRecurse <- P.try $
(True <$ P.string "_{")
<|> (False <$ P.string "#{")
spliceExp <- parseToSeparator Meta.parseExp (pure <$> P.char '}')
void $ P.char '}'
return $ MsgDefContentSplice isRecurse spliceExp
, MsgDefContentLiteral <$> many1Till P.anyChar (P.lookAhead . P.try $ void (P.char '_') <|> void (P.char '#') <|> void P.endOfLine <|> P.eof)
]
parseToSeparator :: (String -> Either String a) -> P.Parser String -> P.Parser a
parseToSeparator innerP sepP = do
w <- many1Till P.anyChar $ P.lookAhead endOfWord
let cont mErrStr = do
endStr <- endOfWord
case endStr of
Nothing -> maybe mzero fail mErrStr
Just sepStr -> parseToSeparator (innerP . ((w <> sepStr) <>)) sepP
case innerP w of
Right res -> return res
<|> cont Nothing
Left errStr -> cont $ Just errStr
where
endOfWord = (Just <$> P.try sepP )
<|> (Nothing <$ P.try P.endOfLine)
many1Till :: P.Stream s m t => P.ParsecT s u m a -> P.ParsecT s u m end -> P.ParsecT s u m [a]
many1Till p end = (:) <$> p <*> P.manyTill p end
notLookAhead :: P.Stream s m t => P.ParsecT s u m a -> P.ParsecT s u m not -> P.ParsecT s u m a
notLookAhead p n = do
followingNot <- (True <$ P.lookAhead n) <|> pure False
bool p mzero followingNot
spaces :: P.Parser ()
spaces = P.skipMany $ notLookAhead P.space P.endOfLine
-- | Stolen from Richard Eisenberg: <https://gitlab.haskell.org/ghc/ghc/-/issues/13054>
newUniqueName :: TH.Quasi q => String -> q TH.Name
newUniqueName str = do
n <- TH.qNewName str
TH.qNewName $ show n

View File

@ -1,4 +1,5 @@
{-# LANGUAGE UndecidableInstances #-} -- for `MonadCrypto` and `MonadSecretBox`
{-# LANGUAGE InstanceSigs #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Foundation.Instances
@ -9,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
@ -27,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
@ -50,37 +51,13 @@ 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
| BCScheduleView | BCScheduleOffset
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
-- Controls the base of generated URLs. For more information on modifying,
-- see: https://github.com/yesodweb/yesod/wiki/Overriding-approot
approot = ApprootRequest $ \app req ->
case app ^. _appRoot of
case app ^. _appRoot . to ($ ApprootDefault) of
Nothing -> getApprootText guessApproot app req
Just root -> root
@ -107,7 +84,8 @@ instance Yesod UniWorX where
-- The page to be redirected to when authentication is required.
authRoute _ = Just $ AuthR LoginR
isAuthorized = evalAccess
isAuthorized :: HasCallStack => Route UniWorX -> Bool -> HandlerFor UniWorX AuthResult
isAuthorized r w = runDBRead $ evalAccess r w
addStaticContent = UniWorX.addStaticContent
@ -129,11 +107,13 @@ unsafeHandler f h = do
-- How to run database actions.
instance YesodPersist UniWorX where
type YesodPersistBackend UniWorX = SqlBackend
runDB = UniWorX.runDB
type YesodPersistBackend UniWorX = SqlBackend
runDB :: HasCallStack => YesodDB UniWorX a -> HandlerFor UniWorX a
runDB = UniWorX.runDB' callStack
instance YesodPersistRunner UniWorX where
getDBRunner = UniWorX.getDBRunner
getDBRunner :: HasCallStack => HandlerFor UniWorX (DBRunner UniWorX, HandlerFor UniWorX ())
getDBRunner = UniWorX.getDBRunner' callStack
instance YesodAuth UniWorX where
@ -182,7 +162,14 @@ instance YesodAuth UniWorX where
_other -> Auth.germanMessage
where lang = Text.splitOn "-" $ selectLanguage' appLanguages ls
maybeAuthId :: (HasCallStack, MonadHandler m, HandlerSite m ~ UniWorX) => m (Maybe (AuthId UniWorX))
maybeAuthId = $cachedHere . runMaybeT $ authIdFromBearer <|> MaybeT defaultMaybeAuthId
where authIdFromBearer = do
BearerToken{..} <- MaybeT . liftHandler $ runDBRead maybeBearerToken
hoistMaybe bearerImpersonate
instance YesodAuthPersist UniWorX where
getAuthEntity :: (HasCallStack, MonadHandler m, HandlerSite m ~ UniWorX) => UserId -> m (Maybe User)
getAuthEntity = liftHandler . runDBRead . get
@ -291,3 +278,6 @@ instance (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadCrypto
instance {-# OVERLAPPING #-} (Monad m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadSecretBox m where
secretBoxKey = getsYesod appSecretBoxKey
instance {-# OVERLAPPING #-} (Monad m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadAuth m where
authKey = getsYesod appAuthKey

View File

@ -0,0 +1,34 @@
{-# 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
| BCScheduleView | BCScheduleOffset
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]

File diff suppressed because it is too large Load Diff

View File

@ -38,6 +38,12 @@ deriving instance Generic SchoolR
deriving instance Generic ExamOfficeR
deriving instance Generic CourseNewsR
deriving instance Generic CourseEventR
deriving instance Generic AdminWorkflowDefinitionR
deriving instance Generic AdminWorkflowInstanceR
deriving instance Generic GlobalWorkflowInstanceR
deriving instance Generic GlobalWorkflowWorkflowR
deriving instance Generic SchoolWorkflowInstanceR
deriving instance Generic SchoolWorkflowWorkflowR
deriving instance Generic (Route UniWorX)
instance Ord (Route Auth) where
@ -58,6 +64,12 @@ deriving instance Ord SchoolR
deriving instance Ord ExamOfficeR
deriving instance Ord CourseNewsR
deriving instance Ord CourseEventR
deriving instance Ord AdminWorkflowDefinitionR
deriving instance Ord AdminWorkflowInstanceR
deriving instance Ord GlobalWorkflowInstanceR
deriving instance Ord GlobalWorkflowWorkflowR
deriving instance Ord SchoolWorkflowInstanceR
deriving instance Ord SchoolWorkflowWorkflowR
deriving instance Ord (Route UniWorX)
data RouteChildren

View File

@ -1,19 +1,22 @@
{-# LANGUAGE UndecidableInstances #-} -- for `MemcachedKeyFavourites`
{-# OPTIONS_GHC -fprof-auto #-}
module Foundation.SiteLayout
( siteLayout', siteLayout
, siteLayoutMsg', siteLayoutMsg
, getSystemMessageState
, storedFavouriteReason
) where
import Import.NoFoundation hiding (embedFile)
import Import.NoFoundation hiding (embedFile, runDB)
import Foundation.Type
import Foundation.Authorization
import Foundation.Routes
import Foundation.Navigation
import Foundation.I18n
import Foundation.DB
import Foundation.Yesod.Persist
import Foundation.Instances.ButtonClass
import Utils.SystemMessage
import Utils.Form
@ -36,9 +39,64 @@ 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 CourseId AuthContext (NonEmpty Lang)
= MemcachedKeyFavouriteQuickActions (TermId, SchoolId, CourseShorthand) AuthContext (NonEmpty Lang)
deriving (Generic, Typeable)
deriving instance Eq AuthContext => Eq MemcachedKeyFavourites
@ -53,24 +111,22 @@ data MemcachedLimitKeyFavourites
deriving anyclass (Hashable, Binary)
siteLayoutMsg :: (RenderMessage site msg, site ~ UniWorX, BearerAuthSite UniWorX, BackendCompatible SqlBackend (YesodPersistBackend UniWorX), 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, BackendCompatible SqlBackend (YesodPersistBackend UniWorX), 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
, BackendCompatible SqlBackend (YesodPersistBackend UniWorX)
, Button UniWorX ButtonSubmit
, YesodPersistBackend UniWorX ~ SqlBackend
)
=> WidgetFor UniWorX () -- ^ `pageHeading`
-> WidgetFor UniWorX () -> HandlerFor UniWorX Html
siteLayout = siteLayout' . Just
siteLayout' :: ( BearerAuthSite UniWorX
, BackendCompatible SqlBackend (YesodPersistBackend UniWorX)
, Button UniWorX ButtonSubmit
, YesodPersistBackend UniWorX ~ SqlBackend
)
=> Maybe (WidgetFor UniWorX ()) -- ^ `pageHeading`
-> WidgetFor UniWorX () -> HandlerFor UniWorX Html
@ -86,24 +142,6 @@ siteLayout' overrideHeading widget = do
currentApproot' <- siteApproot <$> getYesod <*> (reqWaiRequest <$> getRequest)
-- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance.
let
breadcrumbs' mcRoute = do
mr <- getMessageRender
case mcRoute of
Nothing -> return (mr MsgErrorResponseTitleNotFound, [])
Just cRoute -> do
(title, next) <- breadcrumb cRoute
crumbs <- go [] next
return (title, crumbs)
where
go crumbs Nothing = return crumbs
go crumbs (Just cRoute) = do
hasAccess <- hasReadAccessTo cRoute
(title, next) <- breadcrumb cRoute
go ((cRoute, title, hasAccess) : crumbs) next
(title, parents) <- breadcrumbs' mcurrentRoute
-- let isParent :: Route UniWorX -> Bool
-- isParent r = r == (fst parents)
@ -111,11 +149,12 @@ siteLayout' overrideHeading widget = do
now <- liftIO getCurrentTime
-- Lookup Favourites & Theme if possible
(favourites', maxFavouriteTerms, currentTheme) <- do
muid <- maybeAuthPair
muid <- maybeAuthPair
-- Lookup Favourites, Breadcrumbs, Headline, & Theme if possible
(favourites', (title, parents), nav', contentHeadline, mmsgs, maxFavouriteTerms, currentTheme, storedReasonAndToggleRoute) <- do
favCourses'' <- runDBRead . E.select . E.from $ \(course `E.LeftOuterJoin` courseFavourite) -> 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)
@ -155,62 +194,130 @@ siteLayout' overrideHeading widget = do
E.where_ $ ((isFavourite E.||. isAssociated) E.&&. notBlacklist) E.||. isCurrent
return (course, reason, courseVisible)
return ( ( course E.^. CourseName
, course E.^. CourseTerm
, course E.^. CourseSchool
, course E.^. CourseShorthand
)
, reason
, courseVisible
)
favCourses' <- forM favCourses'' $ \(course@(Entity _ Course{..}), reason, E.Value courseVisible) -> do
mayView <- hasReadAccessTo $ CourseR courseTerm courseSchool courseShorthand CShowR
mayEdit <- hasWriteAccessTo $ CourseR courseTerm courseSchool courseShorthand CEditR
return (course, reason, courseVisible, mayView, mayEdit)
favCourses' <- withReaderT (projectBackend @SqlReadBackend) . forM favCourses'' $ \((E.Value cName, E.Value tid, E.Value ssh, E.Value csh), reason, E.Value courseVisible) -> do
mayView <- hasReadAccessTo $ CourseR tid ssh csh CShowR
mayEdit <- hasWriteAccessTo $ CourseR tid ssh csh CEditR
return ((cName, tid, ssh, csh), reason, courseVisible, mayView, mayEdit)
let favCourses = favCourses' & filter (\(_, _, _, mayView, _) -> mayView)
return ( favCourses
, maybe userDefaultMaxFavouriteTerms userMaxFavouriteTerms $ view _2 <$> muid
, maybe userDefaultTheme userTheme $ view _2 <$> muid
)
breadcrumbs''
<- let breadcrumbs' mcRoute = do
mr <- getMessageRender
case mcRoute of
Nothing -> return (mr MsgErrorResponseTitleNotFound, [])
Just cRoute -> do
(title, next) <- breadcrumb cRoute
crumbs <- go [] next
return (title, crumbs)
where
go crumbs Nothing = return crumbs
go crumbs (Just cRoute) = do
hasAccess <- hasReadAccessTo cRoute
(title, next) <- breadcrumb cRoute
go ((cRoute, title, hasAccess) : crumbs) next
in withReaderT (projectBackend @SqlReadBackend) $ breadcrumbs' mcurrentRoute
nav'' <- withReaderT (projectBackend @SqlReadBackend) $ mconcat <$> sequence
[ defaultLinks
, maybe (return []) pageActions mcurrentRoute
]
nav' <- withReaderT (projectBackend @SqlReadBackend) $ catMaybes <$> mapM (runMaybeT . navAccess) nav''
-- contentHeadline :: Maybe (WidgetFor UniWorX ())
contentHeadline <- withReaderT (projectBackend @SqlReadBackend) . runMaybeT $ hoistMaybe overrideHeading <|> (pageHeading =<< hoistMaybe mcurrentRoute)
mmsgs <- if
| isModal -> return mempty
| otherwise -> do
applySystemMessages
authTagPivots <- fromMaybe Set.empty <$> takeSessionJson SessionInactiveAuthTags
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, storedReasonAndToggleRoute)
return ( favCourses
, breadcrumbs''
, nav'
, contentHeadline
, 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 (\(Entity _ Course{..}, _, _, _, _) -> Set.singleton $ unTermKey courseTerm) 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' $ \(Entity cId c@Course{..}, E.Value mFavourite, courseVisible, mayView, mayEdit)
-> let courseRoute = CourseR courseTerm courseSchool courseShorthand CShowR
favourites <- fmap catMaybes . forM favourites' $ \(c@(_, tid, ssh, csh), E.Value mFavourite, courseVisible, mayView, mayEdit)
-> let courseRoute = CourseR tid ssh csh CShowR
favouriteReason = fromMaybe FavouriteCurrent mFavourite
in runMaybeT . guardOnM (unTermKey courseTerm `elem` favouriteTerms) . lift $ do
in runMaybeT . guardOnM (unTermKey tid `elem` favouriteTerms) . lift $ do
ctx <- getAuthContext
MsgRenderer mr <- getMsgRenderer
langs <- selectLanguages appLanguages <$> languages
let cK = MemcachedKeyFavouriteQuickActions cId ctx langs
let cK = MemcachedKeyFavouriteQuickActions (tid, ssh, csh) ctx langs
$logDebugS "FavouriteQuickActions" $ tshow cK <> " Checking..."
items <- memcachedLimitedKeyTimeoutBy
MemcachedLimitKeyFavourites appFavouritesQuickActionsBurstsize appFavouritesQuickActionsAvgInverseRate 1
(Right <$> appFavouritesQuickActionsCacheTTL)
appFavouritesQuickActionsTimeout
cK
cK
. observeFavouritesQuickActionsDuration $ do
$logDebugS "FavouriteQuickActions" $ tshow cK <> " Starting..."
items' <- pageQuickActions NavQuickViewFavourite courseRoute
items <- forM items' $ \n@NavLink{navLabel} -> (mr navLabel,) <$> toTextUrl n
$logDebugS "FavouriteQuickActions" $ tshow cK <> " Done."
return items
poolIsPressured <- dbPoolPressured
items <- if
| poolIsPressured -> Nothing <$ observeFavouritesSkippedDueToDBLoad
| otherwise -> memcachedLimitedKeyTimeoutBy
MemcachedLimitKeyFavourites appFavouritesQuickActionsBurstsize appFavouritesQuickActionsAvgInverseRate 1
(Right <$> appFavouritesQuickActionsCacheTTL)
appFavouritesQuickActionsTimeout
cK
cK
. observeFavouritesQuickActionsDuration . runDBRead $ do
$logDebugS "FavouriteQuickActions" $ tshow cK <> " Starting..."
items' <- pageQuickActions NavQuickViewFavourite courseRoute
items <- forM items' $ \n@NavLink{navLabel} -> fmap (mr navLabel,) $ toTextUrl =<< navLinkRoute n
$logDebugS "FavouriteQuickActions" $ tshow cK <> " Done."
return items
$logDebugS "FavouriteQuickActions" $ tshow cK <> " returning " <> tshow (is _Just items)
return (c, courseRoute, items, favouriteReason, courseVisible, mayView, mayEdit)
nav'' <- mconcat <$> sequence
[ defaultLinks
, maybe (return []) pageActions mcurrentRoute
]
nav' <- catMaybes <$> mapM (runMaybeT . navAccess) nav''
nav <- forM nav' $ \n -> (n,,,) <$> newIdent <*> traverse toTextUrl (n ^? _navLink) <*> traverse (\nc -> (nc,, ) <$> newIdent <*> toTextUrl nc) (n ^. _navChildren)
mmsgs <- if
| isModal -> return mempty
| otherwise -> do
applySystemMessages
authTagPivots <- fromMaybe Set.empty <$> takeSessionJson SessionInactiveAuthTags
forM_ authTagPivots $
\authTag -> addMessageWidget Info $ msgModal [whamlet|_{MsgUnauthorizedDisabledTag authTag}|] (Left $ SomeRoute (AuthPredsR, catMaybes [(toPathPiece GetReferer, ) . toPathPiece <$> mcurrentRoute]))
getMessages
nav <- forM nav' $ \n -> (n,,,) <$> newIdent <*> traverse (toTextUrl <=< navLinkRoute) (n ^? _navLink) <*> traverse (\nc -> (nc,, ) <$> newIdent <*> (toTextUrl <=< navLinkRoute) nc) (n ^. _navChildren)
-- (langFormView, langFormEnctype) <- generateFormPost $ identifyForm FIDLanguage langForm
-- let langFormView' = wrapForm langFormView def
@ -223,13 +330,15 @@ siteLayout' overrideHeading widget = do
-- ^ highlight last route in breadcrumbs, favorites taking priority
highlight = (highR ==) . Just . urlRoute
where crumbs = mcons mcurrentRoute $ view _1 <$> reverse parents
navItems = map (view _2) favourites ++ toListOf (folded . typesUsing @NavChildren @NavLink . to urlRoute) nav
navItems = map (view _2) favourites ++ toListOf (folded . typesUsing @NavChildren @NavLink . to navBaseRoute) nav
highR = find (`elem` navItems) . uncurry (++) $ partition (`elem` map (view _2) favourites) crumbs
highlightNav = (||) <$> navForceActive <*> highlight
favouriteTermReason :: TermIdentifier -> FavouriteReason -> [(Course, Route UniWorX, Maybe [(Text, Text)], FavouriteReason, Bool, Bool, Bool)]
highlightNav = (||) <$> navForceActive <*> (highlight . navBaseRoute)
favouriteTermReason :: TermIdentifier -> FavouriteReason -> [((CourseName, TermId, SchoolId, CourseShorthand), Route UniWorX, Maybe [(Text, Text)], FavouriteReason, Bool, Bool, Bool)]
favouriteTermReason tid favReason' = favourites
& filter (\(Course{..}, _, _, favReason, _, _, _) -> unTermKey courseTerm == tid && favReason == favReason')
& sortOn (\(Course{..}, _, _, _, _, _, _) -> courseName)
& 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
@ -241,15 +350,16 @@ siteLayout' overrideHeading widget = do
navWidget (n, navIdent, navRoute', navChildren') = case n of
NavHeader{ navLink = navLink@NavLink{..}, .. }
| NavTypeLink{..} <- navType
, navModal
-> customModal Modal
{ modalTriggerId = Just navIdent
, modalId = Nothing
, modalTrigger = \mroute ident -> case mroute of
Just route -> $(widgetFile "widgets/navbar/item")
Nothing -> error "navWidget with non-link modal"
, modalContent = Left $ SomeRoute navLink
}
, navModal -> do
modalContent <- liftHandler $ Left <$> navLinkRoute navLink
customModal Modal
{ modalTriggerId = Just navIdent
, modalId = Nothing
, modalTrigger = \mroute ident -> case mroute of
Just route -> $(widgetFile "widgets/navbar/item")
Nothing -> error "navWidget with non-link modal"
, modalContent
}
| NavTypeLink{} <- navType
-> let route = navRoute'
ident = navIdent
@ -257,14 +367,15 @@ siteLayout' overrideHeading widget = do
NavPageActionPrimary{ navLink = navLink@NavLink{..} }
-> let pWidget
| NavTypeLink{..} <- navType
, navModal
= customModal Modal
, navModal = do
modalContent <- liftHandler $ Left <$> navLinkRoute navLink
customModal Modal
{ modalTriggerId = Just navIdent
, modalId = Nothing
, modalTrigger = \mroute ident -> case mroute of
Just route -> $(widgetFile "widgets/pageaction/primary")
Nothing -> error "navWidget with non-link modal"
, modalContent = Left $ SomeRoute navLink
, modalContent
}
| NavTypeLink{} <- navType
= let route = navRoute'
@ -277,15 +388,16 @@ siteLayout' overrideHeading widget = do
in $(widgetFile "widgets/pageaction/primary-wrapper")
NavPageActionSecondary{ navLink = navLink@NavLink{..} }
| NavTypeLink{..} <- navType
, navModal
-> customModal Modal
{ modalTriggerId = Just navIdent
, modalId = Nothing
, modalTrigger = \mroute ident -> case mroute of
Just route -> $(widgetFile "widgets/pageaction/secondary")
Nothing -> error "navWidget with non-link modal"
, modalContent = Left $ SomeRoute navLink
}
, navModal -> do
modalContent <- liftHandler $ Left <$> navLinkRoute navLink
customModal Modal
{ modalTriggerId = Just navIdent
, modalId = Nothing
, modalTrigger = \mroute ident -> case mroute of
Just route -> $(widgetFile "widgets/pageaction/secondary")
Nothing -> error "navWidget with non-link modal"
, modalContent
}
| NavTypeLink{} <- navType
-> let route = navRoute'
ident = navIdent
@ -305,25 +417,27 @@ siteLayout' overrideHeading widget = do
navContainerItemWidget (n, _navIdent, _navRoute', _navChildren') (iN@NavLink{..}, iNavIdent, iNavRoute) = case n of
NavHeaderContainer{}
| NavTypeLink{..} <- navType
, navModal
-> customModal Modal
{ modalTriggerId = Just iNavIdent
, modalId = Nothing
, modalTrigger = \mroute ident -> case mroute of
Just route -> $(widgetFile "widgets/navbar/navbar-container-item--link")
Nothing -> error "navWidget with non-link modal"
, modalContent = Left $ SomeRoute iN
}
, navModal -> do
modalContent <- liftHandler $ Left <$> navLinkRoute iN
customModal Modal
{ modalTriggerId = Just iNavIdent
, modalId = Nothing
, modalTrigger = \mroute ident -> case mroute of
Just route -> $(widgetFile "widgets/navbar/navbar-container-item--link")
Nothing -> error "navWidget with non-link modal"
, modalContent
}
| NavTypeLink{} <- navType
-> let route = iNavRoute
ident = iNavIdent
in $(widgetFile "widgets/navbar/navbar-container-item--link")
| NavTypeButton{..} <- navType -> do
csrfToken <- reqToken <$> getRequest
formAction <- liftHandler $ Just <$> navLinkRoute iN
wrapForm $(widgetFile "widgets/navbar/navbar-container-item--button") def
{ formMethod = navMethod
, formSubmit = FormNoSubmit
, formAction = Just $ SomeRoute iN
, formAction
}
_other -> error "not implemented"
@ -343,8 +457,6 @@ siteLayout' overrideHeading widget = do
where isNavFooter = has $ _1 . _NavFooter
alerts :: WidgetFor UniWorX ()
alerts = $(widgetFile "widgets/alerts/alerts")
contentHeadline :: Maybe (WidgetFor UniWorX ())
contentHeadline = overrideHeading <|> (pageHeading =<< mcurrentRoute)
breadcrumbsWgt :: WidgetFor UniWorX ()
breadcrumbsWgt = $(widgetFile "widgets/breadcrumbs/breadcrumbs")
pageaction :: WidgetFor UniWorX ()
@ -402,19 +514,25 @@ getSystemMessageState smId = liftHandler $ do
where foldSt (Entity _ SystemMessageHidden{..})
= MergeHashMap . HashMap.singleton systemMessageHiddenMessage $ mempty { userSystemMessageHidden = Just systemMessageHiddenTime }
applySystemMessages :: (MonadHandler m, HandlerSite m ~ UniWorX, BackendCompatible SqlBackend (YesodPersistBackend UniWorX), BearerAuthSite UniWorX) => m ()
applySystemMessages = liftHandler . maybeT_ . catchMPlus (Proxy @CryptoIDError) $ do
applySystemMessages :: forall m.
( MonadHandler m, HandlerSite m ~ UniWorX
, YesodPersistBackend UniWorX ~ SqlBackend
, BearerAuthSite UniWorX
, WithRunDB SqlBackend (HandlerFor UniWorX) m
, MonadCatch m
) => m ()
applySystemMessages = maybeT_ . catchMPlus (Proxy @CryptoIDError) $ do
lift $ maybeAuthId >>= traverse_ syncSystemMessageHidden
cRoute <- lift getCurrentRoute
cRoute <- getCurrentRoute
guard $ cRoute /= Just NewsR
lift . runDBRead . runConduit $ selectSource [] [Asc SystemMessageManualPriority] .| C.mapM_ applyMessage
lift . useRunDB . runConduit $ selectSource [] [Asc SystemMessageManualPriority] .| C.mapM_ applyMessage
where
syncSystemMessageHidden :: UserId -> HandlerFor UniWorX ()
syncSystemMessageHidden uid = runDB . withReaderT projectBackend $ do
smSt <- lookupRegisteredCookiesJson id CookieSystemMessageState :: SqlPersistT (HandlerFor UniWorX) (MergeHashMap CryptoUUIDSystemMessage UserSystemMessageState)
iforM_ smSt $ \cID UserSystemMessageState{..} -> do
syncSystemMessageHidden :: UserId -> m ()
syncSystemMessageHidden uid = do
smSt <- lookupRegisteredCookiesJson id CookieSystemMessageState :: m (MergeHashMap CryptoUUIDSystemMessage UserSystemMessageState)
iforM_ smSt $ \cID UserSystemMessageState{..} -> useRunDB $ do
smId <- decrypt cID
whenIsJust userSystemMessageHidden $ \systemMessageHiddenTime -> void $
upsert SystemMessageHidden
@ -431,12 +549,12 @@ applySystemMessages = liftHandler . maybeT_ . catchMPlus (Proxy @CryptoIDError)
-> fmap MergeHashMap . assertM' (/= mempty) $
HashMap.update (\smSt' -> assertM' (/= mempty) $ smSt' { userSystemMessageHidden = Nothing, userSystemMessageUnhidden = Nothing }) cID hm
applyMessage :: Entity SystemMessage -> ReaderT SqlReadBackend (HandlerFor UniWorX) ()
applyMessage :: Entity SystemMessage -> ReaderT SqlBackend (HandlerFor UniWorX) ()
applyMessage (Entity smId SystemMessage{..}) = maybeT_ $ do
guard $ not systemMessageNewsOnly
cID <- encrypt smId
void . assertM (== Authorized) . lift $ evalAccessDB (MessageR cID) False
cID <- lift $ encrypt smId
guardM . lift . hasReadAccessTo $ MessageR cID
now <- liftIO getCurrentTime
guard $ NTop systemMessageFrom <= NTop (Just now)
@ -467,103 +585,102 @@ applySystemMessages = liftHandler . maybeT_ . catchMPlus (Proxy @CryptoIDError)
-- All handlers whose code is under our control should use
-- `siteLayout` instead; `pageHeading` is only a fallback solution for
-- e.g. subsites like `AuthR`
pageHeading :: ( YesodPersist UniWorX
, BackendCompatible SqlBackend (YesodPersistBackend UniWorX)
) => Route UniWorX -> Maybe Widget
pageHeading :: ( YesodPersistBackend UniWorX ~ SqlBackend
, WithRunDB SqlReadBackend (HandlerFor UniWorX) m
, MonadHandler m
) => Route UniWorX -> MaybeT m Widget
pageHeading (AuthR _)
= Just $ i18n MsgLoginHeading
= return $ i18n MsgLoginHeading
pageHeading NewsR
= Just $ i18n MsgNewsHeading
= return $ i18n MsgNewsHeading
pageHeading UsersR
= Just $ i18n MsgUsers
= return $ i18n MsgUsers
pageHeading (AdminUserR _)
= Just $ i18n MsgAdminUserHeading
= return $ i18n MsgAdminUserHeading
pageHeading AdminTestR
= Just [whamlet|Internal Code Demonstration Page|]
= return [whamlet|Internal Code Demonstration Page|]
pageHeading AdminErrMsgR
= Just $ i18n MsgErrMsgHeading
= return $ i18n MsgErrMsgHeading
pageHeading InfoR
= Just $ i18n MsgInfoHeading
= return $ i18n MsgInfoHeading
pageHeading LegalR
= Just $ i18n MsgLegalHeading
= return $ i18n MsgLegalHeading
pageHeading VersionR
= Just $ i18n MsgVersionHeading
= return $ i18n MsgVersionHeading
pageHeading HelpR
= Just $ i18n MsgHelpRequest
= return $ i18n MsgHelpRequest
pageHeading ProfileR
= Just $ i18n MsgProfileHeading
= return $ i18n MsgProfileHeading
pageHeading ProfileDataR
= Just $ i18n MsgProfileDataHeading
= return $ i18n MsgProfileDataHeading
pageHeading TermShowR
= Just $ i18n MsgTermsHeading
= return $ i18n MsgTermsHeading
pageHeading TermCurrentR
= Just $ i18n MsgTermCurrent
= return $ i18n MsgTermCurrent
pageHeading TermEditR
= Just $ i18n MsgTermEditHeading
= return $ i18n MsgTermEditHeading
pageHeading (TermEditExistR tid)
= Just $ i18n $ MsgTermEditTid tid
= return $ i18n $ MsgTermEditTid tid
pageHeading (TermCourseListR tid)
= Just . i18n . MsgTermCourseListHeading $ tid
pageHeading (TermSchoolCourseListR tid ssh)
= Just $ do
School{schoolName=school} <- handlerToWidget . runDB . withReaderT (projectBackend @SqlBackend) $ get404 ssh
i18n $ MsgTermSchoolCourseListHeading tid school
= return . i18n . MsgTermCourseListHeading $ tid
pageHeading (TermSchoolCourseListR tid ssh) = do
School{schoolName=school} <- MaybeT . useRunDB $ get ssh
return . i18n $ MsgTermSchoolCourseListHeading tid school
pageHeading CourseListR
= Just $ i18n MsgCourseListTitle
= return $ i18n MsgCourseListTitle
pageHeading CourseNewR
= Just $ i18n MsgCourseNewHeading
pageHeading (CourseR tid ssh csh CShowR)
= Just $ do
Entity _ Course{..} <- handlerToWidget . runDB . withReaderT (projectBackend @SqlBackend) . getBy404 $ TermSchoolCourseShort tid ssh csh
toWidget courseName
= return $ i18n MsgCourseNewHeading
pageHeading (CourseR tid ssh csh CShowR) = do
Entity _ Course{..} <- MaybeT . useRunDB . getBy $ TermSchoolCourseShort tid ssh csh
return $ toWidget courseName
-- (CourseR tid csh CRegisterR) -- just for POST
pageHeading (CourseR tid ssh csh CEditR)
= Just $ i18n $ MsgCourseEditHeading tid ssh csh
= return $ i18n $ MsgCourseEditHeading tid ssh csh
pageHeading (CourseR tid ssh csh CCorrectionsR)
= Just $ i18n $ MsgSubmissionsCourse tid ssh csh
= return $ i18n $ MsgSubmissionsCourse tid ssh csh
pageHeading (CourseR tid ssh csh SheetListR)
= Just $ i18n $ MsgSheetList tid ssh csh
= return $ i18n $ MsgSheetList tid ssh csh
pageHeading (CourseR tid ssh csh SheetNewR)
= Just $ i18n $ MsgSheetNewHeading tid ssh csh
= return $ i18n $ MsgSheetNewHeading tid ssh csh
pageHeading (CSheetR tid ssh csh shn SShowR)
= Just $ i18n $ MsgSheetTitle tid ssh csh shn
-- = Just $ i18n $ prependCourseTitle tid ssh csh $ SomeMessage shn -- TODO: for consistency use prependCourseTitle throughout ERROR: circularity
= return $ i18n $ MsgSheetTitle tid ssh csh shn
-- = return $ i18n $ prependCourseTitle tid ssh csh $ SomeMessage shn -- TODO: for consistency use prependCourseTitle throughout ERROR: circularity
pageHeading (CSheetR tid ssh csh shn SEditR)
= Just $ i18n $ MsgSheetEditHead tid ssh csh shn
= return $ i18n $ MsgSheetEditHead tid ssh csh shn
pageHeading (CSheetR tid ssh csh shn SDelR)
= Just $ i18n $ MsgSheetDelHead tid ssh csh shn
= return $ i18n $ MsgSheetDelHead tid ssh csh shn
pageHeading (CSheetR _tid _ssh _csh shn SSubsR)
= Just $ i18n $ MsgSubmissionsSheet shn
= return $ i18n $ MsgSubmissionsSheet shn
pageHeading (CSheetR tid ssh csh shn SubmissionNewR)
= Just $ i18n $ MsgSubmissionEditHead tid ssh csh shn
= return $ i18n $ MsgSubmissionEditHead tid ssh csh shn
pageHeading (CSheetR tid ssh csh shn SubmissionOwnR)
= Just $ i18n $ MsgSubmissionEditHead tid ssh csh shn
= return $ i18n $ MsgSubmissionEditHead tid ssh csh shn
pageHeading (CSubmissionR tid ssh csh shn _ SubShowR) -- TODO: Rethink this one!
= Just $ i18n $ MsgSubmissionEditHead tid ssh csh shn
= return $ i18n $ MsgSubmissionEditHead tid ssh csh shn
-- (CSubmissionR tid csh shn cid SubArchiveR) -- just a download
pageHeading (CSubmissionR tid ssh csh shn cid CorrectionR)
= Just $ i18n $ MsgCorrectionHead tid ssh csh shn cid
= return $ i18n $ MsgCorrectionHead tid ssh csh shn cid
-- (CSubmissionR tid csh shn cid SubDownloadR) -- just a download
-- (CSheetR tid ssh csh shn SFileR) -- just for Downloads
pageHeading CorrectionsR
= Just $ i18n MsgCorrectionsTitle
= return $ i18n MsgCorrectionsTitle
pageHeading CorrectionsUploadR
= Just $ i18n MsgCorrUpload
= return $ i18n MsgCorrUpload
pageHeading CorrectionsCreateR
= Just $ i18n MsgCorrCreate
= return $ i18n MsgCorrCreate
pageHeading CorrectionsGradeR
= Just $ i18n MsgCorrGrade
= return $ i18n MsgCorrGrade
pageHeading (MessageR _)
= Just $ i18n MsgSystemMessageHeading
= return $ i18n MsgSystemMessageHeading
pageHeading MessageListR
= Just $ i18n MsgSystemMessageListHeading
= return $ i18n MsgSystemMessageListHeading
-- TODO: add headings for more single course- and single term-pages
pageHeading _
= Nothing
= mzero

View File

@ -5,13 +5,16 @@ module Foundation.Type
( UniWorX(..)
, SomeSessionStorage(..)
, _SessionStorageMemcachedSql, _SessionStorageAcid
, AppMemcached(..)
, _memcachedKey, _memcachedConn
, AppMemcachedLocal(..)
, _memcachedLocalARC
, SMTPPool
, _appSettings', _appStatic, _appConnPool, _appSmtpPool, _appLdapPool, _appWidgetMemcached, _appHttpManager, _appLogger, _appLogSettings, _appCryptoIDKey, _appClusterID, _appInstanceID, _appJobState, _appSessionStore, _appSecretBoxKey, _appJSONWebKeySet, _appHealthReport
, _appSettings', _appStatic, _appConnPool, _appSmtpPool, _appLdapPool, _appWidgetMemcached, _appHttpManager, _appLogger, _appLogSettings, _appCryptoIDKey, _appClusterID, _appInstanceID, _appJobState, _appSessionStore, _appSecretBoxKey, _appJSONWebKeySet, _appHealthReport, _appMemcached, _appUploadCache, _appVerpSecret, _appAuthKey
, DB, Form, MsgRenderer, MailM, DBFile
) where
import Import.NoFoundation
import Database.Persist.Sql (ConnectionPool)
import Jobs.Types
@ -19,46 +22,78 @@ import Yesod.Core.Types (Logger)
import qualified Crypto.Saltine.Core.SecretBox as SecretBox
import qualified Crypto.Saltine.Core.AEAD as AEAD
import qualified Crypto.Saltine.Core.Auth as Auth
import qualified Jose.Jwk as Jose
import qualified Database.Memcached.Binary.IO as Memcached
import Network.Minio (MinioConn)
import Data.IntervalMap.Strict (IntervalMap)
import qualified Utils.Pool as Custom
import Utils.Metrics (DBConnUseState)
import qualified Data.ByteString.Lazy as Lazy
import Data.Time.Clock.POSIX (POSIXTime)
import GHC.Fingerprint (Fingerprint)
type SMTPPool = Pool SMTPConnection
data SomeSessionStorage
= SessionStorageMemcachedSql { sessionStorageMemcachedSql :: MemcachedSqlStorage SessionMap }
| SessionStorageAcid { sessionStorageAcid :: AcidStorage SessionMap }
deriving (Generic, Typeable)
makePrisms ''SomeSessionStorage
data AppMemcached = AppMemcached
{ memcachedKey :: AEAD.Key
, memcachedConn :: Memcached.Connection
} deriving (Generic, Typeable)
makeLenses_ ''AppMemcached
data AppMemcachedLocal = AppMemcachedLocal
{ memcachedLocalARC :: ARCHandle (Fingerprint, Lazy.ByteString) Int (NFDynamic, Maybe POSIXTime)
, memcachedLocalHandleInvalidations :: Async ()
, memcachedLocalInvalidationQueue :: TVar (Seq (Fingerprint, Lazy.ByteString))
} deriving (Generic, Typeable)
makeLenses_ ''AppMemcachedLocal
-- | The foundation datatype for your application. This can be a good place to
-- keep settings and values requiring initialization before your application
-- starts running, such as database connections. Every handler will have
-- access to the data present here.
data UniWorX = UniWorX
{ appSettings' :: AppSettings
, appStatic :: EmbeddedStatic -- ^ Settings for static file serving.
, appConnPool :: ConnectionPool -- ^ Database connection pool.
, appSmtpPool :: Maybe SMTPPool
, appLdapPool :: Maybe (Failover (LdapConf, LdapPool))
, appWidgetMemcached :: Maybe Memcached.Connection -- ^ Actually a proper pool
, appHttpManager :: Manager
, appLogger :: (ReleaseKey, TVar Logger)
, appLogSettings :: TVar LogSettings
, appCryptoIDKey :: CryptoIDKey
, appClusterID :: ClusterId
, appInstanceID :: InstanceId
, appJobState :: TMVar JobState
, appSessionStore :: SomeSessionStorage
, appSecretBoxKey :: SecretBox.Key
, appJSONWebKeySet :: Jose.JwkSet
, appHealthReport :: TVar (Set (UTCTime, HealthReport))
, appMemcached :: Maybe (AEAD.Key, Memcached.Connection)
, appUploadCache :: Maybe MinioConn
, appVerpSecret :: VerpSecret
}
{ appSettings' :: AppSettings
, appStatic :: EmbeddedStatic -- ^ Settings for static file serving.
, appConnPool :: forall m. MonadIO m => Custom.Pool' m DBConnLabel DBConnUseState SqlBackend -- ^ Database connection pool.
, appSmtpPool :: Maybe SMTPPool
, appLdapPool :: Maybe (Failover (LdapConf, LdapPool))
, appWidgetMemcached :: Maybe Memcached.Connection -- ^ Actually a proper pool
, appHttpManager :: Manager
, appLogger :: (ReleaseKey, TVar Logger)
, appLogSettings :: TVar LogSettings
, appCryptoIDKey :: CryptoIDKey
, appClusterID :: ClusterId
, appInstanceID :: InstanceId
, appJobState :: TMVar JobState
, appSessionStore :: SomeSessionStorage
, appSecretBoxKey :: SecretBox.Key
, appJSONWebKeySet :: Jose.JwkSet
, appHealthReport :: TVar (Set (UTCTime, HealthReport))
, appMemcached :: Maybe AppMemcached
, appMemcachedLocal :: Maybe AppMemcachedLocal
, appUploadCache :: Maybe MinioConn
, appVerpSecret :: VerpSecret
, appAuthKey :: Auth.Key
, appFileSourceARC :: Maybe (ARCHandle (FileContentChunkReference, (Int, Int)) Int ByteString)
, appFileSourcePrewarm :: Maybe (LRUHandle (FileContentChunkReference, (Int, Int)) UTCTime Int ByteString)
, appFileInjectInhibit :: TVar (IntervalMap UTCTime (Set FileContentReference))
} deriving (Typeable)
makeLenses_ ''UniWorX
instance HasInstanceID UniWorX InstanceId where

View File

@ -15,6 +15,8 @@ import Handler.Utils.Profile
import Handler.Utils.StudyFeatures
import Handler.Utils.SchoolLdap
import Handler.Utils.LdapSystemFunctions
import Handler.Utils.Memcached
import Foundation.Authorization (AuthorizationCacheKey(..))
import Yesod.Auth.Message
import Auth.LDAP
@ -359,7 +361,7 @@ upsertCampusUser upsertMode ldapData = do
, studyFeaturesFirstObserved = Just now
, studyFeaturesLastObserved = now
, studyFeaturesValid = True
, studyFeaturesRelevanceCached = False
, studyFeaturesRelevanceCached = Nothing
}
(sf :) <$> assimilateSubTerms subterms unusedFeats
Nothing
@ -476,9 +478,10 @@ upsertCampusUser upsertMode ldapData = do
Right str <- return $ Text.decodeUtf8' v'
assertM' (not . Text.null) $ Text.strip str
iforM_ userSystemFunctions $ \func preset -> if
| preset -> void $ upsert (UserSystemFunction userId func False False) []
| otherwise -> deleteWhere [UserSystemFunctionUser ==. userId, UserSystemFunctionFunction ==. func, UserSystemFunctionIsOptOut ==. False, UserSystemFunctionManual ==. False]
iforM_ userSystemFunctions $ \func preset -> do
memcachedByInvalidate (AuthCacheSystemFunctionList func) $ Proxy @(Set UserId)
if | preset -> void $ upsert (UserSystemFunction userId func False False) []
| otherwise -> deleteWhere [UserSystemFunctionUser ==. userId, UserSystemFunctionFunction ==. func, UserSystemFunctionIsOptOut ==. False, UserSystemFunctionManual ==. False]
return user
where

View File

@ -4,54 +4,88 @@ module Foundation.Yesod.ErrorHandler
import Import.NoFoundation hiding (errorHandler)
import Utils.Form
import Foundation.Type
import Foundation.I18n
import Foundation.Authorization
import Foundation.SiteLayout
import Foundation.Routes
import Foundation.DB
import qualified Data.Aeson as JSON
import qualified Data.Text as Text
import qualified Network.Wai as W
errorHandler :: ( MonadSecretBox (HandlerFor UniWorX)
, MonadSecretBox (WidgetFor UniWorX)
, MonadSecretBox (ExceptT EncodedSecretBoxException (HandlerFor UniWorX))
, MonadAuth (HandlerFor UniWorX)
, BearerAuthSite UniWorX
, Button UniWorX ButtonSubmit
, BackendCompatible SqlBackend (YesodPersistBackend UniWorX)
, YesodPersistBackend UniWorX ~ SqlBackend
)
=> ErrorResponse -> HandlerFor UniWorX TypedContent
errorHandler err = do
shouldEncrypt <- do
canDecrypt <- (== Authorized) <$> evalAccess AdminErrMsgR True
shouldEncrypt <- getsYesod $ view _appEncryptErrors
return $ shouldEncrypt && not canDecrypt
let shouldEncrypt' = getsYesod $ view _appEncryptErrors
canDecrypt' = runDBRead $ hasWriteAccessTo AdminErrMsgR
decrypted' <- runMaybeT $ do
internalErrorContent <- hoistMaybe $ err ^? _InternalError
exceptTMaybe $ encodedSecretBoxOpen @Text internalErrorContent
let isEncrypted = is _Just decrypted'
shouldEncrypt <- andM
[ shouldEncrypt'
, return $ not isEncrypted
, not <$> canDecrypt'
]
let decrypted = guardOnM (not shouldEncrypt) decrypted'
sessErr <- bool return (_InternalError $ encodedSecretBox SecretBoxShort) shouldEncrypt err
sessErr <- bool return (traverseOf _InternalError $ encodedSecretBox SecretBoxShort) shouldEncrypt err
void . runMaybeT $ do
reqHost <- MaybeT $ W.requestHeaderHost <$> waiRequest
approotHost <- MaybeT . getsYesod $ approotScopeHost ApprootDefault
when (approotHost /= reqHost) $ do
authErr <- lift $ encodedAuth sessErr
redirect (ErrorR, [(toPathPiece GetError, authErr)])
when (is _NotAuthenticated err) $ do
authed <- is _Just <$> maybeAuthId
unless authed $ do
mCurrent <- getCurrentRoute
gets' <- reqGetParams <$> getRequest
wai <- waiRequest
maybe clearUltDest setUltDest $ do
current <- mCurrent
case current of
_ | W.requestMethod wai /= "GET" -> Nothing
ErrorR -> Nothing
current' -> Just (current', gets')
$logInfoS "errorHandler" "Redirect to LoginR"
redirect $ AuthR LoginR
setSessionJson SessionError sessErr
selectRep $ do
provideRep $ do
mr <- getMessageRender
let
encrypted :: ToJSON a => a -> WidgetFor UniWorX () -> WidgetFor UniWorX ()
encrypted :: Text -> WidgetFor UniWorX () -> WidgetFor UniWorX ()
encrypted plaintextJson plaintext = do
if
| shouldEncrypt -> do
ciphertext <- encodedSecretBox SecretBoxPretty plaintextJson
let displayEncrypted ciphertext =
[whamlet|
$newline never
<p>_{MsgErrorResponseEncrypted}
<pre .literal-error>
#{ciphertext}
|]
if
| isEncrypted && shouldEncrypt -> displayEncrypted plaintextJson
| shouldEncrypt -> displayEncrypted =<< encodedSecretBox SecretBoxPretty plaintextJson
| otherwise -> plaintext
errPage = case err of
NotFound -> [whamlet|<p>_{MsgErrorResponseNotFound}|]
InternalError err' -> encrypted err' [whamlet|<p .literal-error>#{err'}|]
InternalError err' -> encrypted err' [whamlet|<p .literal-error>#{fromMaybe err' decrypted}|]
InvalidArgs errs -> [whamlet|
<ul>
$forall err' <- errs
@ -66,20 +100,27 @@ errorHandler err = do
provideRep . fmap PrettyValue $ case err of
PermissionDenied err' -> return $ object [ "message" JSON..= err' ]
InternalError err'
| isEncrypted && shouldEncrypt
-> return $ object [ "message" JSON..= err'
, "encrypted" JSON..= True
]
| shouldEncrypt -> do
ciphertext <- encodedSecretBox SecretBoxShort err'
return $ object [ "message" JSON..= ciphertext
, "encrypted" JSON..= True
]
| otherwise -> return $ object [ "message" JSON..= err' ]
| otherwise -> return $ object [ "message" JSON..= fromMaybe err' decrypted ]
InvalidArgs errs -> return $ object [ "messages" JSON..= errs ]
_other -> return $ object []
provideRep $ case err of
PermissionDenied err' -> return err'
InternalError err'
| isEncrypted && shouldEncrypt -> do
addHeader "Encrypted-Error-Message" "True"
return err'
| shouldEncrypt -> do
addHeader "Encrypted-Error-Message" "True"
encodedSecretBox SecretBoxPretty err'
| otherwise -> return err'
| otherwise -> return $ fromMaybe err' decrypted
InvalidArgs errs -> return . Text.unlines . map (Text.replace "\n" "\n\t") $ errs
_other -> return Text.empty

View File

@ -1,3 +1,5 @@
{-# OPTIONS_GHC -O0 -fasm -fprof-auto #-}
module Foundation.Yesod.Middleware
( yesodMiddleware
, updateFavourites
@ -8,8 +10,12 @@ import Import.NoFoundation hiding (yesodMiddleware)
import Foundation.Type
import Foundation.Routes
import Foundation.Authorization
import Foundation.I18n
import Utils.Metrics
import Utils.Workflow
import Handler.Utils.Workflow.CanonicalRoute
import qualified Network.Wai as W
import qualified Data.Aeson as JSON
@ -17,13 +23,15 @@ import qualified Data.CaseInsensitive as CI
import Yesod.Core.Types (GHState(..), HandlerData(..), RunHandlerEnv(rheSite, rheChild))
import qualified Data.Map as Map
yesodMiddleware :: ( BearerAuthSite UniWorX
, BackendCompatible SqlReadBackend (YesodPersistBackend UniWorX)
, BackendCompatible SqlBackend (YesodPersistBackend UniWorX)
)
=> HandlerFor UniWorX res -> HandlerFor UniWorX res
yesodMiddleware = storeBearerMiddleware . csrfMiddleware . dryRunMiddleware . observeYesodCacheSizeMiddleware . languagesMiddleware appLanguages . headerMessagesMiddleware . defaultYesodMiddleware . normalizeRouteMiddleware . updateFavouritesMiddleware . setActiveAuthTagsMiddleware
yesodMiddleware = cacheControlMiddleware . storeBearerMiddleware . csrfMiddleware . dryRunMiddleware . observeYesodCacheSizeMiddleware . languagesMiddleware appLanguages . headerMessagesMiddleware . securityMiddleware . normalizeRouteMiddleware . updateFavouritesMiddleware . setActiveAuthTagsMiddleware . normalizeApprootMiddleware
where
dryRunMiddleware :: HandlerFor UniWorX a -> HandlerFor UniWorX a
dryRunMiddleware handler = do
@ -51,7 +59,7 @@ yesodMiddleware = storeBearerMiddleware . csrfMiddleware . dryRunMiddleware . ob
case route of -- update Course Favourites here
CourseR tid ssh csh _ -> do
void . lift . runDB . runMaybeT $ do
guardM . lift $ (== Authorized) <$> evalAccessDB (CourseR tid ssh csh CShowR) False
guardM . lift . hasReadAccessTo $ CourseR tid ssh csh CShowR
lift . updateFavourites $ Just (tid, ssh, csh)
_other -> return ()
normalizeRouteMiddleware :: HandlerFor UniWorX a -> HandlerFor UniWorX a
@ -80,9 +88,24 @@ yesodMiddleware = storeBearerMiddleware . csrfMiddleware . dryRunMiddleware . ob
csrfMiddleware :: HandlerFor UniWorX a -> HandlerFor UniWorX a
csrfMiddleware handler = do
hasBearer <- is _Just <$> lookupBearerAuth
reqHost <- W.requestHeaderHost <$> waiRequest
userGeneratedHost <- getsYesod $ \app ->
guardOnM (views _appRoot ($ ApprootDefault) app /= views _appRoot ($ ApprootUserGenerated) app) $ approotScopeHost ApprootUserGenerated app
mCurrentRoute <- getCurrentRoute
let isError = case mCurrentRoute of
Just ErrorR -> True
_other -> False
if | hasBearer -> local (\HandlerData{..} -> HandlerData{ handlerRequest = handlerRequest { reqToken = Nothing }, .. }) handler
| otherwise -> csrfSetCookieMiddleware' . defaultCsrfCheckMiddleware $ handler
if | hasBearer
-> local (\HandlerData{..} -> HandlerData{ handlerRequest = handlerRequest { reqToken = Nothing }, .. }) handler
| fromMaybe False ((==) <$> reqHost <*> userGeneratedHost) || isError -> do
whenIsJust mCurrentRoute $ \currentRoute -> do
isWrite <- isWriteRequest currentRoute
when isWrite $
permissionDeniedI MsgUnauthorizedCsrfDisabled
handler
| otherwise
-> csrfSetCookieMiddleware' . defaultCsrfCheckMiddleware $ handler
where
csrfSetCookieMiddleware' handler' = do
mcsrf <- reqToken <$> getRequest
@ -103,6 +126,47 @@ yesodMiddleware = storeBearerMiddleware . csrfMiddleware . dryRunMiddleware . ob
for_ mAuthTagActive $ setSessionJson SessionActiveAuthTags . review _ReducedActiveAuthTags
handler
securityMiddleware :: HandlerFor UniWorX a -> HandlerFor UniWorX a
securityMiddleware handler = do
addHeader "X-XSS-Protection" "1; mode=block"
addHeader "X-Frame-Options" "sameorigin"
addHeader "X-Content-Type-Options" "nosniff"
authorizationCheck
handler
cacheControlMiddleware :: HandlerFor UniWorX a -> HandlerFor UniWorX a
cacheControlMiddleware = (addHeader "Vary" "Accept, Accept-Language" *>)
normalizeApprootMiddleware :: HandlerFor UniWorX a -> HandlerFor UniWorX a
normalizeApprootMiddleware handler = maybeT handler $ do
route <- MaybeT getCurrentRoute
case route of
MetricsR -> mzero
HealthR -> mzero
InstanceR -> mzero
AdminCrontabR -> mzero
_other -> return ()
reqHost <- MaybeT $ W.requestHeaderHost <$> waiRequest
let rApproot = authoritiveApproot route
app <- getYesod
approotHost <- hoistMaybe $ approotScopeHost rApproot app
let doRedirect = do
url <- approotRender rApproot route
$logDebugS "normalizeApprootMiddleware" url
redirect url
if | approotHost /= reqHost
, rApproot /= ApprootUserGenerated
-> doRedirect
| approotHost /= reqHost -> do
resp <- try $ lift handler
$logDebugS "normalizeApprootMiddleware" $ tshow (is _Right resp, preview _Left resp)
case resp of
Right _ -> doRedirect
Left sc | is _HCRedirect sc -> throwM sc
Left _ -> doRedirect
| otherwise -> lift handler
updateFavourites :: forall m backend.
( MonadHandler m, HandlerSite m ~ UniWorX
@ -121,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)
@ -152,9 +217,13 @@ routeNormalizers = map (hoist (hoist liftHandler . withReaderT projectBackend) .
, ncTutorial
, ncExam
, ncExternalExam
, ncAdminWorkflowDefinition
, ncWorkflowInstance
, ncWorkflowPayloadLabel
, verifySubmission
, verifyCourseApplication
, verifyCourseNews
, verifyWorkflowWorkflow
, verifyMaterialVideo
]
where
@ -229,9 +298,31 @@ routeNormalizers = map (hoist (hoist liftHandler . withReaderT projectBackend) .
return $ route
& typesUsing @RouteChildren @CourseName . filtered (== coursen) .~ externalExamCourseName
& typesUsing @RouteChildren @ExamName . filtered (== examn) .~ externalExamExamName
ncAdminWorkflowDefinition = maybeOrig $ \route -> do
AdminWorkflowDefinitionR wds wdn _ <- return route
Entity _ WorkflowDefinition{..} <- MaybeT . $cachedHereBinary (wds, wdn) . lift . getBy $ UniqueWorkflowDefinition wdn wds
caseChanged wdn workflowDefinitionName
return $ route
& typesUsing @RouteChildren @WorkflowDefinitionName . filtered (== wdn) .~ workflowDefinitionName
ncWorkflowInstance = maybeOrig $ \route -> do
(rScope, WorkflowInstanceR win _) <- hoistMaybe $ route ^? _WorkflowScopeRoute
dbScope <- fmap (view _DBWorkflowScope) . hoist lift $ fromRouteWorkflowScope rScope
Entity _ WorkflowInstance{..} <- lift . lift . getBy404 $ UniqueWorkflowInstance win dbScope
caseChanged win workflowInstanceName
return $ route
& typesUsing @RouteChildren @WorkflowInstanceName . filtered (== win) .~ workflowInstanceName
ncWorkflowPayloadLabel = maybeOrig $ \route -> do
(_, WorkflowWorkflowR cID (WWFilesR wpl _)) <- hoistMaybe $ route ^? _WorkflowScopeRoute
wwId <- decrypt cID
WorkflowWorkflow{..} <- MaybeT . $cachedHereBinary wwId . lift $ get wwId
wwGraph <- lift . lift $ getSharedDBWorkflowGraph workflowWorkflowGraph
[wpl'] <- return . filter (== wpl) . sortOn (CI.original . unWorkflowPayloadLabel) . foldMap Map.keys $ wgnPayloadView <$> wgNodes wwGraph
(caseChanged `on` unWorkflowPayloadLabel) wpl wpl'
return $ route
& typesUsing @RouteChildren @WorkflowPayloadLabel . filtered (== wpl) .~ wpl'
verifySubmission = maybeOrig $ \route -> do
CSubmissionR _tid _ssh _csh _shn cID sr <- return route
sId <- $cachedHereBinary cID $ decrypt cID
sId <- decrypt cID
Submission{submissionSheet} <- MaybeT . $cachedHereBinary cID . lift $ get sId
Sheet{sheetCourse, sheetName} <- MaybeT . $cachedHereBinary submissionSheet . lift $ get submissionSheet
Course{courseTerm, courseSchool, courseShorthand} <- MaybeT . $cachedHereBinary sheetCourse . lift $ get sheetCourse
@ -254,6 +345,14 @@ routeNormalizers = map (hoist (hoist liftHandler . withReaderT projectBackend) .
let newRoute = CNewsR courseTerm courseSchool courseShorthand cID sr
tell . Any $ route /= newRoute
return newRoute
verifyWorkflowWorkflow = maybeOrig $ \route -> do
(_, WorkflowWorkflowR cID wwR) <- hoistMaybe $ route ^? _WorkflowScopeRoute
wwId <- decrypt cID
WorkflowWorkflow{..} <- lift . lift $ get404 wwId
rScope <- hoist lift . toRouteWorkflowScope $ _DBWorkflowScope # workflowWorkflowScope
let newRoute = _WorkflowScopeRoute # (rScope, WorkflowWorkflowR cID wwR)
tell . Any $ route /= newRoute
return newRoute
verifyMaterialVideo = maybeOrig $ \route -> do
CMaterialR _tid _ssh _csh _mnm (MVideoR cID) <- return route
mfId <- decrypt cID

View File

@ -1,5 +1,8 @@
module Foundation.Yesod.Persist
( runDB, getDBRunner
, runDB', getDBRunner'
-- , runCachedDBRunner
-- , runCachedDBRunner'
, module Foundation.DB
) where
@ -10,35 +13,97 @@ import Foundation.DB
import Foundation.Authorization
import Database.Persist.Sql (transactionUndo)
import qualified Database.Persist.Sql as SQL
import qualified Utils.Pool as Custom
import UnliftIO.Resource (allocate, unprotect)
runDB :: ( YesodPersistBackend UniWorX ~ SqlBackend
, BearerAuthSite UniWorX
, HasCallStack
)
=> YesodDB UniWorX a -> HandlerFor UniWorX a
runDB action = do
-- stack <- liftIO currentCallStack
-- $logDebugS "YesodPersist" . unlines $ "runDB" : map pack stack
$logDebugS "YesodPersist" "runDB"
dryRun <- isDryRun
let action'
| dryRun = action <* transactionUndo
| otherwise = action
runDB = runDB' callStack
runSqlPoolRetry action' . appConnPool =<< getYesod
runDB' :: ( YesodPersistBackend UniWorX ~ SqlBackend
, BearerAuthSite UniWorX
)
=> CallStack -> YesodDB UniWorX a -> HandlerFor UniWorX a
runDB' lbl action = do
$logDebugS "YesodPersist" "runDB"
let action' = do
dryRun <- isDryRunDB
if | dryRun -> action <* transactionUndo
| otherwise -> action
flip (runSqlPoolRetry' action') lbl . appConnPool =<< getYesod
getDBRunner :: ( YesodPersistBackend UniWorX ~ SqlBackend
, BearerAuthSite UniWorX
, HasCallStack
)
=> HandlerFor UniWorX (DBRunner UniWorX, HandlerFor UniWorX ())
getDBRunner = do
(DBRunner{..}, cleanup) <- defaultGetDBRunner appConnPool
getDBRunner = getDBRunner' callStack
getDBRunner' :: ( YesodPersistBackend UniWorX ~ SqlBackend
, BearerAuthSite UniWorX
)
=> CallStack -> HandlerFor UniWorX (DBRunner UniWorX, HandlerFor UniWorX ())
getDBRunner' lbl = do
pool <- getsYesod appConnPool
UnliftIO{..} <- askUnliftIO
let withPrep conn f = f (persistBackend conn) (SQL.getStmtConn $ persistBackend conn)
(relKey, (conn, ident)) <- allocate
(do
(conn, ident) <- unliftIO $ Custom.takeResource' pool lbl
withPrep conn (\c f -> SQL.connBegin c f Nothing)
return (conn, ident)
)
(\(conn, ident) -> do
withPrep conn SQL.connRollback
unliftIO $ Custom.releaseResource True pool (conn, ident)
)
let cleanup = liftIO $ do
withPrep conn SQL.connCommit
unliftIO $ Custom.releaseResource True pool (conn, ident)
void $ unprotect relKey
runDBRunner :: forall a. YesodDB UniWorX a -> HandlerFor UniWorX a
runDBRunner = flip runReaderT conn
return . (, cleanup) $ DBRunner
(\action -> do
dryRun <- isDryRun
let action'
| dryRun = action <* transactionUndo
| otherwise = action
let action' = do
dryRun <- isDryRunDB
if | dryRun -> action <* transactionUndo
| otherwise -> action
$logDebugS "YesodPersist" "runDBRunner"
runDBRunner action'
)
-- runCachedDBRunner :: ( BackendCompatible backend (YesodPersistBackend UniWorX)
-- , YesodPersistBackend UniWorX ~ SqlBackend
-- , BearerAuthSite UniWorX
-- , HasCallStack
-- )
-- => CachedDBRunner backend (HandlerFor UniWorX) a
-- -> HandlerFor UniWorX a
-- runCachedDBRunner = runCachedDBRunner' callStack
-- runCachedDBRunner' :: ( BackendCompatible backend (YesodPersistBackend UniWorX)
-- , YesodPersistBackend UniWorX ~ SqlBackend
-- , BearerAuthSite UniWorX
-- )
-- => CallStack
-- -> CachedDBRunner backend (HandlerFor UniWorX) a
-- -> HandlerFor UniWorX a
-- runCachedDBRunner' lbl act = do
-- cleanups <- newTVarIO []
-- res <- flip runCachedDBRunnerSTM act $ do
-- (runner, cleanup) <- getDBRunner' lbl
-- atomically . modifyTVar' cleanups $ (:) cleanup
-- return $ fromDBRunner runner
-- mapM_ liftHandler =<< readTVarIO cleanups
-- return res

View File

@ -4,6 +4,7 @@ module Foundation.Yesod.Session
import Import.NoFoundation hiding (makeSessionBackend)
import Foundation.Routes
import Foundation.Type
import qualified Web.ServerSession.Core as ServerSession
@ -12,51 +13,92 @@ import qualified Web.ServerSession.Frontend.Yesod.Jwt as JwtSession
import qualified Network.Wai as W
import qualified Network.HTTP.Types.Header as W
import qualified Network.Wai.Middleware.HttpAuth as W (extractBearerAuth)
import qualified Network.HTTP.Types as HTTP
import qualified Data.Map as Map
import Web.Cookie
makeSessionBackend :: Yesod UniWorX => UniWorX -> IO (Maybe SessionBackend)
makeSessionBackend app@UniWorX{ appSettings' = AppSettings{..}, ..} = notForBearer . sameSite $ case appSessionStore of
SessionStorageMemcachedSql sqlStore
-> mkBackend . stateSettings =<< ServerSession.createState sqlStore
SessionStorageAcid acidStore
| appServerSessionAcidFallback
-> mkBackend . stateSettings =<< ServerSession.createState acidStore
_other
-> return Nothing
where
cfg = JwtSession.ServerSessionJwtConfig
{ sJwtJwkSet = appJSONWebKeySet
, sJwtStart = Nothing
, sJwtExpiration = appSessionTokenExpiration
, sJwtEncoding = appSessionTokenEncoding
, sJwtIssueBy = appInstanceID
, sJwtIssueFor = appClusterID
}
mkBackend :: forall sto.
( ServerSession.SessionData sto ~ Map Text ByteString
, ServerSession.Storage sto
)
=> ServerSession.State sto -> IO (Maybe SessionBackend)
mkBackend = JwtSession.backend cfg (JwtSession.siteApproot app)
stateSettings :: forall sto. ServerSession.State sto -> ServerSession.State sto
stateSettings = ServerSession.setCookieName (toPathPiece CookieSession) . applyServerSessionSettings appServerSessionConfig
sameSite
| Just sameSiteStrict == cookieSameSite (getCookieSettings app CookieSession)
= strictSameSiteSessions
| Just sameSiteLax == cookieSameSite (getCookieSettings app CookieSession)
= laxSameSiteSessions
| otherwise
= id
notForBearer :: IO (Maybe SessionBackend) -> IO (Maybe SessionBackend)
notForBearer = fmap $ fmap notForBearer'
where notForBearer' :: SessionBackend -> SessionBackend
notForBearer' (SessionBackend load)
= let load' req
| aHdrs <- mapMaybe (\(h, v) -> v <$ guard (h == W.hAuthorization)) $ W.requestHeaders req
, any (is _Just . W.extractBearerAuth) aHdrs
= return (mempty, const $ return [])
| otherwise
= load req
in SessionBackend load'
makeSessionBackend app@UniWorX{ appSettings' = AppSettings{..}, ..} = noCreateFor (return . forRoute isError) . notFor isUserGenerated . notFor isBearer . sameSite $ case appSessionStore of
SessionStorageMemcachedSql sqlStore
-> mkBackend . stateSettings =<< ServerSession.createState sqlStore
SessionStorageAcid acidStore
| appServerSessionAcidFallback
-> mkBackend . stateSettings =<< ServerSession.createState acidStore
_other
-> return Nothing
where
cfg = JwtSession.ServerSessionJwtConfig
{ sJwtJwkSet = appJSONWebKeySet
, sJwtStart = appSessionTokenStart
, sJwtExpiration = appSessionTokenExpiration
, sJwtEncoding = appSessionTokenEncoding
, sJwtIssueBy = appInstanceID
, sJwtIssueFor = appClusterID
, sJwtClockLeniencyStart = appSessionTokenClockLeniencyStart
, sJwtClockLeniencyEnd = appSessionTokenClockLeniencyEnd
}
mkBackend :: forall sto.
( ServerSession.SessionData sto ~ Map Text ByteString
, ServerSession.Storage sto
)
=> ServerSession.State sto -> IO (Maybe SessionBackend)
mkBackend = JwtSession.backend cfg (JwtSession.siteApproot app)
stateSettings :: forall sto. ServerSession.State sto -> ServerSession.State sto
stateSettings = ServerSession.setCookieName (toPathPiece CookieSession) . applyServerSessionSettings appServerSessionConfig
sameSite
| Just sameSiteStrict == cookieSameSite (getCookieSettings app CookieSession)
= strictSameSiteSessions
| Just sameSiteLax == cookieSameSite (getCookieSettings app CookieSession)
= laxSameSiteSessions
| otherwise
= id
notFor :: (W.Request -> IO Bool) -> IO (Maybe SessionBackend) -> IO (Maybe SessionBackend)
notFor f = fmap $ fmap notFor'
where notFor' :: SessionBackend -> SessionBackend
notFor' (SessionBackend load) = SessionBackend $ \req -> do
pMatches <- f req
if | not pMatches -> load req
| otherwise -> return (mempty, const $ return [])
noCreateFor :: (W.Request -> IO Bool) -> IO (Maybe SessionBackend) -> IO (Maybe SessionBackend)
noCreateFor f = fmap $ fmap notFor'
where notFor' :: SessionBackend -> SessionBackend
notFor' (SessionBackend load) = SessionBackend $ \req -> do
pMatches <- f req
if | not pMatches -> load req
| otherwise -> noCreate <$> load req
noCreate resp@(session, _)
| Map.null session = (session, const $ return [])
| otherwise = resp
forRoute :: (Route UniWorX -> Bool) -> (W.Request -> Bool)
forRoute f req = maybe False f mRoute
where mRoute = parseRoute
( W.pathInfo req
, over (mapped . _2) (fromMaybe "") . HTTP.queryToQueryText $ W.queryString req
)
isBearer req = return $ if
| aHdrs <- mapMaybe (\(h, v) -> v <$ guard (h == W.hAuthorization)) $ W.requestHeaders req
, any (is _Just . W.extractBearerAuth) aHdrs
-> True
| otherwise
-> False
isUserGenerated req = return $ if
| Just approotHost <- approotScopeHost ApprootUserGenerated app
, Just reqHost <- W.requestHeaderHost req
, views _appRoot ($ ApprootUserGenerated) app /= views _appRoot ($ ApprootDefault) app
, reqHost == approotHost
-> True
| otherwise
-> False
isError = \case
ErrorR -> True
_other -> False

View File

@ -1,3 +1,5 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Handler.Admin.Crontab
( getAdminCrontabR
) where
@ -6,39 +8,87 @@ import Import
import Jobs
import Handler.Utils.DateTime
import Data.Aeson.Encode.Pretty (encodePrettyToTextBuilder)
import qualified Data.Aeson.Encode.Pretty as Pretty
import Data.Aeson.Encode.Pretty (encodePrettyToTextBuilder')
import qualified Data.Text as Text
import qualified Data.Text.Lazy.Builder as Text.Builder
import qualified Data.HashSet as HashSet
import qualified Data.HashMap.Strict as HashMap
import qualified Data.UUID as UUID
getAdminCrontabR :: Handler Html
deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece' 1
} ''CronNextMatch
getAdminCrontabR :: Handler TypedContent
getAdminCrontabR = do
jState <- getsYesod appJobState
mCrontab' <- atomically . runMaybeT $ do
JobState{jobCurrentCrontab} <- MaybeT $ tryReadTMVar jState
MaybeT $ readTVar jobCurrentCrontab
let mCrontab = mCrontab' <&> _2 %~ filter (hasn't $ _1 . _MatchNone)
let mCrontab = mCrontab' <&> _2 %~ filter (hasn't $ _3 . _MatchNone)
siteLayoutMsg MsgMenuAdminCrontab $ do
setTitleI MsgMenuAdminCrontab
[whamlet|
$newline never
$maybe (genTime, crontab) <- mCrontab
<p>
^{formatTimeW SelFormatDateTime genTime}
<table .table .table--striped .table--hover>
$forall (match, job) <- crontab
<tr .table__row>
<td .table__td>
$case match
$of MatchAsap
_{MsgCronMatchAsap}
$of MatchNone
_{MsgCronMatchNone}
$of MatchAt t
^{formatTimeW SelFormatDateTime t}
<td .table__td>
<pre>
#{encodePrettyToTextBuilder job}
$nothing
_{MsgAdminCrontabNotGenerated}
|]
instanceId <- getsYesod appInstanceID
selectRep $ do
provideRep $ do
crontabBearer <- runMaybeT . hoist runDB $ do
guardM $ hasGlobalGetParam GetGenerateToken
uid <- MaybeT maybeAuthId
guardM . lift . existsBy $ UniqueUserGroupMember UserGroupCrontab uid
encodeBearer =<< bearerToken (HashSet.singleton . Left $ toJSON UserGroupCrontab) Nothing (HashMap.singleton BearerTokenRouteEval $ HashSet.singleton AdminCrontabR) Nothing (Just Nothing) Nothing
siteLayoutMsg MsgMenuAdminCrontab $ do
setTitleI MsgMenuAdminCrontab
[whamlet|
$newline never
$maybe t <- crontabBearer
<section>
<pre .token>
#{toPathPiece t}
<section>
<dl .deflist>
<dt .deflist__dt>_{MsgInstanceId}
<dd .deflist__dd .uuid>#{UUID.toText instanceId}
<section>
$maybe (genTime, crontab) <- mCrontab
<p>
^{formatTimeW SelFormatDateTime genTime}
<table .table .table--striped .table--hover>
$forall (job, lExec, match) <- crontab
<tr .table__row>
<td .table__td>
$case match
$of MatchAsap
_{MsgCronMatchAsap}
$of MatchNone
_{MsgCronMatchNone}
$of MatchAt t
^{formatTimeW SelFormatDateTime t}
<td .table__td>
$maybe lT <- lExec
^{formatTimeW SelFormatDateTime lT}
<td .table__td .json>
#{doEnc job}
$nothing
<p .explanation>
_{MsgAdminCrontabNotGenerated}
|]
provideJson mCrontab'
provideRep . return . Text.Builder.toLazyText $ doEnc mCrontab'
where
doEnc :: _ => a -> _
doEnc = encodePrettyToTextBuilder' Pretty.defConfig
{ Pretty.confIndent = Pretty.Spaces 2
, Pretty.confCompare = comparing $ \t -> ( t `elem` ["instruction", "job", "notification"]
, Text.splitOn "-" t
)
}

View File

@ -326,7 +326,7 @@ postAdminFeaturesR = do
dbtSQLQuery :: E.SqlExpr (Entity StudyDegree) -> E.SqlQuery (E.SqlExpr (Entity StudyDegree))
dbtSQLQuery = return
dbtRowKey = (E.^. StudyDegreeKey)
dbtProj = return
dbtProj = dbtProjId
dbtColonnade = formColonnade $ mconcat
[ sortable (Just "key") (i18nCell MsgGenericKey) (numCell . view (_dbrOutput . _entityVal . _studyDegreeKey))
, sortable (Just "name") (i18nCell MsgDegreeName) (textInputCell _1 (_dbrOutput . _entityVal . _studyDegreeName) (_dbrOutput . _entityKey))
@ -346,6 +346,7 @@ postAdminFeaturesR = do
& defaultSorting [SortAscBy "key"]
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
dbtExtraReps = []
in dbTable psValidator DBTable{..}
mkStudytermsTable :: Set StudyTermsId -> Set StudyTermsId -> Set (Entity School) -> DB (FormResult (DBFormResult StudyTermsId (Maybe Text, Maybe Text, Set SchoolId, Set StudyTermsId, Maybe StudyDegreeId, Maybe StudyFieldType) (DBRow (Entity StudyTerms, Set (Entity StudyTerms), Set SchoolId))), Widget)
@ -355,7 +356,7 @@ postAdminFeaturesR = do
dbtSQLQuery :: E.SqlExpr (Entity StudyTerms) -> E.SqlQuery (E.SqlExpr (Entity StudyTerms))
dbtSQLQuery = return
dbtRowKey = (E.^. StudyTermsKey)
dbtProj field@(view _dbrOutput -> Entity fId _) = do
dbtProj = dbtProjSimple $ \field@(Entity fId _) -> do
fieldSchools <- fmap (setOf $ folded . _Value) . E.select . E.from $ \school -> do
E.where_ . E.exists . E.from $ \schoolTerms ->
E.where_ $ schoolTerms E.^. SchoolTermsSchool E.==. school E.^. SchoolId
@ -367,7 +368,7 @@ postAdminFeaturesR = do
E.where_ $ subTerms E.^. StudySubTermsChild E.==. E.val fId
E.&&. subTerms E.^. StudySubTermsParent E.==. terms E.^. StudyTermsId
return terms
return $ field & _dbrOutput %~ (, fieldParents, fieldSchools)
return (field, fieldParents, fieldSchools)
dbtColonnade = formColonnade $ mconcat
[ sortable (Just "key") (i18nCell MsgGenericKey) (maybe mempty numCell . preview (_dbrOutput . _1 . _entityVal . _studyTermsKey))
, sortable Nothing (i18nCell MsgStudySubTermsParentKey) (parentsCell _4 (_dbrOutput . _2 . to (Set.map entityKey)) _dbrKey')
@ -401,6 +402,8 @@ postAdminFeaturesR = do
& defaultSorting [SortAscBy "isnew", SortAscBy "isbad", SortAscBy "key"]
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
dbtExtraReps = []
queryField = id
_dbrKey' :: Getter (DBRow (Entity StudyTerms, _, _)) StudyTermsId
@ -413,7 +416,7 @@ postAdminFeaturesR = do
dbtSQLQuery :: E.SqlExpr (Entity StudyTermNameCandidate) -> E.SqlQuery ( E.SqlExpr (Entity StudyTermNameCandidate))
dbtSQLQuery = return
dbtRowKey = (E.^. StudyTermNameCandidateId)
dbtProj = return
dbtProj = dbtProjId
dbtColonnade = dbColonnade $ mconcat
[ sortable (Just "key") (i18nCell MsgStudyTermsKey) (numCell . view (_dbrOutput . _entityVal . _studyTermNameCandidateKey))
, sortable (Just "name") (i18nCell MsgStudyTermsName) (textCell . view (_dbrOutput . _entityVal . _studyTermNameCandidateName))
@ -438,6 +441,7 @@ postAdminFeaturesR = do
psValidator = def & defaultSorting [SortAscBy "incidence", SortAscBy "key", SortAscBy "name"]
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
dbtExtraReps = []
in dbTable psValidator DBTable{..}
mkParentCandidateTable =
@ -455,7 +459,7 @@ postAdminFeaturesR = do
E.on $ parent E.?. StudyTermsKey E.==. E.just (candidate E.^. StudySubTermParentCandidateParent)
return (candidate, parent, child)
dbtRowKey = queryCandidate >>> (E.^. StudySubTermParentCandidateId)
dbtProj = return
dbtProj = dbtProjId
dbtColonnade = dbColonnade $ mconcat
[ sortable (Just "child") (i18nCell MsgStudySubTermsChildKey) (numCell . view (_dbrOutput . _1 . _entityVal . _studySubTermParentCandidateKey))
, sortable (Just "child-name") (i18nCell MsgStudySubTermsChildName) (maybe mempty i18nCell . preview (_dbrOutput . _3 . _Just . _entityVal . _studyTermsName . _Just))
@ -477,6 +481,8 @@ postAdminFeaturesR = do
& defaultSorting [SortAscBy "child", SortAscBy "incidence", SortAscBy "parent"]
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
dbtExtraReps = []
queryCandidate (c `E.LeftOuterJoin` _ `E.LeftOuterJoin` _) = c
queryParent (_ `E.LeftOuterJoin` p `E.LeftOuterJoin` _) = p
@ -496,7 +502,7 @@ postAdminFeaturesR = do
E.on $ sterm E.?. StudyTermsKey E.==. E.just (candidate E.^. StudyTermStandaloneCandidateKey)
return (candidate, sterm)
dbtRowKey = queryCandidate >>> (E.^. StudyTermStandaloneCandidateId)
dbtProj = return
dbtProj = dbtProjId
dbtColonnade = formColonnade $ mconcat
[ sortable (Just "key") (i18nCell MsgStudyTermsKey) (numCell . view (_dbrOutput . _1 . _entityVal . _studyTermStandaloneCandidateKey))
, sortable (Just "name") (i18nCell MsgStudyTermsName) (maybe mempty i18nCell . preview (_dbrOutput . _2 . _Just . _entityVal . _studyTermsName . _Just))
@ -517,6 +523,8 @@ postAdminFeaturesR = do
& defaultSorting [SortAscBy "key", SortAscBy "incidence"]
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
dbtExtraReps = []
queryCandidate (c `E.LeftOuterJoin` _) = c
queryTerm (_ `E.LeftOuterJoin` t) = t

View File

@ -42,6 +42,7 @@ emailTestForm = (,)
<*> areq (selectField $ dateTimeFormatOptions SelFormatDate) (fslI MsgDateFormat) Nothing
<*> areq (selectField $ dateTimeFormatOptions SelFormatTime) (fslI MsgTimeFormat) Nothing
)
<*> pure def
)
where
toMailDateTimeFormat dt d t = \case
@ -233,3 +234,5 @@ postAdminTestR = do
<h2>_{MsgTestDownload}
^{testDownloadWidget}
|]
i18n $ MsgPrintDebugForStupid "DebugForStupid"

View File

@ -16,15 +16,43 @@ import Data.Map ((!), (!?))
import qualified Data.Text as Text
import qualified Database.Esqueleto as E hiding (random_)
import qualified Database.Esqueleto.PostgreSQL as E
import qualified Data.Conduit.Combinators as C
import Data.List (genericTake)
import System.Random.Shuffle (shuffleM)
data BTFImpersonate
= BTFISingle
{ btfiUser :: UserId
}
| BTFIRandom
{ btfiCount :: Int64
, btfiWeightActivity :: Bool
}
deriving (Eq, Ord, Generic, Typeable)
data BTFImpersonate' = BTFINone' | BTFISingle' | BTFIRandom'
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
deriving (Universe, Finite, Hashable)
nullaryPathPiece ''BTFImpersonate' $ let noNone n | n == "none" = "impersonate-" <> n
| otherwise = n
in noNone . camelToPathPiece' 1 . dropSuffix "'"
embedRenderMessage ''UniWorX ''BTFImpersonate' $ ("BearerTokenImpersonate" <>) . dropPrefix "BTFI" . dropSuffix "'"
data BearerTokenForm = BearerTokenForm
{ btfAuthority :: HashSet (Either UserGroupName UserId)
, btfRoutes :: Maybe (HashSet (Route UniWorX))
, btfRestrict :: HashMap (Route UniWorX) Value
, btfAddAuth :: Maybe AuthDNF
, btfExpiresAt :: Maybe (Maybe UTCTime)
, btfStartsAt :: Maybe UTCTime
}
{ btfAuthority :: HashSet (Either UserGroupName UserId)
, btfImpersonate :: Maybe BTFImpersonate
, btfRoutes :: Maybe (HashSet (Route UniWorX))
, btfRestrict :: HashMap (Route UniWorX) Value
, btfAddAuth :: Maybe AuthDNF
, btfExpiresAt :: Maybe (Maybe UTCTime)
, btfStartsAt :: Maybe UTCTime
} deriving (Generic, Typeable)
bearerTokenForm :: WForm Handler (FormResult BearerTokenForm)
bearerTokenForm = do
@ -37,6 +65,15 @@ bearerTokenForm = do
btfAuthority'
= (<>) <$> btfAuthorityGroups <*> ((hoistMaybe =<< btfAuthorityUsers) <|> pure HashSet.empty)
let
btfiActs = mapF $ \case
BTFINone' -> pure Nothing
BTFISingle' -> Just . BTFISingle <$> apreq (checkMap (left MsgBearerTokenImpersonateUnknownUser) Right $ userField False Nothing) (fslpI MsgBearerTokenImpersonateSingleUser (mr MsgLdapIdentificationOrEmail)) Nothing
BTFIRandom' -> fmap Just $ BTFIRandom
<$> apreq (posIntFieldI MsgBearerTokenImpersonateRandomNegative) (fslI MsgBearerTokenImpersonateRandomCount) (Just 1)
<*> apopt checkBoxField (fslI MsgBearerTokenImpersonateRandomWeightActivity) (Just True)
btfImpersonate' <- multiActionW btfiActs (fslI MsgBearerTokenImpersonate) Nothing
let btfRoutesForm = HashSet.fromList <$> massInputListA routeField (const "") MsgBearerTokenRouteMissing (\p -> Just . SomeRoute $ AdminTokensR :#: p) ("token-routes" :: Text) (fslI MsgBearerTokenRoutes & setTooltip MsgBearerTokenRoutesTip) True Nothing
btfRoutes' <- optionalActionW btfRoutesForm (fslI MsgBearerTokenRestrictRoutes) (Just True)
@ -68,6 +105,7 @@ bearerTokenForm = do
return $ BearerTokenForm
<$> btfAuthority'
<*> btfImpersonate'
<*> btfRoutes'
<*> btfRestrict'
<*> btfAddAuth'
@ -86,7 +124,43 @@ postAdminTokensR = do
& HashSet.insert (Right uid)
& HashSet.map (left toJSON)
fmap Just . encodeBearer . set _bearerRestrictions btfRestrict =<< bearerToken btfAuthority' btfRoutes btfAddAuth btfExpiresAt btfStartsAt
case btfImpersonate of
Just BTFIRandom{..} -> do
MsgRenderer mr <- getMsgRenderer
now <- liftIO getCurrentTime
users <- runDB $ if
| not btfiWeightActivity -> fmap (fmap E.unValue) . E.select . E.from $ \user -> do
E.orderBy [E.asc $ E.random_ @Int64]
E.limit btfiCount
return $ user E.^. UserId
| otherwise -> do
users <- fmap (fmap E.unValue) . E.select . E.from $ \user -> do
E.orderBy [ E.asc . E.isNothing $ user E.^. UserLastAuthentication
, E.desc $ user E.^. UserLastAuthentication
]
E.limit $ 2 * btfiCount
return $ user E.^. UserId
genericTake btfiCount <$> shuffleM users
let
toTokenFile :: UserId -> DB (Either Void DBFile)
toTokenFile uid' = do
cID <- encrypt uid' :: DB CryptoUUIDUser
tok <- encodeBearer . set _bearerRestrictions btfRestrict =<< bearerToken btfAuthority' (Just uid') (maybe HashMap.empty (HashMap.singleton BearerTokenRouteEval) btfRoutes) btfAddAuth btfExpiresAt btfStartsAt
return . Right $ File
{ fileTitle = unpack (toPathPiece cID) <.> "jwt"
, fileModified = now
, fileContent = Just . yield $ unJwt tok
}
sendResponse <=< serveZipArchive' ((ensureExtension `on` unpack) extensionZip (mr MsgBearerTokenArchiveName)) $ yieldMany users .| C.mapM toTokenFile
_other -> do
let btfImpersonate' = btfImpersonate <&> \case
BTFISingle{..} -> btfiUser
_other -> error "btfImpersonate: not BTFISingle where expected"
fmap Just . encodeBearer . set _bearerRestrictions btfRestrict =<< bearerToken btfAuthority' btfImpersonate' (maybe HashMap.empty (HashMap.singleton BearerTokenRouteEval) btfRoutes) btfAddAuth btfExpiresAt btfStartsAt
siteLayoutMsg MsgMenuAdminTokens $ do
setTitleI MsgMenuAdminTokens

View File

@ -16,7 +16,7 @@ import qualified Database.Esqueleto as E
data AllocationAddUserForm = AllocationAddUserForm
{ aauUser :: UserId
, aauTotalCourses :: Natural
, aauTotalCourses :: Word64
, aauPriority :: Maybe AllocationPriority
, aauApplications :: Map CourseId ApplicationForm
}
@ -61,7 +61,7 @@ postAAddUserR tid ssh ash = do
unless (courseApplicationCourse `Map.member` aauApplications) $
audit $ TransactionCourseApplicationDeleted courseApplicationCourse courseApplicationUser appId
iforM_ aauApplications $ \cId ApplicationForm{..} -> maybeT (return ()) $ do
iforM_ aauApplications $ \cId ApplicationForm{..} -> maybeT_ $ do
prio <- hoistMaybe afPriority
let rated = afRatingVeto || is _Just afRatingPoints
appId <- lift $ insert CourseApplication
@ -115,7 +115,12 @@ allocationApplicationsForm aId courses FieldSettings{..} fvRequired = formToAFor
afmApplicantEdit = True
afmLecturer = True
appsRes' <- iforM courses $ \cId (course, allocCourse, hasApplicationTemplate) -> over _2 (course, allocCourse, hasApplicationTemplate, ) <$> applicationForm (Just aId) cId Nothing ApplicationFormMode{..} Nothing
appsRes' <- iforM courses $ \cId (course, allocCourse, hasApplicationTemplate) -> do
mApplicationTemplate <- runMaybeT $ do
guard hasApplicationTemplate
let Course{..} = course
toTextUrl $ CourseR courseTerm courseSchool courseShorthand CRegisterTemplateR
over _2 (course, allocCourse, mApplicationTemplate, ) <$> applicationForm (Just aId) cId Nothing ApplicationFormMode{..} Nothing
let appsRes = sequenceA $ view _1 <$> appsRes'
appsViews = view _2 <$> appsRes'
@ -123,7 +128,7 @@ allocationApplicationsForm aId courses FieldSettings{..} fvRequired = formToAFor
[whamlet|
$newline never
<div .allocation__courses>
$forall (Course{courseTerm, courseSchool, courseShorthand, courseName, courseApplicationsInstructions}, AllocationCourse{allocationCourseAcceptSubstitutes}, hasApplicationTemplate, ApplicationFormView{afvPriority, afvForm}) <- Map.elems appsViews
$forall (Course{courseTerm, courseSchool, courseShorthand, courseName, courseApplicationsInstructions}, AllocationCourse{allocationCourseAcceptSubstitutes}, mApplicationTemplate, ApplicationFormView{afvPriority, afvForm}) <- Map.elems appsViews
<div .allocation-course>
<div .allocation-course__priority-label .allocation__label>
_{MsgAllocationPriority}
@ -141,16 +146,16 @@ allocationApplicationsForm aId courses FieldSettings{..} fvRequired = formToAFor
_{MsgCourseAllocationCourseAcceptsSubstitutesNever}
$if allocationCourseAcceptSubstitutes >= Just now
\ ^{iconOK}
$if hasApplicationTemplate || is _Just courseApplicationsInstructions
$if is _Just mApplicationTemplate || is _Just courseApplicationsInstructions
<div .allocation-course__instructions-label .allocation__label>
_{MsgCourseApplicationInstructionsApplication}
<div .allocation-course__instructions>
$maybe aInst <- courseApplicationsInstructions
<p>
#{aInst}
$if hasApplicationTemplate
$maybe templateUrl <- mApplicationTemplate
<p>
<a href=@{CourseR courseTerm courseSchool courseShorthand CRegisterTemplateR}>
<a href=#{templateUrl}>
#{iconRegisterTemplate} _{MsgCourseApplicationTemplateApplication}
<div .allocation-course__application-label .interactive-fieldset__target .allocation__label uw-interactive-fieldset data-conditional-input=#{maybe "" fvId afvPriority} data-conditional-value="" data-conditional-negated>
_{MsgCourseApplication}

View File

@ -49,7 +49,7 @@ data ApplicationFormView = ApplicationFormView
}
data ApplicationForm = ApplicationForm
{ afPriority :: Maybe Natural
{ afPriority :: Maybe Word64
, afText :: Maybe Text
, afFiles :: Maybe FileUploads
, afRatingVeto :: Bool
@ -90,19 +90,19 @@ applicationForm maId@(is _Just -> isAlloc) cid muid ApplicationFormMode{..} mcsr
MsgRenderer mr <- getMsgRenderer
let
oldPrio :: Maybe Natural
oldPrio :: Maybe Word64
oldPrio = mApp >>= courseApplicationAllocationPriority . entityVal
coursesNum' = succ maxPrio `max` coursesNum
mkPrioOption :: Natural -> Option Natural
mkPrioOption :: Word64 -> Option Word64
mkPrioOption i = Option
{ optionDisplay = mr . MsgAllocationCoursePriority $ coursesNum' - i
{ optionDisplay = mr . MsgAllocationCoursePriority . fromIntegral $ coursesNum' - i
, optionInternalValue = i
, optionExternalValue = tshow i
}
prioOptions :: OptionList Natural
prioOptions :: OptionList Word64
prioOptions = OptionList
{ olOptions = sortOn (Down . optionInternalValue) . map mkPrioOption $ [0 .. pred coursesNum']
, olReadExternal = readMay
@ -138,13 +138,15 @@ applicationForm maId@(is _Just -> isAlloc) cid muid ApplicationFormMode{..} mcsr
| otherwise
-> over _2 Just . over (_1 . _FormSuccess) (assertM $ not . Text.null) <$> mopt textField' textFs (Just $ mApp >>= courseApplicationText . entityVal)
hasFiles <- for mApp $ \(Entity appId _)
-> fmap (not . null) . liftHandler . runDB $ selectKeysList [ CourseApplicationFileApplication ==. appId ] [ LimitTo 1 ]
appCID <- for mApp $ encrypt . entityKey
let appFilesInfo = (,) <$> hasFiles <*> appCID
appFilesInfo <- for mApp $ \(Entity appId _) -> liftHandler . runDB $ do
hasFiles <- exists [ CourseApplicationFileApplication ==. appId ]
appCID <- encrypt appId
appFilesLink <- toTextUrl $ CApplicationR courseTerm courseSchool courseShorthand appCID CAFilesR
return (hasFiles, appFilesLink)
let hasFiles = maybe False (view _1) appFilesInfo
filesLinkView <- if
| Just True == hasFiles || (isn't _NoUpload courseApplicationsFiles && not afmApplicantEdit)
| hasFiles || (isn't _NoUpload courseApplicationsFiles && not afmApplicantEdit)
-> let filesLinkField = Field{..}
where
fieldParse _ _ = return $ Right Nothing
@ -153,8 +155,8 @@ applicationForm maId@(is _Just -> isAlloc) cid muid ApplicationFormMode{..} mcsr
= [whamlet|
$newline never
$case appFilesInfo
$of Just (True, appCID)
<a ##{theId} *{attrs} href=@{CApplicationR courseTerm courseSchool courseShorthand appCID CAFilesR}>
$of Just (True, appFilesLink)
<a ##{theId} *{attrs} href=#{appFilesLink}>
_{MsgCourseApplicationFiles}
$of _
<span ##{theId} *{attrs}>
@ -165,18 +167,19 @@ applicationForm maId@(is _Just -> isAlloc) cid muid ApplicationFormMode{..} mcsr
-> return Nothing
filesWarningView <- if
| Just True == hasFiles && isn't _NoUpload courseApplicationsFiles && afmApplicantEdit
| hasFiles && isn't _NoUpload courseApplicationsFiles && afmApplicantEdit
-> fmap (Just . snd) . formMessage =<< messageIconI Info IconFileUpload MsgCourseApplicationFilesNeedReupload
| otherwise
-> return Nothing
(filesRes, filesView) <-
let mkFs = bool MsgCourseApplicationFile MsgCourseApplicationArchive
prevAppFiles (Entity aId _) = runDBSource $ selectSource [CourseApplicationFileApplication ==. aId] [Asc CourseApplicationFileTitle] .| C.map (view $ _FileReference . _1)
in if
| not afmApplicantEdit || is _NoUpload courseApplicationsFiles
-> return (FormSuccess Nothing, Nothing)
| otherwise
-> fmap (over _2 $ Just . ($ [])) . aFormToForm $ fileUploadForm False (fslI . mkFs) courseApplicationsFiles
-> fmap (over _2 $ Just . ($ [])) . aFormToForm $ fileUploadForm False (fslI . mkFs) courseApplicationsFiles (prevAppFiles <$> mApp)
(vetoRes, vetoView) <- if
| afmLecturer

View File

@ -37,10 +37,10 @@ countCourses muid ata now addWhere allocation = E.subSelectCount . E.from $ \all
) E.&&. addWhere allocationCourse
queryAvailable :: Maybe UserId -> AuthTagActive -> UTCTime
-> Getter AllocationTableExpr (E.SqlExpr (E.Value Natural))
-> Getter AllocationTableExpr (E.SqlExpr (E.Value Word64))
queryAvailable muid ata now = queryAllocation . to (countCourses muid ata now $ const E.true)
queryApplied :: AuthTagActive -> UTCTime -> UserId -> Getter AllocationTableExpr (E.SqlExpr (E.Value Natural))
queryApplied :: AuthTagActive -> UTCTime -> UserId -> Getter AllocationTableExpr (E.SqlExpr (E.Value Word64))
queryApplied ata now uid = queryAllocation . to (\allocation -> countCourses (Just uid) ata now (addWhere allocation) allocation)
where
addWhere allocation allocationCourse
@ -77,8 +77,10 @@ getAllocationListR = do
<*> view (queryAvailable muid ata now)
<*> view (maybe (to . const $ E.val 0) (queryApplied ata now) muid)
dbtProj :: DBRow _ -> DB AllocationTableData
dbtProj = return . over (_dbrOutput . _2) E.unValue . over (_dbrOutput . _3) E.unValue
dbtProj :: _ AllocationTableData
dbtProj = dbtProjId
<&> _dbrOutput . _2 %~ fromIntegral . E.unValue
<&> _dbrOutput . _3 %~ fromIntegral . E.unValue
dbtRowKey = view $ queryAllocation . to (E.^. AllocationId)
@ -128,6 +130,8 @@ getAllocationListR = do
dbtCsvEncode = noCsvEncode
dbtCsvDecode = Nothing
dbtExtraReps = []
dbtIdent = allocationListIdent
psValidator :: PSValidator _ _

View File

@ -14,7 +14,7 @@ import Handler.Utils.Form
data AllocationRegisterForm = AllocationRegisterForm
{ arfTotalCourses :: Natural
{ arfTotalCourses :: Word64
}
allocationRegisterForm :: Maybe AllocationRegisterForm -> AForm Handler AllocationRegisterForm

View File

@ -174,6 +174,10 @@ postAShowR tid ssh ash = do
tRoute <- case mApp of
Nothing -> return . AllocationR tid ssh ash $ AApplyR cID
Just (Entity appId _) -> CApplicationR courseTerm courseSchool courseShorthand <$> encrypt appId <*> pure CAEditR
mApplicationTemplate <- runMaybeT $ do
guard hasApplicationTemplate
toTextUrl $ CourseR courseTerm courseSchool courseShorthand CRegisterTemplateR
let mApplyFormView' = view _1 <$> mApplyFormView
overrideVisible = not mayApply && is _Just mApp
case mApplyFormView of

View File

@ -165,8 +165,6 @@ postAUsersR tid ssh ash = do
allocMatching <- fmap (view _4) . hoistMaybe $ allocMap !? (tid, ssh, ash)
return $ Map.fromListWith (<>) [ (uid, opoint cid) | (uid, cid) <- Set.toList allocMatching ] :: _ (Map UserId (NonNull (Set CourseId)))
csvName <- getMessageRender <*> pure (MsgAllocationUsersCsvName tid ssh ash)
let
allocationUsersDBTable = DBTable{..}
where
@ -189,7 +187,7 @@ postAUsersR tid ssh ash = do
, assigned
, vetoed)
dbtRowKey = views queryAllocationUser (E.^. AllocationUserId)
dbtProj = runReaderT $ (asks . set _dbrOutput) <=< magnify _dbrOutput $ do
dbtProj = dbtProjSimple . runReaderT $ do
feats <- lift . allocationUserStudyFeatures aId =<< views _1 entityKey
(,,,,,)
<$> view _1 <*> pure feats <*> view _2 <*> view (_3 . _Value) <*> view (_4 . _Value) <*> view (_5 . _Value)
@ -198,7 +196,7 @@ postAUsersR tid ssh ash = do
[ pure $ colUserDisplayName (resultUser . _entityVal . $(multifocusL 2) _userDisplayName _userSurname)
, pure $ colUserMatriculation (resultUser . _entityVal . _userMatrikelnummer)
, pure $ colStudyFeatures resultStudyFeatures
, pure $ colAllocationRequested (resultAllocationUser . _entityVal . _allocationUserTotalCourses)
, pure $ colAllocationRequested (resultAllocationUser . _entityVal . _allocationUserTotalCourses . _Integral)
, pure . coursesModalApplied $ colAllocationApplied resultAppliedCourses
, pure . coursesModalVetoed $ colAllocationVetoed resultVetoedCourses
, guardOn resultsDone . coursesModalAssigned . bool id (assignedHeated $ view resultAssignedCourses) resultsDone $ colAllocationAssigned resultAssignedCourses
@ -296,6 +294,8 @@ postAUsersR tid ssh ash = do
dbtParams = def
dbtIdent :: Text
dbtIdent = "allocation-users"
dbtCsvName = MsgAllocationUsersCsvName tid ssh ash
dbtCsvSheetName = MsgAllocationUsersCsvSheetName tid ssh ash
dbtCsvEncode = return DBTCsvEncode
{ dbtCsvExportForm = pure ()
, dbtCsvDoEncode = \() -> C.mapM $ \(_, row) -> flip runReaderT row $
@ -305,18 +305,19 @@ postAUsersR tid ssh ash = do
<*> view (resultUser . _entityVal . _userDisplayName)
<*> view (resultUser . _entityVal . _userMatrikelnummer)
<*> view resultStudyFeatures
<*> view (resultAllocationUser . _entityVal . _allocationUserTotalCourses)
<*> view (resultAppliedCourses . to fromIntegral)
<*> view (resultVetoedCourses . to fromIntegral)
<*> view (resultAssignedCourses . to fromIntegral)
<*> view (resultAllocationUser . _entityVal . _allocationUserTotalCourses . _Integral)
<*> view (resultAppliedCourses . _Integral)
<*> view (resultVetoedCourses . _Integral)
<*> view (resultAssignedCourses . _Integral)
<*> views (resultUser . _entityKey) (\uid -> maybe 0 (fromIntegral . olength) . Map.lookup uid <$> allocMatching)
<*> view (resultAllocationUser . _entityVal . _allocationUserPriority)
, dbtCsvName = unpack csvName
, dbtCsvName, dbtCsvSheetName
, dbtCsvNoExportData = Just id
, dbtCsvHeader = \_ -> return . userTableCsvHeader $ is _Just allocMatching
, dbtCsvExampleData = Nothing
}
dbtCsvDecode = Nothing
dbtExtraReps = []
allocationUsersDBTableValidator = def
& defaultSorting [SortAscBy "priority", SortAscBy "user-matriculation"]
& defaultPagesize (PagesizeLimit 500)

View File

@ -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
@ -33,5 +35,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

View File

@ -57,8 +57,8 @@ getCAppsFilesR tid ssh csh = do
E.&&. course E.^. CourseShorthand E.==. E.val csh
return (allocation, user, courseApplication)
apps' <- flip filterM apps $ \(_, _, Entity appId _) -> do
cID <- cachedByBinary appId $ encrypt appId
hasReadAccessTo $ CApplicationR tid ssh csh cID CAFilesR
cID <- encrypt appId
lift . hasReadAccessTo $ CApplicationR tid ssh csh cID CAFilesR
let
applicationAllocs = setOf (folded . _1) apps'
@ -87,7 +87,7 @@ getCAppsFilesR tid ssh csh = do
= id
forM_ apps' $ \(mbAlloc, Entity _ User{..}, Entity appId CourseApplication{..}) -> do
cID <- cachedByBinary appId $ encrypt appId :: _ CryptoFileNameCourseApplication
cID <- encrypt appId :: _ CryptoFileNameCourseApplication
let mkAppDir = mkAllocationDir (entityVal <$> mbAlloc) . (</>) (unpack [st|#{CI.foldedCase $ ciphertext cID}-#{CI.foldCase userSurname}|])
fileEntitySource = E.selectSource . E.from $ \courseApplicationFile -> do
E.where_ $ courseApplicationFile E.^. CourseApplicationFileApplication E.==. E.val appId

Some files were not shown because too many files have changed in this diff Show More