Merge branch 'master' into stundenplan
This commit is contained in:
commit
f46f23785d
5
.gitignore
vendored
5
.gitignore
vendored
@ -41,4 +41,7 @@ tunnel.log
|
||||
/.well-known-cache
|
||||
/**/tmp-*
|
||||
/testdata/bigAlloc_*.csv
|
||||
/sessions
|
||||
/sessions
|
||||
/changelog.json
|
||||
/.current-version
|
||||
/.current-changelog.md
|
||||
425
.gitlab-ci.yml
425
.gitlab-ci.yml
@ -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
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "testdata/workflows"]
|
||||
path = testdata/workflows
|
||||
url = gitlab2.rz.ifi.lmu.de:uni2work/workflows
|
||||
181
.hlint.yaml
181
.hlint.yaml
@ -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
6
.mailmap
Normal 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
27
.versionrc.js
Normal 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}}'
|
||||
};
|
||||
789
CHANGELOG.md
789
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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)
|
||||
|
||||
77
frontend/src/utils/inputs/password.js
Normal file
77
frontend/src/utils/inputs/password.js
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
11
hlint.sh
11
hlint.sh
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
1
messages/uniworx/test/de-de-formal.msg
Normal file
1
messages/uniworx/test/de-de-formal.msg
Normal file
@ -0,0 +1 @@
|
||||
PrintDebugForStupid name@Text: Debug message "#{name}"
|
||||
1
messages/uniworx/test/en-eu.msg
Normal file
1
messages/uniworx/test/en-eu.msg
Normal file
@ -0,0 +1 @@
|
||||
PrintDebugForStupid name: Debug message "#{name}"
|
||||
@ -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
|
||||
@ -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
|
||||
@ -2,3 +2,4 @@ ChangelogItemFirstSeen
|
||||
item ChangelogItem
|
||||
firstSeen Day
|
||||
Primary item
|
||||
deriving Generic
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -3,4 +3,5 @@ Invitation
|
||||
for Value
|
||||
data Value
|
||||
expiresAt UTCTime Maybe
|
||||
UniqueInvitation email for
|
||||
UniqueInvitation email for
|
||||
deriving Generic
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
53
models/workflows.model
Normal 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
7749
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@ -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",
|
||||
|
||||
49
package.yaml
49
package.yaml
@ -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
50
routes
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -90,12 +90,10 @@ data Transaction
|
||||
}
|
||||
|
||||
| TransactionSubmissionFileEdit
|
||||
{ transactionSubmissionFile :: SubmissionFileId
|
||||
, transactionSubmission :: SubmissionId
|
||||
{ transactionSubmissionFile :: Entity SubmissionFile
|
||||
}
|
||||
| TransactionSubmissionFileDelete
|
||||
{ transactionSubmissionFile :: SubmissionFileId
|
||||
, transactionSubmission :: SubmissionId
|
||||
{ transactionSubmissionFile :: Entity SubmissionFile
|
||||
}
|
||||
|
||||
| TransactionExamOfficeUserAdd
|
||||
|
||||
@ -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}
|
||||
|
||||
15
src/Control/Arrow/Instances.hs
Normal file
15
src/Control/Arrow/Instances.hs
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
51
src/CryptoID/Cached.hs
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
14
src/Data/MultiSet/Instances.hs
Normal file
14
src/Data/MultiSet/Instances.hs
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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
324
src/Foundation/I18n/TH.hs
Normal 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
|
||||
@ -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
|
||||
|
||||
34
src/Foundation/Instances/ButtonClass.hs
Normal file
34
src/Foundation/Instances/ButtonClass.hs
Normal 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
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 _ _
|
||||
|
||||
@ -14,7 +14,7 @@ import Handler.Utils.Form
|
||||
|
||||
|
||||
data AllocationRegisterForm = AllocationRegisterForm
|
||||
{ arfTotalCourses :: Natural
|
||||
{ arfTotalCourses :: Word64
|
||||
}
|
||||
|
||||
allocationRegisterForm :: Maybe AllocationRegisterForm -> AForm Handler AllocationRegisterForm
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
Reference in New Issue
Block a user