Merge branch 'master' into formatting-apis
This commit is contained in:
commit
f5d4010629
5
.dir-locals.el
Normal file
5
.dir-locals.el
Normal file
@ -0,0 +1,5 @@
|
||||
;;; Directory Local Variables
|
||||
;;; For more information see (info "(emacs) Directory Variables")
|
||||
|
||||
((nil
|
||||
(indent-tabs-mode)))
|
||||
@ -23,6 +23,7 @@
|
||||
"no-extra-semi": "off",
|
||||
"semi": ["error", "always"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"quotes": ["error", "single"]
|
||||
"quotes": ["error", "single"],
|
||||
"no-var": "error"
|
||||
}
|
||||
}
|
||||
|
||||
149
.gitlab-ci.yml
149
.gitlab-ci.yml
@ -1,7 +1,7 @@
|
||||
default:
|
||||
image:
|
||||
name: fpco/stack-build:lts-15.0
|
||||
cache:
|
||||
name: fpco/stack-build:lts-16.11
|
||||
cache: &global_cache
|
||||
paths:
|
||||
- node_modules
|
||||
- .stack
|
||||
@ -14,6 +14,12 @@ variables:
|
||||
POSTGRES_DB: uniworx_test
|
||||
POSTGRES_USER: uniworx
|
||||
POSTGRES_PASSWORD: uniworx
|
||||
MINIO_ACCESS_KEY: gOel7KvadwNKgjjy
|
||||
MINIO_SECRET_KEY: ugO5pkEla7F0JW9MdPwLi4MWLT5ZbqAL
|
||||
UPLOAD_S3_HOST: localhost
|
||||
UPLOAD_S3_PORT: 9000
|
||||
UPLOAD_S3_KEY_ID: gOel7KvadwNKgjjy
|
||||
UPLOAD_S3_KEY: ugO5pkEla7F0JW9MdPwLi4MWLT5ZbqAL
|
||||
N_PREFIX: "${HOME}/.n"
|
||||
|
||||
stages:
|
||||
@ -30,9 +36,11 @@ npm install:
|
||||
- ./.npmrc.gup
|
||||
- npm install
|
||||
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
|
||||
- apt-get update -y
|
||||
- npm install -g n
|
||||
- n 13.5.0
|
||||
- n 14.8.0
|
||||
- export PATH="${N_PREFIX}/bin:$PATH"
|
||||
- npm install -g npm
|
||||
- hash -r
|
||||
@ -40,9 +48,6 @@ npm install:
|
||||
- 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;
|
||||
after_script:
|
||||
- zip -qr node_modules.zip node_modules
|
||||
- du -hs node_modules node_modules.zip
|
||||
artifacts:
|
||||
paths:
|
||||
- node_modules/
|
||||
@ -83,18 +88,27 @@ frontend:lint:
|
||||
interruptible: true
|
||||
|
||||
yesod:build:dev:
|
||||
services: &build-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 --copy-bins --local-bin-path $(pwd)/bin --fast --flag uniworx:-library-only --flag uniworx:dev --flag uniworx:pedantic
|
||||
- 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:
|
||||
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
|
||||
- apt-get install openssh-client -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;
|
||||
- git restore-mtime
|
||||
artifacts:
|
||||
paths:
|
||||
- bin/
|
||||
@ -110,18 +124,14 @@ yesod:build:dev:
|
||||
interruptible: true
|
||||
|
||||
yesod:build:
|
||||
services: *build-services
|
||||
|
||||
stage: yesod:build
|
||||
script:
|
||||
- stack build --copy-bins --local-bin-path $(pwd)/bin --flag uniworx:-library-only --flag uniworx:-dev --flag uniworx:pedantic
|
||||
- stack build --test --copy-bins --local-bin-path $(pwd)/bin --flag uniworx:-library-only --flag uniworx:-dev --flag uniworx:pedantic --no-strip
|
||||
needs:
|
||||
- frontend:build
|
||||
before_script:
|
||||
- apt-get update -y
|
||||
- apt-get install -y --no-install-recommends locales-all
|
||||
- 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_DEPLOY_KEY} ~/.ssh/deploy && echo "IdentityFile ~/.ssh/deploy" >> ~/.ssh/config;
|
||||
before_script: *haskell
|
||||
artifacts:
|
||||
paths:
|
||||
- bin/
|
||||
@ -137,15 +147,20 @@ yesod:build:
|
||||
resource_group: ram
|
||||
|
||||
frontend:test:
|
||||
cache:
|
||||
<<: *global_cache
|
||||
policy: pull
|
||||
stage: test
|
||||
script:
|
||||
- npm run frontend:test
|
||||
needs:
|
||||
- npm install
|
||||
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
|
||||
- npm install -g n
|
||||
- n 13.5.0
|
||||
- n 14.8.0
|
||||
- export PATH="${N_PREFIX}/bin:$PATH"
|
||||
- npm install -g npm
|
||||
- hash -r
|
||||
@ -155,101 +170,19 @@ frontend:test:
|
||||
retry: 2
|
||||
interruptible: true
|
||||
|
||||
hlint:dev:
|
||||
stage: lint
|
||||
script:
|
||||
- stack test --fast --flag uniworx:-library-only --flag uniworx:dev --flag uniworx:pedantic uniworx:test:hlint
|
||||
needs:
|
||||
- frontend:build
|
||||
- yesod:build:dev # For caching
|
||||
before_script:
|
||||
- apt-get update -y
|
||||
- apt-get install -y --no-install-recommends locales-all
|
||||
dependencies:
|
||||
- frontend:build
|
||||
|
||||
only:
|
||||
variables:
|
||||
- $CI_COMMIT_REF_NAME !~ /^v[0-9].*/
|
||||
retry: 2
|
||||
interruptible: true
|
||||
|
||||
yesod:test:dev:
|
||||
services:
|
||||
- name: postgres:10.10
|
||||
alias: postgres
|
||||
|
||||
stage: test
|
||||
script:
|
||||
- stack test --coverage --fast --flag uniworx:-library-only --flag uniworx:dev --flag uniworx:pedantic --skip hlint
|
||||
needs:
|
||||
- frontend:build
|
||||
- yesod:build:dev # For caching
|
||||
before_script:
|
||||
- apt-get update -y
|
||||
- apt-get install -y --no-install-recommends locales-all
|
||||
dependencies:
|
||||
- frontend:build
|
||||
|
||||
only:
|
||||
variables:
|
||||
- $CI_COMMIT_REF_NAME !~ /^v[0-9].*/
|
||||
retry: 2
|
||||
interruptible: true
|
||||
|
||||
hlint:
|
||||
stage: lint
|
||||
script:
|
||||
- stack test --flag uniworx:-library-only --flag uniworx:-dev --flag uniworx:pedantic uniworx:test:hlint
|
||||
needs:
|
||||
- frontend:build
|
||||
- yesod:build # For caching
|
||||
before_script:
|
||||
- apt-get update -y
|
||||
- apt-get install -y --no-install-recommends locales-all
|
||||
dependencies:
|
||||
- frontend:build
|
||||
|
||||
only:
|
||||
variables:
|
||||
- $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
|
||||
retry: 2
|
||||
interruptible: true
|
||||
resource_group: ram
|
||||
|
||||
yesod:test:
|
||||
services:
|
||||
- name: postgres:10.10
|
||||
alias: postgres
|
||||
|
||||
stage: test
|
||||
script:
|
||||
- stack test --coverage --flag uniworx:-library-only --flag uniworx:-dev --flag uniworx:pedantic --skip hlint
|
||||
needs:
|
||||
- frontend:build
|
||||
- yesod:build # For caching
|
||||
before_script:
|
||||
- apt-get update -y
|
||||
- apt-get install -y --no-install-recommends locales-all
|
||||
dependencies:
|
||||
- frontend:build
|
||||
|
||||
only:
|
||||
variables:
|
||||
- $CI_COMMIT_REF_NAME =~ /^v[0-9].*/
|
||||
retry: 2
|
||||
interruptible: true
|
||||
resource_group: ram
|
||||
|
||||
deploy:uniworx3:
|
||||
cache: {}
|
||||
stage: deploy
|
||||
variables:
|
||||
GIT_STRATEGY: none
|
||||
script:
|
||||
- ssh -i ~/.ssh/id root@uniworx3.ifi.lmu.de <bin/uniworx
|
||||
- zip -qj - bin/uniworx bin/uniworxdb | ssh root@uniworx3.ifi.lmu.de /root/bin/accept_uni2work
|
||||
needs:
|
||||
- yesod:build
|
||||
- yesod:test # For sanity
|
||||
- hlint # For sanity
|
||||
- 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
|
||||
|
||||
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
|
||||
@ -10,6 +10,9 @@
|
||||
- ignore: { name: "Use &&" }
|
||||
- ignore: { name: "Use ++" }
|
||||
- ignore: { name: "Use ***" }
|
||||
- ignore: { name: "Redundant void" }
|
||||
- ignore: { name: "Too strict maybe" }
|
||||
- ignore: { name: "Use Just" }
|
||||
|
||||
- arguments:
|
||||
- -XQuasiQuotes
|
||||
|
||||
1317
CHANGELOG.md
1317
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -736,6 +736,7 @@ text/vnd.in3d.spot spot
|
||||
text/vnd.sun.j2me.app-descriptor jad
|
||||
text/vnd.wap.wml wml
|
||||
text/vnd.wap.wmlscript wmls
|
||||
text/vnd.yaml yaml yml
|
||||
text/x-asm s asm
|
||||
text/x-c hh h dic cc
|
||||
text/x-component htc
|
||||
|
||||
21
config/personalised-sheet-files-collate
Normal file
21
config/personalised-sheet-files-collate
Normal file
@ -0,0 +1,21 @@
|
||||
$# Syntax:
|
||||
$# - Leere zeilen werden ignoriert
|
||||
$# - Zeilen, die mit '$#' beginnen, werden ignoriert
|
||||
$# - Verbleibende Zeilen werden jeweils als `Glob`-Pattern kompiliert
|
||||
|
||||
$# Ignoriere rekursiv alle Ordner __MACOSX und ihren Inhalt
|
||||
**/__MACOSX
|
||||
**/__MACOSX/*
|
||||
**/__MACOSX/**/*
|
||||
|
||||
$# Ignoriere rekursiv alle Dateien .DS_Store (Mac OS)
|
||||
**/.DS_Store
|
||||
|
||||
$# Ignoriere VI-Style-Backup-Files
|
||||
**/*~
|
||||
$# Ignoriere Emacs-Style-Backup-Files
|
||||
**/.#*#
|
||||
|
||||
$# Ignoriere exportierte meta-informationen.yaml
|
||||
**/meta-informationen_*.yaml
|
||||
**/meta-information_*.yaml
|
||||
@ -1 +1,4 @@
|
||||
User-agent: *
|
||||
|
||||
User-agent: AhrefsBot
|
||||
Disallow: /
|
||||
|
||||
@ -12,30 +12,37 @@ 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"
|
||||
mail-object-domain: "_env:MAILOBJECT_DOMAIN:localhost"
|
||||
mail-verp:
|
||||
separator: "_env:VERP_SEPARATOR:+"
|
||||
at-replacement: "_env:VERP_AT_REPLACEMENT:="
|
||||
prefix: "_env:VERP_PREFIX:bounce"
|
||||
mail-support:
|
||||
name: "_env:MAILSUPPORT_NAME:"
|
||||
email: "_env:MAILSUPPORT:uni2work@ifi.lmu.de"
|
||||
mail-retain-sent: 31470547
|
||||
|
||||
job-workers: "_env:JOB_WORKERS:10"
|
||||
job-flush-interval: "_env:JOB_FLUSH:30"
|
||||
job-cron-interval: "_env:CRON_INTERVAL:60"
|
||||
job-stale-threshold: 300
|
||||
job-stale-threshold: 1800
|
||||
job-move-threshold: 30
|
||||
notification-rate-limit: 3600
|
||||
notification-collate-delay: 7200
|
||||
notification-expiration: 259200
|
||||
session-timeout: 7200
|
||||
bearer-expiration: 604800
|
||||
bearer-encoding: HS256
|
||||
maximum-content-length: "_env:MAX_UPLOAD_SIZE:134217728"
|
||||
maximum-content-length: "_env:MAX_UPLOAD_SIZE:805306368"
|
||||
session-files-expire: 3600
|
||||
prune-unreferenced-files: 86400
|
||||
prune-unreferenced-files-within: 57600
|
||||
prune-unreferenced-files-interval: 3600
|
||||
keep-unreferenced-files: 86400
|
||||
health-check-interval:
|
||||
matching-cluster-config: "_env:HEALTHCHECK_INTERVAL_MATCHING_CLUSTER_CONFIG:600"
|
||||
http-reachable: "_env:HEALTHCHECK_INTERVAL_HTTP_REACHABLE:600"
|
||||
@ -61,12 +68,14 @@ log-settings:
|
||||
all: "_env:LOG_ALL:false"
|
||||
minimum-level: "_env:LOGLEVEL:warn"
|
||||
destination: "_env:LOGDEST:stderr"
|
||||
serializable-transaction-retry-limit: 2
|
||||
|
||||
ip-retention-time: 1209600
|
||||
|
||||
# Debugging
|
||||
auth-dummy-login: "_env:DUMMY_LOGIN:false"
|
||||
allow-deprecated: "_env:ALLOW_DEPRECATED:false"
|
||||
encrypt-errors: "_env:ENCRYPT_ERRORS:true"
|
||||
server-session-acid-fallback: "_env:SERVER_SESSION_ACID_FALLBACK:false"
|
||||
|
||||
auth-pw-hash:
|
||||
@ -78,7 +87,7 @@ auth-pw-hash:
|
||||
# reload-templates: false
|
||||
# mutable-static: false
|
||||
# skip-combining: false
|
||||
# encrypt-errors: true
|
||||
# clear-cache: false
|
||||
|
||||
database:
|
||||
user: "_env:PGUSER:uniworx"
|
||||
@ -87,24 +96,26 @@ database:
|
||||
port: "_env:PGPORT:5432"
|
||||
# See config/test-settings.yml for an override during tests
|
||||
database: "_env:PGDATABASE:uniworx"
|
||||
poolsize: "_env:PGPOOLSIZE:10"
|
||||
poolsize: "_env:PGPOOLSIZE:990"
|
||||
|
||||
auto-db-migrate: '_env:AUTO_DB_MIGRATE:true'
|
||||
|
||||
ldap:
|
||||
host: "_env:LDAPHOST:"
|
||||
tls: "_env:LDAPTLS:"
|
||||
port: "_env:LDAPPORT:389"
|
||||
user: "_env:LDAPUSER:"
|
||||
pass: "_env:LDAPPASS:"
|
||||
baseDN: "_env:LDAPBASE:"
|
||||
scope: "_env:LDAPSCOPE:WholeSubtree"
|
||||
timeout: "_env:LDAPTIMEOUT:5"
|
||||
search-timeout: "_env:LDAPSEARCHTIME:5"
|
||||
pool:
|
||||
stripes: "_env:LDAPSTRIPES:1"
|
||||
timeout: "_env:LDAPTIMEOUT:20"
|
||||
limit: "_env:LDAPLIMIT:10"
|
||||
- host: "_env:LDAPHOST:"
|
||||
tls: "_env:LDAPTLS:"
|
||||
port: "_env:LDAPPORT:389"
|
||||
user: "_env:LDAPUSER:"
|
||||
pass: "_env:LDAPPASS:"
|
||||
baseDN: "_env:LDAPBASE:"
|
||||
scope: "_env:LDAPSCOPE:WholeSubtree"
|
||||
timeout: "_env:LDAPTIMEOUT:5"
|
||||
search-timeout: "_env:LDAPSEARCHTIME:5"
|
||||
pool:
|
||||
stripes: "_env:LDAPSTRIPES:1"
|
||||
timeout: "_env:LDAPTIMEOUT:20"
|
||||
limit: "_env:LDAPLIMIT:10"
|
||||
|
||||
ldap-re-test-failover: 60
|
||||
|
||||
smtp:
|
||||
host: "_env:SMTPHOST:"
|
||||
@ -123,7 +134,7 @@ widget-memcached:
|
||||
host: "_env:WIDGET_MEMCACHED_HOST:"
|
||||
port: "_env:WIDGET_MEMCACHED_PORT:11211"
|
||||
auth: []
|
||||
limit: "_env:WIDGET_MEMCACHED_LIMIT:10"
|
||||
limit: "_env:WIDGET_MEMCACHED_LIMIT:1024"
|
||||
timeout: "_env:WIDGET_MEMCACHED_TIMEOUT:20"
|
||||
base-url: "_env:WIDGET_MEMCACHED_ROOT:"
|
||||
expiration: "_env:WIDGET_MEMCACHED_EXPIRATION:3600"
|
||||
@ -132,18 +143,49 @@ session-memcached:
|
||||
host: "_env:SESSION_MEMCACHED_HOST:"
|
||||
port: "_env:SESSION_MEMCACHED_PORT:11211"
|
||||
auth: []
|
||||
limit: "_env:SESSION_MEMCACHED_LIMIT:10"
|
||||
limit: "_env:SESSION_MEMCACHED_LIMIT:1024"
|
||||
timeout: "_env:SESSION_MEMCACHED_TIMEOUT:20"
|
||||
expiration: "_env:SESSION_MEMCACHED_EXPIRATION:28807"
|
||||
|
||||
memcached:
|
||||
host: "_env:MEMCACHED_HOST:"
|
||||
port: "_env:MEMCACHED_PORT:11211"
|
||||
auth: []
|
||||
limit: "_env:MEMCACHED_LIMIT:1024"
|
||||
timeout: "_env:MEMCACHED_TIMEOUT:20"
|
||||
expiration: "_env:MEMCACHED_EXPIRATION:300"
|
||||
|
||||
upload-cache:
|
||||
host: "_env:UPLOAD_S3_HOST:"
|
||||
port: "_env:UPLOAD_S3_PORT:9000"
|
||||
access-key: "_env:UPLOAD_S3_KEY_ID:"
|
||||
secret-key: "_env:UPLOAD_S3_KEY"
|
||||
is-secure: "_env:UPLOAD_S3_SSL:false"
|
||||
region: "_env:UPLOAD_S3_REGION:"
|
||||
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"
|
||||
|
||||
inject-files: 601
|
||||
rechunk-files: 1201
|
||||
check-missing-files: 7207
|
||||
|
||||
file-upload-db-chunksize: 4194304 # 4MiB
|
||||
file-chunking-target-exponent: 21 # 2MiB
|
||||
file-chunking-hash-window: 4096
|
||||
|
||||
server-sessions:
|
||||
idle-timeout: 28807
|
||||
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
|
||||
@ -164,6 +206,11 @@ cookies:
|
||||
same-site: lax
|
||||
http-only: false
|
||||
secure: "_env:COOKIES_SECURE:true"
|
||||
ACTIVE-AUTH-TAGS:
|
||||
expires: 12622780800
|
||||
same-site: lax
|
||||
http-only: true
|
||||
secure: "_env:COOKIES_SECURE:true"
|
||||
|
||||
external-apis-ping-interval: 300
|
||||
external-apis-pong-timeout: 600
|
||||
@ -192,3 +239,34 @@ allocation-grade-ordinal-proportion: 0.075
|
||||
|
||||
instance-id: "_env:INSTANCE_ID:instance"
|
||||
ribbon: "_env:RIBBON:"
|
||||
|
||||
|
||||
favourites-quick-actions-burstsize: 40
|
||||
favourites-quick-actions-avg-inverse-rate: 50e3 # µs/token
|
||||
favourites-quick-actions-timeout: 40e-3 # s
|
||||
favourites-quick-actions-cache-ttl: 120 # s
|
||||
|
||||
|
||||
token-buckets:
|
||||
inject-files:
|
||||
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
|
||||
initial-value: 0
|
||||
rechunk-files:
|
||||
depth: 20971520 # 20MiB
|
||||
inv-rate: 9.5e-7 # 1MiB/s
|
||||
initial-value: 0
|
||||
|
||||
fallback-personalised-sheet-files-keys-expire: 2419200
|
||||
|
||||
download-token-expire: 604801
|
||||
|
||||
memcache-auth: true
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
database:
|
||||
database: "_env:PGDATABASE_TEST:uniworx_test"
|
||||
upload-cache-bucket: "uni2work-test-uploads"
|
||||
|
||||
log-settings:
|
||||
detailed: true
|
||||
@ -10,4 +11,5 @@ log-settings:
|
||||
auth-dummy-login: true
|
||||
server-session-acid-fallback: true
|
||||
|
||||
job-cron-interval: null
|
||||
job-workers: 1
|
||||
|
||||
31
config/video-types
Normal file
31
config/video-types
Normal file
@ -0,0 +1,31 @@
|
||||
# Simple list of mime-types corresponding to video-formats
|
||||
#
|
||||
# Comments are empty lines and any line for which the first non-whitespace symbol is ‘#’
|
||||
#
|
||||
# Format is a single mime-type per line (may not contain whitespace)
|
||||
#
|
||||
# Largely copied from https://en.wikipedia.org/wiki/Video_file_format
|
||||
|
||||
video/webm
|
||||
video/x-matroska
|
||||
video/x-flv
|
||||
video/x-f4v
|
||||
video/ogg
|
||||
video/x-mng
|
||||
video/x-msvideo
|
||||
model/vnd.mts
|
||||
video/quicktime
|
||||
video/x-ms-wmv
|
||||
application/vnd.rn-realmedia
|
||||
application/vnd.rn-realmedia-vbr
|
||||
video/vnd.vivo
|
||||
video/x-ms-asf
|
||||
video/mp4
|
||||
video/mpeg
|
||||
video/x-m4v
|
||||
video/3gpp
|
||||
video/3gpp2
|
||||
application/mxf
|
||||
video/h261
|
||||
video/h263
|
||||
video/h264
|
||||
@ -5,5 +5,5 @@
|
||||
@use "~@fortawesome/fontawesome-pro/scss/solid"
|
||||
|
||||
@use "~typeface-roboto" as roboto
|
||||
|
||||
@use "~typeface-source-sans-pro" as source-sans-pro
|
||||
@use "~typeface-source-code-pro" as source-code-pro
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
// FONTS
|
||||
--font-base: "Source Sans Pro", "Trebuchet MS", sans-serif
|
||||
--font-logo: "Roboto", var(--font-base)
|
||||
--font-monospace: "Source Code Pro", monospace
|
||||
|
||||
// DIMENSIONS
|
||||
--header-height: 100px
|
||||
@ -242,11 +243,9 @@ button:not(.btn-link),
|
||||
.btn:not(.btn-link)
|
||||
font-family: var(--font-base)
|
||||
outline: 0
|
||||
border: 0
|
||||
box-shadow: 0
|
||||
background-color: var(--color-dark)
|
||||
color: white
|
||||
padding: 10px 17px
|
||||
min-width: 100px
|
||||
transition: all .1s
|
||||
font-size: 16px
|
||||
@ -254,6 +253,10 @@ button:not(.btn-link),
|
||||
display: inline-block
|
||||
text-decoration: none
|
||||
|
||||
&:not(.navbar__container-link)
|
||||
padding: 10px 17px
|
||||
border: 0
|
||||
|
||||
a:hover
|
||||
color: white
|
||||
|
||||
@ -270,7 +273,7 @@ button:not(.btn-link),
|
||||
|
||||
.buttongroup
|
||||
display: grid
|
||||
grid: min-content / auto-flow 1fr
|
||||
grid: min-content / auto-flow max-content
|
||||
|
||||
input[type="submit"][disabled]:not(.btn-link),
|
||||
input[type="button"][disabled]:not(.btn-link),
|
||||
@ -330,6 +333,10 @@ input[type="button"].btn-info:not(.btn-link):hover,
|
||||
margin: 21px 0
|
||||
width: 100%
|
||||
|
||||
.table--narrow
|
||||
width: unset
|
||||
|
||||
|
||||
.table:first-child
|
||||
margin-top: 0
|
||||
|
||||
@ -341,11 +348,21 @@ input[type="button"].btn-info:not(.btn-link):hover,
|
||||
.table__td
|
||||
background-color: rgba(0, 0, 0, 0.03)
|
||||
|
||||
&.table--vertical
|
||||
.table__row:not(.no-stripe):not(.table__row--sum):nth-child(even)
|
||||
.table__th
|
||||
background-color: rgba(0, 0, 0, 0.03)
|
||||
|
||||
.table--hover
|
||||
.table__row:not(.no-hover):not(.table__row--sum):not(.table__row--head):not(.table__row--foot):hover
|
||||
.table__td
|
||||
background-color: rgba(0, 0, 0, 0.07)
|
||||
|
||||
&.table--vertical
|
||||
.table__row:not(.no-hover):not(.table__row--sum):not(.table__row--head):not(.table__row--foot):hover
|
||||
.table__th
|
||||
background-color: rgba(0, 0, 0, 0.07)
|
||||
|
||||
.table__row--sum td.table__td::before
|
||||
content: 'Σ'
|
||||
font-weight: bold
|
||||
@ -413,6 +430,7 @@ input[type="button"].btn-info:not(.btn-link):hover,
|
||||
padding-bottom: 10px
|
||||
font-weight: bold
|
||||
text-align: left
|
||||
vertical-align: middle
|
||||
|
||||
a
|
||||
color: white
|
||||
@ -443,7 +461,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
|
||||
@ -451,7 +469,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)
|
||||
|
||||
@ -568,9 +595,6 @@ section
|
||||
border-bottom: none
|
||||
padding-bottom: 0px
|
||||
|
||||
.pseudonym
|
||||
font-family: monospace
|
||||
|
||||
.headline-one
|
||||
margin-bottom: 10px
|
||||
|
||||
@ -613,6 +637,11 @@ section
|
||||
&.notification--broad
|
||||
max-width: none
|
||||
|
||||
&:first-child
|
||||
margin-top: 0
|
||||
&:last-child
|
||||
margin-bottom: 0
|
||||
|
||||
.form-section-notification
|
||||
display: grid
|
||||
grid-template-columns: 1fr 3fr
|
||||
@ -663,7 +692,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
|
||||
@ -700,8 +729,17 @@ section
|
||||
background-color: hsla($hue, 75%, 50%, $opacity) !important
|
||||
|
||||
|
||||
.uuid
|
||||
font-family: monospace
|
||||
.uuid, .pseudonym, .ldap-primary-key, .email, .file-path, .metric-value, .metric-label, .workflow-payload--text, .cryptoid
|
||||
font-family: var(--font-monospace)
|
||||
|
||||
.shown
|
||||
font-family: var(--font-monospace)
|
||||
white-space: pre-wrap
|
||||
|
||||
.token
|
||||
font-family: var(--font-monospace)
|
||||
white-space: pre-wrap
|
||||
word-break: break-all
|
||||
|
||||
.form--inline
|
||||
display: inline-block
|
||||
@ -751,10 +789,13 @@ section
|
||||
.allocation__courses
|
||||
margin: 20px 0 0 40px
|
||||
|
||||
.form-group__input > &
|
||||
margin: 0
|
||||
|
||||
.allocation-course
|
||||
display: grid
|
||||
grid-template-columns: minmax(105px, 1fr) 9fr
|
||||
grid-template-areas: 'name name ' '. registered ' 'prio-label prio ' 'instr-label instr ' 'form-label form '
|
||||
grid-template-areas: 'name name' '. admin-info' '. registered' 'prio-label prio' 'instr-label instr' 'form-label form'
|
||||
grid-gap: 5px 7px
|
||||
margin: 12px 0
|
||||
padding: 0 10px 12px 7px
|
||||
@ -805,17 +846,21 @@ section
|
||||
text-align: right
|
||||
padding-top: 6px
|
||||
|
||||
.allocation-course__admin-info
|
||||
@extend .explanation
|
||||
grid-area: admin-info
|
||||
|
||||
@media (max-width: 426px)
|
||||
.allocation-course
|
||||
grid-template-columns: 1fr
|
||||
grid-template-areas: 'name ' 'registered ' 'prio-label ' 'prio ' 'instr-label' 'instr ' 'form-label ' 'form '
|
||||
grid-template-areas: 'name' 'admin-info' 'registered' 'prio-label' 'prio' 'instr-label' 'instr' 'form-label' 'form'
|
||||
|
||||
.allocation-course__application-label
|
||||
padding-top: 0
|
||||
|
||||
.comment
|
||||
.comment, .literal-error
|
||||
white-space: pre-wrap
|
||||
font-family: monospace
|
||||
font-family: var(--font-monospace)
|
||||
|
||||
th
|
||||
vertical-align: top
|
||||
@ -901,7 +946,7 @@ th, td
|
||||
right: 5px
|
||||
top: 5px
|
||||
|
||||
.occurrence--not-registered, .no-bonus, .allocation-course--excluded
|
||||
.occurrence--not-registered, .no-bonus, .allocation-course--excluded, .allocation-course--inactive
|
||||
text-decoration: line-through
|
||||
|
||||
.result
|
||||
@ -912,11 +957,11 @@ th, td
|
||||
dt, .dt
|
||||
font-weight: 600
|
||||
|
||||
&.sec
|
||||
font-style: italic
|
||||
font-size: 0.9rem
|
||||
font-weight: 600
|
||||
color: var(--color-fontsec)
|
||||
&.sec
|
||||
font-style: italic
|
||||
font-size: 0.9rem
|
||||
font-weight: 600
|
||||
color: var(--color-fontsec)
|
||||
|
||||
dd, .dd
|
||||
margin-left: 12px
|
||||
@ -924,12 +969,24 @@ th, td
|
||||
dd + dt, .dd + dt, dd + .dt, .dd + .dt
|
||||
margin-top: 17px
|
||||
|
||||
.explanation
|
||||
font-style: italic
|
||||
.deflist--no-grid
|
||||
dt, .dt
|
||||
font-weight: 600
|
||||
dd, .dd
|
||||
margin-left: 12px
|
||||
|
||||
.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
|
||||
@ -1074,14 +1131,13 @@ th, td
|
||||
pointer-events: none
|
||||
|
||||
#changelog
|
||||
font-size: 14px
|
||||
white-space: pre-wrap
|
||||
font-family: monospace
|
||||
max-height: 75vh
|
||||
overflow: auto
|
||||
|
||||
#gitrev
|
||||
font-size: 12px
|
||||
white-space: pre-wrap
|
||||
font-family: monospace
|
||||
font-family: var(--font-monospace)
|
||||
|
||||
.breadcrumbs__container
|
||||
position: relative
|
||||
@ -1144,6 +1200,10 @@ a.breadcrumbs__home
|
||||
font-weight: 600
|
||||
opacity: 1
|
||||
|
||||
.recipient-categories
|
||||
overflow: auto
|
||||
max-height: 75vh
|
||||
|
||||
.recipient-category
|
||||
max-width: 400px
|
||||
padding: 3px 0
|
||||
@ -1198,14 +1258,14 @@ a.breadcrumbs__home
|
||||
top: 5px
|
||||
|
||||
.table__td--csv, .table__th--csv
|
||||
font-family: monospace
|
||||
font-family: var(--font-monospace)
|
||||
|
||||
.confirmationText
|
||||
white-space: pre-wrap
|
||||
font-size: 14px
|
||||
font-family: monospace
|
||||
font-family: var(--font-monospace)
|
||||
|
||||
.func-field__wrapper, .allocation-missing-prios, .allocation-users__accept
|
||||
.func-field__wrapper, .allocation-missing-prios, .allocation-users__accept, .corrections-overview__section
|
||||
max-height: 75vh
|
||||
overflow: auto
|
||||
|
||||
@ -1268,7 +1328,7 @@ a.breadcrumbs__home
|
||||
|
||||
.csv-parse-error
|
||||
white-space: pre-wrap
|
||||
font-family: monospace
|
||||
font-family: var(--font-monospace)
|
||||
overflow: auto
|
||||
max-height: 75vh
|
||||
|
||||
@ -1327,3 +1387,160 @@ code
|
||||
|
||||
&--success
|
||||
border-left-color: var(--color-success)
|
||||
|
||||
.faq__question
|
||||
font-size: 18px
|
||||
font-weight: 400
|
||||
|
||||
margin: 0
|
||||
|
||||
.faq__answer
|
||||
margin-left: 17px
|
||||
|
||||
:not(.show-hide--collapsed) > .faq__answer
|
||||
margin-top: 7px
|
||||
|
||||
.faq__section
|
||||
padding-bottom: 10px
|
||||
|
||||
&:last-child, &.show-hide--collapsed
|
||||
border-bottom: none
|
||||
padding-bottom: 0
|
||||
|
||||
& + section:not(.faq__section)
|
||||
border-top: 1px solid #d3d3d3
|
||||
padding-top: 30px
|
||||
|
||||
.faq__section + .faq__section
|
||||
margin-top: 10px
|
||||
|
||||
.faq__question-link
|
||||
opacity: 0.2
|
||||
|
||||
&:hover
|
||||
opacity: 1
|
||||
|
||||
.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))
|
||||
background: black
|
||||
|
||||
.video-container
|
||||
display: flex
|
||||
justify-content: center
|
||||
width: 100%
|
||||
|
||||
& > video
|
||||
object-fit: contain
|
||||
flex-grow: 1
|
||||
|
||||
@ -9,9 +9,10 @@ export const LOCATION = {
|
||||
LOCAL: 'local',
|
||||
SESSION: 'session',
|
||||
WINDOW: 'window',
|
||||
HISTORY: 'history',
|
||||
};
|
||||
|
||||
const LOCATION_SHADOWING = [ LOCATION.WINDOW, LOCATION.SESSION, LOCATION.LOCAL ];
|
||||
const LOCATION_SHADOWING = [ LOCATION.HISTORY, LOCATION.WINDOW, LOCATION.SESSION, LOCATION.LOCAL ];
|
||||
|
||||
export class StorageManager {
|
||||
|
||||
@ -26,7 +27,16 @@ export class StorageManager {
|
||||
constructor(namespace, version, options) {
|
||||
this._debugLog('constructor', namespace, version, options);
|
||||
|
||||
this.namespace = namespace;
|
||||
if (typeof namespace === 'object') {
|
||||
let sep = '_';
|
||||
const namespace_arr = Array.from(namespace);
|
||||
while (namespace_arr.some(str => str.includes(sep)))
|
||||
sep = sep + '_';
|
||||
|
||||
this.namespace = Array.from(namespace).join(sep);
|
||||
} else {
|
||||
this.namespace = namespace;
|
||||
}
|
||||
this.version = semver.valid(version);
|
||||
|
||||
if (!namespace) {
|
||||
@ -48,7 +58,7 @@ export class StorageManager {
|
||||
throw new Error('Cannot setup StorageManager without window or global');
|
||||
|
||||
if (this._options.encryption) {
|
||||
[LOCATION.LOCAL, LOCATION.SESSION].forEach((location) => {
|
||||
[LOCATION.LOCAL, LOCATION.SESSION, LOCATION.HISTORY].forEach((location) => {
|
||||
const encryption = this._options.encryption.all || this._options.encryption[location];
|
||||
if (encryption) this._requestStorageKey({ location: location, encryption: encryption });
|
||||
});
|
||||
@ -70,17 +80,21 @@ export class StorageManager {
|
||||
|
||||
switch (location) {
|
||||
case LOCATION.LOCAL: {
|
||||
this._saveToLocalStorage(this._updateStorage(this._getFromLocalStorage(options), { [key]: value }, LOCATION.LOCAL, options));
|
||||
this._saveToLocalStorage({ ...this._getFromLocalStorage(options), [key]: value}, options);
|
||||
break;
|
||||
}
|
||||
case LOCATION.SESSION: {
|
||||
this._saveToSessionStorage(this._updateStorage(this._getFromSessionStorage(options), { [key]: value }, LOCATION.SESSION, options));
|
||||
this._saveToSessionStorage({ ...this._getFromSessionStorage(options), [key]: value}, options);
|
||||
break;
|
||||
}
|
||||
case LOCATION.WINDOW: {
|
||||
this._saveToWindow({ ...this._getFromWindow(), [key]: value });
|
||||
break;
|
||||
}
|
||||
case LOCATION.HISTORY: {
|
||||
this._saveToHistory({ ...this._getFromHistory(), [key]: value }, options);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error('StorageManager.save cannot save item with unsupported location');
|
||||
}
|
||||
@ -112,6 +126,10 @@ export class StorageManager {
|
||||
val = this._getFromWindow()[key];
|
||||
break;
|
||||
}
|
||||
case LOCATION.HISTORY: {
|
||||
val = this._getFromHistory(options)[key];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error('StorageManager.load cannot load item with unsupported location');
|
||||
}
|
||||
@ -138,14 +156,14 @@ export class StorageManager {
|
||||
|
||||
delete val[key];
|
||||
|
||||
return this._saveToLocalStorage(val);
|
||||
return this._saveToLocalStorage(val, options);
|
||||
}
|
||||
case LOCATION.SESSION: {
|
||||
let val = this._getFromSessionStorage(options);
|
||||
|
||||
delete val[key];
|
||||
|
||||
return this._saveToSessionStorage(val);
|
||||
return this._saveToSessionStorage(val, options);
|
||||
}
|
||||
case LOCATION.WINDOW: {
|
||||
let val = this._getFromWindow();
|
||||
@ -154,6 +172,14 @@ export class StorageManager {
|
||||
|
||||
return this._saveToWindow(val);
|
||||
}
|
||||
case LOCATION.HISTORY: {
|
||||
let val = this._getFromHistory(options);
|
||||
|
||||
delete val[key];
|
||||
|
||||
return this._saveToHistory(val, options);
|
||||
}
|
||||
|
||||
default:
|
||||
console.error('StorageManager.load cannot load item with unsupported location');
|
||||
}
|
||||
@ -177,6 +203,8 @@ export class StorageManager {
|
||||
return this._clearSessionStorage();
|
||||
case LOCATION.WINDOW:
|
||||
return this._clearWindow();
|
||||
case LOCATION.HISTORY:
|
||||
return this._clearHistory(options && options.history);
|
||||
default:
|
||||
console.error('StorageManager.clear cannot clear with unsupported location');
|
||||
}
|
||||
@ -185,7 +213,7 @@ export class StorageManager {
|
||||
}
|
||||
|
||||
|
||||
_getFromLocalStorage(options=this._options) {
|
||||
_getFromLocalStorage(options) {
|
||||
this._debugLog('_getFromLocalStorage', options);
|
||||
|
||||
let state;
|
||||
@ -210,7 +238,7 @@ export class StorageManager {
|
||||
}
|
||||
}
|
||||
|
||||
_saveToLocalStorage(state) {
|
||||
_saveToLocalStorage(state, options) {
|
||||
this._debugLog('_saveToLocalStorage', state);
|
||||
|
||||
if (!state)
|
||||
@ -223,8 +251,8 @@ export class StorageManager {
|
||||
} else {
|
||||
versionedState = { version: this.version, ...state };
|
||||
}
|
||||
|
||||
window.localStorage.setItem(this.namespace, JSON.stringify(versionedState));
|
||||
|
||||
window.localStorage.setItem(this.namespace, JSON.stringify(this._updateStorage({}, versionedState, LOCATION.LOCAL, options)));
|
||||
}
|
||||
|
||||
_clearLocalStorage() {
|
||||
@ -240,10 +268,10 @@ export class StorageManager {
|
||||
if (!this._global || !this._global.App)
|
||||
return {};
|
||||
|
||||
if (!this._global.App.Storage)
|
||||
this._global.App.Storage = {};
|
||||
if (!this._global.App.Storage || !this._global.App.Storage[this.namespace])
|
||||
return {};
|
||||
|
||||
return this._global.App.Storage;
|
||||
return this._global.App.Storage[this.namespace];
|
||||
}
|
||||
|
||||
_saveToWindow(value) {
|
||||
@ -274,8 +302,71 @@ export class StorageManager {
|
||||
}
|
||||
}
|
||||
|
||||
_getFromHistory(options) {
|
||||
this._debugLog('_getFromHistory');
|
||||
|
||||
_getFromSessionStorage(options=this._options) {
|
||||
if (!this._global || !this._global.history)
|
||||
return {};
|
||||
|
||||
if (!this._global.history.state || !this._global.history.state[this.namespace])
|
||||
return {};
|
||||
|
||||
return this._getFromStorage(this._global.history.state[this.namespace], LOCATION.HISTORY, options);
|
||||
}
|
||||
|
||||
_saveToHistory(value, options) {
|
||||
this._debugLog('_saveToHistory', options);
|
||||
|
||||
|
||||
if (!this._global || !this._global.history) {
|
||||
throw new Error('StorageManager._saveToHistory called when window.history is not available');
|
||||
}
|
||||
|
||||
const push = (options.history && typeof options.history.push !== 'undefined') ? !!options.history.push : true;
|
||||
const title = (options.history && options.history.title) || (this._global.document && this._global.document.title) || '';
|
||||
const url = (options.history && options.history.url) || (this._global.document && this._global.document.location);
|
||||
|
||||
const state = this._global.history.state || {};
|
||||
state[this.namespace] = this._updateStorage({}, value, LOCATION.HISTORY, options);
|
||||
|
||||
this._debugLog('_saveToHistory', { state: state, push: push, title: title, url: url});
|
||||
|
||||
if (push)
|
||||
this._global.history.pushState(state, title, url);
|
||||
else
|
||||
this._global.history.replaceState(state, title, url);
|
||||
}
|
||||
|
||||
_clearHistory(options) {
|
||||
this._debugLog('_clearHistory', options);
|
||||
|
||||
if (!this._global || !this._global.history) {
|
||||
throw new Error('StorageManager._clearHistory called when window.history is not available');
|
||||
}
|
||||
|
||||
const push = (options.history && typeof options.history.push !== 'undefined' ? !!options.history.push : true) || true;
|
||||
const title = (options.history && options.history.title) || (this._global.document && this._global.document.title) || '';
|
||||
const url = (options.history && options.history.url) || (this._global.document && this._global.document.location);
|
||||
|
||||
const state = this._global.history.state || {};
|
||||
delete state[this.namespace];
|
||||
|
||||
if (push)
|
||||
this._global.history.pushState(state, title, url);
|
||||
else
|
||||
this._global.history.replaceState(state, title, url);
|
||||
}
|
||||
|
||||
addHistoryListener(listener, options=this._options, ...args) {
|
||||
const modified_listener = (function(event, ...listener_args) { // eslint-disable-line no-unused-vars
|
||||
this._global.setTimeout(() => listener(this._getFromHistory(options), ...listener_args));
|
||||
}).bind(this);
|
||||
|
||||
this._global.addEventListener('popstate', modified_listener, args);
|
||||
}
|
||||
|
||||
|
||||
_getFromSessionStorage(options) {
|
||||
this._debugLog('_getFromSessionStorage', options);
|
||||
|
||||
let state;
|
||||
@ -300,7 +391,7 @@ export class StorageManager {
|
||||
}
|
||||
}
|
||||
|
||||
_saveToSessionStorage(state) {
|
||||
_saveToSessionStorage(state, options) {
|
||||
this._debugLog('_saveToSessionStorage', state);
|
||||
|
||||
if (!state)
|
||||
@ -314,7 +405,7 @@ export class StorageManager {
|
||||
versionedState = { version: this.version, ...state };
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(this.namespace, JSON.stringify(versionedState));
|
||||
window.sessionStorage.setItem(this.namespace, JSON.stringify(this._updateStorage({}, versionedState, LOCATION.SESSION, options)));
|
||||
}
|
||||
|
||||
_clearSessionStorage() {
|
||||
@ -324,10 +415,10 @@ export class StorageManager {
|
||||
}
|
||||
|
||||
|
||||
_getFromStorage(storage, location, options=this._options) {
|
||||
_getFromStorage(storage, location, options) {
|
||||
this._debugLog('_getFromStorage', storage, location, options);
|
||||
|
||||
const encryption = options.encryption && (options.encryption.all || options.encryption[location]);
|
||||
const encryption = options && options.encryption && (options.encryption.all || options.encryption[location]);
|
||||
if (encryption && storage.encryption) {
|
||||
return { ...storage, ...JSON.parse(decrypt(storage.encryption.ciphertext, this._encryptionKey[location]) || null) };
|
||||
} else {
|
||||
@ -335,10 +426,10 @@ export class StorageManager {
|
||||
}
|
||||
}
|
||||
|
||||
_updateStorage(storage, update, location, options=this._options) {
|
||||
_updateStorage(storage, update, location, options) {
|
||||
this._debugLog('_updateStorage', storage, update, location, options);
|
||||
|
||||
const encryption = options.encryption && (options.encryption.all || options.encryption[location]);
|
||||
const encryption = options && options.encryption && (options.encryption.all || options.encryption[location]);
|
||||
if (encryption && storage.encryption) {
|
||||
const updatedDecryptedStorage = { ...JSON.parse(decrypt(storage.encryption.ciphertext, this._encryptionKey[location]) || null), ...update };
|
||||
console.log('updatedDecryptedStorage', updatedDecryptedStorage);
|
||||
@ -357,13 +448,13 @@ export class StorageManager {
|
||||
const enc = this.load('encryption', { ...options, encryption: false });
|
||||
const requestBody = {
|
||||
type : options.encryption,
|
||||
length : 42,
|
||||
length : sodium.crypto_secretbox_KEYBYTES,
|
||||
salt : enc.salt,
|
||||
timestamp : enc.timestamp,
|
||||
};
|
||||
|
||||
this._global.App.httpClient.post({
|
||||
url: '../../../../../../user/storage-key', // TODO use APPROOT instead
|
||||
url: '/user/storage-key',
|
||||
headers: {
|
||||
'Content-Type' : HttpClient.ACCEPT.JSON,
|
||||
'Accept' : HttpClient.ACCEPT.JSON,
|
||||
@ -381,11 +472,10 @@ export class StorageManager {
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
_debugLog() {
|
||||
_debugLog() {}
|
||||
// _debugLog(fName, ...args) {
|
||||
// console.log(`[DEBUGLOG] StorageManager.${fName}`, { args: args, instance: this });
|
||||
}
|
||||
|
||||
// console.log(`[DEBUGLOG] StorageManager.${fName}`, { args: args, instance: this });
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
||||
184
frontend/src/lib/table/table.js
Normal file
184
frontend/src/lib/table/table.js
Normal file
@ -0,0 +1,184 @@
|
||||
const DEBUG_MODE = /localhost/.test(window.location.href) ? 0 : 0;
|
||||
|
||||
import * as defer from 'lodash.defer';
|
||||
|
||||
class Overhang {
|
||||
colSpan;
|
||||
rowSpan;
|
||||
cell;
|
||||
|
||||
constructor(colSpan, rowSpan, cell) {
|
||||
this.colSpan = colSpan;
|
||||
this.rowSpan = rowSpan;
|
||||
this.cell = cell;
|
||||
|
||||
if (new.target === Overhang)
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
nextLine() {
|
||||
return new Overhang(this.colSpan, Math.max(0, this.rowSpan - 1), this.cell);
|
||||
}
|
||||
|
||||
reduceCol(n) {
|
||||
if (this.colSpan > n)
|
||||
return new Overhang(this.colSpan - n, this.rowSpan, this.cell);
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
isHole() {
|
||||
return this.rowSpan <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
const instanceCache = new Map();
|
||||
|
||||
export class TableIndices {
|
||||
_table;
|
||||
|
||||
_cellToIndices = new Map();
|
||||
_indicesToCell = new Array();
|
||||
|
||||
colSpan = cell => cell ? Math.max(1, cell.colSpan || 1) : 1;
|
||||
rowSpan = cell => cell ? Math.max(1, cell.rowSpan || 1) : 1;
|
||||
|
||||
maxRow = 0;
|
||||
maxCol = 0;
|
||||
|
||||
constructor(table, overrides) {
|
||||
const prev = instanceCache.get(table);
|
||||
if ( prev?.instance &&
|
||||
overrides?.colSpan === prev.overrides?.colSpan &&
|
||||
overrides?.rowSpan === prev.overrides?.rowSpan
|
||||
) {
|
||||
if (DEBUG_MODE > 0)
|
||||
console.log('Reusing existing TableIndices', table, overrides, prev);
|
||||
|
||||
return prev.instance;
|
||||
}
|
||||
|
||||
if (overrides && overrides.colSpan)
|
||||
this.colSpan = overrides.colSpan;
|
||||
if (overrides && overrides.rowSpan)
|
||||
this.rowSpan = overrides.rowSpan;
|
||||
|
||||
this._table = table;
|
||||
|
||||
|
||||
let currentOverhangs = new Array();
|
||||
let currentRow = 0;
|
||||
|
||||
for (const rowParent of this._table.rows) {
|
||||
let newOverhangs = new Array();
|
||||
|
||||
let cellBefore = 0;
|
||||
|
||||
for (const cell of rowParent.cells) {
|
||||
let i;
|
||||
|
||||
for (i = 0; i < currentOverhangs.length; i++) {
|
||||
const overhang = currentOverhangs[i];
|
||||
|
||||
if (overhang.isHole())
|
||||
break;
|
||||
else
|
||||
newOverhangs.push(overhang.nextLine());
|
||||
|
||||
if (DEBUG_MODE > 1)
|
||||
console.log('From overhang', overhang);
|
||||
|
||||
cellBefore += overhang.colSpan;
|
||||
}
|
||||
|
||||
currentOverhangs = currentOverhangs.slice(i);
|
||||
let remCols = this.colSpan(cell);
|
||||
while (remCols > 0 && currentOverhangs[0]) {
|
||||
let firstOverhang = currentOverhangs[0].reduceCol(this.colSpan(cell));
|
||||
if (firstOverhang) {
|
||||
if (DEBUG_MODE > 1)
|
||||
console.log('Replace first overhang', remCols, currentOverhangs[0], firstOverhang);
|
||||
currentOverhangs[0] = firstOverhang;
|
||||
break;
|
||||
} else {
|
||||
if (DEBUG_MODE > 1)
|
||||
console.log('Drop first overhang', remCols, currentOverhangs[0], firstOverhang);
|
||||
remCols -= currentOverhangs[0].colSpan;
|
||||
currentOverhangs.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this._cellToIndices.set(cell, { row: currentRow, col: cellBefore });
|
||||
|
||||
let rows = range(currentRow, currentRow + this.rowSpan(cell));
|
||||
let columns = range(cellBefore, cellBefore + this.colSpan(cell));
|
||||
|
||||
if (DEBUG_MODE > 1) {
|
||||
console.log('Result', rows, columns);
|
||||
|
||||
cell.dataset.rows = JSON.stringify(rows);
|
||||
cell.dataset.columns = JSON.stringify(columns);
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
for (const col of columns) {
|
||||
if (!this._indicesToCell[row])
|
||||
this._indicesToCell[row] = new Array();
|
||||
|
||||
this._indicesToCell[row][col] = cell;
|
||||
|
||||
this.maxRow = Math.max(row, this.maxRow);
|
||||
this.maxCol = Math.max(col, this.maxCol);
|
||||
}
|
||||
}
|
||||
|
||||
newOverhangs.push(new Overhang(this.colSpan(cell), this.rowSpan(cell) - 1, cell));
|
||||
|
||||
if (DEBUG_MODE > 1)
|
||||
console.log('From current cell', this.colSpan(cell));
|
||||
cellBefore += this.colSpan(cell);
|
||||
}
|
||||
|
||||
currentOverhangs = Array.from(newOverhangs);
|
||||
currentRow++;
|
||||
}
|
||||
|
||||
if (DEBUG_MODE > 1) {
|
||||
console.log(this._cellToIndices);
|
||||
console.table(this._indicesToCell);
|
||||
}
|
||||
|
||||
instanceCache.set(table, { overrides: overrides, instance: this });
|
||||
defer(() => { instanceCache.delete(table); } );
|
||||
}
|
||||
|
||||
colIndex(cell) {
|
||||
return this.getIndices(cell)?.col;
|
||||
}
|
||||
|
||||
rowIndex(cell) {
|
||||
return this.getIndices(cell)?.row;
|
||||
}
|
||||
|
||||
getIndices(cell) {
|
||||
const res = this._cellToIndices.get(cell);
|
||||
|
||||
if (DEBUG_MODE > 2)
|
||||
console.log('getIndices', cell, res);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
getCell(row, col) {
|
||||
const cell = this._indicesToCell[row]?.[col];
|
||||
|
||||
if (DEBUG_MODE > 2)
|
||||
console.log('getCell', row, col, cell);
|
||||
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
|
||||
function range(from, to) {
|
||||
return [...Array(to - from).keys()].map(n => n + from);
|
||||
}
|
||||
@ -17,7 +17,7 @@ export class HtmlHelpers {
|
||||
idPrefix = this._getIdPrefix();
|
||||
this._prefixIds(element, idPrefix);
|
||||
}
|
||||
return Promise.resolve({ idPrefix, element });
|
||||
return Promise.resolve({ idPrefix, element, headers: response.headers });
|
||||
},
|
||||
Promise.reject,
|
||||
).catch(console.error);
|
||||
|
||||
@ -15,6 +15,27 @@ export class HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
_baseUrl;
|
||||
|
||||
setBaseUrl(baseUrl) {
|
||||
if (typeof this._baseUrl !== 'undefined') {
|
||||
throw new Error('HttpClient baseUrl is already set');
|
||||
}
|
||||
|
||||
this._baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
_defaultUrl;
|
||||
|
||||
setDefaultUrl(defaultUrl) {
|
||||
if (typeof this._defaultUrl !== 'undefined') {
|
||||
throw new Error('HttpClient defaultUrl is already set');
|
||||
}
|
||||
|
||||
this._defaultUrl = defaultUrl;
|
||||
}
|
||||
|
||||
|
||||
get(args) {
|
||||
args.method = 'GET';
|
||||
return this._fetch(args);
|
||||
@ -28,12 +49,17 @@ export class HttpClient {
|
||||
}
|
||||
|
||||
_fetch(options) {
|
||||
options.url = (options.url && options.url.href) || options.url || this._defaultUrl;
|
||||
|
||||
if (this._baseUrl && options.url && options.url.substring(0,1) === '/' && options.url.substring(0,2) !== '//')
|
||||
options.url = this._baseUrl + (this._baseUrl.substring(this._baseUrl.substring.length - 1) === '/' ? '' : '/') + options.url.substring(1,0);
|
||||
|
||||
const requestOptions = {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
};
|
||||
|
||||
return fetch(options.url, requestOptions)
|
||||
return fetch(options.url || window.location.href, requestOptions)
|
||||
.then(
|
||||
(response) => {
|
||||
this._responseInterceptors.forEach((interceptor) => interceptor(response, options));
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
const DEBUG_MODE = /localhost/.test(window.location.href) ? 2 : 0;
|
||||
import * as toposort from 'toposort';
|
||||
|
||||
const DEBUG_MODE = /localhost/.test(window.location.href) ? 1 : 0;
|
||||
|
||||
export class UtilRegistry {
|
||||
|
||||
_registeredUtils = [];
|
||||
_activeUtilInstances = [];
|
||||
_registeredUtils = new Array();
|
||||
_activeUtilInstances = new Array();
|
||||
_appInstance;
|
||||
|
||||
/**
|
||||
@ -50,12 +52,35 @@ export class UtilRegistry {
|
||||
this._appInstance = appInstance;
|
||||
}
|
||||
|
||||
initAll(scope) {
|
||||
let startedInstances = [];
|
||||
initAll(scope = document.body) {
|
||||
let startedInstances = new Array();
|
||||
const setupInstances = this._registeredUtils.map((util) => this.setup(util, scope)).flat();
|
||||
|
||||
setupInstances.forEach((utilInstance) => {
|
||||
const orderedInstances = setupInstances.filter(_isStartOrdered);
|
||||
|
||||
if (DEBUG_MODE > 3) {
|
||||
console.log({ setupInstances, orderedInstances });
|
||||
}
|
||||
|
||||
const startDependencies = new Array();
|
||||
for (const utilInstance of orderedInstances) {
|
||||
for (const otherInstance of setupInstances) {
|
||||
const startOrder = _startOrder(utilInstance, otherInstance);
|
||||
if (typeof startOrder !== 'undefined')
|
||||
startDependencies.push(startOrder);
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_MODE > 2) {
|
||||
console.log('starting instances', { setupInstances, startDependencies, order: toposort.array(setupInstances, startDependencies) });
|
||||
}
|
||||
|
||||
toposort.array(setupInstances, startDependencies).forEach((utilInstance) => {
|
||||
if (utilInstance) {
|
||||
if (DEBUG_MODE > 2) {
|
||||
console.log('starting utilInstance', { util: utilInstance.util.name, utilInstance });
|
||||
}
|
||||
|
||||
const instance = utilInstance.instance;
|
||||
if (instance && typeof instance.start === 'function') {
|
||||
instance.start.bind(instance)();
|
||||
@ -77,7 +102,7 @@ export class UtilRegistry {
|
||||
console.log('setting up util', { util });
|
||||
}
|
||||
|
||||
let instances = [];
|
||||
let instances = new Array();
|
||||
|
||||
if (util) {
|
||||
const elements = this._findUtilElements(util, scope);
|
||||
@ -90,6 +115,7 @@ export class UtilRegistry {
|
||||
} catch(err) {
|
||||
if (DEBUG_MODE > 0) {
|
||||
console.error('Error while trying to initialize a utility!', { util , element, err });
|
||||
console.error(err.stack);
|
||||
}
|
||||
utilInstance = null;
|
||||
}
|
||||
@ -140,3 +166,58 @@ export class UtilRegistry {
|
||||
this._activeUtilInstances = this._activeUtilInstances.filter((util) => !!util);
|
||||
}
|
||||
}
|
||||
|
||||
function _startOrder(utilInstance, otherInstance) {
|
||||
if (utilInstance.element !== otherInstance.element && !(utilInstance.element.contains(otherInstance.element) || otherInstance.element.contains(utilInstance.element)))
|
||||
return undefined;
|
||||
|
||||
if (utilInstance === otherInstance)
|
||||
return undefined;
|
||||
|
||||
if (!_isStartOrdered(utilInstance) || !otherInstance.instance || !otherInstance.util)
|
||||
return undefined;
|
||||
|
||||
function orderParam(name) {
|
||||
if (typeof utilInstance.instance[name] === 'function')
|
||||
return !!utilInstance.instance[name](otherInstance.instance);
|
||||
if (typeof utilInstance.util[name] === 'function')
|
||||
return !!utilInstance.util[name](otherInstance.instance);
|
||||
else if (Array.isArray(utilInstance.instance[name]))
|
||||
return utilInstance.instance[name].some(constr => otherInstance.util === constr);
|
||||
else if (Array.isArray(utilInstance.util[name]))
|
||||
return utilInstance.util[name].some(constr => otherInstance.util === constr);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = orderParam('startAfter');
|
||||
const before = orderParam('startBefore');
|
||||
|
||||
if (DEBUG_MODE > 3) {
|
||||
console.log('compared instances for ordering', { utilInstance, otherInstance }, { after, before });
|
||||
}
|
||||
|
||||
if (after && before) {
|
||||
console.error({ utilInstance, otherInstance });
|
||||
throw new Error(`Incompatible start ordering: ${utilInstance.instance.constructor.name} and ${otherInstance.instance.constructor.name}`);
|
||||
} else if (after)
|
||||
return [otherInstance, utilInstance];
|
||||
else if (before)
|
||||
return [utilInstance, otherInstance];
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function _isStartOrdered(utilInstance) {
|
||||
if (!utilInstance || !utilInstance.instance || !utilInstance.util)
|
||||
return false;
|
||||
|
||||
function isOrderParam(name) {
|
||||
return typeof utilInstance.instance[name] === 'function' ||
|
||||
typeof utilInstance.util[name] === 'function' ||
|
||||
Array.isArray(utilInstance.instance[name]) ||
|
||||
Array.isArray(utilInstance.util[name]);
|
||||
}
|
||||
|
||||
return isOrderParam('startBefore') || isOrderParam('startAfter');
|
||||
}
|
||||
|
||||
@ -116,9 +116,9 @@ describe('UtilRegistry', () => {
|
||||
utilRegistry.initAll();
|
||||
|
||||
expect(utilRegistry.setup.calls.count()).toBe(3);
|
||||
expect(utilRegistry.setup.calls.argsFor(0)).toEqual([TestUtil1, undefined]);
|
||||
expect(utilRegistry.setup.calls.argsFor(1)).toEqual([TestUtil2, undefined]);
|
||||
expect(utilRegistry.setup.calls.argsFor(2)).toEqual([TestUtil3, undefined]);
|
||||
expect(utilRegistry.setup.calls.argsFor(0)).toEqual([TestUtil1, document.body]);
|
||||
expect(utilRegistry.setup.calls.argsFor(1)).toEqual([TestUtil2, document.body]);
|
||||
expect(utilRegistry.setup.calls.argsFor(2)).toEqual([TestUtil3, document.body]);
|
||||
});
|
||||
|
||||
it('should pass the given scope', () => {
|
||||
|
||||
@ -155,7 +155,7 @@ export class Alerts {
|
||||
alertCloser.classList.add(ALERT_CLOSER_CLASS);
|
||||
|
||||
const alertIcon = document.createElement('div');
|
||||
alertIcon.classList.add(ALERT_ICON_CLASS, 'fas', 'fa-fw', 'fa-' + icon);
|
||||
alertIcon.classList.add(ALERT_ICON_CLASS, 'fas', 'fa-' + icon);
|
||||
|
||||
const alertContent = document.createElement('div');
|
||||
alertContent.classList.add(ALERT_CONTENT_CLASS);
|
||||
|
||||
@ -98,18 +98,19 @@
|
||||
padding: 8px 0
|
||||
min-height: 40px
|
||||
position: relative
|
||||
display: flex
|
||||
font-weight: 600
|
||||
align-items: center
|
||||
text-align: left
|
||||
overflow: auto
|
||||
|
||||
.alert__icon
|
||||
text-align: right
|
||||
position: absolute
|
||||
left: 0px
|
||||
top: 0
|
||||
bottom: 0
|
||||
width: 50px
|
||||
height: 100%
|
||||
max-height: 100vh
|
||||
z-index: 40
|
||||
|
||||
&::before
|
||||
@ -130,9 +131,10 @@
|
||||
text-align: right
|
||||
position: absolute
|
||||
right: 0px
|
||||
top: 0
|
||||
bottom: 0
|
||||
width: 60px
|
||||
height: 100%
|
||||
max-height: 100vh
|
||||
transition: all .3s ease
|
||||
z-index: 40
|
||||
|
||||
|
||||
@ -232,12 +232,22 @@
|
||||
.asidenav__nested-list
|
||||
min-width: 200px
|
||||
|
||||
.asidenav__nested-list--unavailable
|
||||
font-size: 0.9rem
|
||||
color: var(--color-fontsec)
|
||||
font-weight: 600
|
||||
padding: 7px
|
||||
min-width: 200px
|
||||
|
||||
@media (max-width: 425px)
|
||||
.asidenav__list-item
|
||||
padding-left: 10px
|
||||
|
||||
.asidenav__nested-list
|
||||
display: none
|
||||
|
||||
.asidenav__nested-list--unavailable
|
||||
display: none
|
||||
|
||||
.asidenav__nested-list-item
|
||||
position: relative
|
||||
@ -317,10 +327,9 @@
|
||||
color: var(--color-font)
|
||||
padding: 0
|
||||
|
||||
.asidenav__nested-list,
|
||||
.asidenav__link-label
|
||||
.asidenav__nested-list, .asidenav__link-label, .asidenav__nested-list--unavailable
|
||||
display: none
|
||||
|
||||
|
||||
.asidenav__list-item--active
|
||||
.asidenav__link-wrapper
|
||||
background-color: var(--color-lightwhite)
|
||||
|
||||
@ -13,7 +13,9 @@ const INPUT_DEBOUNCE = 600;
|
||||
const FILTER_DEBOUNCE = 100;
|
||||
const HEADER_HEIGHT = 80;
|
||||
|
||||
const ASYNC_TABLE_LOCAL_STORAGE_KEY = 'ASYNC_TABLE';
|
||||
const ASYNC_TABLE_STORAGE_KEY = 'ASYNC_TABLE';
|
||||
const ASYNC_TABLE_STORAGE_VERSION = '2.0.0';
|
||||
|
||||
const ASYNC_TABLE_SCROLLTABLE_SELECTOR = '.scrolltable';
|
||||
const ASYNC_TABLE_INITIALIZED_CLASS = 'async-table--initialized';
|
||||
const ASYNC_TABLE_LOADING_CLASS = 'async-table--loading';
|
||||
@ -47,7 +49,10 @@ export class AsyncTable {
|
||||
};
|
||||
_ignoreRequest = false;
|
||||
|
||||
_storageManager = new StorageManager(ASYNC_TABLE_LOCAL_STORAGE_KEY, '1.0.0', { location: LOCATION.WINDOW });
|
||||
_windowStorage;
|
||||
_historyStorage;
|
||||
|
||||
_active = true;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
@ -79,21 +84,56 @@ export class AsyncTable {
|
||||
this._cssIdPrefix = findCssIdPrefix(rawTableId);
|
||||
this._asyncTableId = rawTableId.replace(this._cssIdPrefix, '');
|
||||
|
||||
if (!this._asyncTableId) {
|
||||
throw new Error('Async Table cannot be set up without an ident!');
|
||||
}
|
||||
|
||||
this._windowStorage = new StorageManager([ASYNC_TABLE_STORAGE_KEY, this._asyncTableId], ASYNC_TABLE_STORAGE_VERSION, { location: LOCATION.WINDOW });
|
||||
this._historyStorage = new StorageManager([ASYNC_TABLE_STORAGE_KEY, this._asyncTableId], ASYNC_TABLE_STORAGE_VERSION, { location: LOCATION.HISTORY });
|
||||
|
||||
// find scrolltable wrapper
|
||||
this._scrollTable = this._element.querySelector(ASYNC_TABLE_SCROLLTABLE_SELECTOR);
|
||||
if (!this._scrollTable) {
|
||||
throw new Error('Async Table cannot be set up without a scrolltable element!');
|
||||
}
|
||||
|
||||
this._setupTableFilter();
|
||||
|
||||
this._processStorage();
|
||||
|
||||
// clear currentTableUrl from previous requests
|
||||
this._storageManager.remove('currentTableUrl');
|
||||
this._setupTableFilter();
|
||||
|
||||
this._windowStorage.remove('currentTableUrl');
|
||||
|
||||
if (!('currentTableUrl' in this._element.dataset)) {
|
||||
this._element.dataset['currentTableUrl'] = document.location.href;
|
||||
this._historyStorage.save('currentTableUrl', document.location.href, { location: LOCATION.HISTORY, history: { push: false } });
|
||||
}
|
||||
|
||||
this._historyListener();
|
||||
|
||||
this._historyStorage.addHistoryListener(this._historyListener.bind(this));
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS);
|
||||
if (this._active)
|
||||
this._element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
_historyListener(historyState) {
|
||||
if (!this._active)
|
||||
return;
|
||||
|
||||
const windowUrl = this._element.dataset['currentTableUrl'];
|
||||
const historyUrl = historyState ? historyState['currentTableUrl'] : this._historyStorage.load('currentTableUrl');
|
||||
this._debugLog('_historyListener', historyState, windowUrl, historyUrl);
|
||||
|
||||
if (this._isEquivalentUrl(windowUrl, historyUrl))
|
||||
return;
|
||||
|
||||
this._debugLog('_historyListener', historyUrl);
|
||||
this._updateTableFrom(historyUrl || document.location.href, undefined, true);
|
||||
}
|
||||
|
||||
_isEquivalentUrl(a, b) {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
start() {
|
||||
@ -113,7 +153,7 @@ export class AsyncTable {
|
||||
|
||||
this._ths.forEach((th) => {
|
||||
th.clickHandler = (event) => {
|
||||
this._storageManager.save('horizPos', (this._scrollTable || {}).scrollLeft);
|
||||
this._windowStorage.save('horizPos', (this._scrollTable || {}).scrollLeft);
|
||||
this._linkClickHandler(event);
|
||||
};
|
||||
th.element.addEventListener('click', th.clickHandler);
|
||||
@ -135,7 +175,7 @@ export class AsyncTable {
|
||||
left: this._scrollTable.offsetLeft || 0,
|
||||
behavior: 'smooth',
|
||||
};
|
||||
this._storageManager.save('scrollTo', scrollTo);
|
||||
this._windowStorage.save('scrollTo', scrollTo);
|
||||
}
|
||||
this._linkClickHandler(event);
|
||||
};
|
||||
@ -256,7 +296,7 @@ export class AsyncTable {
|
||||
const prefix = findCssIdPrefix(focusedInput.id);
|
||||
const focusId = focusedInput.id.replace(prefix, '');
|
||||
callback = function(wrapper) {
|
||||
const idPrefix = this._storageManager.load('cssIdPrefix');
|
||||
const idPrefix = this._windowStorage.load('cssIdPrefix');
|
||||
const toBeFocused = wrapper.querySelector('#' + idPrefix + focusId);
|
||||
if (toBeFocused) {
|
||||
toBeFocused.focus();
|
||||
@ -268,34 +308,33 @@ export class AsyncTable {
|
||||
}
|
||||
|
||||
_serializeTableFilterToURL(tableFilterForm) {
|
||||
const url = new URL(this._storageManager.load('currentTableUrl') || window.location.href);
|
||||
const url = new URL(this._windowStorage.load('currentTableUrl') || window.location.href);
|
||||
|
||||
// create new FormData and format any date values
|
||||
const formData = Datepicker.unformatAll(tableFilterForm, new FormData(tableFilterForm));
|
||||
|
||||
for (var k of url.searchParams.keys()) {
|
||||
url.searchParams.delete(k);
|
||||
}
|
||||
this._debugLog('_serializeTableFilterToURL', Array.from(formData.entries()), url.href);
|
||||
|
||||
for (var kv of formData.entries()) {
|
||||
url.searchParams.append(kv[0], kv[1]);
|
||||
}
|
||||
const searchParams = new URLSearchParams(Array.from(formData.entries()));
|
||||
url.search = searchParams.toString();
|
||||
|
||||
this._debugLog('_serializeTableFilterToURL', url.href);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
_processStorage() {
|
||||
const scrollTo = this._storageManager.load('scrollTo');
|
||||
const scrollTo = this._windowStorage.load('scrollTo');
|
||||
if (scrollTo && this._scrollTable) {
|
||||
window.scrollTo(scrollTo);
|
||||
}
|
||||
this._storageManager.remove('scrollTo');
|
||||
this._windowStorage.remove('scrollTo');
|
||||
|
||||
const horizPos = this._storageManager.load('horizPos');
|
||||
const horizPos = this._windowStorage.load('horizPos');
|
||||
if (horizPos && this._scrollTable) {
|
||||
this._scrollTable.scrollLeft = horizPos;
|
||||
}
|
||||
this._storageManager.remove('horizPos');
|
||||
this._windowStorage.remove('horizPos');
|
||||
}
|
||||
|
||||
_removeListeners() {
|
||||
@ -330,16 +369,16 @@ export class AsyncTable {
|
||||
}
|
||||
|
||||
_changePagesizeHandler = () => {
|
||||
const url = new URL(this._storageManager.load('currentTableUrl') || window.location.href);
|
||||
const url = new URL(this._windowStorage.load('currentTableUrl') || window.location.href);
|
||||
|
||||
// create new FormData and format any date values
|
||||
const formData = Datepicker.unformatAll(this._pagesizeForm, new FormData(this._pagesizeForm));
|
||||
|
||||
for (var k of url.searchParams.keys()) {
|
||||
for (const k of url.searchParams.keys()) {
|
||||
url.searchParams.delete(k);
|
||||
}
|
||||
|
||||
for (var kv of formData.entries()) {
|
||||
for (const kv of formData.entries()) {
|
||||
url.searchParams.append(kv[0], kv[1]);
|
||||
}
|
||||
|
||||
@ -347,7 +386,9 @@ export class AsyncTable {
|
||||
}
|
||||
|
||||
// fetches new sorted element from url with params and replaces contents of current element
|
||||
_updateTableFrom(url, callback) {
|
||||
_updateTableFrom(url, callback, isPopState) {
|
||||
url = new URL(url);
|
||||
|
||||
const cancelPendingUpdates = (() => {
|
||||
this._cancelPendingUpdates.forEach(f => f());
|
||||
}).bind(this);
|
||||
@ -372,23 +413,33 @@ export class AsyncTable {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._storageManager.save('currentTableUrl', url.href);
|
||||
if (!isPopState)
|
||||
this._historyStorage.save('currentTableUrl', url.href, { location: LOCATION.HISTORY, history: { push: true, url: response.headers.get('DB-Table-Canonical-URL') || url.href } });
|
||||
|
||||
this._windowStorage.save('currentTableUrl', url.href);
|
||||
// reset table
|
||||
this._removeListeners();
|
||||
this._active = false;
|
||||
this._element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS);
|
||||
this._element.dataset['currentTableUrl'] = url.href;
|
||||
// update table with new
|
||||
this._element.innerHTML = response.element.innerHTML;
|
||||
|
||||
this._app.utilRegistry.initAll(this._element);
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
this._storageManager.save('cssIdPrefix', response.idPrefix);
|
||||
this._windowStorage.save('cssIdPrefix', response.idPrefix);
|
||||
callback(this._element);
|
||||
this._storageManager.remove('cssIdPrefix');
|
||||
this._windowStorage.remove('cssIdPrefix');
|
||||
}
|
||||
}).catch((err) => console.error(err)
|
||||
).finally(() => this._element.classList.remove(ASYNC_TABLE_LOADING_CLASS));
|
||||
}
|
||||
|
||||
_debugLog() {}
|
||||
// _debugLog(fName, ...args) {
|
||||
// console.log(`[DEBUGLOG] AsyncTable.${fName}`, { args: args, instance: this });
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ describe('AsyncTable', () => {
|
||||
const element = document.createElement('div');
|
||||
const scrollTable = document.createElement('div');
|
||||
const table = document.createElement('table');
|
||||
table.id = 'ident';
|
||||
scrollTable.classList.add('scrolltable');
|
||||
scrollTable.appendChild(table);
|
||||
element.appendChild(scrollTable);
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
const DEBUG_MODE = /localhost/.test(window.location.href) ? 0 : 0;
|
||||
|
||||
import { Utility } from '../../core/utility';
|
||||
import { TableIndices } from '../../lib/table/table';
|
||||
|
||||
const CHECKBOX_SELECTOR = '[type="checkbox"]';
|
||||
|
||||
@ -8,13 +11,12 @@ const CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized';
|
||||
selector: 'table:not([uw-no-check-all])',
|
||||
})
|
||||
export class CheckAll {
|
||||
|
||||
_element;
|
||||
_app;
|
||||
|
||||
_columns = [];
|
||||
_checkboxColumn = [];
|
||||
_checkAllCheckbox = null;
|
||||
_columns = new Array();
|
||||
_checkAllColumns = new Array();
|
||||
|
||||
_tableIndices;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
@ -22,80 +24,85 @@ export class CheckAll {
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._tableIndices = new TableIndices(this._element);
|
||||
|
||||
this._gatherColumns();
|
||||
this._setupCheckAllCheckbox();
|
||||
|
||||
if (DEBUG_MODE > 0)
|
||||
console.log(this._columns);
|
||||
|
||||
this._findCheckboxColumns().forEach(columnId => this._checkAllColumns.push(new CheckAllColumn(this._element, app, this._columns[columnId])));
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(CHECK_ALL_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._checkAllCheckbox.destroy();
|
||||
}
|
||||
|
||||
_getCheckboxId() {
|
||||
return 'check-all-checkbox-' + Math.floor(Math.random() * 100000);
|
||||
}
|
||||
|
||||
_gatherColumns() {
|
||||
const rows = Array.from(this._element.querySelectorAll('tr'));
|
||||
const cols = [];
|
||||
rows.forEach((tr) => {
|
||||
const cells = Array.from(tr.querySelectorAll('td'));
|
||||
cells.forEach((cell, cellIndex) => {
|
||||
if (!cols[cellIndex]) {
|
||||
cols[cellIndex] = [];
|
||||
}
|
||||
cols[cellIndex].push(cell);
|
||||
});
|
||||
});
|
||||
this._columns = cols;
|
||||
for (const rowIndex of Array(this._tableIndices.maxRow + 1).keys()) {
|
||||
for (const colIndex of Array(this._tableIndices.maxCol + 1).keys()) {
|
||||
if (DEBUG_MODE > 1)
|
||||
console.log(rowIndex, colIndex);
|
||||
|
||||
const cell = this._tableIndices.getCell(rowIndex, colIndex);
|
||||
|
||||
if (!cell)
|
||||
continue;
|
||||
|
||||
if (!this._columns[colIndex])
|
||||
this._columns[colIndex] = new Array();
|
||||
|
||||
this._columns[colIndex][rowIndex] = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_findCheckboxColumn(columns) {
|
||||
let checkboxColumnId = null;
|
||||
columns.forEach((col, i) => {
|
||||
_findCheckboxColumns() {
|
||||
let checkboxColumnIds = new Array();
|
||||
this._columns.forEach((col, i) => {
|
||||
if (this._isCheckboxColumn(col)) {
|
||||
checkboxColumnId = i;
|
||||
checkboxColumnIds.push(i);
|
||||
}
|
||||
});
|
||||
return checkboxColumnId;
|
||||
return checkboxColumnIds;
|
||||
}
|
||||
|
||||
_isCheckboxColumn(col) {
|
||||
let onlyCheckboxes = true;
|
||||
col.forEach((cell) => {
|
||||
if (onlyCheckboxes && !cell.querySelector(CHECKBOX_SELECTOR)) {
|
||||
onlyCheckboxes = false;
|
||||
}
|
||||
});
|
||||
return onlyCheckboxes;
|
||||
return col.every(cell => cell.tagName == 'TH' || cell.querySelector(CHECKBOX_SELECTOR))
|
||||
&& col.some(cell => cell.querySelector(CHECKBOX_SELECTOR));
|
||||
}
|
||||
}
|
||||
|
||||
_setupCheckAllCheckbox() {
|
||||
const checkboxColumnId = this._findCheckboxColumn(this._columns);
|
||||
if (checkboxColumnId === null) {
|
||||
return;
|
||||
}
|
||||
class CheckAllColumn {
|
||||
_app;
|
||||
_table;
|
||||
_column;
|
||||
|
||||
_checkAllCheckbox;
|
||||
_checkboxId = 'check-all-checkbox-' + Math.floor(Math.random() * 100000);
|
||||
|
||||
constructor(table, app, column) {
|
||||
this._column = column;
|
||||
this._table = table;
|
||||
this._app = app;
|
||||
|
||||
const th = this._column.filter(element => element.tagName == 'TH')[0];
|
||||
if (!th)
|
||||
return false;
|
||||
|
||||
this._checkboxColumn = this._columns[checkboxColumnId];
|
||||
const firstRow = this._element.querySelector('tr');
|
||||
const th = Array.from(firstRow.querySelectorAll('th, td'))[checkboxColumnId];
|
||||
this._checkAllCheckbox = document.createElement('input');
|
||||
this._checkAllCheckbox.setAttribute('type', 'checkbox');
|
||||
this._checkAllCheckbox.setAttribute('id', this._getCheckboxId());
|
||||
this._checkAllCheckbox.setAttribute('id', this._checkboxId);
|
||||
th.insertBefore(this._checkAllCheckbox, th.firstChild);
|
||||
|
||||
// set up new checkbox
|
||||
this._app.utilRegistry.initAll(th);
|
||||
|
||||
this._checkAllCheckbox.addEventListener('input', () => this._onCheckAllCheckboxInput());
|
||||
this._checkAllCheckbox.addEventListener('input', this._onCheckAllCheckboxInput.bind(this));
|
||||
this._setupCheckboxListeners();
|
||||
}
|
||||
|
||||
@ -104,23 +111,25 @@ export class CheckAll {
|
||||
}
|
||||
|
||||
_setupCheckboxListeners() {
|
||||
this._checkboxColumn.map((cell) => {
|
||||
return cell.querySelector(CHECKBOX_SELECTOR);
|
||||
})
|
||||
.forEach((checkbox) => {
|
||||
checkbox.addEventListener('input', () => this._updateCheckAllCheckboxState());
|
||||
});
|
||||
this._column
|
||||
.flatMap(cell => cell.tagName == 'TH' ? new Array() : Array.from(cell.querySelectorAll(CHECKBOX_SELECTOR)))
|
||||
.forEach(checkbox =>
|
||||
checkbox.addEventListener('input', this._updateCheckAllCheckboxState.bind(this))
|
||||
);
|
||||
}
|
||||
|
||||
_updateCheckAllCheckboxState() {
|
||||
const allChecked = this._checkboxColumn.every((cell) => {
|
||||
return cell.querySelector(CHECKBOX_SELECTOR).checked;
|
||||
});
|
||||
const allChecked = this._column.every(cell =>
|
||||
cell.tagName == 'TH' || cell.querySelector(CHECKBOX_SELECTOR).checked
|
||||
);
|
||||
this._checkAllCheckbox.checked = allChecked;
|
||||
}
|
||||
|
||||
_toggleAll(checked) {
|
||||
this._checkboxColumn.forEach((cell) => {
|
||||
this._column.forEach(cell => {
|
||||
if (cell.tagName == 'TH')
|
||||
return;
|
||||
|
||||
cell.querySelector(CHECKBOX_SELECTOR).checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ describe('CheckAll', () => {
|
||||
let checkAll;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
const element = document.createElement('table');
|
||||
checkAll = new CheckAll(element, MOCK_APP);
|
||||
});
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './course-teaser.sass';
|
||||
|
||||
var COURSE_TEASER_INITIALIZED_CLASS = 'course-teaser--initialized';
|
||||
const COURSE_TEASER_INITIALIZED_CLASS = 'course-teaser--initialized';
|
||||
|
||||
var COURSE_TEASER_EXPANDED_CLASS = 'course-teaser__expanded';
|
||||
var COURSE_TEASER_CHEVRON_CLASS = 'course-teaser__chevron';
|
||||
const COURSE_TEASER_EXPANDED_CLASS = 'course-teaser__expanded';
|
||||
const COURSE_TEASER_CHEVRON_CLASS = 'course-teaser__chevron';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-course-teaser]:not(.course-teaser__not-expandable)',
|
||||
@ -25,9 +25,9 @@ export class CourseTeaser {
|
||||
}
|
||||
|
||||
_onToggleExpand(event) {
|
||||
var isLink = event.target.tagName.toLowerCase() === 'a';
|
||||
var isChevron = event.target.classList.contains(COURSE_TEASER_CHEVRON_CLASS);
|
||||
var isExpanded = this._element.classList.contains(COURSE_TEASER_EXPANDED_CLASS);
|
||||
const isLink = event.target.tagName.toLowerCase() === 'a';
|
||||
const isChevron = event.target.classList.contains(COURSE_TEASER_CHEVRON_CLASS);
|
||||
const isExpanded = this._element.classList.contains(COURSE_TEASER_EXPANDED_CLASS);
|
||||
|
||||
if ((!isExpanded && !isLink) || isChevron) {
|
||||
this._element.classList.toggle(COURSE_TEASER_EXPANDED_CLASS);
|
||||
|
||||
@ -7,7 +7,6 @@ import moment from 'moment';
|
||||
import './exam-correct.sass';
|
||||
|
||||
|
||||
const EXAM_CORRECT_URL_POST = 'correct';
|
||||
const EXAM_CORRECT_HEADERS = {
|
||||
'Content-Type': HttpClient.ACCEPT.JSON,
|
||||
'Accept': HttpClient.ACCEPT.JSON,
|
||||
@ -20,6 +19,7 @@ const EXAM_CORRECT_SEND_BTN_ID = 'exam-correct__send-btn';
|
||||
const EXAM_CORRECT_USER_INPUT_ID = 'exam-correct__user';
|
||||
const EXAM_CORRECT_USER_INPUT_STATUS_ID = 'exam-correct__user-status';
|
||||
const EXAM_CORRECT_USER_INPUT_CANDIDATES_ID = 'exam-correct__user-candidates';
|
||||
const EXAM_CORRECT_USER_INPUT_CANDIDATES_MORE_ID = 'exam-correct__user-candidates-more';
|
||||
const EXAM_CORRECT_INPUT_BODY_ID = 'exam-correct__new';
|
||||
const EXAM_CORRECT_USER_ATTR = 'exam-correct--user-id';
|
||||
const EXAM_CORRECT_USER_DNAME_ATTR = 'exam-correct--user-dname';
|
||||
@ -46,6 +46,7 @@ export class ExamCorrect {
|
||||
_userInput;
|
||||
_userInputStatus;
|
||||
_userInputCandidates;
|
||||
_userInputCandidatesMore;
|
||||
_partInputs;
|
||||
_resultSelect;
|
||||
_resultGradeSelect;
|
||||
@ -78,6 +79,7 @@ export class ExamCorrect {
|
||||
this._userInput = document.getElementById(EXAM_CORRECT_USER_INPUT_ID);
|
||||
this._userInputStatus = document.getElementById(EXAM_CORRECT_USER_INPUT_STATUS_ID);
|
||||
this._userInputCandidates = document.getElementById(EXAM_CORRECT_USER_INPUT_CANDIDATES_ID);
|
||||
this._userInputCandidatesMore = document.getElementById(EXAM_CORRECT_USER_INPUT_CANDIDATES_MORE_ID);
|
||||
this._partInputs = [...this._element.querySelectorAll(`input[${EXAM_CORRECT_PART_INPUT_ATTR}]`)];
|
||||
const resultCell = document.getElementById('uw-exam-correct__result');
|
||||
this._resultSelect = resultCell && resultCell.querySelector('select');
|
||||
@ -110,6 +112,10 @@ export class ExamCorrect {
|
||||
throw new Error('ExamCorrect utility could not detect user input candidate list!');
|
||||
}
|
||||
|
||||
if (!this._userInputCandidatesMore) {
|
||||
throw new Error('ExamCorrect utility could not detect user input candidate more element');
|
||||
}
|
||||
|
||||
// TODO get date format by post request
|
||||
this._dateFormat = 'DD.MM.YYYY HH:mm:ss';
|
||||
|
||||
@ -179,6 +185,7 @@ export class ExamCorrect {
|
||||
// do nothing in case of empty or invalid input
|
||||
if (!this._userInput.value || this._userInput.reportValidity && !this._userInput.reportValidity()) {
|
||||
removeAllChildren(this._userInputCandidates);
|
||||
this._userInputCandidatesMore.hidden = true;
|
||||
setStatus(this._userInputStatus, STATUS.NONE);
|
||||
return;
|
||||
}
|
||||
@ -198,7 +205,6 @@ export class ExamCorrect {
|
||||
const body = this._toRequestBody(this._userInput.value);
|
||||
|
||||
this._app.httpClient.post({
|
||||
url: EXAM_CORRECT_URL_POST,
|
||||
headers: EXAM_CORRECT_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
}).then(
|
||||
@ -214,6 +220,7 @@ export class ExamCorrect {
|
||||
// TODO avoid code duplication
|
||||
if (this._userInput.reportValidity && !this._userInput.reportValidity()) {
|
||||
removeAllChildren(this._userInputCandidates);
|
||||
this._userInputCandidatesMore.hidden = true;
|
||||
setStatus(this._userInput, STATUS.NONE);
|
||||
return;
|
||||
}
|
||||
@ -290,7 +297,6 @@ export class ExamCorrect {
|
||||
const body = this._toRequestBody(userId || user, results, result);
|
||||
|
||||
this._app.httpClient.post({
|
||||
url: EXAM_CORRECT_URL_POST,
|
||||
headers: EXAM_CORRECT_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
}).then(
|
||||
@ -308,6 +314,7 @@ export class ExamCorrect {
|
||||
if (response.users) {
|
||||
// delete candidate list entries from previous requests
|
||||
removeAllChildren(this._userInputCandidates);
|
||||
this._userInputCandidatesMore.hidden = true;
|
||||
|
||||
// show error if there are no matches for this input
|
||||
if (response.users.length === 0) {
|
||||
@ -338,6 +345,7 @@ export class ExamCorrect {
|
||||
|
||||
// remove all candidates on accept
|
||||
removeAllChildren(this._userInputCandidates);
|
||||
this._userInputCandidatesMore.hidden = true;
|
||||
|
||||
setStatus(this._userInputStatus, STATUS.SUCCESS);
|
||||
|
||||
@ -347,6 +355,7 @@ export class ExamCorrect {
|
||||
|
||||
this._userInputCandidates.appendChild(candidateItem);
|
||||
});
|
||||
this._userInputCandidatesMore.hidden = response['has-more'] !== true;
|
||||
} else {
|
||||
// TODO what to do in this case?
|
||||
setStatus(this._userInputStatus, STATUS.FAILURE);
|
||||
@ -391,6 +400,7 @@ export class ExamCorrect {
|
||||
// TODO set edit button visibility
|
||||
status = STATUS.AMBIGUOUS;
|
||||
newEntry.users = response.users;
|
||||
newEntry.hasMore = response['has-more'] === true;
|
||||
newEntry.message = response.message || null;
|
||||
break;
|
||||
case 'failure':
|
||||
@ -429,7 +439,7 @@ export class ExamCorrect {
|
||||
userElem.innerHTML = userToHTML(user);
|
||||
userElem.setAttribute(EXAM_CORRECT_USER_ATTR, user.id || user);
|
||||
} else if (userElem && newEntry.users) {
|
||||
row.replaceChild(userElem, this._showUserList(row, newEntry.users, { partResults: request.results, result: request.grade } ));
|
||||
row.replaceChild(userElem, this._showUserList(row, newEntry.users, { partResults: request.results, result: request.grade }, newEntry.hasMore === true));
|
||||
}
|
||||
|
||||
for (let [k, v] of Object.entries(newEntry.results)) {
|
||||
@ -480,7 +490,7 @@ export class ExamCorrect {
|
||||
}
|
||||
|
||||
// TODO better name
|
||||
_showUserList(row, users, results) {
|
||||
_showUserList(row, users, results, hasMore) {
|
||||
let userElem = row.cells.item(this._cIndices.get('user'));
|
||||
if (!userElem) {
|
||||
userElem = document.createElement('TD');
|
||||
@ -502,6 +512,12 @@ export class ExamCorrect {
|
||||
list.appendChild(listItem);
|
||||
}
|
||||
userElem.appendChild(list);
|
||||
if (hasMore === true) {
|
||||
const moreElem = this._userInputCandidatesMore.cloneNode(true);
|
||||
moreElem.removeAttribute('id');
|
||||
moreElem.hidden = false;
|
||||
userElem.appendChild(moreElem);
|
||||
}
|
||||
} else {
|
||||
console.error('Unable to show users from invalid response');
|
||||
}
|
||||
@ -522,7 +538,6 @@ export class ExamCorrect {
|
||||
const body = this._toRequestBody(listItem.getAttribute(EXAM_CORRECT_USER_ATTR), results.partResults, results.result);
|
||||
|
||||
this._app.httpClient.post({
|
||||
url: EXAM_CORRECT_URL_POST,
|
||||
headers: EXAM_CORRECT_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
}).then(
|
||||
@ -602,6 +617,7 @@ export class ExamCorrect {
|
||||
|
||||
_clearUserInput() {
|
||||
removeAllChildren(this._userInputCandidates);
|
||||
this._userInputCandidatesMore.hidden = true;
|
||||
clearInput(this._userInput);
|
||||
this._userInput.removeAttribute(EXAM_CORRECT_USER_ATTR);
|
||||
this._userInput.removeAttribute(EXAM_CORRECT_USER_DNAME_ATTR);
|
||||
|
||||
88
frontend/src/utils/form/communication-recipients.js
Normal file
88
frontend/src/utils/form/communication-recipients.js
Normal file
@ -0,0 +1,88 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const MASS_INPUT_SELECTOR = '.massinput';
|
||||
const RECIPIENT_CATEGORIES_SELECTOR = '.recipient-categories';
|
||||
const RECIPIENT_CATEGORY_SELECTOR = '.recipient-category';
|
||||
const RECIPIENT_CATEGORY_CHECKBOX_SELECTOR = '.recipient-category__checkbox';
|
||||
const RECIPIENT_CATEGORY_OPTIONS_SELECTOR = '.recipient-category__options';
|
||||
const RECIPIENT_CATEGORY_TOGGLE_ALL_SELECTOR = '.recipient-category__toggle-all [type="checkbox"]';
|
||||
const RECIPIENT_CATEGORY_CHECKED_COUNTER_SELECTOR = '.recipient-category__checked-counter';
|
||||
|
||||
|
||||
@Utility({
|
||||
selector: RECIPIENT_CATEGORIES_SELECTOR,
|
||||
})
|
||||
export class CommunicationRecipients {
|
||||
massInputElement;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Communication Recipient utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this.massInputElement = element.closest(MASS_INPUT_SELECTOR);
|
||||
|
||||
this.setupRecipientCategories();
|
||||
|
||||
const recipientObserver = new MutationObserver(this.setupRecipientCategories.bind(this));
|
||||
recipientObserver.observe(this.massInputElement, { childList: true });
|
||||
}
|
||||
|
||||
setupRecipientCategories() {
|
||||
Array.from(this.massInputElement.querySelectorAll(RECIPIENT_CATEGORY_SELECTOR)).forEach(setupRecipientCategory);
|
||||
}
|
||||
}
|
||||
|
||||
function setupRecipientCategory(recipientCategoryElement) {
|
||||
const categoryCheckbox = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_CHECKBOX_SELECTOR);
|
||||
const categoryOptions = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_OPTIONS_SELECTOR);
|
||||
|
||||
if (categoryOptions) {
|
||||
const categoryCheckboxes = Array.from(categoryOptions.querySelectorAll('[type="checkbox"]'));
|
||||
const toggleAllCheckbox = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_TOGGLE_ALL_SELECTOR);
|
||||
|
||||
// setup category checkbox to toggle all child checkboxes if changed
|
||||
categoryCheckbox.addEventListener('change', () => {
|
||||
categoryCheckboxes.filter(checkbox => !checkbox.disabled).forEach(checkbox => {
|
||||
checkbox.checked = categoryCheckbox.checked;
|
||||
});
|
||||
updateCheckedCounter(recipientCategoryElement, categoryCheckboxes);
|
||||
updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes);
|
||||
});
|
||||
|
||||
// update counter and toggle checkbox initially
|
||||
updateCheckedCounter(recipientCategoryElement, categoryCheckboxes);
|
||||
updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes);
|
||||
|
||||
// register change listener for individual checkboxes
|
||||
categoryCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
updateCheckedCounter(recipientCategoryElement, categoryCheckboxes);
|
||||
updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes);
|
||||
});
|
||||
});
|
||||
|
||||
// register change listener for toggle all checkbox
|
||||
if (toggleAllCheckbox) {
|
||||
toggleAllCheckbox.addEventListener('change', () => {
|
||||
categoryCheckboxes.filter(checkbox => !checkbox.disabled).forEach(checkbox => {
|
||||
checkbox.checked = toggleAllCheckbox.checked;
|
||||
});
|
||||
updateCheckedCounter(recipientCategoryElement, categoryCheckboxes);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update checked state of toggle all checkbox based on all other checkboxes
|
||||
function updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes) {
|
||||
const allChecked = categoryCheckboxes.reduce((acc, checkbox) => acc && checkbox.checked, true);
|
||||
toggleAllCheckbox.checked = allChecked;
|
||||
}
|
||||
|
||||
// update value of checked counter
|
||||
function updateCheckedCounter(recipientCategoryElement, categoryCheckboxes) {
|
||||
const checkedCounter = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_CHECKED_COUNTER_SELECTOR);
|
||||
const checkedCheckboxes = categoryCheckboxes.reduce((acc, checkbox) => checkbox.checked ? acc + 1 : acc, 0);
|
||||
checkedCounter.innerHTML = checkedCheckboxes + '/' + categoryCheckboxes.length;
|
||||
}
|
||||
@ -29,7 +29,7 @@
|
||||
visibility: hidden;
|
||||
direction: ltr;
|
||||
border-collapse: separate;
|
||||
font-family: "Open Sans", Calibri, Arial, sans-serif;
|
||||
/* font-family: "Open Sans", Calibri, Arial, sans-serif; */
|
||||
background-color: white;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
@ -724,4 +724,4 @@
|
||||
}
|
||||
/* @end RTL */
|
||||
|
||||
/*# sourceMappingURL=tail.datetime-default-green.map */
|
||||
/*# sourceMappingURL=tail.datetime-default-green.map */
|
||||
|
||||
@ -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
|
||||
var 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
|
||||
|
||||
@ -3,44 +3,43 @@ import { Utility } from '../../core/utility';
|
||||
const FORM_ERROR_REMOVER_INITIALIZED_CLASS = 'form-error-remover--initialized';
|
||||
const FORM_ERROR_REMOVER_INPUTS_SELECTOR = 'input:not([type="hidden"]), textarea, select';
|
||||
|
||||
const FORM_GROUP_SELECTOR = '.form-group';
|
||||
const FORM_GROUP_WITH_ERRORS_CLASS = 'form-group--has-error';
|
||||
const FORM_GROUP_WITH_ERRORS_CLASSES = ['form-group--has-error', 'standalone-field--has-error'];
|
||||
const FORM_GROUP_SELECTOR = FORM_GROUP_WITH_ERRORS_CLASSES.map(c => '.' + c).join(', ');
|
||||
|
||||
@Utility({
|
||||
selector: 'form',
|
||||
selector: FORM_GROUP_SELECTOR,
|
||||
})
|
||||
export class FormErrorRemover {
|
||||
|
||||
_element;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
if (!element)
|
||||
throw new Error('Form Error Remover utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(FORM_ERROR_REMOVER_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
if (element.classList.contains(FORM_ERROR_REMOVER_INITIALIZED_CLASS))
|
||||
return;
|
||||
|
||||
// find form groups
|
||||
const formGroups = Array.from(element.querySelectorAll(FORM_GROUP_SELECTOR));
|
||||
if (FORM_GROUP_WITH_ERRORS_CLASSES.every(c => !element.classList.contains(c)))
|
||||
return;
|
||||
|
||||
formGroups.forEach((formGroup) => {
|
||||
if (!formGroup.classList.contains(FORM_GROUP_WITH_ERRORS_CLASS)) {
|
||||
return;
|
||||
}
|
||||
this._element = element;
|
||||
|
||||
this._element.classList.add(FORM_ERROR_REMOVER_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
const inputElements = Array.from(formGroup.querySelectorAll(FORM_ERROR_REMOVER_INPUTS_SELECTOR));
|
||||
if (!inputElements) {
|
||||
return false;
|
||||
}
|
||||
start() {
|
||||
if (!this._element)
|
||||
return;
|
||||
|
||||
inputElements.forEach((inputElement) => {
|
||||
inputElement.addEventListener('input', () => {
|
||||
formGroup.classList.remove(FORM_GROUP_WITH_ERRORS_CLASS);
|
||||
});
|
||||
const inputElements = Array.from(this._element.querySelectorAll(FORM_ERROR_REMOVER_INPUTS_SELECTOR));
|
||||
|
||||
inputElements.forEach((inputElement) => {
|
||||
inputElement.addEventListener('input', () => {
|
||||
if (!inputElement.willValidate || inputElement.validity.vaild) {
|
||||
FORM_GROUP_WITH_ERRORS_CLASSES.forEach(c => { this._element.classList.remove(c); });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(FORM_ERROR_REMOVER_INITIALIZED_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
68
frontend/src/utils/form/form-error-reporter.js
Normal file
68
frontend/src/utils/form/form-error-reporter.js
Normal file
@ -0,0 +1,68 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import * as defer from 'lodash.defer';
|
||||
|
||||
const FORM_ERROR_REPORTER_INITIALIZED_CLASS = 'form-error-remover--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'input, textarea, select',
|
||||
})
|
||||
export class FormErrorReporter {
|
||||
_element;
|
||||
_err;
|
||||
|
||||
constructor(element) {
|
||||
if (!element)
|
||||
throw new Error('Form Error Reporter utility needs to be passed an element!');
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(FORM_ERROR_REPORTER_INITIALIZED_CLASS))
|
||||
return;
|
||||
|
||||
this._element.classList.add(FORM_ERROR_REPORTER_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this._element.willValidate) {
|
||||
this._element.addEventListener('invalid', this.report.bind(this));
|
||||
this._element.addEventListener('change', () => { defer(this.report.bind(this)); } );
|
||||
}
|
||||
}
|
||||
|
||||
report() {
|
||||
const msg = this._element.validity.valid ? null : this._element.validationMessage;
|
||||
|
||||
const target = this._element.closest('.standalone-field, .form-group');
|
||||
|
||||
if (!target)
|
||||
return;
|
||||
|
||||
if (this._err && this._err.parentNode) {
|
||||
this._err.parentNode.removeChild(this._err);
|
||||
this._err = undefined;
|
||||
}
|
||||
|
||||
if (!msg) {
|
||||
target.classList.remove('standalone-field--has-error', 'form-group--has-error');
|
||||
} else {
|
||||
if (target.classList.contains('form-group')) {
|
||||
target.classList.add('form-group--has-error');
|
||||
|
||||
const container = target.querySelector('.form-group__input');
|
||||
if (container) {
|
||||
this._err = document.createElement('div');
|
||||
this._err.classList.add('form-error');
|
||||
this._err.innerText = msg;
|
||||
container.appendChild(this._err);
|
||||
}
|
||||
} else {
|
||||
target.classList.add('standalone-field--has-error');
|
||||
|
||||
this._err = document.createElement('div');
|
||||
this._err.classList.add('standalone-field__error');
|
||||
this._err.innerText = msg;
|
||||
target.appendChild(this._err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,15 +3,19 @@ import { AutoSubmitButton } from './auto-submit-button';
|
||||
import { AutoSubmitInput } from './auto-submit-input';
|
||||
import { Datepicker } from './datepicker';
|
||||
import { FormErrorRemover } from './form-error-remover';
|
||||
import { FormErrorReporter } from './form-error-reporter';
|
||||
import { InteractiveFieldset } from './interactive-fieldset';
|
||||
import { NavigateAwayPrompt } from './navigate-away-prompt';
|
||||
import { CommunicationRecipients } from './communication-recipients';
|
||||
|
||||
export const FormUtils = [
|
||||
AutoSubmitButton,
|
||||
AutoSubmitInput,
|
||||
Datepicker,
|
||||
FormErrorRemover,
|
||||
FormErrorReporter,
|
||||
InteractiveFieldset,
|
||||
NavigateAwayPrompt,
|
||||
CommunicationRecipients,
|
||||
// ReactiveSubmitButton // not used currently
|
||||
];
|
||||
|
||||
@ -5,10 +5,13 @@ const INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR = '.interactive-fieldset__target
|
||||
const INTERACTIVE_FIELDSET_INITIALIZED_CLASS = 'interactive-fieldset--initialized';
|
||||
const INTERACTIVE_FIELDSET_CHILD_SELECTOR = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])';
|
||||
|
||||
let fieldsetCounter = 0;
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-interactive-fieldset]',
|
||||
})
|
||||
export class InteractiveFieldset {
|
||||
fieldsetIdent = (fieldsetCounter++).toString();
|
||||
|
||||
_element;
|
||||
|
||||
@ -56,21 +59,23 @@ export class InteractiveFieldset {
|
||||
this.target = this._element;
|
||||
}
|
||||
|
||||
this.childInputs = Array.from(this._element.querySelectorAll(INTERACTIVE_FIELDSET_CHILD_SELECTOR));
|
||||
this.childInputs = Array.from(this._element.querySelectorAll(INTERACTIVE_FIELDSET_CHILD_SELECTOR)).filter(child => child.closest('[uw-interactive-fieldset]') === this._element);
|
||||
|
||||
// add event listener
|
||||
const observer = new MutationObserver(() => this._updateVisibility());
|
||||
const observer = new MutationObserver(this._updateVisibility.bind(this));
|
||||
observer.observe(this.conditionalInput, { attributes: true, attributeFilter: ['data-interactive-fieldset-hidden'] });
|
||||
this.conditionalInput.addEventListener('input', () => this._updateVisibility());
|
||||
|
||||
// initial visibility update
|
||||
this._updateVisibility();
|
||||
this.conditionalInput.addEventListener('input', this._updateVisibility.bind(this));
|
||||
|
||||
// mark as initialized
|
||||
this._element.classList.add(INTERACTIVE_FIELDSET_INITIALIZED_CLASS);
|
||||
|
||||
}
|
||||
|
||||
start() {
|
||||
// initial visibility update
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
@ -78,7 +83,20 @@ export class InteractiveFieldset {
|
||||
_updateVisibility() {
|
||||
const active = this._matchesConditionalValue() && !this.conditionalInput.dataset.interactiveFieldsetHidden;
|
||||
|
||||
this.target.classList.toggle('hidden', !active);
|
||||
let hiddenBy = (this.target.dataset.interactiveFieldsetHiddenBy || '').split(',').filter(str => str.length !== 0);
|
||||
|
||||
if (active)
|
||||
hiddenBy = hiddenBy.filter(ident => ident !== this.fieldsetIdent);
|
||||
else if (hiddenBy.every(ident => ident !== this.fieldsetIdent))
|
||||
hiddenBy = [ ...hiddenBy, this.fieldsetIdent ];
|
||||
|
||||
if (hiddenBy.length !== 0) {
|
||||
this.target.dataset.interactiveFieldsetHiddenBy = hiddenBy.join(',');
|
||||
this.target.classList.add('hidden');
|
||||
} else {
|
||||
delete this.target.dataset['interactiveFieldsetHiddenBy'];
|
||||
this.target.classList.remove('hidden');
|
||||
}
|
||||
|
||||
this.childInputs.forEach((el) => this._updateChildVisibility(el, active));
|
||||
}
|
||||
@ -94,10 +112,13 @@ export class InteractiveFieldset {
|
||||
}
|
||||
|
||||
_matchesConditionalValue() {
|
||||
var matches;
|
||||
let matches;
|
||||
|
||||
if (this._isCheckbox()) {
|
||||
matches = this.conditionalInput.checked === true;
|
||||
} else if (this._isRadio()) {
|
||||
const radios = Array.from(this.conditionalInput.querySelectorAll('input[type=radio]'));
|
||||
matches = radios.some(radio => radio.checked && radio.value === this.conditionalValue);
|
||||
} else {
|
||||
matches = this.conditionalInput.value === this.conditionalValue;
|
||||
}
|
||||
@ -112,4 +133,8 @@ export class InteractiveFieldset {
|
||||
_isCheckbox() {
|
||||
return this.conditionalInput.getAttribute('type') === 'checkbox';
|
||||
}
|
||||
|
||||
_isRadio() {
|
||||
return !!this.conditionalInput.querySelector('input[type=radio]');
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,11 @@ import { Utility } from '../../core/utility';
|
||||
import { AUTO_SUBMIT_BUTTON_UTIL_SELECTOR } from './auto-submit-button';
|
||||
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
|
||||
@ -31,41 +36,59 @@ export class NavigateAwayPrompt {
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._initFormData = new FormData(this._element);
|
||||
|
||||
if (this._element.classList.contains(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore forms that get submitted automatically
|
||||
if (this._element.querySelector(AUTO_SUBMIT_BUTTON_UTIL_SELECTOR) || this._element.querySelector(AUTO_SUBMIT_INPUT_UTIL_SELECTOR)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._element.matches(NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT)) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
|
||||
this._element.addEventListener('submit', () => {
|
||||
this._unloadDueToSubmit = true;
|
||||
});
|
||||
if (this._element.attributes.target === '_blank') {
|
||||
return;
|
||||
}
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
static startAfter = [ Datepicker, InteractiveFieldset ];
|
||||
|
||||
start() {
|
||||
if (!this._isActive())
|
||||
return;
|
||||
|
||||
this._initFormData = new FormData(this._element);
|
||||
window.addEventListener('beforeunload', this._beforeUnloadHandler.bind(this));
|
||||
|
||||
this._element.addEventListener('submit', () => {
|
||||
this._unloadDueToSubmit = true;
|
||||
defer(() => { this._unloadDueToSubmit = false; } ); // Restore state after event loop is settled
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
this._element.classList.remove(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
_beforeUnloadHandler = (event) => {
|
||||
_isActive() {
|
||||
return this._element.classList.contains(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
_beforeUnloadHandler(event) {
|
||||
if (!this._isActive() || !this._initFormData)
|
||||
return;
|
||||
|
||||
// compare every value of the current FormData with every corresponding value of the initial FormData and set formDataHasChanged to true if there is at least one change
|
||||
const currentFormData = new FormData(this._element);
|
||||
var formDataHasChanged = false;
|
||||
for (let key of generatorFromFormDatas(this._initFormData, currentFormData)) {
|
||||
let formDataHasChanged = false;
|
||||
for (const key of generatorFromFormDatas(this._initFormData, currentFormData)) {
|
||||
if (currentFormData.get(key) !== this._initFormData.get(key)) {
|
||||
formDataHasChanged = true;
|
||||
break;
|
||||
@ -75,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,6 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
var REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS = 'reactive-submit-button--initialized';
|
||||
const REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS = 'reactive-submit-button--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'form',
|
||||
@ -56,8 +56,8 @@ export class ReactiveSubmitButton {
|
||||
|
||||
setupInputs() {
|
||||
this._requiredInputs.forEach((el) => {
|
||||
var checkbox = el.getAttribute('type') === 'checkbox';
|
||||
var eventType = checkbox ? 'change' : 'input';
|
||||
const checkbox = el.getAttribute('type') === 'checkbox';
|
||||
const eventType = checkbox ? 'change' : 'input';
|
||||
el.addEventListener(eventType, () => {
|
||||
this.updateButtonState();
|
||||
});
|
||||
@ -73,9 +73,9 @@ export class ReactiveSubmitButton {
|
||||
}
|
||||
|
||||
inputsValid() {
|
||||
var done = true;
|
||||
let done = true;
|
||||
this._requiredInputs.forEach((inp) => {
|
||||
var len = inp.value.trim().length;
|
||||
const len = inp.value.trim().length;
|
||||
if (done && len === 0) {
|
||||
done = false;
|
||||
}
|
||||
|
||||
@ -2,10 +2,13 @@ import { Utility } from '../../core/utility';
|
||||
import { StorageManager, LOCATION } from '../../lib/storage-manager/storage-manager';
|
||||
import './hide-columns.sass';
|
||||
|
||||
import { TableIndices } from '../../lib/table/table';
|
||||
|
||||
const HIDE_COLUMNS_CONTAINER_IDENT = 'uw-hide-columns';
|
||||
const TABLE_HEADER_IDENT = 'uw-hide-column-header';
|
||||
const HIDE_COLUMNS_HIDER_LABEL = 'uw-hide-columns--hider-label';
|
||||
const HIDE_COLUMNS_NO_HIDE = 'uw-hide-columns--no-hide';
|
||||
const HIDE_COLUMNS_DEFAULT_HIDDEN = 'uw-hide-column-default-hidden';
|
||||
|
||||
const TABLE_UTILS_ATTR = 'table-utils';
|
||||
const TABLE_UTILS_CONTAINER_SELECTOR = `[${TABLE_UTILS_ATTR}]`;
|
||||
@ -18,6 +21,8 @@ const TABLE_PILL_CLASS = 'table-pill';
|
||||
const CELL_HIDDEN_CLASS = 'hide-columns--hidden-cell';
|
||||
const CELL_ORIGINAL_COLSPAN = 'uw-hide-column-original-colspan';
|
||||
|
||||
const HIDE_COLUMNS_INITIALIZED = 'uw-hide-columns--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: `[${HIDE_COLUMNS_CONTAINER_IDENT}] table`,
|
||||
})
|
||||
@ -33,6 +38,8 @@ export class HideColumns {
|
||||
|
||||
_mutationObserver;
|
||||
|
||||
_tableIndices;
|
||||
|
||||
headerToHider = new Map();
|
||||
hiderToHeader = new Map();
|
||||
|
||||
@ -44,17 +51,20 @@ export class HideColumns {
|
||||
constructor(element) {
|
||||
this._autoHide = this._storageManager.load('autoHide', {}) || false;
|
||||
|
||||
if (!element) {
|
||||
if (!element)
|
||||
throw new Error('Hide Columns utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
// do not provide hide-column ability in tables inside modals, async forms with response or tail.datetime instances
|
||||
if (element.closest('[uw-modal], .async-form__response, .tail-datetime-calendar')) {
|
||||
if (element.closest('[uw-modal], .async-form__response, .tail-datetime-calendar'))
|
||||
return false;
|
||||
|
||||
if (element.classList.contains(HIDE_COLUMNS_INITIALIZED))
|
||||
return false;
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
this._tableIndices = new TableIndices(this._element);
|
||||
|
||||
const hideColumnsContainer = this._element.closest(`[${HIDE_COLUMNS_CONTAINER_IDENT}]`);
|
||||
if (!hideColumnsContainer) {
|
||||
throw new Error('Hide Columns utility needs to be setup on a table inside a hide columns container!');
|
||||
@ -74,10 +84,12 @@ export class HideColumns {
|
||||
|
||||
this._mutationObserver = new MutationObserver(this._tableMutated.bind(this));
|
||||
this._mutationObserver.observe(this._element, { childList: true, subtree: true });
|
||||
|
||||
this._element.classList.add(HIDE_COLUMNS_INITIALIZED);
|
||||
}
|
||||
|
||||
setupHideButton(th) {
|
||||
const preHidden = this.isHiddenColumn(th);
|
||||
const preHidden = this.isHiddenTH(th);
|
||||
|
||||
const hider = document.createElement('span');
|
||||
|
||||
@ -104,20 +116,20 @@ export class HideColumns {
|
||||
hider.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.switchColumnDisplay(th, hider);
|
||||
this.switchColumnDisplay(th);
|
||||
// this._tableHiderContainer.getElementsByClassName(TABLE_HIDER_CLASS).forEach(hider => this.hideHiderBehindHeader(hider));
|
||||
});
|
||||
|
||||
hider.addEventListener('mouseover', () => {
|
||||
hider.classList.add(TABLE_HIDER_VISIBLE_CLASS);
|
||||
const currentlyHidden = this.isHiddenColumn(th);
|
||||
const currentlyHidden = this.hiderStatus(th);
|
||||
this.updateHiderIcon(hider, !currentlyHidden);
|
||||
});
|
||||
hider.addEventListener('mouseout', () => {
|
||||
if (hider.classList.contains(TABLE_HIDER_CLASS)) {
|
||||
hider.classList.remove(TABLE_HIDER_VISIBLE_CLASS);
|
||||
}
|
||||
const currentlyHidden = this.isHiddenColumn(th);
|
||||
const currentlyHidden = this.hiderStatus(th);
|
||||
this.updateHiderIcon(hider, currentlyHidden);
|
||||
});
|
||||
|
||||
@ -126,64 +138,76 @@ export class HideColumns {
|
||||
// reposition hider on each window resize event
|
||||
// window.addEventListener('resize', () => this.repositionHider(hider));
|
||||
|
||||
this.updateColumnDisplay(this.colIndex(th), preHidden);
|
||||
this.updateHider(hider, preHidden);
|
||||
|
||||
if (preHidden) {
|
||||
this._tableUtilContainer.appendChild(hider);
|
||||
} else {
|
||||
this.hideHiderBehindHeader(hider);
|
||||
}
|
||||
this.switchColumnDisplay(th, preHidden);
|
||||
}
|
||||
|
||||
switchColumnDisplay(th, hider) {
|
||||
const hidden = !this.isHiddenColumn(th);
|
||||
const originalColspan = Math.max(1, th.getAttribute(CELL_ORIGINAL_COLSPAN)) || 1;
|
||||
const colspan = Math.max(1, th.colSpan) || 1;
|
||||
const columnIndex = this.colIndex(th);
|
||||
switchColumnDisplay(th, hidden) {
|
||||
hidden = typeof(hidden) === 'undefined' ? !this.isHiddenTH(th) : !!hidden;
|
||||
|
||||
for (var i = 0; i < Math.max(colspan, originalColspan); i++) {
|
||||
this.updateColumnDisplay(columnIndex + i, hidden);
|
||||
}
|
||||
this.updateHider(hider, hidden);
|
||||
|
||||
// persist new hidden setting for column
|
||||
if ((hidden && this.isEmptyColumn(columnIndex) && this._autoHide) || (!hidden && (!this.isEmptyColumn(columnIndex) || !this._autoHide))) {
|
||||
this._storageManager.remove(this.getStorageKey(th));
|
||||
} else {
|
||||
this._storageManager.save(this.getStorageKey(th), hidden);
|
||||
}
|
||||
this.cellColumns(th).forEach(columnIndex => this.updateColumnDisplay(columnIndex, hidden));
|
||||
}
|
||||
|
||||
updateColumnDisplay(columnIndex, hidden) {
|
||||
this._element.getElementsByTagName('tr').forEach(row => {
|
||||
// console.debug('updateColumnDisplay', { columnIndex, hidden });
|
||||
this._element.rows.forEach(row => {
|
||||
const cell = this.getCol(row, columnIndex);
|
||||
|
||||
if (cell) {
|
||||
const originalColspan = cell.getAttribute(CELL_ORIGINAL_COLSPAN);
|
||||
const colspan = Math.max(1, cell.colSpan) || 1;
|
||||
const visibleColumns = this.cellColumns(cell).reduce((count, cColumnIndex) => (cColumnIndex === columnIndex ? hidden : this.isHiddenColumn(cColumnIndex)) ? count : count + 1, 0);
|
||||
|
||||
if (hidden) {
|
||||
if (colspan > 1) {
|
||||
// if (cell.tagName === 'TH') {
|
||||
// console.debug({cell, originalColspan, colspan, visibleColumns, isHidden: cell.classList.contains(CELL_HIDDEN_CLASS)});
|
||||
// }
|
||||
|
||||
|
||||
if (visibleColumns <= 0) {
|
||||
cell.classList.add(CELL_HIDDEN_CLASS);
|
||||
} else {
|
||||
cell.classList.remove(CELL_HIDDEN_CLASS);
|
||||
|
||||
if (colspan !== visibleColumns) {
|
||||
if (!originalColspan) {
|
||||
cell.setAttribute(CELL_ORIGINAL_COLSPAN, colspan);
|
||||
}
|
||||
cell.colSpan--;
|
||||
} else {
|
||||
cell.classList.add(CELL_HIDDEN_CLASS);
|
||||
}
|
||||
} else {
|
||||
if (cell.classList.contains(CELL_HIDDEN_CLASS)) {
|
||||
cell.classList.remove(CELL_HIDDEN_CLASS);
|
||||
} else if (originalColspan && colspan < originalColspan) {
|
||||
cell.colSpan++;
|
||||
cell.colSpan = visibleColumns;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const touchedColumns = new Array();
|
||||
|
||||
this.columnTHs(columnIndex)
|
||||
.forEach(th => {
|
||||
touchedColumns.push(...this.cellColumns(th));
|
||||
|
||||
if (!this._element.classList.contains(HIDE_COLUMNS_INITIALIZED))
|
||||
return;
|
||||
|
||||
const thHidden = this.cellColumns(th).every(cColumnIndex => cColumnIndex === columnIndex ? hidden : this.isHiddenColumn(cColumnIndex));
|
||||
// persist new hidden setting for column
|
||||
if (thHidden == this.isDefaultHiddenTH(th)) {
|
||||
this._storageManager.remove(this.getStorageKey(th));
|
||||
} else {
|
||||
this._storageManager.save(this.getStorageKey(th), thHidden);
|
||||
}
|
||||
});
|
||||
|
||||
touchedColumns.flatMap(cColumnIndex => this.columnTHs(cColumnIndex))
|
||||
.forEach(th => {
|
||||
const hider = this.headerToHider.get(th);
|
||||
if (!hider)
|
||||
return;
|
||||
|
||||
this.updateHider(hider, this.hiderStatus(th));
|
||||
});
|
||||
}
|
||||
|
||||
updateHider(hider, hidden) {
|
||||
// console.debug({hider, hidden, columnIndex: this.colIndex(this.hiderToHeader.get(hider)), colSpan: this.colSpan(this.hiderToHeader.get(hider))});
|
||||
|
||||
if (hidden) {
|
||||
hider.classList.remove(TABLE_HIDER_CLASS);
|
||||
hider.classList.add(TABLE_PILL_CLASS);
|
||||
@ -223,12 +247,20 @@ export class HideColumns {
|
||||
}
|
||||
|
||||
_tableMutated(mutationList) {
|
||||
// console.log('_tableMutated', mutationList, observer);
|
||||
|
||||
if (!Array.from(mutationList).some(mutation => mutation.type === 'childList'))
|
||||
return;
|
||||
|
||||
[...this._element.querySelectorAll('th')].filter(th => !th.hasAttribute(HIDE_COLUMNS_NO_HIDE)).forEach(th => this.updateColumnDisplay(this.colIndex(th), this.isHiddenColumn(th)));
|
||||
if (Array.from(mutationList).every(mRecord => mRecord.type === 'childList' && [...mRecord.addedNodes, ...mRecord.removedNodes].every(isTableHider)))
|
||||
return;
|
||||
|
||||
// console.debug('_tableMutated', { mutationList });
|
||||
|
||||
this._tableIndices = new TableIndices(this._element, { colSpan: this.colSpan.bind(this) });
|
||||
|
||||
Array.from(this._element.rows)
|
||||
.flatMap(row => Array.from(row.cells))
|
||||
.filter(th => th.tagName === 'TH' && !th.hasAttribute(HIDE_COLUMNS_NO_HIDE))
|
||||
.forEach(th => this.updateColumnDisplay(this.colIndex(th), this.isHiddenTH(th)));
|
||||
}
|
||||
|
||||
getStorageKey(th) {
|
||||
@ -257,9 +289,9 @@ export class HideColumns {
|
||||
}
|
||||
|
||||
isEmptyColumn(columnIndex) {
|
||||
for (let row of this._element.getElementsByTagName('tr')) {
|
||||
for (let row of this._element.rows) {
|
||||
const cell = this.getCol(row, columnIndex);
|
||||
if (cell.matches('th'))
|
||||
if (!cell || cell.tagName == 'TH')
|
||||
continue;
|
||||
if (cell.querySelector('.table__td-content')) {
|
||||
for (let child of cell.children) {
|
||||
@ -273,10 +305,47 @@ export class HideColumns {
|
||||
}
|
||||
}
|
||||
|
||||
isHiddenColumn(th) {
|
||||
const hidden = this._storageManager.load(this.getStorageKey(th)),
|
||||
emptyColumn = this.isEmptyColumn(this.colIndex(th));
|
||||
return hidden === true || hidden === undefined && emptyColumn && this._autoHide;
|
||||
columnTHs(columnIndex) {
|
||||
return Array.from(this._element.rows)
|
||||
.map(row => this.getCol(row, columnIndex))
|
||||
.filter(cell => cell && cell.tagName === 'TH');
|
||||
}
|
||||
|
||||
cellColumns(cell) {
|
||||
const columnIndex = this.colIndex(cell);
|
||||
return Array.from(new Array(this.colSpan(cell)), (_x, i) => columnIndex + i);
|
||||
}
|
||||
|
||||
isHiddenTH(th) {
|
||||
return this.cellColumns(th).every(columnIndex => this.isHiddenColumn(columnIndex));
|
||||
}
|
||||
|
||||
hiderStatus(th) {
|
||||
const columnsHidden = this.isHiddenTH(th);
|
||||
const shadowed = this.cellColumns(th).every(columnIndex => this.isHiddenColumn(columnIndex, this.columnTHs(columnIndex).filter(oTH => oTH !== th)));
|
||||
const isFirst = this.cellColumns(th).some(columnIndex => this.columnTHs(columnIndex)[0] === th);
|
||||
|
||||
// console.debug("hiderStatus", { th, columnsHidden, shadowed, isFirst });
|
||||
|
||||
return columnsHidden && (!shadowed || isFirst);
|
||||
}
|
||||
|
||||
isDefaultHiddenTH(th) {
|
||||
return this.cellColumns(th).every(columnIndex => this.isDefaultHiddenColumn(columnIndex, Array.of(th)));
|
||||
}
|
||||
|
||||
isHiddenColumn(columnIndex, ths) {
|
||||
ths = ths === undefined ? this.columnTHs(columnIndex) : ths;
|
||||
|
||||
const hidden = ths.map(th => this._storageManager.load(this.getStorageKey(th)));
|
||||
|
||||
return hidden.every(h => h === undefined) ? this.isDefaultHiddenColumn(columnIndex, ths) : hidden.some(h => h);
|
||||
}
|
||||
|
||||
isDefaultHiddenColumn(columnIndex, ths) {
|
||||
ths = ths === undefined ? this.columnTHs(columnIndex) : ths;
|
||||
|
||||
return this.isEmptyColumn(columnIndex) && this._autoHide || ths.some(th => th.hasAttribute(HIDE_COLUMNS_DEFAULT_HIDDEN));
|
||||
}
|
||||
|
||||
colSpan(cell) {
|
||||
@ -290,31 +359,11 @@ export class HideColumns {
|
||||
}
|
||||
|
||||
colIndex(cell) {
|
||||
if (!cell)
|
||||
return 0;
|
||||
|
||||
const rowParent = cell.closest('tr');
|
||||
|
||||
if (!rowParent)
|
||||
return 0;
|
||||
|
||||
var i = 0;
|
||||
for (const sibling of Array.from(rowParent.cells).slice(0, cell.cellIndex)) {
|
||||
i += this.colSpan(sibling);
|
||||
}
|
||||
|
||||
return i;
|
||||
return this._tableIndices.colIndex(cell);
|
||||
}
|
||||
|
||||
getCol(row, columnIndex) {
|
||||
var c = 0;
|
||||
|
||||
for (const cell of row.cells) {
|
||||
c += cell ? this.colSpan(cell) : 1;
|
||||
|
||||
if (columnIndex < c)
|
||||
return cell;
|
||||
}
|
||||
getCol(row, col) {
|
||||
return this._tableIndices.getCell(row.rowIndex, col);
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,3 +375,10 @@ function isEmptyElement(element) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTableHider(element) {
|
||||
return element && element.classList && (
|
||||
element.classList.contains(TABLE_HIDER_CLASS)
|
||||
|| element.classList.contains(TABLE_HIDER_VISIBLE_CLASS)
|
||||
|| element.classList.contains(TABLE_PILL_CLASS)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './checkbox.sass';
|
||||
|
||||
var CHECKBOX_CLASS = 'checkbox';
|
||||
var RADIOBOX_CLASS = 'radiobox';
|
||||
var CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized';
|
||||
const CHECKBOX_CLASS = 'checkbox';
|
||||
const RADIOBOX_CLASS = 'radiobox';
|
||||
const CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'input[type="checkbox"]:not([uw-no-checkbox]), input[type="radio"]:not([uw-no-radiobox])',
|
||||
@ -32,13 +32,13 @@ export class Checkbox {
|
||||
return false;
|
||||
}
|
||||
|
||||
var siblingEl = element.nextSibling;
|
||||
var parentEl = element.parentElement;
|
||||
const siblingEl = element.nextSibling;
|
||||
const parentEl = element.parentElement;
|
||||
|
||||
var wrapperEl = document.createElement('div');
|
||||
const wrapperEl = document.createElement('div');
|
||||
wrapperEl.classList.add(box_class);
|
||||
|
||||
var labelEl = document.createElement('label');
|
||||
const labelEl = document.createElement('label');
|
||||
labelEl.setAttribute('for', element.id);
|
||||
|
||||
wrapperEl.appendChild(element);
|
||||
|
||||
@ -89,7 +89,7 @@
|
||||
\:checked + label::before
|
||||
background-color: white
|
||||
|
||||
[disabled] + label
|
||||
[disabled] + label, [readonly] + label
|
||||
pointer-events: none
|
||||
border: none
|
||||
opacity: 0.6
|
||||
@ -99,3 +99,9 @@
|
||||
th .checkbox
|
||||
margin-right: 7px
|
||||
vertical-align: bottom
|
||||
|
||||
th .checkbox:only-child
|
||||
margin: 0
|
||||
|
||||
.checkbox-only
|
||||
width: 36px
|
||||
|
||||
44
frontend/src/utils/inputs/file-max-size.js
Normal file
44
frontend/src/utils/inputs/file-max-size.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const FILE_MAX_SIZE_INITIALIZED_CLASS = 'file-max-size--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'input[type="file"][data-max-size]',
|
||||
})
|
||||
export class FileMaxSize {
|
||||
_element;
|
||||
_app;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element)
|
||||
throw new Error('FileMaxSize utility cannot be setup without an element!');
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(FILE_MAX_SIZE_INITIALIZED_CLASS)) {
|
||||
throw new Error('FileMaxSize utility already initialized!');
|
||||
}
|
||||
|
||||
this._element.classList.add(FILE_MAX_SIZE_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
start() {
|
||||
this._element.addEventListener('change', this._change.bind(this));
|
||||
}
|
||||
|
||||
_change() {
|
||||
const hasOversized = Array.from(this._element.files).some(file => file.size > this._element.dataset.maxSize);
|
||||
if (hasOversized) {
|
||||
if (this._element.files.length > 1) {
|
||||
this._element.setCustomValidity(this._app.i18n.get('fileTooLargeMultiple'));
|
||||
} else {
|
||||
this._element.setCustomValidity(this._app.i18n.get('fileTooLarge'));
|
||||
}
|
||||
} else {
|
||||
this._element.setCustomValidity('');
|
||||
}
|
||||
|
||||
this._element.reportValidity();
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { Checkbox } from './checkbox';
|
||||
import { FileInput } from './file-input';
|
||||
import { FileMaxSize } from './file-max-size';
|
||||
|
||||
import './inputs.sass';
|
||||
import './radio-group.sass';
|
||||
@ -7,4 +8,5 @@ import './radio-group.sass';
|
||||
export const InputUtils = [
|
||||
Checkbox,
|
||||
FileInput,
|
||||
FileMaxSize,
|
||||
];
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@use "../../app" as *
|
||||
|
||||
// GENERAL STYLES FOR FORMS
|
||||
|
||||
// FORM GROUPS
|
||||
@ -24,7 +26,7 @@
|
||||
margin-top: 40px
|
||||
|
||||
.form-section-legend
|
||||
color: var(--color-fontsec)
|
||||
@extend .explanation
|
||||
margin: 7px 0
|
||||
|
||||
.form-group__hint, .form-section-title__hint
|
||||
@ -47,10 +49,19 @@
|
||||
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
|
||||
@ -60,17 +71,47 @@
|
||||
grid-column: 1
|
||||
|
||||
.form-group--has-error
|
||||
background-color: rgba(255, 0, 0, 0.1)
|
||||
background-color: rgba(140, 7, 7, 0.05)
|
||||
|
||||
.form-group-label
|
||||
border-left: 2px solid var(--color-error)
|
||||
align-self: stretch
|
||||
padding-left: 7px
|
||||
|
||||
input, textarea
|
||||
border-color: var(--color-error) !important
|
||||
|
||||
.form-error
|
||||
display: block
|
||||
font-weight: 600
|
||||
color: var(--color-error)
|
||||
margin: 7px 0
|
||||
white-space: pre-wrap
|
||||
|
||||
.form-error
|
||||
display: none
|
||||
|
||||
.standalone-field
|
||||
display: inline-flex
|
||||
|
||||
&__error
|
||||
display: none
|
||||
align-self: center
|
||||
flex: 0 0 auto
|
||||
|
||||
.tooltip__content
|
||||
font-weight: 600
|
||||
color: var(--color-error)
|
||||
white-space: pre-wrap
|
||||
|
||||
&--has-error
|
||||
input, textarea
|
||||
border-color: var(--color-error) !important
|
||||
|
||||
.standalone-field__error
|
||||
display: block
|
||||
|
||||
|
||||
@media (max-width: 768px)
|
||||
.form-group
|
||||
grid-template-columns: 1fr
|
||||
@ -192,11 +233,24 @@ 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
|
||||
|
||||
tr:last-child td
|
||||
padding-bottom: 0
|
||||
|
||||
.file-input__list-item
|
||||
font-family: var(--font-monospace)
|
||||
font-size: 15px
|
||||
word-break: break-all
|
||||
|
||||
// PREVIOUSLY UPLOADED FILES
|
||||
|
||||
.file-uploads-label
|
||||
@ -209,3 +263,13 @@ 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
|
||||
|
||||
@ -4,52 +4,52 @@
|
||||
.radio-group
|
||||
display: flex
|
||||
|
||||
.radio
|
||||
position: relative
|
||||
display: inline-block
|
||||
.radio
|
||||
position: relative
|
||||
display: inline-block
|
||||
|
||||
[type='radio']
|
||||
position: fixed
|
||||
top: -1px
|
||||
left: -1px
|
||||
width: 1px
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
[type='radio']
|
||||
position: fixed
|
||||
top: -1px
|
||||
left: -1px
|
||||
width: 1px
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
|
||||
label
|
||||
display: block
|
||||
height: 34px
|
||||
min-width: 42px
|
||||
line-height: 34px
|
||||
text-align: center
|
||||
padding: 0 13px
|
||||
background-color: #f3f3f3
|
||||
box-shadow: inset 2px 1px 2px 1px rgba(50, 50, 50, 0.05)
|
||||
color: var(--color-font)
|
||||
cursor: pointer
|
||||
label
|
||||
display: block
|
||||
height: 34px
|
||||
min-width: 42px
|
||||
line-height: 34px
|
||||
text-align: center
|
||||
padding: 0 13px
|
||||
background-color: #f3f3f3
|
||||
box-shadow: inset 2px 1px 2px 1px rgba(50, 50, 50, 0.05)
|
||||
color: var(--color-font)
|
||||
cursor: pointer
|
||||
|
||||
\:checked + label
|
||||
background-color: var(--color-primary)
|
||||
color: var(--color-lightwhite)
|
||||
box-shadow: inset -2px -1px 2px 1px rgba(255, 255, 255, 0.15)
|
||||
\:checked + label
|
||||
background-color: var(--color-primary)
|
||||
color: var(--color-lightwhite)
|
||||
box-shadow: inset -2px -1px 2px 1px rgba(255, 255, 255, 0.15)
|
||||
|
||||
\:focus + label
|
||||
border-color: #3273dc
|
||||
box-shadow: 0 0 0.125em 0 rgba(50, 115, 220, 0.8)
|
||||
outline: 0
|
||||
\:focus + label
|
||||
border-color: #3273dc
|
||||
box-shadow: 0 0 0.125em 0 rgba(50, 115, 220, 0.8)
|
||||
outline: 0
|
||||
|
||||
[disabled] + label
|
||||
pointer-events: none
|
||||
border: none
|
||||
opacity: 0.6
|
||||
filter: grayscale(1)
|
||||
[disabled] + label
|
||||
pointer-events: none
|
||||
border: none
|
||||
opacity: 0.6
|
||||
filter: grayscale(1)
|
||||
|
||||
.radio:first-child
|
||||
label
|
||||
border-top-left-radius: 4px
|
||||
border-bottom-left-radius: 4px
|
||||
.radio:first-child
|
||||
label
|
||||
border-top-left-radius: 4px
|
||||
border-bottom-left-radius: 4px
|
||||
|
||||
.radio:last-child
|
||||
label
|
||||
border-top-right-radius: 4px
|
||||
border-bottom-right-radius: 4px
|
||||
.radio:last-child
|
||||
label
|
||||
border-top-right-radius: 4px
|
||||
border-bottom-right-radius: 4px
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './radio.sass';
|
||||
|
||||
var RADIO_CLASS = 'radiobox';
|
||||
var RADIO_INITIALIZED_CLASS = 'radio--initialized';
|
||||
const RADIO_CLASS = 'radiobox';
|
||||
const RADIO_INITIALIZED_CLASS = 'radio--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'input[type="radio"]:not([uw-no-radio])',
|
||||
@ -28,13 +28,13 @@ export class Radio {
|
||||
return false;
|
||||
}
|
||||
|
||||
var siblingEl = element.nextSibling;
|
||||
var parentEl = element.parentElement;
|
||||
const siblingEl = element.nextSibling;
|
||||
const parentEl = element.parentElement;
|
||||
|
||||
var wrapperEl = document.createElement('div');
|
||||
const wrapperEl = document.createElement('div');
|
||||
wrapperEl.classList.add(RADIO_CLASS);
|
||||
|
||||
var labelEl = document.createElement('label');
|
||||
const labelEl = document.createElement('label');
|
||||
labelEl.setAttribute('for', element.id);
|
||||
|
||||
wrapperEl.appendChild(element);
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
box-shadow: 0 0 0.125em 0 rgba(50, 115, 220, 0.8)
|
||||
outline: 0
|
||||
|
||||
[disabled] + label
|
||||
[disabled] + label, [readonly] + label
|
||||
pointer-events: none
|
||||
border: none
|
||||
opacity: 0.6
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
/* global global:writable */
|
||||
|
||||
import { Utility } from '../../core/utility';
|
||||
import { Datepicker } from '../form/datepicker';
|
||||
import './mass-input.sass';
|
||||
@ -7,6 +9,11 @@ const MASS_INPUT_ADD_CELL_SELECTOR = '.massinput__cell--add';
|
||||
const MASS_INPUT_SUBMIT_BUTTON_CLASS = 'massinput__submit-button';
|
||||
const MASS_INPUT_INITIALIZED_CLASS = 'mass-input--initialized';
|
||||
|
||||
const MASS_INPUT_ADD_CHANGE_FIELD_SELECTOR = 'select, input[type=radio]';
|
||||
|
||||
// const MASS_INPUT_SAFETY_SUBMITTED_CLASS = 'massinput--safety-submitted';
|
||||
// const MASS_INPUT_SAFETY_SUBMITTED_TIMEOUT = 1000;
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-mass-input]',
|
||||
})
|
||||
@ -14,11 +21,14 @@ export class MassInput {
|
||||
|
||||
_element;
|
||||
_app;
|
||||
_global;
|
||||
|
||||
_massInputId;
|
||||
_massInputFormSubmitHandler;
|
||||
_massInputForm;
|
||||
|
||||
_changedAdd = new Array();
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Mass Input utility cannot be setup without an element!');
|
||||
@ -27,6 +37,14 @@ export class MassInput {
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (global !== undefined)
|
||||
this._global = global;
|
||||
else if (window !== undefined)
|
||||
this._global = window;
|
||||
else
|
||||
throw new Error('Cannot setup Mass Input utility without window or global');
|
||||
|
||||
|
||||
if (this._element.classList.contains(MASS_INPUT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
@ -47,8 +65,10 @@ export class MassInput {
|
||||
this._setupSubmitButton(button);
|
||||
});
|
||||
|
||||
this._massInputForm.addEventListener('submit', this._massInputFormSubmitHandler);
|
||||
this._massInputForm.addEventListener('keypress', this._keypressHandler);
|
||||
this._massInputForm.addEventListener('submit', this._massInputFormSubmitHandler.bind(this));
|
||||
this._massInputForm.addEventListener('keypress', this._keypressHandler.bind(this));
|
||||
|
||||
Array.from(this._element.querySelectorAll(MASS_INPUT_ADD_CELL_SELECTOR)).forEach(this._setupChangedHandlers.bind(this));
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(MASS_INPUT_INITIALIZED_CLASS);
|
||||
@ -58,6 +78,26 @@ export class MassInput {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_setupChangedHandlers(addCell) {
|
||||
Array.from(addCell.querySelectorAll(MASS_INPUT_ADD_CHANGE_FIELD_SELECTOR)).forEach(inputElem => {
|
||||
if (inputElem.closest('[uw-mass-input]') !== this._element)
|
||||
return;
|
||||
|
||||
inputElem.addEventListener('change', () => { this._changedAdd.push(addCell); });
|
||||
});
|
||||
}
|
||||
|
||||
_unsafeAddCells() {
|
||||
let changedAdd = this._changedAdd;
|
||||
|
||||
Array.from(this._element.querySelectorAll(MASS_INPUT_ADD_CELL_SELECTOR)).forEach(addCell => addCell.querySelectorAll('input:not([type=checkbox]):not([type=radio])').forEach(inputElem => {
|
||||
if (inputElem.closest('[uw-mass-input]') === this._element && inputElem.value !== '' && (inputElem.defaultValue || inputElem.getAttribute('value')) !== inputElem.value)
|
||||
changedAdd.push(addCell);
|
||||
}));
|
||||
|
||||
return changedAdd;
|
||||
}
|
||||
|
||||
_makeSubmitHandler() {
|
||||
const method = this._massInputForm.getAttribute('method') || 'POST';
|
||||
const url = this._massInputForm.getAttribute('action') || window.location.href;
|
||||
@ -69,31 +109,58 @@ export class MassInput {
|
||||
}
|
||||
|
||||
return (event) => {
|
||||
let activeElement;
|
||||
let submitButton;
|
||||
let isAddCell;
|
||||
|
||||
let isMassInputSubmit = (() => {
|
||||
let activeElement;
|
||||
|
||||
// check if event occured from either a mass input add/delete button or
|
||||
// from inside one of massinput's inputs (i.e. a child is focused/active)
|
||||
activeElement = this._element.querySelector(':focus, :active');
|
||||
// check if event occured from either a mass input add/delete button or
|
||||
// from inside one of massinput's inputs (i.e. a child is focused/active)
|
||||
activeElement = this._element.querySelector(':focus, :active');
|
||||
|
||||
if (!activeElement) {
|
||||
return false;
|
||||
if (!activeElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// find the according massinput cell thats hosts the element that triggered the submit
|
||||
const massInputCell = activeElement.closest(MASS_INPUT_CELL_SELECTOR);
|
||||
if (!massInputCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
submitButton = massInputCell.querySelector('.' + MASS_INPUT_SUBMIT_BUTTON_CLASS);
|
||||
if (!submitButton) {
|
||||
return false;
|
||||
}
|
||||
|
||||
isAddCell = massInputCell.matches(MASS_INPUT_ADD_CELL_SELECTOR);
|
||||
const submitButtonIsActive = submitButton.matches(':focus, :active');
|
||||
// if the cell is not an add cell the active element must at least be the cells submit button
|
||||
if (!isAddCell && !submitButtonIsActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})();
|
||||
|
||||
let unsafeAddCells = this._unsafeAddCells();
|
||||
|
||||
if (unsafeAddCells.length > 0 && !isMassInputSubmit) {
|
||||
let addButtons = Array.from(unsafeAddCells[0].querySelectorAll('.' + MASS_INPUT_SUBMIT_BUTTON_CLASS)).filter(addButton => addButton.closest('[uw-mass-input]') === this._element);
|
||||
|
||||
if (addButtons.length > 0) {
|
||||
submitButton = addButtons[0];
|
||||
isMassInputSubmit = true;
|
||||
isAddCell = false;
|
||||
|
||||
this._element.scrollIntoView();
|
||||
// this._element.classList.add(MASS_INPUT_SAFETY_SUBMITTED_CLASS);
|
||||
// this._global.setTimeout(() => { this._element.classList.remove(MASS_INPUT_SAFETY_SUBMITTED_CLASS) }, MASS_INPUT_SAFETY_SUBMITTED_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
// find the according massinput cell thats hosts the element that triggered the submit
|
||||
const massInputCell = activeElement.closest(MASS_INPUT_CELL_SELECTOR);
|
||||
if (!massInputCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const submitButton = massInputCell.querySelector('.' + MASS_INPUT_SUBMIT_BUTTON_CLASS);
|
||||
if (!submitButton) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAddCell = massInputCell.matches(MASS_INPUT_ADD_CELL_SELECTOR);
|
||||
const submitButtonIsActive = submitButton.matches(':focus, :active');
|
||||
// if the cell is not an add cell the active element must at least be the cells submit button
|
||||
if (!isAddCell && !submitButtonIsActive) {
|
||||
if (!isMassInputSubmit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -159,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);
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@use "../../app" as *
|
||||
|
||||
.massinput-list__wrapper, .massinput-list__cell
|
||||
display: grid
|
||||
grid: auto / auto 50px
|
||||
@ -12,3 +14,14 @@
|
||||
|
||||
.massinput-list__cell
|
||||
grid-column: 1 / 3
|
||||
|
||||
/* .massinput--safety-submitted
|
||||
/* animation: massinput--safety-submitted linear 1s
|
||||
|
||||
/* @keyframes massinput--safety-submitted
|
||||
/* 0%
|
||||
/* background-color: rgba(252, 153, 0, 0)
|
||||
/* 50%
|
||||
/* background-color: rgba(252, 153, 0, 0.8)
|
||||
/* 100%
|
||||
/* background-color: rgba(252, 153, 0, 0)
|
||||
|
||||
@ -57,23 +57,44 @@ export class ShowHide {
|
||||
this._element.classList.add(SHOW_HIDE_TOGGLE_RIGHT_CLASS);
|
||||
}
|
||||
|
||||
this._checkHash();
|
||||
|
||||
window.addEventListener('hashchange', this._checkHash.bind(this));
|
||||
|
||||
// mark as initialized
|
||||
this._element.classList.add(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._element.removeEventListener('click', this._clickHandler);
|
||||
}
|
||||
destroy() {}
|
||||
|
||||
_addClickListener() {
|
||||
this._element.addEventListener('click', this._clickHandler);
|
||||
this._element.addEventListener('click', this._clickHandler.bind(this));
|
||||
}
|
||||
|
||||
_clickHandler = () => {
|
||||
const newState = this._element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS);
|
||||
_show() {
|
||||
this._element.parentElement.classList.remove(SHOW_HIDE_COLLAPSED_CLASS);
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
return this._element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS);
|
||||
}
|
||||
|
||||
_clickHandler(event) {
|
||||
if (event.target.closest('a') && event.target.closest('a') !== this._element)
|
||||
return;
|
||||
if (event.target.matches('a') && event.target !== this._element)
|
||||
return;
|
||||
|
||||
const newState = this._toggle();
|
||||
|
||||
if (this._showHideId) {
|
||||
this._storageManager.save(this._showHideId, newState);
|
||||
}
|
||||
}
|
||||
|
||||
_checkHash() {
|
||||
if (this._element.id && '#' + this._element.id === location.hash) {
|
||||
this._show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ $show-hide-toggle-size: 6px
|
||||
border-right: 2px solid currentColor
|
||||
border-top: 2px solid currentColor
|
||||
transition: transform .2s ease
|
||||
transform: translateY(-50%) rotate(-45deg)
|
||||
transform: translateY(2px) translateY(-50%) rotate(-45deg)
|
||||
|
||||
@media (max-width: 768px)
|
||||
left: auto
|
||||
@ -33,7 +33,7 @@ $show-hide-toggle-size: 6px
|
||||
|
||||
.show-hide--collapsed
|
||||
.show-hide__toggle::before
|
||||
transform: translateY(-50%) rotate(135deg)
|
||||
transform: translateY(-2px) translateY(-50%) rotate(135deg)
|
||||
|
||||
& > :not(.show-hide__toggle)
|
||||
display: block
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
import './tabber.sass';
|
||||
|
||||
(function($) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
'use strict';
|
||||
|
||||
// define plugin
|
||||
$.fn.tabgroup = function() {
|
||||
|
||||
var $this = $(this);
|
||||
var $openers = $('<div class="tab-group-openers"></div>');
|
||||
$this.prepend($openers);
|
||||
|
||||
var openedByDefault = $this.data('tab-open') || 0;
|
||||
var tabs = [];
|
||||
var currentTab = {};
|
||||
|
||||
$this.find('.tab').each(function(i, t) {
|
||||
var tab = $(t);
|
||||
tab.data('tab-index', i);
|
||||
var tabName = tab.data('tab-name') || 'Tab '+i;
|
||||
var tabFile = tab.data('tab-file') || false;
|
||||
var $opener = makeOpener(tabName, i);
|
||||
$openers.append($opener);
|
||||
if (tab.find('.tab-title')) {
|
||||
tab.find('.tab-title').remove();
|
||||
}
|
||||
tab.hide();
|
||||
var loaded = false;
|
||||
tabs.push({index: i, name: tabName, file: tabFile, dom: tab, opener: $opener, loaded: loaded });
|
||||
});
|
||||
|
||||
$this.on('click', 'a[href^="#"]', function(event) {
|
||||
var $target = $(event.currentTarget);
|
||||
var tab = getTabByName($target.attr('href').replace('#', ''));
|
||||
if ( tab ) {
|
||||
showTab(tab.index);
|
||||
}
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
function getTabByName(name) {
|
||||
var it = -1;
|
||||
$.each(tabs, function(i, t) {
|
||||
if ( t.name.toLowerCase() === name.toLowerCase() ) {
|
||||
it = i;
|
||||
}
|
||||
});
|
||||
if ( it >= 0 ) {
|
||||
return tabs[it];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeOpener(tabName, i) {
|
||||
return $('<span class="tab-opener">'+tabName+'</span>').
|
||||
on('click', function() {
|
||||
showTab(i);
|
||||
});
|
||||
}
|
||||
|
||||
function showTab(i) {
|
||||
tabs.forEach(function(t) {
|
||||
t.dom.hide();
|
||||
t.opener.removeClass('tab-visible');
|
||||
});
|
||||
currentTab = tabs[i];
|
||||
if ( !currentTab.loaded && currentTab.file ){
|
||||
$.get(currentTab.file, function(res) {
|
||||
currentTab.dom.html(res);
|
||||
currentTab.loaded = true;
|
||||
});
|
||||
}
|
||||
currentTab.opener.addClass('tab-visible');
|
||||
currentTab.dom.show();
|
||||
}
|
||||
|
||||
showTab(openedByDefault);
|
||||
currentTab = tabs[openedByDefault];
|
||||
|
||||
};
|
||||
|
||||
// apply plugin to all available tab-groups if on wide screen
|
||||
if (window.innerWidth > 768) {
|
||||
$('.tab-group').each(function(i, t) {
|
||||
$(t).tabgroup();
|
||||
});
|
||||
}
|
||||
});
|
||||
})($);
|
||||
@ -1,35 +0,0 @@
|
||||
.tab-group
|
||||
border-top: 2px solid #dcdcdc
|
||||
padding-top: 30px
|
||||
|
||||
.tab-group-openers
|
||||
display: flex
|
||||
justify-content: stretch
|
||||
line-height: 40px
|
||||
font-size: 14px
|
||||
margin-bottom: 40px
|
||||
|
||||
.tab-opener
|
||||
display: inline-block
|
||||
flex: 1
|
||||
text-align: center
|
||||
padding: 0 13px
|
||||
margin: 0 2px
|
||||
background-color: var(--color-dark)
|
||||
color: white
|
||||
font-size: 16px
|
||||
text-transform: uppercase
|
||||
font-weight: 600
|
||||
transition: all .1s ease
|
||||
border-bottom: 5px solid rgba(100, 100, 100, 0.2)
|
||||
|
||||
.tab-opener:not(.tab-visible):hover
|
||||
cursor: pointer
|
||||
background-color: transparent
|
||||
color: rgb(52, 48, 58)
|
||||
border-bottom-color: grey
|
||||
|
||||
.tab-opener.tab-visible
|
||||
background-color: transparent
|
||||
color: rgb(52, 48, 58)
|
||||
border-bottom-color: var(--color-primary)
|
||||
@ -2,11 +2,11 @@ import { Utility } from '../../core/utility';
|
||||
import './tooltips.sass';
|
||||
import { MovementObserver } from '../../lib/movement-observer/movement-observer';
|
||||
|
||||
var TOOLTIP_CLASS = 'tooltip';
|
||||
var TOOLTIP_INITIALIZED_CLASS = 'tooltip--initialized';
|
||||
var TOOLTIP_OPEN_CLASS = 'tooltip--active';
|
||||
var TOOLTIP_ORIENTATION_MARGIN = 10;
|
||||
var TOOLTIP_CENTER_THRESHOLD = 20;
|
||||
const TOOLTIP_CLASS = 'tooltip';
|
||||
const TOOLTIP_INITIALIZED_CLASS = 'tooltip--initialized';
|
||||
const TOOLTIP_OPEN_CLASS = 'tooltip--active';
|
||||
const TOOLTIP_ORIENTATION_MARGIN = 10;
|
||||
const TOOLTIP_CENTER_THRESHOLD = 20;
|
||||
|
||||
@Utility({
|
||||
selector: `.${TOOLTIP_CLASS}`,
|
||||
|
||||
@ -138,3 +138,12 @@
|
||||
box-shadow: unset
|
||||
padding: 2px 15px 2px 2px
|
||||
background-color: rgba(0,0,0,0.05)
|
||||
|
||||
td.tooltip-only, th.tooltip-only
|
||||
.table--condensed &
|
||||
width: 22px
|
||||
padding: 4px
|
||||
|
||||
.tooltip__handle
|
||||
margin: 0
|
||||
|
||||
|
||||
10
load.sh
Executable file
10
load.sh
Executable file
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Options: see /test/Load.hs (Main)
|
||||
|
||||
set -e
|
||||
|
||||
[ "${FLOCKER}" != "$0" ] && exec env FLOCKER="$0" flock -en .stack-work.lock "$0" "$@" || :
|
||||
|
||||
stack build --fast --flag uniworx:-library-only --flag uniworx:dev
|
||||
|
||||
stack exec uniworxload -- $@
|
||||
364
load/Load.hs
Normal file
364
load/Load.hs
Normal file
@ -0,0 +1,364 @@
|
||||
{-# OPTIONS_GHC -fno-warn-unused-top-binds #-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-# OPTIONS_GHC -fno-warn-deprecations #-}
|
||||
|
||||
module Load
|
||||
( main
|
||||
) where
|
||||
|
||||
import "uniworx" Import hiding (Option(..), Normal, responseBody, responseStatus)
|
||||
import Utils.Form (FormIdentifier(..))
|
||||
import Handler.Admin.Test.Download (generateDownload', seedNew)
|
||||
|
||||
import System.Console.GetOpt
|
||||
|
||||
import qualified Data.Text as Text
|
||||
|
||||
import qualified Data.Map.Strict as Map
|
||||
|
||||
import Data.Random.Normal
|
||||
import qualified Control.Monad.Random.Class as Random
|
||||
import System.Random (RandomGen)
|
||||
|
||||
import System.Exit (exitWith, ExitCode(..))
|
||||
import System.IO (hPutStrLn)
|
||||
|
||||
import UnliftIO.Concurrent (threadDelay)
|
||||
|
||||
import System.Clock (getTime, Clock(Monotonic))
|
||||
import qualified System.Clock as Clock
|
||||
|
||||
import Network.URI
|
||||
|
||||
import qualified Data.ByteString.Lazy as Lazy (ByteString)
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Char8 as CBS
|
||||
import qualified Data.Char as Char (isSpace)
|
||||
|
||||
import Network.Wreq
|
||||
import Network.Wreq.Types (FormValue(..))
|
||||
import Network.Wreq.Session (Session)
|
||||
import qualified Network.Wreq.Session as Session
|
||||
import Network.HTTP.Client.MultipartFormData (partFileRequestBody)
|
||||
import Network.HTTP.Client.TLS (tlsManagerSettings)
|
||||
|
||||
import qualified Text.HTML.Scalpel as Scalpel
|
||||
|
||||
import qualified Data.Conduit.Combinators as C
|
||||
import Data.List (genericLength)
|
||||
|
||||
import qualified Control.Retry as Retry
|
||||
|
||||
|
||||
data Normal k = Normal
|
||||
{ dAvg :: k
|
||||
, dRelDev :: Centi
|
||||
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
|
||||
sampleN :: (Random.MonadSplit g m, RandomGen g) => (k -> Centi -> k) -> Normal k -> m k
|
||||
sampleN scale Normal{..}
|
||||
| dRelDev == 0 = return dAvg
|
||||
| otherwise = do
|
||||
gen <- Random.getSplit
|
||||
let (realToFrac -> r, _) = normal' (1, realToFrac dRelDev :: Double) gen
|
||||
return $ dAvg `scale` r
|
||||
|
||||
instance PathPiece k => PathPiece (Normal k) where
|
||||
toPathPiece Normal{dRelDev = MkFixed perc, dAvg}
|
||||
| perc == 0 = toPathPiece dAvg
|
||||
| otherwise = toPathPiece dAvg <> ";" <> toPathPiece perc <> "%"
|
||||
fromPathPiece t
|
||||
| (avg, relDev') <- Text.breakOn ";" t
|
||||
, Just relDev <- Text.stripSuffix "%" =<< Text.stripPrefix ";" relDev'
|
||||
= Normal <$> fromPathPiece avg <*> (MkFixed <$> fromPathPiece relDev)
|
||||
| otherwise
|
||||
= Normal <$> fromPathPiece t <*> pure 0
|
||||
|
||||
scaleDiffTime :: DiffTime -> Centi -> DiffTime
|
||||
scaleDiffTime (diffTimeToPicoseconds -> ps) s = picosecondsToDiffTime . round $ s * fromIntegral ps
|
||||
|
||||
sampleNDiffTime :: (Random.MonadSplit g m, RandomGen g) => Normal DiffTime -> m DiffTime
|
||||
sampleNDiffTime = sampleN scaleDiffTime
|
||||
|
||||
|
||||
scaleIntegral :: Integral n => n -> Centi -> n
|
||||
scaleIntegral n s = round $ toRational n * toRational s
|
||||
|
||||
sampleIntegral :: (Random.MonadSplit g m, RandomGen g, Integral n) => Normal n -> m n
|
||||
sampleIntegral = sampleN scaleIntegral
|
||||
|
||||
|
||||
instance PathPiece DiffTime where
|
||||
toPathPiece = (toPathPiece :: Pico -> Text) . MkFixed . diffTimeToPicoseconds
|
||||
fromPathPiece t = fromPathPiece t <&> \(MkFixed ps :: Pico) -> picosecondsToDiffTime ps
|
||||
|
||||
|
||||
data LoadSimulation
|
||||
= LoadSheetDownload
|
||||
| LoadSheetSubmission
|
||||
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
|
||||
deriving anyclass (Universe, Finite)
|
||||
|
||||
nullaryPathPiece ''LoadSimulation $ camelToPathPiece' 1
|
||||
|
||||
data LoadOptions = LoadOptions
|
||||
{ loadSimulations :: Map LoadSimulation SimulationOptions
|
||||
, loadBaseURI :: URI
|
||||
, loadToken :: Maybe Jwt
|
||||
, loadTerm :: TermId, loadSchool :: SchoolId, loadCourse :: CourseShorthand, loadSheet :: SheetName
|
||||
, loadUploadChunks :: Normal Natural, loadUploadChunkSize :: Normal Natural
|
||||
} deriving (Eq, Ord, Show, Generic, Typeable)
|
||||
|
||||
instance Default LoadOptions where
|
||||
def = LoadOptions
|
||||
{ loadSimulations = Map.empty
|
||||
, loadBaseURI = error "No BaseURI given"
|
||||
, loadToken = Nothing
|
||||
, loadTerm = error "No term given", loadSchool = error "No school given", loadCourse = error "No course given", loadSheet = error "No sheet given"
|
||||
, loadUploadChunks = Normal 48 0.11
|
||||
, loadUploadChunkSize = Normal (2^16) 0
|
||||
}
|
||||
|
||||
data SimulationOptions = SimulationOptions
|
||||
{ simParallel :: Natural
|
||||
, simDelay, simDuration :: Normal DiffTime
|
||||
} deriving (Eq, Ord, Show, Generic, Typeable)
|
||||
|
||||
instance Default SimulationOptions where
|
||||
def = SimulationOptions
|
||||
{ simParallel = 1
|
||||
, simDelay = Normal 0 0
|
||||
, simDuration = Normal 10 0
|
||||
}
|
||||
|
||||
|
||||
data SimulationContext = SimulationContext
|
||||
{ loadOptions :: LoadOptions
|
||||
, simulationOptions :: SimulationOptions
|
||||
, targetDuration :: DiffTime
|
||||
, runtime :: forall m. MonadIO m => m DiffTime
|
||||
}
|
||||
|
||||
|
||||
makeLenses_ ''LoadOptions
|
||||
makeLenses_ ''SimulationOptions
|
||||
makeLenses_ ''SimulationContext
|
||||
|
||||
|
||||
_MapF :: (Finite k, Ord k) => Iso' (Map k v) (k -> Maybe v)
|
||||
_MapF = iso (flip Map.lookup) (\f -> Map.fromList $ mapMaybe (\k -> (k, ) <$> f k) universeF)
|
||||
|
||||
|
||||
argsDescr :: [OptDescr (Kleisli IO LoadOptions LoadOptions)]
|
||||
argsDescr
|
||||
= [ Option ['n', 'p'] ["number", "parallel"] (ReqArg (\(splitArg -> (cloneIndexedTraversal -> f, arg)) -> Kleisli $ return . over f (set _simParallel arg)) "NATURAL") "Number of simulations to run in parallel"
|
||||
, Option ['r'] ["run"] (ReqArg (\(ppArg -> sim) -> Kleisli $ return . over (_loadSimulations . at sim) (<|> Just def)) "SIMULATION") "Run the given Simulation"
|
||||
, Option ['d'] ["duration"] (ReqArg (\(splitArg -> (cloneIndexedTraversal -> f, arg)) -> Kleisli $ return . over f (set _simDuration arg)) "DURATION") "Try to run each simulation to take up the given duration"
|
||||
, Option ['w', 's'] ["wait", "delay", "stagger"] (ReqArg (\(splitArg -> (cloneIndexedTraversal -> f, arg)) -> Kleisli $ return . over f (set _simDelay arg)) "DURATION") "Wait the given time before starting each simulation"
|
||||
, Option ['b', 'u'] ["base", "uri"] (ReqArg (\uriStr -> let uri = fromMaybe (error $ "Could not parse URI: " <> uriStr) $ parseURI uriStr in Kleisli $ return . set _loadBaseURI uri ) "URI") "Base URI"
|
||||
, Option ['t'] ["token"] (ReqArg (Kleisli . loadTokenFile) "FILE") "File containing bearer token"
|
||||
, Option [] ["tid", "term"] (ReqArg (\(ppArg -> tid) -> Kleisli $ return . set _loadTerm tid) "TERM") "TermId"
|
||||
, Option [] ["ssh", "school"] (ReqArg (\(ppArg -> ssh) -> Kleisli $ return . set _loadSchool ssh) "SCHOOL") "SchoolId"
|
||||
, Option [] ["csh", "course"] (ReqArg (\(ppArg -> csh) -> Kleisli $ return . set _loadCourse csh) "COURSE") "CourseName"
|
||||
, Option [] ["shn", "sheet"] (ReqArg (\(ppArg -> shn) -> Kleisli $ return . set _loadSheet shn) "SHEET") "SheetName"
|
||||
, Option [] ["chunks"] (ReqArg (\(ppArg -> cs) -> Kleisli $ return . set _loadUploadChunks cs) "NATURAL") "Number of chunks to upload"
|
||||
, Option [] ["chunk-size"] (ReqArg (\(ppArg -> cs) -> Kleisli $ return . set _loadUploadChunkSize cs) "NATURAL") "Size of chunks to upload"
|
||||
]
|
||||
where
|
||||
splitArg :: PathPiece p => String -> (AnIndexedTraversal' LoadSimulation LoadOptions SimulationOptions, p)
|
||||
splitArg (Text.pack -> t)
|
||||
| (ref, arg) <- Text.breakOn ":" t
|
||||
, let refs = Text.splitOn "," ref
|
||||
sArg = Text.stripPrefix ":" arg
|
||||
, Just refs' <- if | is _Just sArg -> mapM fromPathPiece refs
|
||||
| otherwise -> Just []
|
||||
, Just arg' <- fromPathPiece $ fromMaybe ref sArg
|
||||
= (, arg') $ if
|
||||
| null refs' -> _loadSimulations . itraversed
|
||||
| otherwise -> _loadSimulations . _MapF . itraversed . indices (`elem` refs') . iplens (fromMaybe def) (const Just)
|
||||
| otherwise
|
||||
= terror $ "Invalid option argument: " <> t
|
||||
|
||||
ppArg :: PathPiece p => String -> p
|
||||
ppArg (Text.pack -> a) = fromMaybe (terror $ "Invalid option argument: " <> a) $ fromPathPiece a
|
||||
|
||||
loadTokenFile :: FilePath -> LoadOptions -> IO LoadOptions
|
||||
loadTokenFile fp pOpts = do
|
||||
token <- Jwt . CBS.filter (not . Char.isSpace) <$> BS.readFile fp
|
||||
return $ pOpts & _loadToken ?~ token
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
args <- map unpack <$> getArgs
|
||||
case getOpt Permute argsDescr args of
|
||||
(kl, [], []) -> do
|
||||
cfg <- over (mapped . _loadSimulations) (Map.filter $ (> 0) . simParallel) . (`runKleisli` def) . getDual $ foldMap Dual kl
|
||||
if | not . Map.null $ loadSimulations cfg
|
||||
-> imapM_ (\sim simOpts -> runReaderT (runSimulation sim) (cfg & _loadSimulations . at sim .~ Nothing, simOpts)) $ loadSimulations cfg
|
||||
| otherwise -> do
|
||||
hPutStrLn stderr $ usageInfo "uniworxload" argsDescr
|
||||
exitWith $ ExitFailure 2
|
||||
(_, _, errs) -> do
|
||||
forM_ errs $ hPutStrLn stderr
|
||||
hPutStrLn stderr $ usageInfo "uniworxload" argsDescr
|
||||
exitWith $ ExitFailure 2
|
||||
|
||||
runSimulation :: LoadSimulation -> ReaderT (LoadOptions, SimulationOptions) IO ()
|
||||
runSimulation sim = do
|
||||
p <- view $ _2 . _simParallel
|
||||
delays <- replicateM (fromIntegral p) $ do
|
||||
d <- view $ _2 . _simDelay
|
||||
sampleNDiffTime d
|
||||
|
||||
forConcurrently_ ([1..p] `zip` sort delays) $ \(n, d') -> do
|
||||
begin <- liftIO getCurrentTime
|
||||
|
||||
dur <- view $ _2 . _simDuration
|
||||
tDuration <- sampleNDiffTime dur
|
||||
|
||||
let MkFixed us = realToFrac d' :: Micro
|
||||
threadDelay $ fromInteger us
|
||||
start <- liftIO getCurrentTime
|
||||
print ("start", n, diffUTCTime start begin, utctDayTime start)
|
||||
|
||||
cTime <- liftIO $ getTime Monotonic
|
||||
let running :: forall m. MonadIO m => m DiffTime
|
||||
running = do
|
||||
cTime' <- liftIO $ getTime Monotonic
|
||||
let diff = MkFixed . Clock.toNanoSecs $ cTime' - cTime :: Nano
|
||||
MkFixed ps = realToFrac diff :: Pico
|
||||
return $ picosecondsToDiffTime ps
|
||||
|
||||
withReaderT (\(lO, sO) -> SimulationContext lO sO tDuration running) $ runSimulation' sim
|
||||
end <- liftIO getCurrentTime
|
||||
|
||||
print ("end", n, diffUTCTime start begin, diffUTCTime end start)
|
||||
|
||||
|
||||
delayRemaining :: (MonadReader SimulationContext m, MonadIO m, RealFrac r) => r -> m ()
|
||||
delayRemaining p = do
|
||||
total <- asks targetDuration
|
||||
cTime <- join $ asks runtime
|
||||
let remaining = MkFixed . diffTimeToPicoseconds $ total - cTime :: Pico
|
||||
MkFixed us = realToFrac $ realToFrac remaining * p :: Micro
|
||||
threadDelay $ fromInteger us
|
||||
|
||||
|
||||
runSimulation' :: LoadSimulation -> ReaderT SimulationContext IO ()
|
||||
runSimulation' LoadSheetDownload = do
|
||||
session <- newLoadSession
|
||||
uri <- sheetZipURI
|
||||
resp <- liftIO . Session.get session $ uriToString id uri mempty
|
||||
void . evaluate $! resp
|
||||
-- print . length $ resp ^. responseBody
|
||||
runSimulation' LoadSheetSubmission = do
|
||||
LoadOptions{..} <- asks loadOptions
|
||||
session <- newLoadSession
|
||||
let formURI = formURI' `relativeTo` loadBaseURI
|
||||
where formURI' = nullURI { uriPath = unpack . Text.intercalate "/" $ "." : formPath }
|
||||
(formPath, _) = renderRoute $ CSheetR loadTerm loadSchool loadCourse loadSheet SubmissionNewR
|
||||
resp <- liftIO . httpRetry . Session.get session $ uriToString id formURI mempty
|
||||
void . evaluate $! resp
|
||||
procStart <- join $ asks runtime
|
||||
-- Just formData <- return . getFormData FIDsubmission $ resp ^. responseBody
|
||||
-- Just addButtonData <- return . flip (runFormScraper FIDsubmission) (resp ^. responseBody) $ do
|
||||
-- let btnSel = "button" Scalpel.@: [Scalpel.hasClass "btn-mass-input-add"]
|
||||
|
||||
-- name <- Scalpel.attr "name" btnSel
|
||||
-- value <- Scalpel.attr "value" btnSel
|
||||
-- guard $ value == "add__0__0"
|
||||
-- return $ toStrict name := value
|
||||
-- let miData = addButtonData : map addEmail formData
|
||||
-- where addEmail dat@(name := _)
|
||||
-- | "__add__0__fields__emails" `isSuffixOf` name = name := ("loadtest@example.invalid" :: Text)
|
||||
-- | otherwise = dat
|
||||
-- resp2 <- liftIO $ Session.post session (uriToString id formURI mempty) miData
|
||||
-- Just formData2 <- return . getFormData FIDsubmission $ resp2 ^. responseBody
|
||||
Just formData2 <- return . getFormData FIDsubmission $ resp ^. responseBody
|
||||
uploadSeed <- liftIO seedNew
|
||||
chunkCount <- sampleIntegral loadUploadChunks
|
||||
chunks <- replicateM (fromIntegral chunkCount) $ sampleIntegral loadUploadChunkSize
|
||||
simCtx <- ask
|
||||
let fileUploadPart = requestBodySourceChunked $
|
||||
yieldMany (zip [0..] chunks)
|
||||
.| runReaderC simCtx
|
||||
( C.mapM $ \(ci, cs) ->
|
||||
fromIntegral cs <$ delayRemaining (1 % (genericLength chunks - ci) :: Rational)
|
||||
)
|
||||
.| generateDownload' uploadSeed
|
||||
-- print $ ala Sum foldMap chunks
|
||||
Just fileData <- return . flip (runFormScraper FIDsubmission) (resp ^. responseBody) $ do
|
||||
let fileSel = "input" Scalpel.@: ["type" Scalpel.@= "file"]
|
||||
|
||||
name <- Scalpel.attr "name" fileSel
|
||||
return $ partFileRequestBody (decodeUtf8 $ toStrict name) "loadtest.bin" fileUploadPart
|
||||
|
||||
let subData = (:) fileData $ formData2 >>= \(name := (renderFormValue -> value)) -> do
|
||||
guard $ name /= encodeUtf8 (fileData ^. partName)
|
||||
return $ partBS (decodeUtf8 name) value
|
||||
void . evaluate $! subData
|
||||
procEnd <- join $ asks runtime
|
||||
|
||||
print ("proc", procEnd - procStart)
|
||||
|
||||
resp3 <- liftIO . httpRetry $ Session.post session (uriToString id formURI mempty) subData
|
||||
void . evaluate $! resp3
|
||||
where
|
||||
httpRetry act = Retry.recovering policy handlers $ \Retry.RetryStatus{..} -> do
|
||||
putStrLn $ "httpRetry; rsIterNumber = " <> tshow rsIterNumber
|
||||
act
|
||||
where policy = Retry.fullJitterBackoff 1e3 & Retry.limitRetriesByCumulativeDelay 10e6
|
||||
handlers = Retry.skipAsyncExceptions `snoc` Retry.logRetries suggestRetry logRetry
|
||||
|
||||
suggestRetry :: forall m. Monad m => SomeException -> m Bool
|
||||
suggestRetry _ = return True
|
||||
|
||||
logRetry :: forall e m.
|
||||
( Exception e
|
||||
, MonadIO m
|
||||
)
|
||||
=> Bool -- ^ Will retry
|
||||
-> e
|
||||
-> Retry.RetryStatus
|
||||
-> m ()
|
||||
logRetry shouldRetry err status = liftIO . putStrLn . pack $ Retry.defaultLogMsg shouldRetry err status
|
||||
|
||||
|
||||
-- runSimulation' other = terror $ "Not implemented: " <> tshow other
|
||||
|
||||
runFormScraper :: FormIdentifier -> Scalpel.Scraper Lazy.ByteString a -> Lazy.ByteString -> Maybe a
|
||||
runFormScraper fid innerS = fmap join . flip Scalpel.scrapeStringLike $
|
||||
fmap listToMaybe . Scalpel.chroots "form" $ do
|
||||
fid' <- Scalpel.attr "value" $ "input" Scalpel.@: ["name" Scalpel.@= "form-identifier"]
|
||||
guard $ fid' == encodeUtf8 (fromStrict $ toPathPiece fid)
|
||||
|
||||
innerS
|
||||
|
||||
getFormData :: FormIdentifier -> Lazy.ByteString -> Maybe [FormParam]
|
||||
getFormData = flip runFormScraper $
|
||||
Scalpel.chroots ("input") $ do
|
||||
name <- Scalpel.attr "name" Scalpel.anySelector
|
||||
value <- Scalpel.attr "value" Scalpel.anySelector <|> pure ""
|
||||
return $ toStrict name := value
|
||||
|
||||
|
||||
newLoadSession :: ReaderT SimulationContext IO Session
|
||||
newLoadSession = do
|
||||
LoadOptions{..} <- asks loadOptions
|
||||
|
||||
let withToken = case loadToken of
|
||||
Nothing -> id
|
||||
Just (Jwt bs) -> (:) (hAuthorization, "Bearer " <> bs) . filter ((/= hAuthorization) . fst)
|
||||
|
||||
|
||||
liftIO . Session.newSessionControl (Just mempty) $ tlsManagerSettings
|
||||
{ managerModifyRequest = \req -> return $ req { requestHeaders = withToken $ requestHeaders req }
|
||||
, managerResponseTimeout = responseTimeoutNone
|
||||
}
|
||||
|
||||
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 -- FIXME: Broken with ApprootUserGenerated
|
||||
return $ zipURI `relativeTo` loadBaseURI
|
||||
@ -1,6 +1,4 @@
|
||||
CampusIdentPlaceholder: Vorname.Nachname@campus.lmu.de
|
||||
CampusIdent: Campus-Kennung
|
||||
CampusPassword: Passwort
|
||||
CampusPasswordPlaceholder: Passwort
|
||||
CampusSubmit: Abschicken
|
||||
CampusInvalidCredentials: Ungültige Logindaten
|
||||
CampusPasswordPlaceholder: Passwort
|
||||
@ -1,6 +1,4 @@
|
||||
CampusIdentPlaceholder: First.Last@campus.lmu.de
|
||||
CampusIdent: Campus account
|
||||
CampusPassword: Password
|
||||
CampusPasswordPlaceholder: Password
|
||||
CampusSubmit: Send
|
||||
CampusInvalidCredentials: Invalid login
|
||||
CampusPasswordPlaceholder: Password
|
||||
8
messages/faq/de-de-formal.msg
Normal file
8
messages/faq/de-de-formal.msg
Normal file
@ -0,0 +1,8 @@
|
||||
FAQNoCampusAccount: Ich habe keine LMU-Benutzerkennung (ehem. Campus-Kennung); kann ich trotzdem Zugang zum System erhalten?
|
||||
FAQForgottenPassword: Ich habe mein Passwort vergessen
|
||||
FAQCampusCantLogin: Ich kann mich mit meiner LMU-Benutzerkennung (ehem. Campus-Kennung) nicht anmelden
|
||||
FAQCourseCorrectorsTutors: Wie kann ich Tutoren oder Korrektoren für meinen Kurs konfigurieren?
|
||||
FAQNotLecturerHowToCreateCourses: Wie kann ich einen neuen Kurs anlegen?
|
||||
FAQExamPoints: Warum kann ich bei meiner Klausur keine Punkte eintragen?
|
||||
FAQInvalidCredentialsAdAccountDisabled: Ich kann mich nicht anmelden und bekomme die Meldung „Benutzereintrag gesperrt“
|
||||
FAQAllocationNoPlaces: Ich habe über eine Zentralanmeldung keine Plätze/nicht die Plätze, die ich möchte, erhalten
|
||||
8
messages/faq/en-eu.msg
Normal file
8
messages/faq/en-eu.msg
Normal file
@ -0,0 +1,8 @@
|
||||
FAQNoCampusAccount: I don't have a LMU user ID (formerly Campus-ID); can I still get access to Uni2work?
|
||||
FAQForgottenPassword: I have forgotten my password
|
||||
FAQCampusCantLogin: I can't log in using my LMU user ID (formerly Campus-ID)
|
||||
FAQCourseCorrectorsTutors: How can I add tutors or correctors to my course?
|
||||
FAQNotLecturerHowToCreateCourses: How can I create new courses?
|
||||
FAQExamPoints: Why can't I enter achievements for my exam as points?
|
||||
FAQInvalidCredentialsAdAccountDisabled: I can't log in and am instead given the message “Account disabled”
|
||||
FAQAllocationNoPlaces: I did not receive any places/the places I wanted from a central allocation
|
||||
@ -1,4 +1,6 @@
|
||||
FilesSelected: Dateien ausgewählt
|
||||
SelectFile: Datei auswählen
|
||||
SelectFiles: Datei(en) auswählen
|
||||
AsyncFormFailure: Da ist etwas schief gelaufen, das tut uns Leid. Falls das erneut passiert schicken Sie uns bitte eine kurze Beschreibung dieses Ereignisses über das Hilfe-Widget rechts oben. Vielen Dank für Ihre Hilfe!
|
||||
AsyncFormFailure: Da ist etwas schief gelaufen, das tut uns Leid. Falls das erneut passiert schicken Sie uns bitte eine kurze Beschreibung dieses Ereignisses über das Hilfe-Widget rechts oben. Vielen Dank für Ihre Hilfe!
|
||||
FileTooLarge: Die ausgewählte Datei ist zu groß
|
||||
FileTooLargeMultiple: Mindestens eine der ausgewählten Dateien ist zu groß
|
||||
@ -2,3 +2,5 @@ FilesSelected: Files selected
|
||||
SelectFile: Select file
|
||||
SelectFiles: Select file(s)
|
||||
AsyncFormFailure: Something went wrong, we are sorry. If this error occurs again, please let us know by clicking the Support button in the upper right corner. Thank you very much!
|
||||
FileTooLarge: The selected file is too large
|
||||
FileTooLargeMultiple: At least one of the selected files is too large
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
12
minio-file-uploads.md
Normal file
12
minio-file-uploads.md
Normal file
@ -0,0 +1,12 @@
|
||||
- `SessionFile` should only have `touched` and `content`; just there to keep `FileContent` alive
|
||||
Store `fileTitle` and `fileModified` within actual session
|
||||
|
||||
Better symmetry with e.g. `SubmissionFile`
|
||||
- Restrict `genericFileField` to not allow duplicate `fileTitle`s
|
||||
|
||||
Make `fieldAdditionalFiles` isomophoric to `[FileReference, FileFieldUserOption Bool]`
|
||||
|
||||
`type FileUploads = Map FileTitle (Maybe FileContentReference, UTCTime)` (`~ [FileReference]`)
|
||||
- Route um Uploads nachzureichen
|
||||
|
||||
Cronjob, der `FileContent` aus MinIO füttert sollte panische E-Mail mit Link auf jene Route verschicken, wenn Datei nicht zur Hand ist
|
||||
@ -79,11 +79,19 @@ for msgDirectory (${msgDirectories}); do
|
||||
|
||||
# printf ">>> %s\n" ${msgDirectory}
|
||||
if [[ $fix != 0 ]]; then
|
||||
diff -u0 --suppress-common-lines -wB ${diffArgs} | grep -vE '^@@.*@@'
|
||||
diffStatus=$pipestatus[0]
|
||||
if [[ ${#dirMsgFiles} -gt 1 ]]; then
|
||||
diff -u0 --suppress-common-lines -wB ${diffArgs} | grep -vE '^@@.*@@'
|
||||
diffStatus=$pipestatus[1]
|
||||
else
|
||||
diffStatus=1
|
||||
fi
|
||||
else
|
||||
diff -u0 --suppress-common-lines -wB ${diffArgs} >/dev/null
|
||||
diffStatus=$?
|
||||
if [[ ${#dirMsgFiles} -gt 1 ]]; then
|
||||
diff -u0 --suppress-common-lines -wB ${diffArgs} >/dev/null
|
||||
diffStatus=$?
|
||||
else
|
||||
diffStatus=1
|
||||
fi
|
||||
|
||||
if [[ ${diffStatus} == 1 ]]; then
|
||||
./translate.hs msgs ${dirMsgFiles} && diffStatus=0
|
||||
@ -116,23 +124,46 @@ for templateDirectory (templates/i18n/**/*(FN)); do
|
||||
fi
|
||||
done
|
||||
|
||||
typeset -a templatePrefixes
|
||||
templatePrefixes=()
|
||||
for templateFile (${templateFiles}); do
|
||||
[[ ${templateFile:h} == ${templateDirectory} ]] || continue
|
||||
|
||||
templatePrefix=$(sed -r 's/^(.*\.)?[^.]+\.[^.]+$/\1/' <<<"${templateFile:t}")
|
||||
|
||||
if ! ((${templatePrefixes[(Ie)${templatePrefix}]})); then
|
||||
templatePrefixes+=("${templatePrefix}")
|
||||
fi
|
||||
done
|
||||
|
||||
# printf "%d %s\n" ${#templatePrefixes} "${templatePrefixes}"
|
||||
|
||||
for ext (${templateExtensions}); do
|
||||
for lang (${requiredLangs}); do
|
||||
foundLang=0
|
||||
for templateFile (${templateDirectory}/*.${ext}); do
|
||||
[[ ${templateFile:t} =~ "(^|.)${lang}[-.]" ]] || continue
|
||||
foundLang=1
|
||||
break
|
||||
done
|
||||
|
||||
if [[ $foundLang -ne 1 ]]; then
|
||||
templateDifference=1
|
||||
[[ $fix != 0 ]] && printf "%s: %s (%s)\n" $templateDirectory $lang $ext
|
||||
|
||||
if [[ $fix == 0 ]]; then
|
||||
./translate.hs dir $templateDirectory && templateDifference=0
|
||||
fi
|
||||
fi
|
||||
for prefixQ (${(q)templatePrefixes}); do
|
||||
prefix=${(Q)prefixQ}
|
||||
# printf ">> %s %s %s\n" ${prefix} ${lang} ${ext}
|
||||
|
||||
foundLang=1
|
||||
for templateFile (${templateDirectory}/*.${ext}); do
|
||||
# printf "%s\n" ${templateFile}
|
||||
[[ ${templateFile:t} =~ "^${prefix}${lang}[-.]" ]] || continue
|
||||
# printf "match\n"
|
||||
foundLang=0
|
||||
break
|
||||
done
|
||||
|
||||
# printf ">> %s\n" ${foundLang}
|
||||
|
||||
if [[ $foundLang -ne 0 ]]; then
|
||||
templateDifference=1
|
||||
[[ $fix != 0 ]] && printf "%s: %s*.%s (%s)\n" "$templateDirectory" "$prefix" "$ext" "$lang"
|
||||
|
||||
if [[ $fix == 0 ]]; then
|
||||
./translate.hs dir $templateDirectory && templateDifference=0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
@ -3,8 +3,8 @@ Allocation -- attributes with prefix staff- affect lecturers only, but are invis
|
||||
school SchoolId -- school that manages this central allocation, not necessarily school of courses
|
||||
shorthand AllocationShorthand -- practical shorthand
|
||||
name AllocationName
|
||||
description Html Maybe -- description for prospective students
|
||||
staffDescription Html Maybe -- description seen by prospective lecturers only
|
||||
description StoredMarkup Maybe -- description for prospective students
|
||||
staffDescription StoredMarkup Maybe -- description seen by prospective lecturers only
|
||||
staffRegisterFrom UTCTime Maybe -- lectureres may register courses
|
||||
staffRegisterTo UTCTime Maybe -- course registration stops
|
||||
-- staffDeregisterUntil not needed: staff may make arbitrary changes until staffRegisterTo, always frozen afterwards
|
||||
@ -21,6 +21,7 @@ Allocation -- attributes with prefix staff- affect lecturers only, but are invis
|
||||
registerByCourse UTCTime Maybe -- course registration dates are ignored until this day has passed or always prohibited
|
||||
overrideDeregister UTCTime Maybe -- course deregistration enforced to be this date, i.e. students may disenrol from course after or never
|
||||
-- overrideVisible not needed, since courses are always visible
|
||||
matchingSeed ByteString default='\x'::bytea
|
||||
TermSchoolAllocationShort term school shorthand -- shorthand must be unique within school and semester
|
||||
TermSchoolAllocationName term school name -- name must be unique within school and semester
|
||||
deriving Show Eq Ord Generic
|
||||
@ -29,23 +30,32 @@ AllocationMatching
|
||||
allocation AllocationId
|
||||
fingerprint AllocationFingerprint
|
||||
time UTCTime
|
||||
log FileId
|
||||
log FileContentReference
|
||||
|
||||
AllocationCourse
|
||||
allocation AllocationId
|
||||
course CourseId
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
AllocationNotificationSetting
|
||||
user UserId
|
||||
allocation AllocationId
|
||||
isOptOut Bool
|
||||
UniqueAllocationNotificationSetting user allocation
|
||||
@ -4,4 +4,5 @@ TransactionLog
|
||||
instance InstanceId
|
||||
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`
|
||||
info Value -- JSON-encoded `Transaction`
|
||||
deriving Eq Read Show Generic Typeable
|
||||
4
models/changelog.model
Normal file
4
models/changelog.model
Normal file
@ -0,0 +1,4 @@
|
||||
ChangelogItemFirstSeen
|
||||
item ChangelogItem
|
||||
firstSeen Day
|
||||
Primary item
|
||||
@ -5,20 +5,23 @@ DegreeCourse json -- for which degree programmes this course is appropriate fo
|
||||
UniqueDegreeCourse course degree terms
|
||||
Course -- Information about a single course; contained info is always visible to all users
|
||||
name (CI Text)
|
||||
description Html Maybe -- user-defined large Html, ought to contain module description
|
||||
linkExternal Text Maybe -- arbitrary user-defined url for external course page
|
||||
description StoredMarkup Maybe -- user-defined large Html, ought to contain module description
|
||||
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
|
||||
capacity Int Maybe -- number of allowed enrolements, if restricted
|
||||
-- canRegisterNow = maybe False (<= currentTime) registerFrom && maybe True (>= currentTime) registerTo
|
||||
visibleFrom UTCTime Maybe default=now() -- course may be visible from a given day onwards or always hidden
|
||||
visibleTo UTCTime Maybe -- course may be hidden from a given date onwards
|
||||
registerFrom UTCTime Maybe -- enrolement allowed from a given day onwwards or prohibited
|
||||
registerTo UTCTime Maybe -- enrolement may be prohibited from a given date onwards
|
||||
deregisterUntil UTCTime Maybe -- unenrolement may be prohibited from a given date onwards
|
||||
deregisterNoShow Bool default=false
|
||||
registerSecret Text Maybe -- enrolement maybe protected by a simple common passphrase
|
||||
materialFree Bool -- False: only enrolled users may see course materials not stored in this table
|
||||
applicationsRequired Bool default=false
|
||||
applicationsInstructions Html Maybe
|
||||
applicationsInstructions StoredMarkup Maybe
|
||||
applicationsText Bool default=false
|
||||
applicationsFiles UploadMode "default='{\"mode\": \"no-upload\"}'::jsonb"
|
||||
applicationsRatingsVisible Bool default=false
|
||||
@ -28,15 +31,18 @@ Course -- Information about a single course; contained info is always visible
|
||||
CourseEvent
|
||||
type (CI Text)
|
||||
course CourseId
|
||||
room Text
|
||||
room RoomReference Maybe
|
||||
roomHidden Bool default=false
|
||||
time Occurrences
|
||||
note Html Maybe
|
||||
note StoredMarkup Maybe
|
||||
lastChanged UTCTime default=now()
|
||||
|
||||
CourseAppInstructionFile
|
||||
course CourseId
|
||||
file FileId
|
||||
UniqueCourseAppInstructionFile course file
|
||||
course CourseId
|
||||
title FilePath
|
||||
content FileContentReference Maybe
|
||||
modified UTCTime
|
||||
UniqueCourseAppInstructionFile course title
|
||||
|
||||
CourseEdit -- who edited when a row in table "Course", kept indefinitely (might be replaced by generic Audit Table; like all ...-Edit tables)
|
||||
user UserId
|
||||
@ -51,9 +57,11 @@ CourseParticipant -- course enrolement
|
||||
course CourseId
|
||||
user UserId
|
||||
registration UTCTime -- time of last enrolement for this course
|
||||
field StudyFeaturesId Maybe -- associated degree course, user-defined; required for communicating grades
|
||||
field StudyFeaturesId Maybe MigrationOnly
|
||||
allocated AllocationId Maybe -- participant was centrally allocated
|
||||
state CourseParticipantState
|
||||
UniqueParticipant user course
|
||||
deriving Eq Ord Show
|
||||
-- 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
|
||||
@ -65,7 +73,7 @@ CourseParticipant -- course enrolement
|
||||
CourseUserNote -- lecturers of a specific course may share a text note on each enrolled student
|
||||
course CourseId
|
||||
user UserId
|
||||
note Html -- arbitrary user-defined text; visible only to lecturer of this course
|
||||
note StoredMarkup -- arbitrary user-defined text; visible only to lecturer of this course
|
||||
UniqueCourseUserNote user course
|
||||
CourseUserNoteEdit -- who edited a participants course note when
|
||||
user UserId
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
CourseApplication
|
||||
course CourseId
|
||||
user UserId
|
||||
field StudyFeaturesId Maybe -- associated degree course, user-defined; required for communicating grades
|
||||
field StudyFeaturesId Maybe MigrationOnly
|
||||
text Text Maybe -- free text entered by user
|
||||
ratingVeto Bool default=false
|
||||
ratingPoints ExamGrade Maybe
|
||||
ratingComment Text Maybe
|
||||
allocation AllocationId Maybe
|
||||
allocationPriority Natural Maybe
|
||||
allocationPriority Word64 Maybe
|
||||
time UTCTime default=now()
|
||||
ratingTime UTCTime Maybe
|
||||
|
||||
CourseApplicationFile
|
||||
application CourseApplicationId
|
||||
file FileId
|
||||
UniqueApplicationFile application file
|
||||
title FilePath
|
||||
content FileContentReference Maybe
|
||||
modified UTCTime
|
||||
UniqueCourseApplicationFile application title
|
||||
|
||||
@ -2,12 +2,14 @@ Material -- course material for disemination to course participants
|
||||
course CourseId
|
||||
name (CI Text)
|
||||
type (CI Text) Maybe
|
||||
description Html Maybe
|
||||
description StoredMarkup Maybe
|
||||
visibleFrom UTCTime Maybe -- Invisible to enrolled participants before
|
||||
lastEdit UTCTime
|
||||
UniqueMaterial course name
|
||||
deriving Generic
|
||||
MaterialFile -- a file that is part of a material distribution
|
||||
material MaterialId
|
||||
file FileId
|
||||
UniqueMaterialFile material file
|
||||
title FilePath
|
||||
content FileContentReference Maybe
|
||||
modified UTCTime
|
||||
UniqueMaterialFile material title
|
||||
@ -3,10 +3,12 @@ CourseNews
|
||||
visibleFrom UTCTime Maybe
|
||||
participantsOnly Bool
|
||||
title Text Maybe
|
||||
content Html
|
||||
summary Html Maybe
|
||||
content StoredMarkup
|
||||
summary StoredMarkup Maybe
|
||||
lastEdit UTCTime
|
||||
CourseNewsFile
|
||||
news CourseNewsId
|
||||
file FileId
|
||||
UniqueCourseNewsFile news file
|
||||
news CourseNewsId
|
||||
title FilePath
|
||||
content FileContentReference Maybe
|
||||
modified UTCTime
|
||||
UniqueCourseNewsFile news title
|
||||
@ -16,7 +16,10 @@ Exam
|
||||
closed UTCTime Maybe -- Prüfungsamt hat Einsicht (notification)
|
||||
publicStatistics Bool
|
||||
gradingMode ExamGradingMode
|
||||
description Html Maybe
|
||||
description StoredMarkup Maybe
|
||||
examMode ExamMode
|
||||
staff Text Maybe
|
||||
partsFrom UTCTime Maybe
|
||||
UniqueExam course name
|
||||
ExamPart
|
||||
exam ExamId
|
||||
@ -26,14 +29,16 @@ ExamPart
|
||||
weight Rational
|
||||
UniqueExamPartNumber exam number
|
||||
UniqueExamPartName exam name !force
|
||||
deriving Read Show Eq Ord Generic Typeable
|
||||
ExamOccurrence
|
||||
exam ExamId
|
||||
name ExamOccurrenceName
|
||||
room Text
|
||||
capacity Natural
|
||||
room RoomReference Maybe
|
||||
roomHidden Bool default=false
|
||||
capacity Word64
|
||||
start UTCTime
|
||||
end UTCTime Maybe
|
||||
description Html Maybe
|
||||
description StoredMarkup Maybe
|
||||
UniqueExamOccurrence exam name
|
||||
ExamRegistration
|
||||
exam ExamId
|
||||
@ -41,24 +46,28 @@ ExamRegistration
|
||||
occurrence ExamOccurrenceId Maybe
|
||||
time UTCTime default=now()
|
||||
UniqueExamRegistration exam user
|
||||
deriving Eq Ord Show
|
||||
ExamPartResult
|
||||
examPart ExamPartId
|
||||
user UserId
|
||||
result ExamResultPoints
|
||||
lastChanged UTCTime default=now()
|
||||
UniqueExamPartResult examPart user
|
||||
deriving Eq Ord Show
|
||||
ExamBonus
|
||||
exam ExamId
|
||||
user UserId
|
||||
bonus Points
|
||||
lastChanged UTCTime default=now()
|
||||
UniqueExamBonus exam user
|
||||
deriving Eq Ord Show
|
||||
ExamResult
|
||||
exam ExamId
|
||||
user UserId
|
||||
result ExamResultPassedGrade
|
||||
lastChanged UTCTime default=now()
|
||||
UniqueExamResult exam user
|
||||
deriving Eq Ord Show
|
||||
ExamCorrector
|
||||
exam ExamId
|
||||
user UserId
|
||||
@ -66,4 +75,8 @@ ExamCorrector
|
||||
ExamPartCorrector
|
||||
part ExamPartId
|
||||
corrector ExamCorrectorId
|
||||
UniqueExamPartCorrector part corrector
|
||||
UniqueExamPartCorrector part corrector
|
||||
ExamOfficeSchool
|
||||
school SchoolId
|
||||
exam ExamId
|
||||
UniqueExamOfficeSchool exam school
|
||||
@ -13,6 +13,7 @@ ExternalExamResult
|
||||
time UTCTime
|
||||
lastChanged UTCTime
|
||||
UniqueExternalExamResult exam user
|
||||
deriving Eq Ord Show
|
||||
ExternalExamStaff
|
||||
user UserId
|
||||
exam ExternalExamId
|
||||
|
||||
@ -1,14 +1,30 @@
|
||||
-- Table storing all kinds of larges files as 8bit-byte vectors (regardless of encoding)
|
||||
-- PostgreSQL is intelligent enough to handle this in a sensible manner;
|
||||
-- helps to ensure consistency of database snapshots, no data is stored outside database
|
||||
File
|
||||
title FilePath
|
||||
content ByteString Maybe -- Nothing iff this is a directory
|
||||
modified UTCTime
|
||||
deriving Show Eq Generic
|
||||
FileContentEntry
|
||||
hash FileContentReference
|
||||
ix Word64
|
||||
chunkHash FileContentChunkId
|
||||
UniqueFileContentEntry hash ix
|
||||
|
||||
FileContentChunk
|
||||
hash FileContentChunkReference
|
||||
content ByteString
|
||||
contentBased Bool default=false -- For Migration
|
||||
Primary hash
|
||||
|
||||
FileContentChunkUnreferenced
|
||||
hash FileContentChunkId
|
||||
since UTCTime
|
||||
UniqueFileContentChunkUnreferenced hash
|
||||
|
||||
SessionFile
|
||||
user UserId
|
||||
reference SessionFileReference
|
||||
file FileId
|
||||
touched UTCTime
|
||||
content FileContentReference Maybe
|
||||
touched UTCTime
|
||||
|
||||
FileLock
|
||||
content FileContentReference
|
||||
instance InstanceId
|
||||
time UTCTime
|
||||
|
||||
FileChunkLock
|
||||
hash FileContentChunkReference
|
||||
instance InstanceId
|
||||
time UTCTime
|
||||
@ -17,9 +17,8 @@ CronLastExec
|
||||
instance InstanceId -- Which uni2work-instance did the work
|
||||
UniqueCronLastExec job
|
||||
|
||||
|
||||
SentNotification
|
||||
content Value
|
||||
user UserId
|
||||
time UTCTime
|
||||
instance InstanceId
|
||||
TokenBucket
|
||||
ident TokenBucketIdent
|
||||
lastValue Int64
|
||||
lastAccess UTCTime
|
||||
Primary ident
|
||||
13
models/mail.model
Normal file
13
models/mail.model
Normal file
@ -0,0 +1,13 @@
|
||||
SentMail
|
||||
sentAt UTCTime
|
||||
sentBy InstanceId
|
||||
objectId MailObjectId Maybe
|
||||
bounceSecret BounceSecret Maybe
|
||||
recipient UserId Maybe
|
||||
headers MailHeaders
|
||||
contentRef SentMailContentId
|
||||
|
||||
SentMailContent
|
||||
hash MailContentReference
|
||||
content MailContent
|
||||
Primary hash
|
||||
@ -3,6 +3,11 @@
|
||||
School json
|
||||
name (CI Text)
|
||||
shorthand (CI Text) -- SchoolKey :: SchoolShorthand -> SchoolId
|
||||
examMinimumRegisterBeforeStart NominalDiffTime Maybe
|
||||
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 }
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
Sheet -- exercise sheet for a given course
|
||||
course CourseId
|
||||
name (CI Text)
|
||||
description Html Maybe
|
||||
type SheetType -- Does it count towards overall course grade?
|
||||
description StoredMarkup Maybe
|
||||
type (SheetType SqlBackendKey) -- ExamPartId; Does it count towards overall course grade?
|
||||
grouping SheetGroup -- May participants submit in groups of certain sizes?
|
||||
markingText Html Maybe -- Instructons for correctors, included in marking templates
|
||||
markingText StoredMarkup Maybe -- Instructons for correctors, included in marking templates
|
||||
visibleFrom UTCTime Maybe -- Invisible to enrolled participants before
|
||||
activeFrom UTCTime Maybe -- Download of questions and submission is permitted afterwards
|
||||
activeTo UTCTime Maybe -- Submission is only permitted before
|
||||
@ -12,6 +12,9 @@ Sheet -- exercise sheet for a given course
|
||||
solutionFrom UTCTime Maybe -- Solution is made available
|
||||
submissionMode SubmissionMode -- Submission upload by students and/or through tutors?
|
||||
autoDistribute Bool default=false -- Should correctors be assigned submissions automagically?
|
||||
anonymousCorrection Bool default=true
|
||||
requireExamRegistration ExamId Maybe -- Students may only submit if they are registered for the given exam
|
||||
allowNonPersonalisedSubmission Bool default=true
|
||||
CourseSheet course name
|
||||
deriving Generic
|
||||
SheetEdit -- who edited when a row in table "Course", kept indefinitely
|
||||
@ -36,7 +39,25 @@ SheetCorrector -- grant corrector role to user for a sheet
|
||||
UniqueSheetCorrector user sheet
|
||||
deriving Show Eq Ord
|
||||
SheetFile -- a file that is part of an exercise sheet
|
||||
sheet SheetId
|
||||
file FileId
|
||||
type SheetFileType -- excercise, marking, hint or solution
|
||||
UniqueSheetFile file sheet type
|
||||
sheet SheetId
|
||||
type SheetFileType -- excercise, marking, hint or solution
|
||||
title FilePath
|
||||
content FileContentReference Maybe
|
||||
modified UTCTime
|
||||
UniqueSheetFile sheet type title
|
||||
PersonalisedSheetFile
|
||||
sheet SheetId
|
||||
user UserId
|
||||
type SheetFileType
|
||||
title FilePath
|
||||
content FileContentReference Maybe
|
||||
modified UTCTime
|
||||
UniquePersonalisedSheetFile sheet user type title
|
||||
deriving Eq Ord Read Show Generic Typeable
|
||||
|
||||
FallbackPersonalisedSheetFilesKey
|
||||
course CourseId
|
||||
index Word24
|
||||
secret ByteString
|
||||
generated UTCTime
|
||||
UniqueFallbackPersonalisedSheetFilesKey course index
|
||||
58
models/study-features.model
Normal file
58
models/study-features.model
Normal file
@ -0,0 +1,58 @@
|
||||
StudyFeatures -- multiple entries possible for students pursuing several degrees at once, usually created upon LDAP login
|
||||
user UserId
|
||||
degree StudyDegreeId -- Abschluss, i.e. Master, Bachelor, etc.
|
||||
field StudyTermsId -- Fach, i.e. Informatics, Philosophy, etc.
|
||||
superField StudyTermsId Maybe
|
||||
type StudyFieldType -- Major or minor, i.e. Haupt-/Nebenfach
|
||||
semester Int
|
||||
firstObserved UTCTime Maybe
|
||||
lastObserved UTCTime default=now() -- last update from LDAP
|
||||
valid Bool default=true
|
||||
relevanceCached Bool default=false
|
||||
UniqueStudyFeatures user degree field type semester
|
||||
deriving Eq Show
|
||||
-- UniqueUserSubject ubuser degree field -- There exists a counterexample
|
||||
|
||||
RelevantStudyFeatures
|
||||
term TermId
|
||||
studyFeatures StudyFeaturesId
|
||||
UniqueRelevantStudyFeatures term studyFeatures
|
||||
|
||||
StudyDegree -- Studienabschluss
|
||||
key Int -- LMU-internal key
|
||||
shorthand Text Maybe -- admin determined shorthand
|
||||
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
|
||||
StudyTerms -- Studiengang
|
||||
key Int -- standardised key
|
||||
shorthand Text Maybe -- admin determined shorthand
|
||||
name Text Maybe -- description given by LDAP
|
||||
defaultDegree StudyDegreeId Maybe
|
||||
defaultType StudyFieldType Maybe
|
||||
Primary key -- column key is used as actual DB row key
|
||||
-- newtype Key StudyTerms = StudyTermsKey' { unStudyTermsKey :: Int }
|
||||
deriving Eq Ord Show
|
||||
StudySubTerms
|
||||
child StudyTermsId
|
||||
parent StudyTermsId
|
||||
UniqueStudySubTerms child parent
|
||||
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.
|
||||
-- If a login provides n keys and n plan texts, then n^2 rows with the same incidence are created, storing all combinations
|
||||
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
|
||||
StudySubTermParentCandidate
|
||||
incidence TermCandidateIncidence
|
||||
key Int
|
||||
parent Int
|
||||
deriving Show Eq Ord
|
||||
StudyTermStandaloneCandidate
|
||||
incidence TermCandidateIncidence
|
||||
key Int
|
||||
deriving Show Eq Ord
|
||||
|
||||
@ -7,15 +7,17 @@ Submission -- submission for marking by a CourseParticipa
|
||||
ratingTime UTCTime Maybe -- "Just" here indicates done; marking is made visible to participant
|
||||
deriving Show Generic
|
||||
SubmissionEdit -- user uploads new version of their submission
|
||||
user UserId -- track id, important for group submissions
|
||||
user UserId Maybe -- track id, important for group submissions
|
||||
time UTCTime
|
||||
submission SubmissionId
|
||||
SubmissionFile -- files that are part of a submission
|
||||
submission SubmissionId
|
||||
file FileId
|
||||
isUpdate Bool -- is this the file updated by a corrector (original will always be retained)
|
||||
isDeletion Bool -- only set if isUpdate is also set, but file was deleted by corrector
|
||||
UniqueSubmissionFile file submission isUpdate
|
||||
submission SubmissionId
|
||||
title FilePath
|
||||
content FileContentReference Maybe
|
||||
modified UTCTime
|
||||
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
|
||||
SubmissionUser -- which submission belongs to whom
|
||||
user UserId
|
||||
@ -23,12 +25,10 @@ SubmissionUser -- which submission belongs to whom
|
||||
UniqueSubmissionUser user submission -- multiple users may share same submission, in case of (ad-hoc) submission groups
|
||||
SubmissionGroup -- pre-defined submission groups; some courses only allow pre-defined submission groups
|
||||
course CourseId
|
||||
name Text Maybe
|
||||
SubmissionGroupEdit -- who edited a submissionGroup when?
|
||||
user UserId
|
||||
time UTCTime
|
||||
submissionGroup SubmissionGroupId
|
||||
name SubmissionGroupName
|
||||
UniqueSubmissionGroup course name
|
||||
SubmissionGroupUser -- Registered submission groups, just for checking upon submission, but independent of actual SubmissionUser
|
||||
submissionGroup SubmissionGroupId
|
||||
user UserId
|
||||
UniqueSubmissionGroupUser submissionGroup user
|
||||
UniqueSubmissionGroupUser submissionGroup user
|
||||
deriving Eq Ord Show
|
||||
@ -6,17 +6,19 @@ 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 Word64 Maybe
|
||||
created UTCTime default=now()
|
||||
lastChanged UTCTime default=now()
|
||||
lastUnhide UTCTime default=now()
|
||||
defaultLanguage Lang -- Language of @content@ and @summary@
|
||||
content Html -- Detailed message shown when clicking on the @summary@-popup or when no @summary@ is specified
|
||||
summary Html Maybe
|
||||
content StoredMarkup -- Detailed message shown when clicking on the @summary@-popup or when no @summary@ is specified
|
||||
summary StoredMarkup Maybe
|
||||
|
||||
SystemMessageTranslation -- Translation of a @SystemMessage@ into another language; which language to choose is determined by user-sent HTTP-headers
|
||||
message SystemMessageId
|
||||
language Lang
|
||||
content Html
|
||||
summary Html Maybe
|
||||
content StoredMarkup
|
||||
summary StoredMarkup Maybe
|
||||
UniqueSystemMessageTranslation message language
|
||||
|
||||
SystemMessageHidden
|
||||
|
||||
@ -3,7 +3,8 @@ Tutorial json
|
||||
course CourseId
|
||||
type (CI Text) -- "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
|
||||
@ -20,4 +21,5 @@ Tutor
|
||||
TutorialParticipant
|
||||
tutorial TutorialId
|
||||
user UserId
|
||||
UniqueTutorialParticipant tutorial user
|
||||
UniqueTutorialParticipant tutorial user
|
||||
deriving Eq Ord Show
|
||||
@ -17,6 +17,7 @@ User json -- Each Uni2work user has a corresponding row in this table; create
|
||||
lastAuthentication UTCTime Maybe -- last login date
|
||||
created UTCTime default=now()
|
||||
lastLdapSynchronisation UTCTime Maybe
|
||||
ldapPrimaryKey Text Maybe
|
||||
tokensIssuedAfter UTCTime Maybe -- do not accept bearer tokens issued before this time (accept all tokens if null)
|
||||
matrikelnummer UserMatriculation Maybe -- optional immatriculation-string; usually a number, but not always (e.g. lecturers, pupils, guests,...)
|
||||
firstName Text -- For export in tables, pre-split firstName from displayName
|
||||
@ -37,11 +38,18 @@ User json -- Each Uni2work user has a corresponding row in this table; create
|
||||
UniqueAuthentication ident -- Column 'ident' can be used as a row-key in this table
|
||||
UniqueEmail email -- Column 'email' can be used as a row-key in this table
|
||||
deriving Show Eq Ord Generic -- Haskell-specific settings for runtime-value representing a row in memory
|
||||
|
||||
UserFunction -- Administratively assigned functions (lecturer, admin, evaluation, ...)
|
||||
user UserId
|
||||
school SchoolId
|
||||
function SchoolFunction
|
||||
UniqueUserFunction user school function
|
||||
UserSystemFunction
|
||||
user UserId
|
||||
function SystemFunction
|
||||
manual Bool
|
||||
isOptOut Bool
|
||||
UniqueUserSystemFunction user function
|
||||
UserExamOffice
|
||||
user UserId
|
||||
field StudyTermsId
|
||||
@ -51,55 +59,6 @@ UserSchool -- Managed by users themselves, encodes "schools of interest"
|
||||
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
|
||||
StudyFeatures -- multiple entries possible for students pursuing several degrees at once, usually created upon LDAP login
|
||||
user UserId
|
||||
degree StudyDegreeId -- Abschluss, i.e. Master, Bachelor, etc.
|
||||
field StudyTermsId -- Fach, i.e. Informatics, Philosophy, etc.
|
||||
superField StudyTermsId Maybe
|
||||
type StudyFieldType -- Major or minor, i.e. Haupt-/Nebenfach
|
||||
semester Int
|
||||
updated UTCTime default=now() -- last update from LDAP
|
||||
valid Bool default=true -- marked as active in LDAP (students may switch, but LDAP never forgets)
|
||||
UniqueStudyFeatures user degree field type semester
|
||||
deriving Eq Show
|
||||
-- UniqueUserSubject ubuser degree field -- There exists a counterexample
|
||||
StudyDegree -- Studienabschluss
|
||||
key Int -- LMU-internal key
|
||||
shorthand Text Maybe -- admin determined shorthand
|
||||
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
|
||||
StudyTerms -- Studiengang
|
||||
key Int -- standardised key
|
||||
shorthand Text Maybe -- admin determined shorthand
|
||||
name Text Maybe -- description given by LDAP
|
||||
defaultDegree StudyDegreeId Maybe
|
||||
defaultType StudyFieldType Maybe
|
||||
Primary key -- column key is used as actual DB row key
|
||||
-- newtype Key StudyTerms = StudyTermsKey' { unStudyTermsKey :: Int }
|
||||
deriving Eq Ord Show
|
||||
StudySubTerms
|
||||
child StudyTermsId
|
||||
parent StudyTermsId
|
||||
UniqueStudySubTerms child parent
|
||||
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.
|
||||
-- If a login provides n keys and n plan texts, then n^2 rows with the same incidence are created, storing all combinations
|
||||
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
|
||||
StudySubTermParentCandidate
|
||||
incidence TermCandidateIncidence
|
||||
key Int
|
||||
parent Int
|
||||
deriving Show Eq Ord
|
||||
StudyTermStandaloneCandidate
|
||||
incidence TermCandidateIncidence
|
||||
key Int
|
||||
deriving Show Eq Ord
|
||||
|
||||
UserGroupMember
|
||||
group UserGroupName
|
||||
|
||||
41
models/workflows.model
Normal file
41
models/workflows.model
Normal file
@ -0,0 +1,41 @@
|
||||
WorkflowDefinition
|
||||
graph (WorkflowGraph FileReference SqlBackendKey) -- UserId
|
||||
scope WorkflowScope'
|
||||
name WorkflowDefinitionName
|
||||
instanceCategory WorkflowInstanceCategory Maybe
|
||||
UniqueWorkflowDefinition name scope
|
||||
|
||||
WorkflowDefinitionDescription
|
||||
definition WorkflowDefinitionId
|
||||
language Lang
|
||||
title Text
|
||||
description StoredMarkup Maybe
|
||||
UniqueWorkflowDefinitionDescription definition language
|
||||
|
||||
WorkflowDefinitionInstanceDescription
|
||||
definition WorkflowDefinitionId
|
||||
language Lang
|
||||
title Text
|
||||
description StoredMarkup Maybe
|
||||
UniqueWorkflowDefinitionInstanceDescription definition language
|
||||
|
||||
WorkflowInstance
|
||||
definition WorkflowDefinitionId Maybe
|
||||
graph (WorkflowGraph FileReference SqlBackendKey) -- UserId
|
||||
scope (WorkflowScope TermIdentifier SchoolShorthand SqlBackendKey) -- TermId, SchoolId, CourseId
|
||||
name WorkflowInstanceName
|
||||
category WorkflowInstanceCategory Maybe
|
||||
UniqueWorkflowInstance name scope
|
||||
|
||||
WorkflowInstanceDescription
|
||||
instance WorkflowInstanceId
|
||||
language Lang
|
||||
title Text
|
||||
description StoredMarkup Maybe
|
||||
UniqueWorkflowInstanceDescription instance language
|
||||
|
||||
WorkflowWorkflow
|
||||
instance WorkflowInstanceId Maybe
|
||||
scope (WorkflowScope TermIdentifier SchoolShorthand SqlBackendKey) -- TermId, SchoolId, CourseId
|
||||
graph (WorkflowGraph FileReference SqlBackendKey) -- UserId
|
||||
state (WorkflowState FileReference SqlBackendKey) -- UserId
|
||||
@ -4,6 +4,7 @@
|
||||
import ((nixpkgs {}).fetchFromGitHub {
|
||||
owner = "NixOS";
|
||||
repo = "nixpkgs";
|
||||
rev = "0d97ef510bdc9d66f1023f970be58fdab2eb87fa";
|
||||
sha256 = "00lnna6097wzrlmwqk8bqayh4qd2gz61zcd4yh7amirqflz3z2ll";
|
||||
rev = "a7a1447e5d40a9ad90983d33e151f5474eddeed9";
|
||||
sha256 = "1zb8wgsq9grrsdcz81y08h45rj8i5r8ckjhg2cv1cqmam4dczcrf";
|
||||
fetchSubmodules = true;
|
||||
})
|
||||
|
||||
13143
package-lock.json
generated
13143
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
73
package.json
73
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uni2work",
|
||||
"version": "15.0.0",
|
||||
"version": "23.6.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@ -53,78 +53,83 @@
|
||||
"defaults"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.5",
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.7.4",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.6",
|
||||
"@commitlint/cli": "^8.2.0",
|
||||
"@commitlint/config-conventional": "^8.2.0",
|
||||
"@fortawesome/fontawesome-pro": "^5.12.0",
|
||||
"autoprefixer": "^9.7.3",
|
||||
"@babel/cli": "^7.10.5",
|
||||
"@babel/core": "^7.11.4",
|
||||
"@babel/plugin-proposal-class-properties": "^7.10.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.10.5",
|
||||
"@babel/plugin-transform-runtime": "^7.11.0",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@commitlint/cli": "^10.0.0",
|
||||
"@commitlint/config-conventional": "^10.0.0",
|
||||
"@fortawesome/fontawesome-pro": "^5.14.0",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"babel-core": "^6.26.3",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.5",
|
||||
"babel-preset-es2015": "^6.24.1",
|
||||
"cbt_tunnels": "^1.2.2",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^5.1.0",
|
||||
"copy-webpack-plugin": "^6.0.3",
|
||||
"css-loader": "^2.1.1",
|
||||
"eslint": "^5.16.0",
|
||||
"file-loader": "^5.0.2",
|
||||
"file-loader": "^5.1.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"glob": "^7.1.6",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"husky": "^2.7.0",
|
||||
"jasmine-core": "^3.5.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"karma": "^4.4.1",
|
||||
"jasmine-core": "^3.6.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"karma": "^5.1.1",
|
||||
"karma-chrome-launcher": "^2.2.0",
|
||||
"karma-cli": "^2.0.0",
|
||||
"karma-jasmine": "^2.0.1",
|
||||
"karma-jasmine-html-reporter": "^1.4.2",
|
||||
"karma-jasmine-html-reporter": "^1.5.4",
|
||||
"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.0",
|
||||
"mini-css-extract-plugin": "^0.8.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"null-loader": "^2.0.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"real-favicon-webpack-plugin": "^0.2.3",
|
||||
"remove-files-webpack-plugin": "^1.1.3",
|
||||
"request": "^2.88.0",
|
||||
"request-promise": "^4.2.5",
|
||||
"remove-files-webpack-plugin": "^1.4.3",
|
||||
"request": "^2.88.2",
|
||||
"request-promise": "^4.2.6",
|
||||
"resolve-url-loader": "^3.1.1",
|
||||
"sass": "^1.23.7",
|
||||
"sass": "^1.26.10",
|
||||
"sass-loader": "^7.3.1",
|
||||
"semver": "^6.3.0",
|
||||
"standard-version": "^6.0.1",
|
||||
"standard-version": "^9.0.0",
|
||||
"style-loader": "^0.23.1",
|
||||
"terser-webpack-plugin": "^2.2.3",
|
||||
"terser-webpack-plugin": "^2.3.8",
|
||||
"tmp": "^0.1.0",
|
||||
"typeface-roboto": "0.0.75",
|
||||
"typeface-source-sans-pro": "0.0.75",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.3.10",
|
||||
"typeface-source-code-pro": "^1.1.3",
|
||||
"webpack": "^4.44.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-manifest-plugin": "^2.2.0",
|
||||
"webpack-plugin-hash-output": "^3.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.6",
|
||||
"@babel/runtime": "^7.11.2",
|
||||
"@juggle/resize-observer": "^2.5.0",
|
||||
"core-js": "^3.4.8",
|
||||
"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",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"moment": "^2.24.0",
|
||||
"npm": "^6.13.7",
|
||||
"sodium-javascript": "^0.5.5",
|
||||
"moment": "^2.27.0",
|
||||
"npm": "^6.14.8",
|
||||
"sodium-javascript": "^0.5.6",
|
||||
"tail.datetime": "git+ssh://git@gitlab2.rz.ifi.lmu.de/uni2work/tail.DateTime.git#master",
|
||||
"whatwg-fetch": "^3.0.0"
|
||||
"toposort": "^2.0.2",
|
||||
"whatwg-fetch": "^3.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
99
package.yaml
99
package.yaml
@ -1,5 +1,5 @@
|
||||
name: uniworx
|
||||
version: 15.0.0
|
||||
version: 23.6.0
|
||||
|
||||
dependencies:
|
||||
- base
|
||||
@ -9,6 +9,7 @@ dependencies:
|
||||
- yesod-auth
|
||||
- yesod-static
|
||||
- yesod-form
|
||||
- yesod-persistent
|
||||
- classy-prelude
|
||||
- classy-prelude-yesod
|
||||
- bytestring
|
||||
@ -26,7 +27,7 @@ dependencies:
|
||||
- directory
|
||||
- warp
|
||||
- data-default
|
||||
- aeson
|
||||
- aeson >=1.5
|
||||
- conduit
|
||||
- monad-logger
|
||||
- fast-logger
|
||||
@ -43,6 +44,7 @@ dependencies:
|
||||
- cryptonite-conduit
|
||||
- saltine
|
||||
- base64-bytestring
|
||||
- base32
|
||||
- memory
|
||||
- http-api-data
|
||||
- profunctors
|
||||
@ -63,7 +65,6 @@ dependencies:
|
||||
- cryptoids-class
|
||||
- binary
|
||||
- binary-instances
|
||||
- cereal
|
||||
- mtl
|
||||
- esqueleto >=3.1.0
|
||||
- mime-types
|
||||
@ -71,6 +72,7 @@ dependencies:
|
||||
- blaze-html
|
||||
- conduit-resumablesink >=0.2
|
||||
- parsec
|
||||
- parsec-numbers
|
||||
- attoparsec
|
||||
- uuid
|
||||
- exceptions
|
||||
@ -118,7 +120,7 @@ dependencies:
|
||||
- hsass
|
||||
- semigroupoids
|
||||
- http-types
|
||||
- ip
|
||||
- http-client
|
||||
- jose-jwt
|
||||
- mono-traversable
|
||||
- lens-aeson
|
||||
@ -142,25 +144,42 @@ dependencies:
|
||||
- wai-middleware-prometheus
|
||||
- extended-reals
|
||||
- rfc5051
|
||||
- unidecode
|
||||
- pandoc
|
||||
- insert-ordered-containers
|
||||
- servant
|
||||
- servant-server
|
||||
- servant-swagger
|
||||
- servant-docs
|
||||
- servant-client
|
||||
- servant-client-core
|
||||
- servant-quickcheck
|
||||
- swagger2
|
||||
- haskell-src-meta
|
||||
- network-uri
|
||||
- insert-ordered-containers
|
||||
- vault
|
||||
- tagged
|
||||
- token-bucket
|
||||
- async
|
||||
- pointedlist
|
||||
- clock
|
||||
- HsYAML
|
||||
- HsYAML-aeson
|
||||
- minio-hs
|
||||
- network-ip
|
||||
- data-textual
|
||||
- fastcdc
|
||||
- bimap
|
||||
- list-t
|
||||
- topograph
|
||||
- network-uri
|
||||
|
||||
other-extensions:
|
||||
- GeneralizedNewtypeDeriving
|
||||
- IncoherentInstances
|
||||
- OverloadedLists
|
||||
- UndecidableInstances
|
||||
- ApplicativeDo
|
||||
|
||||
default-extensions:
|
||||
- OverloadedStrings
|
||||
@ -213,6 +232,9 @@ default-extensions:
|
||||
- RecursiveDo
|
||||
- TypeFamilyDependencies
|
||||
- QuantifiedConstraints
|
||||
- EmptyDataDeriving
|
||||
- StandaloneKindSignatures
|
||||
- NoStarIsType
|
||||
|
||||
ghc-options:
|
||||
- -Wall
|
||||
@ -232,40 +254,39 @@ when:
|
||||
ghc-options:
|
||||
- -Werror
|
||||
- -fwarn-tabs
|
||||
- condition: flag(dev)
|
||||
then:
|
||||
ghc-options:
|
||||
- -O0
|
||||
- -ddump-splices
|
||||
- -ddump-to-file
|
||||
cpp-options: -DDEVELOPMENT
|
||||
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
|
||||
when:
|
||||
- condition: flag(dev)
|
||||
then:
|
||||
ghc-options:
|
||||
- -O0
|
||||
- -ddump-splices
|
||||
- -ddump-to-file
|
||||
cpp-options: -DDEVELOPMENT
|
||||
ghc-prof-options:
|
||||
- -fprof-auto
|
||||
else:
|
||||
ghc-options:
|
||||
- -O2
|
||||
|
||||
# Runnable executable for our application
|
||||
executables:
|
||||
uniworx:
|
||||
main: main.hs
|
||||
source-dirs: app
|
||||
ghc-options: -threaded -rtsopts "-with-rtsopts=-N -T"
|
||||
dependencies:
|
||||
- uniworx
|
||||
when:
|
||||
- condition: flag(library-only)
|
||||
buildable: false
|
||||
ghc-options:
|
||||
- -threaded -rtsopts "-with-rtsopts=-N -T"
|
||||
uniworxdb:
|
||||
main: Database.hs
|
||||
ghc-options:
|
||||
- -main-is Database
|
||||
- -threaded -rtsopts "-with-rtsopts=-N -T"
|
||||
source-dirs: test
|
||||
dependencies:
|
||||
- uniworx
|
||||
@ -274,6 +295,38 @@ executables:
|
||||
when:
|
||||
- condition: flag(library-only)
|
||||
buildable: false
|
||||
uniworxload:
|
||||
main: Load.hs
|
||||
ghc-options:
|
||||
- -main-is Load
|
||||
- -threaded -rtsopts "-with-rtsopts=-N -T"
|
||||
source-dirs: load
|
||||
dependencies:
|
||||
- uniworx
|
||||
- normaldistribution
|
||||
- network-uri
|
||||
- wreq
|
||||
- http-client
|
||||
- http-client-tls
|
||||
- scalpel
|
||||
other-modules: []
|
||||
when:
|
||||
- condition: flag(library-only)
|
||||
buildable: false
|
||||
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
|
||||
|
||||
# Test suite
|
||||
tests:
|
||||
@ -291,11 +344,13 @@ tests:
|
||||
- quickcheck-instances
|
||||
- generic-arbitrary
|
||||
- http-types
|
||||
- yesod-persistent
|
||||
- quickcheck-io
|
||||
- network-arbitrary
|
||||
- lens-properties
|
||||
ghc-options:
|
||||
- -fno-warn-orphans
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
- -with-rtsopts=-N
|
||||
- -threaded -rtsopts "-with-rtsopts=-N -T"
|
||||
hlint:
|
||||
main: Hlint.hs
|
||||
other-modules: []
|
||||
|
||||
105
routes
105
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
|
||||
@ -56,14 +58,46 @@
|
||||
/admin/test AdminTestR GET POST
|
||||
/admin/errMsg AdminErrMsgR GET POST
|
||||
/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
|
||||
/info/lecturer InfoLecturerR GET !lecturer
|
||||
/info/lecturer InfoLecturerR GET !free
|
||||
/info/legal LegalR GET !free
|
||||
/info/allocation InfoAllocationR GET !free
|
||||
/info/glossary GlossaryR GET !free
|
||||
/info/faq FaqR GET !free
|
||||
/version VersionR GET !free
|
||||
|
||||
/help HelpR GET POST !free
|
||||
@ -79,10 +113,10 @@
|
||||
/user/storage-key StorageKeyR POST !free
|
||||
|
||||
/exam-office ExamOfficeR !exam-office:
|
||||
/ EOExamsR GET
|
||||
/ EOExamsR GET !system-exam-office
|
||||
/fields EOFieldsR GET POST
|
||||
/users EOUsersR GET POST
|
||||
/users/invite EOUsersInviteR GET POST
|
||||
/users EOUsersR GET POST !system-exam-office
|
||||
/users/invite EOUsersInviteR GET POST !system-exam-office
|
||||
|
||||
/external-exam EExamListR GET !lecturer !¬empty
|
||||
/external-exam/new EExamNewR GET POST !lecturer
|
||||
@ -92,6 +126,7 @@
|
||||
/users EEUsersR GET POST
|
||||
/grades EEGradesR GET POST !exam-office
|
||||
/staff-invite EEStaffInviteR GET POST
|
||||
/correct EECorrectR GET POST
|
||||
|
||||
|
||||
/term TermShowR GET !free
|
||||
@ -106,12 +141,27 @@
|
||||
/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 !free
|
||||
/ AShowR GET POST !free
|
||||
/register ARegisterR POST !time
|
||||
/course/#CryptoUUIDCourse/apply AApplyR POST !timeANDallocation-registered
|
||||
/users AUsersR GET POST !allocation-admin
|
||||
/users/add AAddUserR GET POST !allocation-admin
|
||||
/priorities APriosR GET POST !allocation-admin
|
||||
/compute AComputeR GET POST !allocation-admin
|
||||
/accept AAcceptR GET POST !allocation-admin
|
||||
@ -125,10 +175,10 @@
|
||||
/course/ CourseListR GET !free
|
||||
!/course/new CourseNewR GET POST !lecturer
|
||||
/course/#TermId/#SchoolId/#CourseShorthand CourseR !lecturer:
|
||||
/ CShowR GET !free
|
||||
/ CShowR GET !tutor !corrector !exam-corrector !course-registered !course-time !evaluation !exam-office !allocation-admin
|
||||
/favourite CFavouriteR POST
|
||||
/register CRegisterR GET POST !timeANDcapacityANDallocation-timeAND¬course-registered !timeANDallocation-timeAND¬exam-resultANDcourse-registered !lecturerANDallocation-time
|
||||
/register-template CRegisterTemplateR GET !free
|
||||
/register CRegisterR GET POST !timeANDcapacityANDallocation-timeAND¬course-registeredANDcourse-time !timeANDallocation-timeAND¬exam-resultANDcourse-registered !lecturerANDallocation-time
|
||||
/register-template CRegisterTemplateR GET !course-time
|
||||
/edit CEditR GET POST
|
||||
/lecturer-invite CLecInviteR GET POST
|
||||
/delete CDeleteR GET POST !lecturerANDemptyANDallocation-time
|
||||
@ -142,40 +192,42 @@
|
||||
/exam-office CExamOfficeR GET POST !course-registered
|
||||
/subs CCorrectionsR GET POST
|
||||
/subs/assigned CAssignR GET POST
|
||||
/sheet SheetListR GET !course-registered !materials !corrector
|
||||
/sheet SheetListR GET !course-registered !materialsANDcourse-time !corrector !tutor
|
||||
/sheet/new SheetNewR GET POST
|
||||
/sheet/current SheetCurrentR GET !course-registered !materials !corrector
|
||||
/sheet/current SheetCurrentR GET !course-registered !materialsANDcourse-time !corrector !tutor
|
||||
/sheet/unassigned SheetOldUnassignedR GET
|
||||
/sheet/#SheetName SheetR:
|
||||
/show SShowR GET !timeANDcourse-registered !timeANDmaterials !corrector !timeANDtutor
|
||||
/show/download SArchiveR GET !timeANDcourse-registered !timeANDmaterials !corrector !timeANDtutor
|
||||
/show SShowR GET !timeANDcourse-registered !timeANDmaterialsANDcourse-time !corrector !timeANDtutor
|
||||
/show/download SArchiveR GET !timeANDcourse-registeredANDexam-registered !timeANDmaterialsANDexam-registeredANDcourse-time !corrector !timeANDtutor
|
||||
/edit SEditR GET POST
|
||||
/delete SDelR GET POST
|
||||
/subs SSubsR GET POST -- for lecturer only
|
||||
!/subs/new SubmissionNewR GET POST !timeANDcourse-registeredANDuser-submissions
|
||||
!/subs/own SubmissionOwnR GET !free -- just redirect
|
||||
!/subs/new SubmissionNewR GET POST !timeANDcourse-registeredANDuser-submissionsANDsubmission-groupANDexam-registeredANDpersonalised-sheet-files
|
||||
!/subs/own SubmissionOwnR GET !free
|
||||
!/subs/assign SAssignR GET POST !lecturerANDtime
|
||||
/subs/#CryptoFileNameSubmission SubmissionR:
|
||||
/ SubShowR GET POST !ownerANDtimeANDuser-submissions !ownerANDread !correctorANDread
|
||||
/delete SubDelR GET POST !ownerANDtimeANDuser-submissions
|
||||
/ 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
|
||||
/invite SInviteR GET POST !ownerANDtimeANDuser-submissions
|
||||
/invite SInviteR GET POST !ownerANDtimeANDuser-submissionsANDsubmission-groupANDexam-registeredANDpersonalised-sheet-files
|
||||
!/#SubmissionFileType SubArchiveR GET !owner !corrector
|
||||
!/#SubmissionFileType/*FilePath SubDownloadR GET !owner !corrector
|
||||
/iscorrector SIsCorrR GET !corrector -- Route is used to check for corrector access to this sheet
|
||||
/pseudonym SPseudonymR GET POST !course-registeredANDcorrector-submissions
|
||||
/pseudonym SPseudonymR GET POST !course-registeredANDcorrector-submissionsANDexam-registered
|
||||
/corrector-invite/ SCorrInviteR GET POST
|
||||
!/#SheetFileType SZipR GET !timeANDcourse-registered !timeANDmaterials !corrector !timeANDtutor
|
||||
!/#SheetFileType/*FilePath SFileR GET !timeANDcourse-registered !timeANDmaterials !corrector !timeANDtutor
|
||||
/file MaterialListR GET !course-registered !materials !corrector !tutor
|
||||
/personalised-files SPersonalFilesR GET
|
||||
!/#SheetFileType SZipR GET !timeANDcourse-registeredANDexam-registered !timeANDmaterialsANDexam-registered !corrector !timeANDtutor
|
||||
!/#SheetFileType/*FilePath SFileR GET !timeANDcourse-registeredANDexam-registered !timeANDmaterialsANDexam-registered !corrector !timeANDtutor
|
||||
/file MaterialListR GET !course-registered !materialsANDcourse-time !corrector !tutor
|
||||
/file/new MaterialNewR GET POST
|
||||
/file/#MaterialName MaterialR:
|
||||
/edit MEditR GET POST
|
||||
/delete MDelR GET POST
|
||||
/show MShowR GET !timeANDcourse-registered !timeANDmaterials !corrector !tutor
|
||||
!/download MArchiveR GET !timeANDcourse-registered !timeANDmaterials !corrector !tutor
|
||||
!/download/*FilePath MFileR GET !timeANDcourse-registered !timeANDmaterials !corrector !tutor
|
||||
/show MShowR GET !timeANDcourse-registered !timeANDmaterialsANDcourse-time !corrector !tutor
|
||||
!/download MArchiveR GET !timeANDcourse-registered !timeANDmaterialsANDcourse-time !corrector !tutor
|
||||
!/download/*FilePath MFileR GET !timeANDcourse-registered !timeANDmaterialsANDcourse-time !corrector !tutor
|
||||
/video/#CryptoUUIDMaterialFile MVideoR GET !timeANDcourse-registered !timeANDmaterialsANDcourse-time !corrector !tutor
|
||||
/tuts CTutorialListR GET !tutor -- THIS route is used to check for overall course tutor access!
|
||||
/tuts/new CTutorialNewR GET POST
|
||||
/tuts/#TutorialName TutorialR:
|
||||
@ -185,10 +237,10 @@
|
||||
/register TRegisterR POST !timeANDcapacityANDcourse-registeredANDregister-group !timeANDtutorial-registered
|
||||
/communication TCommR GET POST !tutor
|
||||
/tutor-invite TInviteR GET POST !tutorANDtutor-control
|
||||
/exams CExamListR GET !free
|
||||
/exams CExamListR GET !tutor !corrector !exam-corrector !course-registered !course-time !exam-office
|
||||
/exams/new CExamNewR GET POST
|
||||
/exams/#ExamName ExamR:
|
||||
/show EShowR GET !time !exam-office
|
||||
/show EShowR GET !timeANDtutor !timeANDcorrector !timeANDexam-corrector !timeANDcourse-registered !timeANDcourse-time !exam-office
|
||||
/edit EEditR GET POST
|
||||
/corrector-invite ECInviteR GET POST
|
||||
/users EUsersR GET POST
|
||||
@ -215,6 +267,7 @@
|
||||
/events/#CryptoUUIDCourseEvent CourseEventR:
|
||||
/edit CEvEditR GET POST
|
||||
/delete CEvDeleteR GET POST
|
||||
/personalised-sheet-files CPersonalFilesR GET
|
||||
|
||||
|
||||
/subs CorrectionsR GET POST !corrector !lecturer
|
||||
|
||||
90
shell.nix
90
shell.nix
@ -2,7 +2,8 @@
|
||||
|
||||
let
|
||||
inherit (nixpkgs {}) pkgs;
|
||||
haskellPackages = import ./stackage.nix { inherit nixpkgs; };
|
||||
# haskellPackages = import ./stackage.nix { inherit nixpkgs; };
|
||||
haskellPackages = pkgs.haskellPackages;
|
||||
|
||||
drv = haskellPackages.callPackage ./uniworx.nix {};
|
||||
|
||||
@ -19,12 +20,24 @@ let
|
||||
'';
|
||||
|
||||
override = oldAttrs: {
|
||||
nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; [ nodejs-13_x postgresql openldap google-chrome exiftool postman ]) ++ (with pkgs.haskellPackages; [ stack yesod-bin hlint cabal-install weeder ]);
|
||||
nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; [ nodejs-14_x postgresql_12 openldap google-chrome exiftool postman memcached minio minio-client ]) ++ (with pkgs.haskellPackages; [ stack yesod-bin hlint cabal-install weeder profiteur ]);
|
||||
shellHook = ''
|
||||
export PROMPT_INFO="${oldAttrs.name}"
|
||||
|
||||
export EDITOR=emacsclient
|
||||
|
||||
cleanup() {
|
||||
set +e -x
|
||||
type cleanup_postgres &>/dev/null && cleanup_postgres
|
||||
type cleanup_widget_memcached &>/dev/null && cleanup_widget_memcached
|
||||
type cleanup_session_memcached &>/dev/null && cleanup_session_memcached
|
||||
type cleanup_cache_memcached &>/dev/null && cleanup_cache_memcached
|
||||
type cleanup_minio &>/dev/null && cleanup_minio
|
||||
set +x
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ -z "$PGHOST" ]]; then
|
||||
set -xe
|
||||
|
||||
@ -32,23 +45,90 @@ let
|
||||
pgSockDir=$(mktemp -d)
|
||||
pgLogFile=$(mktemp)
|
||||
initdb --no-locale -D ''${pgDir}
|
||||
pg_ctl start -D ''${pgDir} -l ''${pgLogFile} -w -o "-k ''${pgSockDir} -c listen_addresses=''' -c hba_file='${postgresHba}' -c unix_socket_permissions=0700"
|
||||
pg_ctl start -D ''${pgDir} -l ''${pgLogFile} -w -o "-k ''${pgSockDir} -c listen_addresses=''' -c hba_file='${postgresHba}' -c unix_socket_permissions=0700 -c max_connections=9990 -c shared_preload_libraries=pg_stat_statements -c auto_explain.log_min_duration=100ms"
|
||||
export PGHOST=''${pgSockDir} PGLOG=''${pgLogFile}
|
||||
psql -f ${postgresSchema} postgres
|
||||
printf "Postgres logfile is %s\nPostgres socket directory is %s\n" ''${pgLogFile} ''${pgSockDir}
|
||||
|
||||
cleanup() {
|
||||
cleanup_postgres() {
|
||||
set +e -x
|
||||
pg_ctl stop -D ''${pgDir}
|
||||
rm -rvf ''${pgDir} ''${pgSockDir} ''${pgLogFile}
|
||||
set +x
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
set +xe
|
||||
fi
|
||||
|
||||
if [[ -z "$WIDGET_MEMCACHED_HOST" ]]; then
|
||||
set -xe
|
||||
|
||||
memcached -l localhost -p 11211 &>/dev/null &
|
||||
widget_memcached_pid=$?
|
||||
|
||||
cleanup_widget_memcached() {
|
||||
[[ -n "$widget_memcached_pid" ]] && kill $widget_memcached_pid
|
||||
}
|
||||
|
||||
export WIDGET_MEMCACHED_HOST=localhost WIDGET_MEMCACHED_PORT=11211
|
||||
|
||||
set +xe
|
||||
fi
|
||||
|
||||
if [[ -z "$SESSION_MEMCACHED_HOST" ]]; then
|
||||
set -xe
|
||||
|
||||
memcached -l localhost -p 11212 &>/dev/null &
|
||||
session_memcached_pid=$?
|
||||
|
||||
cleanup_session_memcached() {
|
||||
[[ -n "$session_memcached_pid" ]] && kill $session_memcached_pid
|
||||
}
|
||||
|
||||
export SESSION_MEMCACHED_HOST=localhost SESSION_MEMCACHED_PORT=11212
|
||||
|
||||
set +xe
|
||||
fi
|
||||
|
||||
if [[ -z "$MEMCACHED_HOST" ]]; then
|
||||
set -xe
|
||||
|
||||
memcached -l localhost -p 11213 &>/dev/null &
|
||||
memcached_pid=$?
|
||||
|
||||
cleanup_session_memcached() {
|
||||
[[ -n "$memcached_pid" ]] && kill $memcached_pid
|
||||
}
|
||||
|
||||
export MEMCACHED_HOST=localhost MEMCACHED_PORT=11212
|
||||
|
||||
set +xe
|
||||
fi
|
||||
|
||||
if [[ -z "$UPLOAD_S3_HOST" ]]; then
|
||||
set -xe
|
||||
|
||||
cleanup_minio() {
|
||||
[[ -n "$minio_pid" ]] && kill $minio_pid
|
||||
[[ -n "$minio_dir" ]] && rm -rvf ''${minio_dir}
|
||||
[[ -n "MINIO_LOGFILE" ]] && rm -rvf ''${MINIO_LOGFILE}
|
||||
}
|
||||
|
||||
export MINIO_DIR=$(mktemp -d)
|
||||
export MINIO_LOGFILE=$(mktemp --tmpdir minio.XXXXXX.log)
|
||||
export MINIO_ACCESS_KEY=$(${pkgs.pwgen}/bin/pwgen -s 16 1)
|
||||
export MINIO_SECRET_KEY=$(${pkgs.pwgen}/bin/pwgen -s 32 1)
|
||||
|
||||
minio server --address localhost:9000 ''${MINIO_DIR} &>''${MINIO_LOGFILE} &
|
||||
minio_pid=$?
|
||||
|
||||
sleep 1
|
||||
|
||||
export UPLOAD_S3_HOST=localhost UPLOAD_S3_PORT=9000 UPLOAD_S3_SSL=false UPLOAD_S3_KEY_ID=''${MINIO_ACCESS_KEY} UPLOAD_S3_KEY=''${MINIO_SECRET_KEY}
|
||||
|
||||
set +xe
|
||||
fi
|
||||
|
||||
if [ -n "$ZSH_VERSION" ]; then
|
||||
autoload -U +X compinit && compinit
|
||||
autoload -U +X bashcompinit && bashcompinit
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
|
||||
module Application
|
||||
( getAppDevSettings
|
||||
( getAppSettings, getAppDevSettings
|
||||
, appMain
|
||||
, develMain
|
||||
, makeFoundation
|
||||
, makeLogWare
|
||||
, makeMiddleware
|
||||
-- * for DevelMain
|
||||
, foundationStoreNum
|
||||
, getApplicationRepl
|
||||
, shutdownApp
|
||||
-- * for GHCI
|
||||
, handler
|
||||
, db
|
||||
, handler, handler'
|
||||
, db, db'
|
||||
, addPWEntry
|
||||
) where
|
||||
|
||||
@ -95,6 +95,14 @@ import Handler.Utils.Routes (classifyHandler)
|
||||
import qualified Data.Acid.Memory as Acid
|
||||
import qualified Web.ServerSession.Backend.Acid as Acid
|
||||
|
||||
import qualified Ldap.Client as Ldap (Host(Plain, Tls))
|
||||
|
||||
import qualified Network.Minio as Minio
|
||||
|
||||
import Web.ServerSession.Core (StorageException(..))
|
||||
|
||||
import GHC.RTS.Flags (getRTSFlags)
|
||||
|
||||
-- Import all relevant handler modules here.
|
||||
-- (HPack takes care to add new modules to our cabal file nowadays.)
|
||||
import Handler.News
|
||||
@ -109,7 +117,6 @@ import Handler.Course
|
||||
import Handler.Sheet
|
||||
import Handler.Submission
|
||||
import Handler.Tutorial
|
||||
import Handler.Corrections
|
||||
import Handler.Material
|
||||
import Handler.CryptoIDDispatch
|
||||
import Handler.SystemMessage
|
||||
@ -121,6 +128,8 @@ import Handler.Metrics
|
||||
import Handler.ExternalExam
|
||||
import Handler.Participants
|
||||
import Handler.StorageKey
|
||||
import Handler.Workflow
|
||||
import Handler.Error
|
||||
import Handler.ApiDocs
|
||||
import Handler.Swagger
|
||||
|
||||
@ -136,10 +145,10 @@ mkYesodDispatch "UniWorX" resourcesUniWorX
|
||||
-- performs initialization and returns a foundation datatype value. This is also
|
||||
-- the place to put your migrate statements to have automatic database
|
||||
-- migrations handled by Yesod.
|
||||
makeFoundation :: (MonadResource m, MonadUnliftIO m, MonadThrow m) => AppSettings -> m UniWorX
|
||||
makeFoundation appSettings'@AppSettings{..} = do
|
||||
makeFoundation :: (MonadResource m, MonadUnliftIO m, MonadCatch m) => AppSettings -> m UniWorX
|
||||
makeFoundation appSettings''@AppSettings{..} = do
|
||||
registerGHCMetrics
|
||||
|
||||
|
||||
-- Some basic initializations: HTTP connection manager, logger, and static
|
||||
-- subsite.
|
||||
appHttpManager <- newManager
|
||||
@ -179,11 +188,12 @@ makeFoundation appSettings'@AppSettings{..} = do
|
||||
-- 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 = UniWorX {..}
|
||||
let mkFoundation appSettings' appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached 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")
|
||||
@ -193,10 +203,15 @@ makeFoundation appSettings'@AppSettings{..} = do
|
||||
(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")
|
||||
(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
|
||||
$logDebugS "setup" "SMTP-Pool"
|
||||
@ -212,27 +227,58 @@ makeFoundation appSettings'@AppSettings{..} = do
|
||||
(pgConnStr appDatabaseConf)
|
||||
(pgPoolSize appDatabaseConf)
|
||||
|
||||
ldapPool <- for appLdapConf $ \LdapConf{..} -> do
|
||||
$logDebugS "setup" "LDAP-Pool"
|
||||
createLdapPool ldapHost ldapPort (poolStripes ldapPool) (poolTimeout ldapPool) ldapTimeout (poolLimit ldapPool)
|
||||
ldapPool <- traverse mkFailoverLabeled <=< forOf (traverse . traverse) appLdapConf $ \conf@LdapConf{..} -> do
|
||||
let ldapLabel = case ldapHost of
|
||||
Ldap.Plain str -> pack str <> ":" <> tshow ldapPort
|
||||
Ldap.Tls str _ -> pack str <> ":" <> tshow ldapPort
|
||||
$logDebugS "setup" $ "LDAP-Pool " <> ldapLabel
|
||||
(ldapLabel,) . (conf,) <$> createLdapPool ldapHost ldapPort (poolStripes ldapPool) (poolTimeout ldapPool) ldapTimeout (poolLimit ldapPool)
|
||||
forM_ ldapPool $ registerFailoverMetrics "ldap"
|
||||
|
||||
-- Perform database migration using our application's logging settings.
|
||||
if
|
||||
| appAutoDbMigrate -> do
|
||||
$logDebugS "setup" "Migration"
|
||||
migrateAll `runSqlPool` sqlPool
|
||||
| otherwise -> whenM (requiresMigration `runSqlPool` sqlPool) $ do
|
||||
$logErrorS "setup" "Migration required"
|
||||
liftIO . exitWith $ ExitFailure 2
|
||||
flip runReaderT tempFoundation $
|
||||
if
|
||||
| appAutoDbMigrate -> do
|
||||
$logDebugS "setup" "Migration"
|
||||
migrateAll `runSqlPool` sqlPool
|
||||
| otherwise -> whenM (requiresMigration `runSqlPool` 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
|
||||
appAuthKey <- clusterSetting (Proxy :: Proxy 'ClusterAuthKey) `runSqlPool` sqlPool
|
||||
|
||||
appSessionStore <- mkSessionStore appSettings' sqlPool `runSqlPool` sqlPool
|
||||
needsRechunk <- exists [FileContentChunkContentBased !=. True] `runSqlPool` sqlPool
|
||||
let appSettings' = appSettings''
|
||||
& _appRechunkFiles %~ guardOnM needsRechunk
|
||||
|
||||
let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID
|
||||
appMemcached <- for appMemcachedConf $ \memcachedConf -> do
|
||||
$logDebugS "setup" "Memcached"
|
||||
memcachedKey <- clusterSetting (Proxy :: Proxy 'ClusterMemcachedKey) `runSqlPool` sqlPool
|
||||
memcached <- createMemcached memcachedConf
|
||||
when appClearCache $ do
|
||||
$logWarnS "setup" "Clearing memcached"
|
||||
liftIO $ Memcached.flushAll memcached
|
||||
return (memcachedKey, memcached)
|
||||
|
||||
appSessionStore <- mkSessionStore appSettings'' sqlPool `runSqlPool` sqlPool
|
||||
|
||||
appUploadCache <- for appUploadCacheConf $ \minioConf -> liftIO $ do
|
||||
conn <- Minio.connect minioConf
|
||||
let isBucketExists Minio.BucketAlreadyOwnedByYou = True
|
||||
isBucketExists _ = False
|
||||
either throwM return <=< Minio.runMinioWith conn $
|
||||
handleIf isBucketExists (const $ return ()) $ Minio.makeBucket appUploadCacheBucket Nothing
|
||||
return conn
|
||||
|
||||
$logDebugS "Runtime configuration" $ tshow appSettings'
|
||||
|
||||
let foundation = mkFoundation appSettings' sqlPool smtpPool ldapPool appCryptoIDKey appSessionStore appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID appMemcached appUploadCache appVerpSecret appAuthKey
|
||||
|
||||
-- Return the foundation
|
||||
$logDebugS "setup" "Done"
|
||||
@ -327,19 +373,47 @@ createMemcached MemcachedConf{memcachedConnectInfo} = snd <$> allocate (Memcache
|
||||
-- | Convert our foundation to a WAI Application by calling @toWaiAppPlain@ and
|
||||
-- applying some additional middlewares.
|
||||
makeApplication :: MonadIO m => UniWorX -> m Application
|
||||
makeApplication foundation = liftIO $ do
|
||||
logWare <- makeLogWare foundation
|
||||
-- Create the WAI application and apply middlewares
|
||||
appPlain <- toWaiAppPlain foundation
|
||||
return . observeHTTPRequestLatency classifyHandler . logWare . normalizeCookies $ defaultMiddlewaresNoLogging appPlain
|
||||
makeApplication foundation = liftIO $ makeMiddleware foundation <*> toWaiAppPlain foundation
|
||||
|
||||
makeMiddleware :: MonadIO m => UniWorX -> m Middleware
|
||||
makeMiddleware app = do
|
||||
logWare <- makeLogWare
|
||||
return $ observeHTTPRequestLatency classifyHandler . logWare . normalizeCookies . defaultMiddlewaresNoLogging
|
||||
where
|
||||
makeLogWare = do
|
||||
logWareMap <- liftIO $ newTVarIO HashMap.empty
|
||||
|
||||
let
|
||||
mkLogWare ls@LogSettings{..} = do
|
||||
logger <- readTVarIO . snd $ appLogger app
|
||||
logWare <- mkRequestLogger def
|
||||
{ outputFormat = bool
|
||||
(Apache . bool FromSocket FromHeader $ app ^. _appIpFromHeader)
|
||||
(Detailed True)
|
||||
logDetailed
|
||||
, destination = Logger $ loggerSet logger
|
||||
}
|
||||
atomically . modifyTVar' logWareMap $ HashMap.insert ls logWare
|
||||
return logWare
|
||||
|
||||
void. liftIO $
|
||||
mkLogWare =<< readTVarIO (appLogSettings app)
|
||||
|
||||
return $ \wai req fin -> do
|
||||
lookupRes <- atomically $ do
|
||||
ls <- readTVar $ appLogSettings app
|
||||
existing <- HashMap.lookup ls <$> readTVar logWareMap
|
||||
return $ maybe (Left ls) Right existing
|
||||
logWare <- either mkLogWare return lookupRes
|
||||
logWare wai req fin
|
||||
|
||||
normalizeCookies :: Wai.Middleware
|
||||
normalizeCookies app req respond = app req $ \res -> do
|
||||
normalizeCookies waiApp req respond = waiApp req $ \res -> do
|
||||
resHdrs' <- go $ Wai.responseHeaders res
|
||||
respond $ Wai.mapResponseHeaders (const resHdrs') res
|
||||
where parseSetCookie' :: ByteString -> IO (Maybe SetCookie)
|
||||
parseSetCookie' = fmap (either (\(_ :: SomeException) -> Nothing) Just) . try . evaluate . force . parseSetCookie
|
||||
|
||||
|
||||
go [] = return []
|
||||
go (hdr@(hdrName, hdrValue) : hdrs)
|
||||
| hdrName == hSetCookie = do
|
||||
@ -359,33 +433,7 @@ makeApplication foundation = liftIO $ do
|
||||
| otherwise -> go hdrs
|
||||
| otherwise = (hdr :) <$> go hdrs
|
||||
|
||||
makeLogWare :: MonadIO m => UniWorX -> m Middleware
|
||||
makeLogWare app = do
|
||||
logWareMap <- liftIO $ newTVarIO HashMap.empty
|
||||
|
||||
let
|
||||
mkLogWare ls@LogSettings{..} = do
|
||||
logger <- readTVarIO . snd $ appLogger app
|
||||
logWare <- mkRequestLogger def
|
||||
{ outputFormat = bool
|
||||
(Apache . bool FromSocket FromHeader $ app ^. _appIpFromHeader)
|
||||
(Detailed True)
|
||||
logDetailed
|
||||
, destination = Logger $ loggerSet logger
|
||||
}
|
||||
atomically . modifyTVar' logWareMap $ HashMap.insert ls logWare
|
||||
return logWare
|
||||
|
||||
void. liftIO $
|
||||
mkLogWare =<< readTVarIO (appLogSettings app)
|
||||
|
||||
return $ \wai req fin -> do
|
||||
lookupRes <- atomically $ do
|
||||
ls <- readTVar $ appLogSettings app
|
||||
existing <- HashMap.lookup ls <$> readTVar logWareMap
|
||||
return $ maybe (Left ls) Right existing
|
||||
logWare <- either mkLogWare return lookupRes
|
||||
logWare wai req fin
|
||||
|
||||
-- | Warp settings for the given foundation value.
|
||||
warpSettings :: UniWorX -> Settings
|
||||
@ -412,7 +460,7 @@ warpSettings foundation = defaultSettings
|
||||
& setHost (foundation ^. _appHost)
|
||||
& setPort (foundation ^. _appPort)
|
||||
& setOnException (\_req e ->
|
||||
when (defaultShouldDisplayException e) $ do
|
||||
when (shouldDisplayException e) $ do
|
||||
logger <- readTVarIO . snd $ appLogger foundation
|
||||
messageLoggerSource
|
||||
foundation
|
||||
@ -422,7 +470,17 @@ warpSettings foundation = defaultSettings
|
||||
LevelError
|
||||
(toLogStr $ "Exception from Warp: " ++ show e)
|
||||
)
|
||||
|
||||
where
|
||||
shouldDisplayException e = and
|
||||
[ defaultShouldDisplayException e
|
||||
, case fromException e of
|
||||
Just (SessionDoesNotExist{} :: StorageException (MemcachedSqlStorage SessionMap)) -> False
|
||||
_other -> True
|
||||
, case fromException e of
|
||||
Just (SessionDoesNotExist{} :: StorageException (AcidStorage SessionMap)) -> False
|
||||
_other -> True
|
||||
]
|
||||
|
||||
|
||||
getAppDevSettings, getAppSettings :: MonadIO m => m AppSettings
|
||||
getAppDevSettings = liftIO $ loadYamlSettings [configSettingsYml] [configSettingsYmlValue] useEnv
|
||||
@ -443,7 +501,7 @@ develMain = runResourceT $ do
|
||||
lift $ threadDelay 100e3
|
||||
whenM (lift $ doesFileExist "yesod-devel/devel-terminate") $
|
||||
callCC ($ ())
|
||||
|
||||
|
||||
void . liftIO $ installHandler sigINT (Signals.Catch $ return ()) Nothing
|
||||
runAppLoggingT foundation $ handleJobs foundation
|
||||
void . liftIO $ awaitTermination `race` runSettings wsettings app
|
||||
@ -494,12 +552,12 @@ appMain = runResourceT $ do
|
||||
case watchdogMicroSec of
|
||||
Just wInterval
|
||||
| maybe True (== myProcessID) watchdogProcess
|
||||
-> let notifyWatchdog :: forall a. IO a
|
||||
notifyWatchdog = runAppLoggingT foundation $ go Nothing
|
||||
-> let notifyWatchdog :: forall a m'. ( MonadLogger m', MonadIO m') => m' a
|
||||
notifyWatchdog = go Nothing
|
||||
where
|
||||
go :: Maybe (Set (UTCTime, HealthReport)) -> LoggingT IO a
|
||||
go :: Maybe (Set (UTCTime, HealthReport)) -> m' a
|
||||
go pResults = do
|
||||
let delay = floor $ wInterval % 2
|
||||
let delay = floor $ wInterval % 4
|
||||
d <- liftIO $ newDelay delay
|
||||
|
||||
$logDebugS "Notify" $ "Waiting up to " <> tshow delay <> "µs..."
|
||||
@ -566,9 +624,11 @@ shutdownApp :: (MonadIO m, MonadUnliftIO m) => UniWorX -> m ()
|
||||
shutdownApp app = do
|
||||
stopJobCtl app
|
||||
liftIO $ do
|
||||
for_ (appWidgetMemcached app) Memcached.close
|
||||
for_ (appSmtpPool app) destroyAllResources
|
||||
destroyAllResources $ 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
|
||||
release . fst $ appLogger app
|
||||
|
||||
liftIO $ threadDelay 1e6
|
||||
@ -579,17 +639,19 @@ shutdownApp app = do
|
||||
---------------------------------------------
|
||||
|
||||
-- | Run a handler
|
||||
handler :: Handler a -> IO a
|
||||
handler, handler' :: Handler a -> IO a
|
||||
handler h = runResourceT $ getAppDevSettings >>= makeFoundation >>= liftIO . flip unsafeHandler h
|
||||
handler' h = runResourceT $ getAppSettings >>= makeFoundation >>= liftIO . flip unsafeHandler h
|
||||
|
||||
-- | Run DB queries
|
||||
db :: DB a -> IO a
|
||||
db, db' :: DB a -> IO a
|
||||
db = handler . runDB
|
||||
db' = handler' . runDB
|
||||
|
||||
addPWEntry :: User
|
||||
-> Text {-^ Password -}
|
||||
-> IO ()
|
||||
addPWEntry User{ userAuthentication = _, ..} (Text.encodeUtf8 -> pw) = db $ do
|
||||
addPWEntry User{ userAuthentication = _, ..} (Text.encodeUtf8 -> pw) = db' $ do
|
||||
PWHashConf{..} <- getsYesod $ view _appAuthPWHash
|
||||
(AuthPWHash . Text.decodeUtf8 -> userAuthentication) <- liftIO $ makePasswordWith pwHashAlgorithm pw pwHashStrength
|
||||
void $ insert User{..}
|
||||
|
||||
23
src/Audit.hs
23
src/Audit.hs
@ -19,11 +19,13 @@ import Utils.Lens
|
||||
import qualified Network.Wai as Wai
|
||||
import qualified Network.Socket as Wai
|
||||
|
||||
import qualified Net.IP as IP
|
||||
import qualified Net.IPv6 as IPv6
|
||||
import Network.IP.Addr (IP46(..), ip4FromOctets, ip6FromWords, anyIP6)
|
||||
import qualified Data.Textual as Textual
|
||||
|
||||
import Control.Exception (ErrorCall(..))
|
||||
|
||||
import GHC.Stack
|
||||
|
||||
{-# ANN module ("HLint: ignore Use newtype instead of data" :: String) #-}
|
||||
|
||||
|
||||
@ -51,24 +53,24 @@ getRemote = handle testHandler $ do
|
||||
(h, v) <- Wai.requestHeaders wai
|
||||
guard $ h `elem` ["x-real-ip", "x-forwarded-for"]
|
||||
v' <- either (const mzero) return $ Text.decodeUtf8' v
|
||||
maybeToList $ IP.decode v'
|
||||
|
||||
maybeToList $ Textual.fromText v'
|
||||
|
||||
byRemoteHost wai = case Wai.remoteHost wai of
|
||||
Wai.SockAddrInet _ hAddr
|
||||
-> let (b1, b2, b3, b4) = Wai.hostAddressToTuple hAddr
|
||||
in return $ IP.ipv4 b1 b2 b3 b4
|
||||
in return . IPv4 $ ip4FromOctets b1 b2 b3 b4
|
||||
Wai.SockAddrInet6 _ _ hAddr _
|
||||
-> let (w1, w2, w3, w4, w5, w6, w7, w8) = Wai.hostAddress6ToTuple hAddr
|
||||
in return $ IP.ipv6 w1 w2 w3 w4 w5 w6 w7 w8
|
||||
in return . IPv6 $ ip6FromWords w1 w2 w3 w4 w5 w6 w7 w8
|
||||
_other -> throwM ARUnsupportedSocketKind
|
||||
|
||||
testHandler :: ErrorCall -> m IP
|
||||
-- ^ `Yesod.Core.Unsafe.runFakeHandler` does not set a `Wai.remoteHost`
|
||||
--
|
||||
-- We catch only the specific error call used by
|
||||
-- `Yesod.Core.Unsafe.runFakeHandler` and replace it with `IPv6.any` as a
|
||||
-- `Yesod.Core.Unsafe.runFakeHandler` and replace it with `anyIP6` as a
|
||||
-- placeholder value for testing.
|
||||
testHandler (ErrorCall "runFakeHandler-remoteHost") = return $ IP.fromIPv6 IPv6.any
|
||||
testHandler (ErrorCall "runFakeHandler-remoteHost") = return $ IPv6 anyIP6
|
||||
testHandler err = throwM err
|
||||
|
||||
|
||||
@ -86,6 +88,7 @@ audit :: ( AuthId (HandlerSite m) ~ Key User
|
||||
, MonadHandler m
|
||||
, MonadCatch m
|
||||
, HasAppSettings (HandlerSite m)
|
||||
, HasCallStack
|
||||
)
|
||||
=> Transaction -- ^ Transaction to record
|
||||
-> ReaderT (YesodPersistBackend (HandlerSite m)) m ()
|
||||
@ -94,7 +97,7 @@ audit :: ( AuthId (HandlerSite m) ~ Key User
|
||||
-- - `transactionLogTime` is now
|
||||
-- - `transactionLogInitiator` is currently logged in user (or none)
|
||||
-- - `transactionLogRemote` is determined from current HTTP-Request
|
||||
audit (toJSON -> transactionLogInfo) = do
|
||||
audit transaction@(toJSON -> transactionLogInfo) = do
|
||||
|
||||
transactionLogTime <- liftIO getCurrentTime
|
||||
transactionLogInstance <- getsYesod $ view instanceID
|
||||
@ -102,3 +105,5 @@ audit (toJSON -> transactionLogInfo) = do
|
||||
transactionLogRemote <- handle (throwM . AuditRemoteException) $ Just <$> getRemote
|
||||
|
||||
insert_ TransactionLog{..}
|
||||
|
||||
$logInfoS "Audit" $ tshow (transaction, transactionLogInitiator, transactionLogRemote) <> "\n" <> pack (prettyCallStack callStack)
|
||||
|
||||
@ -23,7 +23,7 @@ data Transaction
|
||||
{ transactionExam :: ExamId
|
||||
, transactionUser :: UserId
|
||||
}
|
||||
|
||||
|
||||
| TransactionExamPartResultEdit
|
||||
{ transactionExamPart :: ExamPartId
|
||||
, transactionUser :: UserId
|
||||
@ -88,32 +88,14 @@ data Transaction
|
||||
{ transactionSubmission :: SubmissionId
|
||||
, transactionUser :: UserId
|
||||
}
|
||||
|
||||
|
||||
| TransactionSubmissionFileEdit
|
||||
{ transactionSubmissionFile :: SubmissionFileId
|
||||
, transactionSubmission :: SubmissionId
|
||||
, transactionFile :: FileId
|
||||
}
|
||||
| TransactionSubmissionFileDelete
|
||||
{ transactionSubmissionFile :: SubmissionFileId
|
||||
, transactionSubmission :: SubmissionId
|
||||
, transactionFile :: FileId
|
||||
}
|
||||
|
||||
-- TODO: not yet audited
|
||||
| TransactionUserEdit
|
||||
{ transactionUser :: UserId
|
||||
}
|
||||
| TransactionUserDelete
|
||||
{ transactionUser :: UserId
|
||||
}
|
||||
|
||||
-- TODO: not yet audited
|
||||
| TransactionFileEdit
|
||||
{ transactionFile :: FileId
|
||||
}
|
||||
| TransactionFileDelete
|
||||
{ transactionFile :: FileId
|
||||
}
|
||||
|
||||
| TransactionExamOfficeUserAdd
|
||||
@ -151,7 +133,7 @@ data Transaction
|
||||
{ transactionExternalExam :: ExternalExamId
|
||||
, transactionSchool :: SchoolId
|
||||
}
|
||||
|
||||
|
||||
| TransactionExternalExamStaffEdit
|
||||
{ transactionExternalExam :: ExternalExamId
|
||||
, transactionUser :: UserId
|
||||
@ -178,6 +160,21 @@ data Transaction
|
||||
, transactionUser :: UserId
|
||||
}
|
||||
|
||||
| TransactionSubmissionGroupSet
|
||||
{ transactionCourse :: CourseId
|
||||
, transactionUser :: UserId
|
||||
, transactionSubmissionGroup :: SubmissionGroupName
|
||||
}
|
||||
| TransactionSubmissionGroupUnset
|
||||
{ transactionCourse :: CourseId
|
||||
, transactionUser :: UserId
|
||||
}
|
||||
|
||||
| TransactionUserAssimilated
|
||||
{ transactionUser :: UserId
|
||||
, transactionAssimilatedUser :: UserId
|
||||
}
|
||||
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
|
||||
deriveJSON defaultOptions
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
module Auth.Dummy
|
||||
( dummyLogin
|
||||
( apDummy
|
||||
, dummyLogin
|
||||
, DummyMessage(..)
|
||||
) where
|
||||
|
||||
import Import.NoFoundation
|
||||
import Database.Persist.Sql (SqlBackendCanRead)
|
||||
|
||||
import Utils.Metrics
|
||||
import Utils.Form
|
||||
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
@ -18,34 +20,39 @@ data DummyMessage = MsgDummyIdent
|
||||
|
||||
|
||||
dummyForm :: ( RenderMessage (HandlerSite m) FormMessage
|
||||
, RenderMessage (HandlerSite m) (ValueRequired (HandlerSite m))
|
||||
, RenderMessage (HandlerSite m) DummyMessage
|
||||
, YesodPersist (HandlerSite m)
|
||||
, SqlBackendCanRead (YesodPersistBackend (HandlerSite m))
|
||||
, MonadHandler m
|
||||
) => AForm m (CI Text)
|
||||
dummyForm = wFormToAForm $ do
|
||||
) => WForm m (FormResult (CI Text))
|
||||
dummyForm = do
|
||||
mr <- getMessageRender
|
||||
aFormToWForm $ areq (ciField & addDatalist userList) (fslpI MsgDummyIdent (mr MsgDummyIdentPlaceholder) & noAutocomplete & addName PostLoginDummy) Nothing
|
||||
wreq (ciField & addDatalist userList) (fslpI MsgDummyIdent (mr MsgDummyIdentPlaceholder) & noAutocomplete & addName PostLoginDummy) Nothing
|
||||
where
|
||||
userList = fmap mkOptionList . runDB $ withReaderT projectBackend (map toOption <$> selectList [] [Asc UserIdent] :: ReaderT SqlBackend _ [Option UserIdent])
|
||||
toOption (Entity _ User{..}) = Option userDisplayName userIdent (CI.original userIdent)
|
||||
|
||||
apDummy :: Text
|
||||
apDummy = "dummy"
|
||||
|
||||
dummyLogin :: forall site.
|
||||
( YesodAuth site
|
||||
, YesodPersist site
|
||||
, SqlBackendCanRead (YesodPersistBackend site)
|
||||
, RenderMessage site AFormMessage
|
||||
, RenderMessage site DummyMessage
|
||||
, RenderMessage site (ValueRequired site)
|
||||
, Button site ButtonSubmit
|
||||
) => AuthPlugin site
|
||||
dummyLogin = AuthPlugin{..}
|
||||
where
|
||||
apName :: Text
|
||||
apName = "dummy"
|
||||
|
||||
apName = apDummy
|
||||
|
||||
apDispatch :: forall m. MonadAuthHandler site m => Text -> [Text] -> m TypedContent
|
||||
apDispatch "POST" [] = liftSubHandler $ do
|
||||
((loginRes, _), _) <- runFormPost $ renderAForm FormStandard dummyForm
|
||||
apDispatch method [] | encodeUtf8 method == methodPost = liftSubHandler $ do
|
||||
((loginRes, _), _) <- runFormPost $ renderWForm FormStandard dummyForm
|
||||
tp <- getRouteToParent
|
||||
case loginRes of
|
||||
FormFailure errs -> do
|
||||
@ -54,16 +61,18 @@ dummyLogin = AuthPlugin{..}
|
||||
FormMissing -> do
|
||||
addMessageI Warning MsgDummyNoFormData
|
||||
redirect $ tp LoginR
|
||||
FormSuccess ident ->
|
||||
setCredsRedirect $ Creds "dummy" (CI.original ident) []
|
||||
FormSuccess ident -> do
|
||||
observeLoginOutcome apName LoginSuccessful
|
||||
setCredsRedirect $ Creds apName (CI.original ident) []
|
||||
apDispatch _ [] = badMethod
|
||||
apDispatch _ _ = notFound
|
||||
|
||||
|
||||
apLogin :: (Route Auth -> Route site) -> WidgetFor site ()
|
||||
apLogin toMaster = do
|
||||
(login, loginEnctype) <- handlerToWidget . generateFormPost $ renderAForm FormStandard dummyForm
|
||||
(login, loginEnctype) <- handlerToWidget . generateFormPost $ renderWForm FormStandard dummyForm
|
||||
let loginForm = wrapForm login FormSettings
|
||||
{ formMethod = POST
|
||||
, formAction = Just . SomeRoute . toMaster $ PluginR "dummy" []
|
||||
, formAction = Just . SomeRoute . toMaster $ PluginR apName []
|
||||
, formEncoding = loginEnctype
|
||||
, formAttrs = [("uw-no-navigate-away-prompt","")]
|
||||
, formSubmit = FormSubmit
|
||||
|
||||
205
src/Auth/LDAP.hs
205
src/Auth/LDAP.hs
@ -1,23 +1,24 @@
|
||||
module Auth.LDAP
|
||||
( apLdap
|
||||
, ADError(..), ADInvalidCredentials(..)
|
||||
, campusLogin
|
||||
, CampusUserException(..)
|
||||
, campusUser, campusUser'
|
||||
, campusUserReTest, campusUserReTest'
|
||||
, campusUserMatr, campusUserMatr'
|
||||
, CampusMessage(..)
|
||||
, ldapUserPrincipalName, ldapUserEmail, ldapUserDisplayName
|
||||
, ldapUserMatriculation, ldapUserFirstName, ldapUserSurname
|
||||
, ldapUserTitle, ldapUserStudyFeatures, ldapUserFieldName
|
||||
, ldapUserSchoolAssociation, ldapUserSubTermsSemester, ldapSex
|
||||
, ldapAffiliation, ldapPrimaryKey
|
||||
) where
|
||||
|
||||
import Import.NoFoundation
|
||||
import Network.Connection
|
||||
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
|
||||
import qualified Control.Monad.Catch as Exc
|
||||
|
||||
import Utils.Metrics
|
||||
import Utils.Form
|
||||
|
||||
import qualified Ldap.Client as Ldap
|
||||
@ -26,6 +27,8 @@ import qualified Data.Text.Encoding as Text
|
||||
|
||||
import qualified Yesod.Auth.Message as Msg
|
||||
|
||||
import Auth.LDAP.AD
|
||||
|
||||
|
||||
data CampusLogin = CampusLogin
|
||||
{ campusIdent :: CI Text
|
||||
@ -36,8 +39,6 @@ data CampusMessage = MsgCampusIdentPlaceholder
|
||||
| MsgCampusIdent
|
||||
| MsgCampusPassword
|
||||
| MsgCampusPasswordPlaceholder
|
||||
| MsgCampusSubmit
|
||||
| MsgCampusInvalidCredentials
|
||||
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
|
||||
|
||||
|
||||
@ -55,7 +56,7 @@ findUser conf@LdapConf{..} ldap ident retAttrs = fromMaybe [] <$> findM (assertM
|
||||
[ ldapUserDisplayName Ldap.:= Text.encodeUtf8 ident
|
||||
, ldapUserMatriculation Ldap.:= Text.encodeUtf8 ident
|
||||
]
|
||||
|
||||
|
||||
findUserMatr :: LdapConf -> Ldap -> Text -> [Ldap.Attr] -> IO [Ldap.SearchEntry]
|
||||
findUserMatr conf@LdapConf{..} ldap userMatr retAttrs = fromMaybe [] <$> findM (assertM (not . null) . lift . flip (Ldap.search ldap ldapBase $ userSearchSettings conf) retAttrs) userFilters
|
||||
where
|
||||
@ -71,7 +72,7 @@ userSearchSettings LdapConf{..} = mconcat
|
||||
, Ldap.derefAliases Ldap.DerefAlways
|
||||
]
|
||||
|
||||
ldapUserPrincipalName, ldapUserDisplayName, ldapUserMatriculation, ldapUserFirstName, ldapUserSurname, ldapUserTitle, ldapUserStudyFeatures, ldapUserFieldName, ldapUserSchoolAssociation, ldapSex, ldapUserSubTermsSemester :: Ldap.Attr
|
||||
ldapUserPrincipalName, ldapUserDisplayName, ldapUserMatriculation, ldapUserFirstName, ldapUserSurname, ldapUserTitle, ldapUserStudyFeatures, ldapUserFieldName, ldapUserSchoolAssociation, ldapSex, ldapUserSubTermsSemester, ldapAffiliation, ldapPrimaryKey :: Ldap.Attr
|
||||
ldapUserPrincipalName = Ldap.Attr "userPrincipalName"
|
||||
ldapUserDisplayName = Ldap.Attr "displayName"
|
||||
ldapUserMatriculation = Ldap.Attr "LMU-Stud-Matrikelnummer"
|
||||
@ -79,10 +80,12 @@ ldapUserFirstName = Ldap.Attr "givenName"
|
||||
ldapUserSurname = Ldap.Attr "sn"
|
||||
ldapUserTitle = Ldap.Attr "title"
|
||||
ldapUserStudyFeatures = Ldap.Attr "dfnEduPersonFeaturesOfStudy"
|
||||
ldapUserFieldName = Ldap.Attr "LMU-Stg-Fach"
|
||||
ldapUserSchoolAssociation = Ldap.Attr "LMU-IFI-eduPersonOrgUnitDNString"
|
||||
ldapUserFieldName = Ldap.Attr "LMU-Stg-Fach"
|
||||
ldapUserSchoolAssociation = Ldap.Attr "LMU-IFI-eduPersonOrgUnitDNString"
|
||||
ldapSex = Ldap.Attr "schacGender"
|
||||
ldapUserSubTermsSemester = Ldap.Attr "LMU-Stg-FachundFS"
|
||||
ldapAffiliation = Ldap.Attr "eduPersonAffiliation"
|
||||
ldapPrimaryKey = Ldap.Attr "eduPersonPrincipalName"
|
||||
|
||||
ldapUserEmail :: NonEmpty Ldap.Attr
|
||||
ldapUserEmail = Ldap.Attr "mail" :|
|
||||
@ -91,9 +94,6 @@ ldapUserEmail = Ldap.Attr "mail" :|
|
||||
|
||||
|
||||
data CampusUserException = CampusUserLdapError LdapPoolError
|
||||
| CampusUserHostNotResolved String
|
||||
| CampusUserLineTooLong
|
||||
| CampusUserHostCannotConnect String [IOException]
|
||||
| CampusUserNoResult
|
||||
| CampusUserAmbiguous
|
||||
deriving (Show, Eq, Generic, Typeable)
|
||||
@ -102,63 +102,80 @@ instance Exception CampusUserException
|
||||
|
||||
makePrisms ''CampusUserException
|
||||
|
||||
campusUser :: MonadUnliftIO m => LdapConf -> LdapPool -> Creds site -> m (Ldap.AttrList [])
|
||||
campusUser conf@LdapConf{..} pool Creds{..} = liftIO . (`catches` errHandlers) $ either (throwM . CampusUserLdapError) return <=< withLdap pool $ \ldap -> do
|
||||
Ldap.bind ldap ldapDn ldapPassword
|
||||
campusUserWith :: ( MonadUnliftIO m
|
||||
, MonadCatch m
|
||||
)
|
||||
=> ( Lens (LdapConf, LdapPool) (LdapConf, Ldap) LdapPool Ldap
|
||||
-> Failover (LdapConf, LdapPool)
|
||||
-> FailoverMode
|
||||
-> ((LdapConf, Ldap) -> m (Either CampusUserException (Ldap.AttrList [])))
|
||||
-> m (Either LdapPoolError (Either CampusUserException (Ldap.AttrList [])))
|
||||
)
|
||||
-> Failover (LdapConf, LdapPool)
|
||||
-> FailoverMode
|
||||
-> Creds site
|
||||
-> m (Either CampusUserException (Ldap.AttrList []))
|
||||
campusUserWith withLdap' pool mode Creds{..} = either (throwM . CampusUserLdapError) return <=< withLdap' _2 pool mode $ \(conf@LdapConf{..}, ldap) -> liftIO . runExceptT $ do
|
||||
lift $ Ldap.bind ldap ldapDn ldapPassword
|
||||
results <- case lookup "DN" credsExtra of
|
||||
Just userDN -> do
|
||||
let userFilter = Ldap.Present ldapUserPrincipalName
|
||||
Ldap.search ldap (Ldap.Dn userDN) (userSearchSettings conf) userFilter []
|
||||
lift $ Ldap.search ldap (Ldap.Dn userDN) (userSearchSettings conf) userFilter []
|
||||
Nothing -> do
|
||||
findUser conf ldap credsIdent []
|
||||
lift $ findUser conf ldap credsIdent []
|
||||
case results of
|
||||
[] -> throwM CampusUserNoResult
|
||||
[] -> throwE CampusUserNoResult
|
||||
[Ldap.SearchEntry _ attrs] -> return attrs
|
||||
_otherwise -> throwM CampusUserAmbiguous
|
||||
where
|
||||
errHandlers = [ Exc.Handler $ \LineTooLong -> throwM CampusUserLineTooLong
|
||||
, Exc.Handler $ \(HostNotResolved host) -> throwM $ CampusUserHostNotResolved host
|
||||
, Exc.Handler $ \(HostCannotConnect host excs) -> throwM $ CampusUserHostCannotConnect host excs
|
||||
]
|
||||
_otherwise -> throwE CampusUserAmbiguous
|
||||
|
||||
campusUser' :: (MonadCatch m, MonadUnliftIO m) => LdapConf -> LdapPool -> User -> m (Maybe (Ldap.AttrList []))
|
||||
campusUser' conf pool User{userIdent}
|
||||
= runMaybeT . catchIfMaybeT (is _CampusUserNoResult) $ campusUser conf pool (Creds apLdap (CI.original userIdent) [])
|
||||
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' :: (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' :: (MonadMask m, MonadUnliftIO m, MonadLogger m) => Failover (LdapConf, LdapPool) -> FailoverMode -> User -> m (Maybe (Ldap.AttrList []))
|
||||
campusUser' pool mode User{userIdent}
|
||||
= runMaybeT . catchIfMaybeT (is _CampusUserNoResult) $ campusUser pool mode (Creds apLdap (CI.original userIdent) [])
|
||||
|
||||
|
||||
campusUserMatr :: MonadUnliftIO m => LdapConf -> LdapPool -> UserMatriculation -> m (Ldap.AttrList [])
|
||||
campusUserMatr conf@LdapConf{..} pool userMatr = liftIO . (`catches` errHandlers) $ either (throwM . CampusUserLdapError) return <=< withLdap pool $ \ldap -> do
|
||||
campusUserMatr :: (MonadUnliftIO m, MonadMask m, MonadLogger m) => Failover (LdapConf, LdapPool) -> FailoverMode -> UserMatriculation -> m (Ldap.AttrList [])
|
||||
campusUserMatr pool mode userMatr = either (throwM . CampusUserLdapError) return <=< withLdapFailover _2 pool mode $ \(conf@LdapConf{..}, ldap) -> liftIO $ do
|
||||
Ldap.bind ldap ldapDn ldapPassword
|
||||
results <- findUserMatr conf ldap userMatr []
|
||||
case results of
|
||||
[] -> throwM CampusUserNoResult
|
||||
[Ldap.SearchEntry _ attrs] -> return attrs
|
||||
_otherwise -> throwM CampusUserAmbiguous
|
||||
where
|
||||
errHandlers = [ Exc.Handler $ \LineTooLong -> throwM CampusUserLineTooLong
|
||||
, Exc.Handler $ \(HostNotResolved host) -> throwM $ CampusUserHostNotResolved host
|
||||
, Exc.Handler $ \(HostCannotConnect host excs) -> throwM $ CampusUserHostCannotConnect host excs
|
||||
]
|
||||
|
||||
campusUserMatr' :: (MonadMask m, MonadUnliftIO m, MonadLogger m) => Failover (LdapConf, LdapPool) -> FailoverMode -> UserMatriculation -> m (Maybe (Ldap.AttrList []))
|
||||
campusUserMatr' pool mode
|
||||
= runMaybeT . catchIfMaybeT (is _CampusUserNoResult) . campusUserMatr pool mode
|
||||
|
||||
|
||||
|
||||
newtype ADInvalidCredentials = ADInvalidCredentials ADError
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
deriving newtype (Universe, Finite, Enum, Bounded, PathPiece, ToJSON, FromJSON, ToJSONKey, FromJSONKey)
|
||||
|
||||
isUnusualADError :: ADError -> Bool
|
||||
isUnusualADError = flip notElem [ADNoSuchObject, ADLogonFailure]
|
||||
|
||||
|
||||
campusUserMatr' :: (MonadCatch m, MonadUnliftIO m) => LdapConf -> LdapPool -> UserMatriculation -> m (Maybe (Ldap.AttrList []))
|
||||
campusUserMatr' conf pool
|
||||
= runMaybeT . catchIfMaybeT (is _CampusUserNoResult) . campusUserMatr conf pool
|
||||
|
||||
|
||||
|
||||
campusForm :: ( RenderMessage (HandlerSite m) FormMessage
|
||||
, RenderMessage (HandlerSite m) (ValueRequired (HandlerSite m))
|
||||
, RenderMessage (HandlerSite m) CampusMessage
|
||||
, MonadHandler m
|
||||
) => WForm m (FormResult CampusLogin)
|
||||
campusForm = do
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
|
||||
ident <- wreq ciField (fslpI MsgCampusIdent (mr MsgCampusIdentPlaceholder) & addAttr "autofocus" "") Nothing
|
||||
password <- wreq passwordField (fslpI MsgCampusPassword (mr MsgCampusPasswordPlaceholder)) Nothing
|
||||
|
||||
return $ CampusLogin
|
||||
<$> ident
|
||||
<*> password
|
||||
aFormToWForm $ CampusLogin
|
||||
<$> areq ciField (fslpI MsgCampusIdent (mr MsgCampusIdentPlaceholder) & addAttr "autofocus" "") Nothing
|
||||
<*> areq passwordField (fslpI MsgCampusPassword (mr MsgCampusPasswordPlaceholder)) Nothing
|
||||
|
||||
apLdap :: Text
|
||||
apLdap = "LDAP"
|
||||
@ -167,46 +184,68 @@ campusLogin :: forall site.
|
||||
( YesodAuth site
|
||||
, RenderMessage site CampusMessage
|
||||
, RenderMessage site AFormMessage
|
||||
, RenderMessage site (ValueRequired site)
|
||||
, RenderMessage site ADInvalidCredentials
|
||||
, Button site ButtonSubmit
|
||||
) => LdapConf -> LdapPool -> AuthPlugin site
|
||||
campusLogin conf@LdapConf{..} pool = AuthPlugin{..}
|
||||
) => Failover (LdapConf, LdapPool) -> FailoverMode -> AuthPlugin site
|
||||
campusLogin pool mode = AuthPlugin{..}
|
||||
where
|
||||
apName :: Text
|
||||
apName = apLdap
|
||||
|
||||
|
||||
apDispatch :: forall m. MonadAuthHandler site m => Text -> [Text] -> m TypedContent
|
||||
apDispatch "POST" [] = liftSubHandler $ do
|
||||
apDispatch method [] | encodeUtf8 method == methodPost = liftSubHandler $ do
|
||||
((loginRes, _), _) <- runFormPost $ renderWForm FormStandard campusForm
|
||||
tp <- getRouteToParent
|
||||
case loginRes of
|
||||
FormFailure errs -> do
|
||||
forM_ errs $ addMessage Error . toHtml
|
||||
redirect $ tp LoginR
|
||||
FormMissing -> redirect $ tp LoginR
|
||||
FormSuccess CampusLogin{ campusIdent = CI.original -> campusIdent, ..} -> do
|
||||
ldapResult <- withLdap pool $ \ldap -> liftIO $ do
|
||||
Ldap.bind ldap ldapDn ldapPassword
|
||||
searchResults <- findUser conf ldap campusIdent [ldapUserPrincipalName]
|
||||
case searchResults of
|
||||
[Ldap.SearchEntry (Ldap.Dn userDN) userAttrs]
|
||||
| [principalName] <- fold [ v | (k, v) <- userAttrs, k == ldapUserPrincipalName ]
|
||||
, Right credsIdent <- Text.decodeUtf8' principalName
|
||||
-> Right (userDN, credsIdent) <$ Ldap.bind ldap (Ldap.Dn credsIdent) (Ldap.Password $ Text.encodeUtf8 campusPassword)
|
||||
other -> return $ Left other
|
||||
case ldapResult of
|
||||
Left err
|
||||
| LdapError (Ldap.ResponseError (Ldap.ResponseErrorCode _ Ldap.InvalidCredentials _ _)) <- err
|
||||
-> do
|
||||
$logDebugS "LDAP" "Invalid credentials"
|
||||
loginErrorMessageI LoginR Msg.InvalidLogin
|
||||
| otherwise -> do
|
||||
$logErrorS "LDAP" $ "Error during login: " <> tshow err
|
||||
loginErrorMessageI LoginR Msg.AuthError
|
||||
Right (Right (userDN, credsIdent)) ->
|
||||
setCredsRedirect $ Creds apName credsIdent [("DN", userDN)]
|
||||
Right (Left searchResults) -> do
|
||||
$logWarnS "LDAP" $ "Could not extract principal name: " <> tshow searchResults
|
||||
loginErrorMessageI LoginR Msg.AuthError
|
||||
|
||||
resp <- formResultMaybe loginRes $ \CampusLogin{ campusIdent = CI.original -> campusIdent, ..} -> Just <$> do
|
||||
ldapResult <- withLdapFailover _2 pool mode $ \(conf@LdapConf{..}, ldap) -> liftIO $ do
|
||||
Ldap.bind ldap ldapDn ldapPassword
|
||||
searchResults <- findUser conf ldap campusIdent [ldapUserPrincipalName]
|
||||
case searchResults of
|
||||
[Ldap.SearchEntry (Ldap.Dn userDN) userAttrs]
|
||||
| [principalName] <- nub $ fold [ v | (k, v) <- userAttrs, k == ldapUserPrincipalName ]
|
||||
, Right credsIdent <- Text.decodeUtf8' principalName
|
||||
-> handleIf isInvalidCredentials (return . Left) $ do
|
||||
Ldap.bind ldap (Ldap.Dn credsIdent) . Ldap.Password $ Text.encodeUtf8 campusPassword
|
||||
return . Right $ Right (userDN, credsIdent)
|
||||
other -> return . Right $ Left other
|
||||
case ldapResult of
|
||||
Left err -> do
|
||||
$logErrorS apName $ "Error during login: " <> tshow err
|
||||
observeLoginOutcome apName LoginError
|
||||
loginErrorMessageI LoginR Msg.AuthError
|
||||
Right (Left (Ldap.ResponseErrorCode _ errCode _ errTxt))
|
||||
| Right adError <- parseADError errCode errTxt
|
||||
, isUnusualADError adError -> do
|
||||
$logInfoS apName [st|#{campusIdent}: #{toPathPiece adError}|]
|
||||
observeLoginOutcome apName LoginADInvalidCredentials
|
||||
MsgRenderer mr <- liftHandler getMsgRenderer
|
||||
setSessionJson SessionError . PermissionDenied . toPathPiece $ ADInvalidCredentials adError
|
||||
loginErrorMessage (tp LoginR) . mr $ ADInvalidCredentials adError
|
||||
Right (Left bindErr) -> do
|
||||
case bindErr of
|
||||
Ldap.ResponseErrorCode _ _ _ errTxt ->
|
||||
$logInfoS apName [st|#{campusIdent}: #{errTxt}|]
|
||||
_other -> return ()
|
||||
$logDebugS apName "Invalid credentials"
|
||||
observeLoginOutcome apName LoginInvalidCredentials
|
||||
loginErrorMessageI LoginR Msg.InvalidLogin
|
||||
Right (Right (Left searchResults))
|
||||
| null searchResults -> do
|
||||
$logDebugS apName "User not found"
|
||||
observeLoginOutcome apName LoginInvalidCredentials
|
||||
loginErrorMessageI LoginR Msg.InvalidLogin
|
||||
| otherwise -> do
|
||||
$logWarnS apName $ "Could not extract principal name: " <> tshow searchResults
|
||||
observeLoginOutcome apName LoginError
|
||||
loginErrorMessageI LoginR Msg.AuthError
|
||||
Right (Right (Right (userDN, credsIdent))) -> do
|
||||
observeLoginOutcome apName LoginSuccessful
|
||||
setCredsRedirect $ Creds apName credsIdent [("DN", userDN)]
|
||||
|
||||
maybe (redirect $ tp LoginR) return resp
|
||||
apDispatch _ [] = badMethod
|
||||
apDispatch _ _ = notFound
|
||||
|
||||
apLogin :: (Route Auth -> Route site) -> WidgetFor site ()
|
||||
@ -214,10 +253,14 @@ campusLogin conf@LdapConf{..} pool = AuthPlugin{..}
|
||||
(login, loginEnctype) <- handlerToWidget . generateFormPost $ renderWForm FormStandard campusForm
|
||||
let loginForm = wrapForm login FormSettings
|
||||
{ formMethod = POST
|
||||
, formAction = Just . SomeRoute . toMaster $ PluginR "LDAP" []
|
||||
, formAction = Just . SomeRoute . toMaster $ PluginR apName []
|
||||
, formEncoding = loginEnctype
|
||||
, formAttrs = [("uw-no-navigate-away-prompt","")]
|
||||
, formSubmit = FormSubmit
|
||||
, formAnchor = Just "login--campus" :: Maybe Text
|
||||
}
|
||||
$(widgetFile "widgets/campus-login/campus-login-form")
|
||||
|
||||
isInvalidCredentials = \case
|
||||
Ldap.ResponseErrorCode _ Ldap.InvalidCredentials _ _ -> True
|
||||
_other -> False
|
||||
|
||||
76
src/Auth/LDAP/AD.hs
Normal file
76
src/Auth/LDAP/AD.hs
Normal file
@ -0,0 +1,76 @@
|
||||
module Auth.LDAP.AD
|
||||
( ADError(..)
|
||||
, parseADError
|
||||
) where
|
||||
|
||||
import Import.NoFoundation hiding (try)
|
||||
|
||||
import Model.Types.TH.PathPiece
|
||||
|
||||
import qualified Data.IntMap.Strict as IntMap
|
||||
import qualified Data.Map.Strict as Map
|
||||
|
||||
import Text.Parsec hiding ((<|>))
|
||||
import Text.Parsec.String
|
||||
import Text.ParserCombinators.Parsec.Number (hexnum)
|
||||
|
||||
import Ldap.Client (ResultCode(..))
|
||||
|
||||
|
||||
-- | Copied from <https://ldapwiki.com/wiki/Common%20Active%20Directory%20Bind%20Errors>
|
||||
data ADError
|
||||
= ADNoSuchObject
|
||||
| ADLogonFailure
|
||||
| ADAccountRestriction
|
||||
| ADInvalidLogonHours
|
||||
| ADInvalidWorkstation
|
||||
| ADPasswordExpired
|
||||
| ADAccountDisabled
|
||||
| ADTooManyContextIds
|
||||
| ADAccountExpired
|
||||
| ADPasswordMustChange
|
||||
| ADAccountLockedOut
|
||||
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
|
||||
deriving anyclass (Universe, Finite)
|
||||
|
||||
nullaryPathPiece ''ADError $ camelToPathPiece' 1
|
||||
pathPieceJSON ''ADError
|
||||
pathPieceJSONKey ''ADError
|
||||
derivePersistFieldPathPiece ''ADError
|
||||
|
||||
|
||||
fromADErrorCode :: ResultCode -> Word32 -> Maybe ADError
|
||||
fromADErrorCode resCode subResCode = IntMap.lookup (fromIntegral subResCode) =<< Map.lookup resCode errorCodes
|
||||
where
|
||||
errorCodes = Map.fromList
|
||||
[ ( InvalidCredentials
|
||||
, IntMap.fromList
|
||||
[ ( 0x525, ADNoSuchObject )
|
||||
, ( 0x52e, ADLogonFailure )
|
||||
, ( 0x52f, ADAccountRestriction )
|
||||
, ( 0x530, ADInvalidLogonHours )
|
||||
, ( 0x531, ADInvalidWorkstation )
|
||||
, ( 0x532, ADPasswordExpired )
|
||||
, ( 0x533, ADAccountDisabled )
|
||||
, ( 0x568, ADTooManyContextIds )
|
||||
, ( 0x701, ADAccountExpired )
|
||||
, ( 0x773, ADPasswordMustChange )
|
||||
, ( 0x775, ADAccountLockedOut )
|
||||
, ( 0x80090346, ADAccountLockedOut )
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
parseADError :: ResultCode -> Text -> Either ParseError ADError
|
||||
parseADError resCode = parse (pADError resCode <* eof) "LDAP" . unpack
|
||||
|
||||
pADError :: ResultCode -> Parser ADError
|
||||
pADError resCode = do
|
||||
void . manyTill anyChar . try $ string ": "
|
||||
let pItem = asum
|
||||
[ do
|
||||
void $ string "data "
|
||||
fmap Just $ hexnum >>= hoistMaybe . fromADErrorCode resCode
|
||||
, Nothing <$ manyTill anyChar (lookAhead . try $ void (string ", ") <|> eof)
|
||||
]
|
||||
(hoistMaybe =<<) $ ala First foldMap <$> pItem `sepBy1` try (string ", ")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user