Merge branch 'master' into formatting-apis

This commit is contained in:
Gregor Kleen 2021-01-21 23:35:54 +01:00
commit f5d4010629
962 changed files with 51514 additions and 23133 deletions

5
.dir-locals.el Normal file
View File

@ -0,0 +1,5 @@
;;; Directory Local Variables
;;; For more information see (info "(emacs) Directory Variables")
((nil
(indent-tabs-mode)))

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View 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

View File

@ -1 +1,4 @@
User-agent: *
User-agent: AhrefsBot
Disallow: /

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 });
// }
}

View 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);
}

View File

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

View File

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

View File

@ -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');
}

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

@ -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 });
// }
}

View File

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

View File

@ -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;
});
}

View File

@ -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);
});

View File

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

View File

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

View 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;
}

View File

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

View File

@ -3,6 +3,8 @@ import './datepicker.css';
import { Utility } from '../../core/utility';
import moment from 'moment';
import * as defer from 'lodash.defer';
const KEYCODE_ESCAPE = 27;
const Z_INDEX_MODAL = 9999;
@ -77,8 +79,11 @@ export class Datepicker {
datepickerInstance;
_element;
elementType;
initialValue;
_locale;
_unloadIsDueToSubmit = false;
constructor(element) {
if (!element) {
throw new Error('Datepicker utility needs to be passed an element!');
@ -100,6 +105,9 @@ export class Datepicker {
// store the previously set type to select the input format
this.elementType = this._element.getAttribute('type');
// store initial value prior to changing type
this.initialValue = this._element.value || this._element.getAttribute('value');
// manually set the type attribute to text because datepicker handles displaying the date
this._element.setAttribute('type', 'text');
@ -120,7 +128,7 @@ export class Datepicker {
// FIXME dirty hack below; fix tail.datetime instead
// get date object from internal format before datetime does nasty things with it
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

View File

@ -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);
}
}

View 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);
}
}
}
}

View File

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

View File

@ -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]');
}
}

View File

@ -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();

View File

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

View File

@ -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)
);
}

View File

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

View File

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

View 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();
}
}

View File

@ -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,
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
}
}
}

View File

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

View File

@ -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();
});
}
});
})($);

View File

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

View File

@ -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}`,

View File

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

View File

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

View File

@ -1,6 +1,4 @@
CampusIdentPlaceholder: First.Last@campus.lmu.de
CampusIdent: Campus account
CampusPassword: Password
CampusPasswordPlaceholder: Password
CampusSubmit: Send
CampusInvalidCredentials: Invalid login
CampusPasswordPlaceholder: Password

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

View File

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

View File

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

View File

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

View File

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

View File

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

@ -0,0 +1,4 @@
ChangelogItemFirstSeen
item ChangelogItem
firstSeen Day
Primary item

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ ExternalExamResult
time UTCTime
lastChanged UTCTime
UniqueExternalExamResult exam user
deriving Eq Ord Show
ExternalExamStaff
user UserId
exam ExternalExamId

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

@ -41,6 +41,8 @@
/metrics MetricsR GET
/err ErrorR GET !free
/ NewsR GET !free
/users UsersR GET POST -- no tags, i.e. admins only
/users/#CryptoUUIDUser AdminUserR GET POST
@ -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

View File

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

View File

@ -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{..}

View File

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

View File

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

View File

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

View File

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