Merge branch 'master' into 302-transaction-log
This commit is contained in:
commit
baa7a52cdb
3
.babelrc
3
.babelrc
@ -4,6 +4,7 @@
|
||||
],
|
||||
"plugins": [
|
||||
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||
["@babel/plugin-proposal-class-properties", { "loose": true }]
|
||||
["@babel/plugin-proposal-class-properties", { "loose": true }],
|
||||
["@babel/transform-runtime"]
|
||||
]
|
||||
}
|
||||
|
||||
111
CHANGELOG.md
111
CHANGELOG.md
@ -2,6 +2,117 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
## [6.4.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.3.0...v6.4.0) (2019-09-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **allocations:** don't show all allocation information to lecturers ([ad6c503](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/ad6c503))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **changelog:** prettify date formatting ([2b3aef7](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/2b3aef7))
|
||||
|
||||
|
||||
|
||||
## [6.3.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.2.1...v6.3.0) (2019-09-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix build ([1a66716](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/1a66716))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **allocations:** notifications ([6d52ed5](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/6d52ed5))
|
||||
|
||||
|
||||
|
||||
### [6.2.1](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.2.0...v6.2.1) (2019-09-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **course-edit:** show old allocation ([fc53497](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/fc53497)), closes [#450](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/issues/450)
|
||||
|
||||
|
||||
|
||||
## [6.2.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.1.0...v6.2.0) (2019-09-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **datepicker:** removes idle cancel and submit buttons ([805676f](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/805676f))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **users:** ldap-synchronise arbitrary subsets of users ([0789536](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/0789536))
|
||||
|
||||
|
||||
|
||||
## [6.1.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v6.0.0...v6.1.0) (2019-08-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **async-table:** update legacy call to datepicker ([d56e12d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d56e12d))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ldap:** manually trigger ldap sync ([83afb6f](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/83afb6f))
|
||||
|
||||
|
||||
|
||||
## [6.0.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v5.5.0...v6.0.0) (2019-08-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **datepicker:** fix selecting date from manual input in internal format ([8bdcc92](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/8bdcc92))
|
||||
* **datepicker:** format time on copy paste as well ([99d9efa](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/99d9efa))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **allocations:** additional info and explanation for participants ([38949cf](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/38949cf))
|
||||
* **crontab:** cronjob for pruning expired invitations ([a9c5276](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/a9c5276))
|
||||
* **datepicker:** add option to change the position of the datepicker ([85f46ef](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/85f46ef))
|
||||
* **datepicker:** also parse manual input in internal format ([8a3ac72](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/8a3ac72))
|
||||
* **datepicker:** close datepicker on click outside ([88a6b85](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/88a6b85))
|
||||
* **datepicker:** close datepicker on escape keydown ([0e5707a](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/0e5707a))
|
||||
* **datepicker:** currently broken version using tail.datetime instead ([4282554](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/4282554))
|
||||
* **datepicker:** define instance collection singleton ([f5636b8](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/f5636b8))
|
||||
* **datepicker:** display datepicker on the right ([cbb7e95](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/cbb7e95))
|
||||
* **datepicker:** do not replace value if input is no valid date ([ecab0ac](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/ecab0ac))
|
||||
* **datepicker:** format according to input type; position datepicker ([db345ee](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/db345ee))
|
||||
* **datepicker:** format any dates before submission ([1eccb0e](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/1eccb0e))
|
||||
* **datepicker:** format time on submit ([9f8749c](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/9f8749c))
|
||||
* **datepicker:** formatting dates for mass-inputs ([b9fd4d7](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/b9fd4d7))
|
||||
* **datepicker:** helper functions and updated tail.datetime fork ([2512d69](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/2512d69))
|
||||
* **datepicker:** more sane datetime config ([5a44263](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/5a44263))
|
||||
* **datepicker:** new approach stub for formatting dates in formdata ([9ea7b2e](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/9ea7b2e))
|
||||
* **datepicker:** only update datepicker date if date is valid ([d857af3](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/d857af3))
|
||||
* **datepicker:** switch to tail.datetime fork to fix time selection ([863971f](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/863971f))
|
||||
* **datepicker:** update dependencies ([427ffbf](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/427ffbf))
|
||||
* **invitations:** save expiresAt to DB ([1c2f2b7](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/1c2f2b7))
|
||||
* **ldap:** automatically synchronise user data from ldap ([b39ba8b](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/b39ba8b))
|
||||
* **navigate-away-prompt:** prompt on actual value change only ([293ab6d](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/293ab6d))
|
||||
* **schools:** implement cru ([18ae28a](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/18ae28a))
|
||||
* **user-schools:** allow users to override automatic school assoc' ([7d927fd](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/7d927fd))
|
||||
* **user-schools:** automatically assign users to schools ([12067de](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/12067de))
|
||||
* **users:** generalise UserLecturer and UserAdmin to UserFunction ([76f8da5](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/commit/76f8da5)), closes [#320](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/issues/320)
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* **users:** Remove UserLecturer and UserAdmin
|
||||
|
||||
|
||||
|
||||
## [5.5.0](https://gitlab.cip.ifi.lmu.de/jost/UniWorX/compare/v5.4.0...v5.5.0) (2019-08-27)
|
||||
|
||||
|
||||
|
||||
@ -41,6 +41,9 @@ health-check-delay-notify: "_env:HEALTHCHECK_DELAY_NOTIFY:true"
|
||||
health-check-http: "_env:HEALTHCHECK_HTTP:true" # Can we assume, that we can reach ourselves under APPROOT via HTTP (reverse proxies or firewalls might prevent this)?
|
||||
health-check-active-job-executors-timeout: "_env:HEALTHCHECK_ACTIVE_JOB_EXECUTORS_TIMEOUT:5"
|
||||
|
||||
synchronise-ldap-users-within: "_env:SYNCHRONISE_LDAP_WITHIN:1209600"
|
||||
synchronise-ldap-users-interval: "_env:SYNCHRONISE_LDAP_INTERVAL:3600"
|
||||
|
||||
log-settings:
|
||||
detailed: "_env:DETAILED_LOGGING:false"
|
||||
all: "_env:LOG_ALL:false"
|
||||
|
||||
@ -1,466 +0,0 @@
|
||||
(function(self) {
|
||||
'use strict';
|
||||
|
||||
if (self.fetch) {
|
||||
return
|
||||
}
|
||||
|
||||
var support = {
|
||||
searchParams: 'URLSearchParams' in self,
|
||||
iterable: 'Symbol' in self && 'iterator' in Symbol,
|
||||
blob: 'FileReader' in self && 'Blob' in self && (function() {
|
||||
try {
|
||||
new Blob()
|
||||
return true
|
||||
} catch(e) {
|
||||
return false
|
||||
}
|
||||
})(),
|
||||
formData: 'FormData' in self,
|
||||
arrayBuffer: 'ArrayBuffer' in self
|
||||
}
|
||||
|
||||
if (support.arrayBuffer) {
|
||||
var viewClasses = [
|
||||
'[object Int8Array]',
|
||||
'[object Uint8Array]',
|
||||
'[object Uint8ClampedArray]',
|
||||
'[object Int16Array]',
|
||||
'[object Uint16Array]',
|
||||
'[object Int32Array]',
|
||||
'[object Uint32Array]',
|
||||
'[object Float32Array]',
|
||||
'[object Float64Array]'
|
||||
]
|
||||
|
||||
var isDataView = function(obj) {
|
||||
return obj && DataView.prototype.isPrototypeOf(obj)
|
||||
}
|
||||
|
||||
var isArrayBufferView = ArrayBuffer.isView || function(obj) {
|
||||
return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeName(name) {
|
||||
if (typeof name !== 'string') {
|
||||
name = String(name)
|
||||
}
|
||||
if (/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(name)) {
|
||||
throw new TypeError('Invalid character in header field name')
|
||||
}
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeValue(value) {
|
||||
if (typeof value !== 'string') {
|
||||
value = String(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Build a destructive iterator for the value list
|
||||
function iteratorFor(items) {
|
||||
var iterator = {
|
||||
next: function() {
|
||||
var value = items.shift()
|
||||
return {done: value === undefined, value: value}
|
||||
}
|
||||
}
|
||||
|
||||
if (support.iterable) {
|
||||
iterator[Symbol.iterator] = function() {
|
||||
return iterator
|
||||
}
|
||||
}
|
||||
|
||||
return iterator
|
||||
}
|
||||
|
||||
function Headers(headers) {
|
||||
this.map = {}
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach(function(value, name) {
|
||||
this.append(name, value)
|
||||
}, this)
|
||||
} else if (Array.isArray(headers)) {
|
||||
headers.forEach(function(header) {
|
||||
this.append(header[0], header[1])
|
||||
}, this)
|
||||
} else if (headers) {
|
||||
Object.getOwnPropertyNames(headers).forEach(function(name) {
|
||||
this.append(name, headers[name])
|
||||
}, this)
|
||||
}
|
||||
}
|
||||
|
||||
Headers.prototype.append = function(name, value) {
|
||||
name = normalizeName(name)
|
||||
value = normalizeValue(value)
|
||||
var oldValue = this.map[name]
|
||||
this.map[name] = oldValue ? oldValue+','+value : value
|
||||
}
|
||||
|
||||
Headers.prototype['delete'] = function(name) {
|
||||
delete this.map[normalizeName(name)]
|
||||
}
|
||||
|
||||
Headers.prototype.get = function(name) {
|
||||
name = normalizeName(name)
|
||||
return this.has(name) ? this.map[name] : null
|
||||
}
|
||||
|
||||
Headers.prototype.has = function(name) {
|
||||
return this.map.hasOwnProperty(normalizeName(name))
|
||||
}
|
||||
|
||||
Headers.prototype.set = function(name, value) {
|
||||
this.map[normalizeName(name)] = normalizeValue(value)
|
||||
}
|
||||
|
||||
Headers.prototype.forEach = function(callback, thisArg) {
|
||||
for (var name in this.map) {
|
||||
if (this.map.hasOwnProperty(name)) {
|
||||
callback.call(thisArg, this.map[name], name, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Headers.prototype.keys = function() {
|
||||
var items = []
|
||||
this.forEach(function(value, name) { items.push(name) })
|
||||
return iteratorFor(items)
|
||||
}
|
||||
|
||||
Headers.prototype.values = function() {
|
||||
var items = []
|
||||
this.forEach(function(value) { items.push(value) })
|
||||
return iteratorFor(items)
|
||||
}
|
||||
|
||||
Headers.prototype.entries = function() {
|
||||
var items = []
|
||||
this.forEach(function(value, name) { items.push([name, value]) })
|
||||
return iteratorFor(items)
|
||||
}
|
||||
|
||||
if (support.iterable) {
|
||||
Headers.prototype[Symbol.iterator] = Headers.prototype.entries
|
||||
}
|
||||
|
||||
function consumed(body) {
|
||||
if (body.bodyUsed) {
|
||||
return Promise.reject(new TypeError('Already read'))
|
||||
}
|
||||
body.bodyUsed = true
|
||||
}
|
||||
|
||||
function fileReaderReady(reader) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
reader.onload = function() {
|
||||
resolve(reader.result)
|
||||
}
|
||||
reader.onerror = function() {
|
||||
reject(reader.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function readBlobAsArrayBuffer(blob) {
|
||||
var reader = new FileReader()
|
||||
var promise = fileReaderReady(reader)
|
||||
reader.readAsArrayBuffer(blob)
|
||||
return promise
|
||||
}
|
||||
|
||||
function readBlobAsText(blob) {
|
||||
var reader = new FileReader()
|
||||
var promise = fileReaderReady(reader)
|
||||
reader.readAsText(blob)
|
||||
return promise
|
||||
}
|
||||
|
||||
function readArrayBufferAsText(buf) {
|
||||
var view = new Uint8Array(buf)
|
||||
var chars = new Array(view.length)
|
||||
|
||||
for (var i = 0; i < view.length; i++) {
|
||||
chars[i] = String.fromCharCode(view[i])
|
||||
}
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
function bufferClone(buf) {
|
||||
if (buf.slice) {
|
||||
return buf.slice(0)
|
||||
} else {
|
||||
var view = new Uint8Array(buf.byteLength)
|
||||
view.set(new Uint8Array(buf))
|
||||
return view.buffer
|
||||
}
|
||||
}
|
||||
|
||||
function Body() {
|
||||
this.bodyUsed = false
|
||||
|
||||
this._initBody = function(body) {
|
||||
this._bodyInit = body
|
||||
if (!body) {
|
||||
this._bodyText = ''
|
||||
} else if (typeof body === 'string') {
|
||||
this._bodyText = body
|
||||
} else if (support.blob && Blob.prototype.isPrototypeOf(body)) {
|
||||
this._bodyBlob = body
|
||||
} else if (support.formData && FormData.prototype.isPrototypeOf(body)) {
|
||||
this._bodyFormData = body
|
||||
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
|
||||
this._bodyText = body.toString()
|
||||
} else if (support.arrayBuffer && support.blob && isDataView(body)) {
|
||||
this._bodyArrayBuffer = bufferClone(body.buffer)
|
||||
// IE 10-11 can't handle a DataView body.
|
||||
this._bodyInit = new Blob([this._bodyArrayBuffer])
|
||||
} else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) {
|
||||
this._bodyArrayBuffer = bufferClone(body)
|
||||
} else {
|
||||
throw new Error('unsupported BodyInit type')
|
||||
}
|
||||
|
||||
if (!this.headers.get('content-type')) {
|
||||
if (typeof body === 'string') {
|
||||
this.headers.set('content-type', 'text/plain;charset=UTF-8')
|
||||
} else if (this._bodyBlob && this._bodyBlob.type) {
|
||||
this.headers.set('content-type', this._bodyBlob.type)
|
||||
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
|
||||
this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (support.blob) {
|
||||
this.blob = function() {
|
||||
var rejected = consumed(this)
|
||||
if (rejected) {
|
||||
return rejected
|
||||
}
|
||||
|
||||
if (this._bodyBlob) {
|
||||
return Promise.resolve(this._bodyBlob)
|
||||
} else if (this._bodyArrayBuffer) {
|
||||
return Promise.resolve(new Blob([this._bodyArrayBuffer]))
|
||||
} else if (this._bodyFormData) {
|
||||
throw new Error('could not read FormData body as blob')
|
||||
} else {
|
||||
return Promise.resolve(new Blob([this._bodyText]))
|
||||
}
|
||||
}
|
||||
|
||||
this.arrayBuffer = function() {
|
||||
if (this._bodyArrayBuffer) {
|
||||
return consumed(this) || Promise.resolve(this._bodyArrayBuffer)
|
||||
} else {
|
||||
return this.blob().then(readBlobAsArrayBuffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.text = function() {
|
||||
var rejected = consumed(this)
|
||||
if (rejected) {
|
||||
return rejected
|
||||
}
|
||||
|
||||
if (this._bodyBlob) {
|
||||
return readBlobAsText(this._bodyBlob)
|
||||
} else if (this._bodyArrayBuffer) {
|
||||
return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer))
|
||||
} else if (this._bodyFormData) {
|
||||
throw new Error('could not read FormData body as text')
|
||||
} else {
|
||||
return Promise.resolve(this._bodyText)
|
||||
}
|
||||
}
|
||||
|
||||
if (support.formData) {
|
||||
this.formData = function() {
|
||||
return this.text().then(decode)
|
||||
}
|
||||
}
|
||||
|
||||
this.json = function() {
|
||||
return this.text().then(JSON.parse)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// HTTP methods whose capitalization should be normalized
|
||||
var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT']
|
||||
|
||||
function normalizeMethod(method) {
|
||||
var upcased = method.toUpperCase()
|
||||
return (methods.indexOf(upcased) > -1) ? upcased : method
|
||||
}
|
||||
|
||||
function Request(input, options) {
|
||||
options = options || {}
|
||||
var body = options.body
|
||||
|
||||
if (input instanceof Request) {
|
||||
if (input.bodyUsed) {
|
||||
throw new TypeError('Already read')
|
||||
}
|
||||
this.url = input.url
|
||||
this.credentials = input.credentials
|
||||
if (!options.headers) {
|
||||
this.headers = new Headers(input.headers)
|
||||
}
|
||||
this.method = input.method
|
||||
this.mode = input.mode
|
||||
if (!body && input._bodyInit != null) {
|
||||
body = input._bodyInit
|
||||
input.bodyUsed = true
|
||||
}
|
||||
} else {
|
||||
this.url = String(input)
|
||||
}
|
||||
|
||||
this.credentials = options.credentials || this.credentials || 'omit'
|
||||
if (options.headers || !this.headers) {
|
||||
this.headers = new Headers(options.headers)
|
||||
}
|
||||
this.method = normalizeMethod(options.method || this.method || 'GET')
|
||||
this.mode = options.mode || this.mode || null
|
||||
this.referrer = null
|
||||
|
||||
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
|
||||
throw new TypeError('Body not allowed for GET or HEAD requests')
|
||||
}
|
||||
this._initBody(body)
|
||||
}
|
||||
|
||||
Request.prototype.clone = function() {
|
||||
return new Request(this, { body: this._bodyInit })
|
||||
}
|
||||
|
||||
function decode(body) {
|
||||
var form = new FormData()
|
||||
body.trim().split('&').forEach(function(bytes) {
|
||||
if (bytes) {
|
||||
var split = bytes.split('=')
|
||||
var name = split.shift().replace(/\+/g, ' ')
|
||||
var value = split.join('=').replace(/\+/g, ' ')
|
||||
form.append(decodeURIComponent(name), decodeURIComponent(value))
|
||||
}
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
function parseHeaders(rawHeaders) {
|
||||
var headers = new Headers()
|
||||
// Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
|
||||
// https://tools.ietf.org/html/rfc7230#section-3.2
|
||||
var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ')
|
||||
preProcessedHeaders.split(/\r?\n/).forEach(function(line) {
|
||||
var parts = line.split(':')
|
||||
var key = parts.shift().trim()
|
||||
if (key) {
|
||||
var value = parts.join(':').trim()
|
||||
headers.append(key, value)
|
||||
}
|
||||
})
|
||||
return headers
|
||||
}
|
||||
|
||||
Body.call(Request.prototype)
|
||||
|
||||
function Response(bodyInit, options) {
|
||||
if (!options) {
|
||||
options = {}
|
||||
}
|
||||
|
||||
this.type = 'default'
|
||||
this.status = options.status === undefined ? 200 : options.status
|
||||
this.ok = this.status >= 200 && this.status < 300
|
||||
this.statusText = 'statusText' in options ? options.statusText : 'OK'
|
||||
this.headers = new Headers(options.headers)
|
||||
this.url = options.url || ''
|
||||
this._initBody(bodyInit)
|
||||
}
|
||||
|
||||
Body.call(Response.prototype)
|
||||
|
||||
Response.prototype.clone = function() {
|
||||
return new Response(this._bodyInit, {
|
||||
status: this.status,
|
||||
statusText: this.statusText,
|
||||
headers: new Headers(this.headers),
|
||||
url: this.url
|
||||
})
|
||||
}
|
||||
|
||||
Response.error = function() {
|
||||
var response = new Response(null, {status: 0, statusText: ''})
|
||||
response.type = 'error'
|
||||
return response
|
||||
}
|
||||
|
||||
var redirectStatuses = [301, 302, 303, 307, 308]
|
||||
|
||||
Response.redirect = function(url, status) {
|
||||
if (redirectStatuses.indexOf(status) === -1) {
|
||||
throw new RangeError('Invalid status code')
|
||||
}
|
||||
|
||||
return new Response(null, {status: status, headers: {location: url}})
|
||||
}
|
||||
|
||||
self.Headers = Headers
|
||||
self.Request = Request
|
||||
self.Response = Response
|
||||
|
||||
self.fetch = function(input, init) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var request = new Request(input, init)
|
||||
var xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.onload = function() {
|
||||
var options = {
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
headers: parseHeaders(xhr.getAllResponseHeaders() || '')
|
||||
}
|
||||
options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL')
|
||||
var body = 'response' in xhr ? xhr.response : xhr.responseText
|
||||
resolve(new Response(body, options))
|
||||
}
|
||||
|
||||
xhr.onerror = function() {
|
||||
reject(new TypeError('Network request failed'))
|
||||
}
|
||||
|
||||
xhr.ontimeout = function() {
|
||||
reject(new TypeError('Network request failed'))
|
||||
}
|
||||
|
||||
xhr.open(request.method, request.url, true)
|
||||
|
||||
if (request.credentials === 'include') {
|
||||
xhr.withCredentials = true
|
||||
} else if (request.credentials === 'omit') {
|
||||
xhr.withCredentials = false
|
||||
}
|
||||
|
||||
if ('responseType' in xhr && support.blob) {
|
||||
xhr.responseType = 'blob'
|
||||
}
|
||||
|
||||
request.headers.forEach(function(value, name) {
|
||||
xhr.setRequestHeader(name, value)
|
||||
})
|
||||
|
||||
xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit)
|
||||
})
|
||||
}
|
||||
self.fetch.polyfill = true
|
||||
})(typeof self !== 'undefined' ? self : this);
|
||||
@ -1,2 +0,0 @@
|
||||
import './fetch';
|
||||
import './url-search-params';
|
||||
@ -1,348 +0,0 @@
|
||||
(function(global) {
|
||||
/**
|
||||
* Polyfill URLSearchParams
|
||||
*
|
||||
* Inspired from : https://github.com/WebReflection/url-search-params/blob/master/src/url-search-params.js
|
||||
*/
|
||||
|
||||
var checkIfIteratorIsSupported = function() {
|
||||
try {
|
||||
return !!Symbol.iterator;
|
||||
} catch(error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
var iteratorSupported = checkIfIteratorIsSupported();
|
||||
|
||||
var createIterator = function(items) {
|
||||
var iterator = {
|
||||
next: function() {
|
||||
var value = items.shift();
|
||||
return { done: value === void 0, value: value };
|
||||
}
|
||||
};
|
||||
|
||||
if(iteratorSupported) {
|
||||
iterator[Symbol.iterator] = function() {
|
||||
return iterator;
|
||||
};
|
||||
}
|
||||
|
||||
return iterator;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search param name and values should be encoded according to https://url.spec.whatwg.org/#urlencoded-serializing
|
||||
* encodeURIComponent() produces the same result except encoding spaces as `%20` instead of `+`.
|
||||
*/
|
||||
var serializeParam = function(value) {
|
||||
return encodeURIComponent(value).replace(/%20/g, '+');
|
||||
};
|
||||
|
||||
var deserializeParam = function(value) {
|
||||
return decodeURIComponent(value).replace(/\+/g, ' ');
|
||||
};
|
||||
|
||||
var polyfillURLSearchParams= function() {
|
||||
|
||||
var URLSearchParams = function(searchString) {
|
||||
Object.defineProperty(this, '_entries', { value: {} });
|
||||
|
||||
if(typeof searchString === 'string') {
|
||||
if(searchString !== '') {
|
||||
searchString = searchString.replace(/^\?/, '');
|
||||
var attributes = searchString.split('&');
|
||||
var attribute;
|
||||
for(var i = 0; i < attributes.length; i++) {
|
||||
attribute = attributes[i].split('=');
|
||||
this.append(
|
||||
deserializeParam(attribute[0]),
|
||||
(attribute.length > 1) ? deserializeParam(attribute[1]) : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if(searchString instanceof URLSearchParams) {
|
||||
var _this = this;
|
||||
searchString.forEach(function(value, name) {
|
||||
_this.append(value, name);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var proto = URLSearchParams.prototype;
|
||||
|
||||
proto.append = function(name, value) {
|
||||
if(name in this._entries) {
|
||||
this._entries[name].push(value.toString());
|
||||
} else {
|
||||
this._entries[name] = [value.toString()];
|
||||
}
|
||||
};
|
||||
|
||||
proto.delete = function(name) {
|
||||
delete this._entries[name];
|
||||
};
|
||||
|
||||
proto.get = function(name) {
|
||||
return (name in this._entries) ? this._entries[name][0] : null;
|
||||
};
|
||||
|
||||
proto.getAll = function(name) {
|
||||
return (name in this._entries) ? this._entries[name].slice(0) : [];
|
||||
};
|
||||
|
||||
proto.has = function(name) {
|
||||
return (name in this._entries);
|
||||
};
|
||||
|
||||
proto.set = function(name, value) {
|
||||
this._entries[name] = [value.toString()];
|
||||
};
|
||||
|
||||
proto.forEach = function(callback, thisArg) {
|
||||
var entries;
|
||||
for(var name in this._entries) {
|
||||
if(this._entries.hasOwnProperty(name)) {
|
||||
entries = this._entries[name];
|
||||
for(var i = 0; i < entries.length; i++) {
|
||||
callback.call(thisArg, entries[i], name, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
proto.keys = function() {
|
||||
var items = [];
|
||||
this.forEach(function(value, name) { items.push(name); });
|
||||
return createIterator(items);
|
||||
};
|
||||
|
||||
proto.values = function() {
|
||||
var items = [];
|
||||
this.forEach(function(value) { items.push(value); });
|
||||
return createIterator(items);
|
||||
};
|
||||
|
||||
proto.entries = function() {
|
||||
var items = [];
|
||||
this.forEach(function(value, name) { items.push([name, value]); });
|
||||
return createIterator(items);
|
||||
};
|
||||
|
||||
if(iteratorSupported) {
|
||||
proto[Symbol.iterator] = proto.entries;
|
||||
}
|
||||
|
||||
proto.toString = function() {
|
||||
var searchString = '';
|
||||
this.forEach(function(value, name) {
|
||||
if(searchString.length > 0) searchString+= '&';
|
||||
searchString += serializeParam(name) + '=' + serializeParam(value);
|
||||
});
|
||||
return searchString;
|
||||
};
|
||||
|
||||
global.URLSearchParams = URLSearchParams;
|
||||
};
|
||||
|
||||
if(!('URLSearchParams' in global) || (new URLSearchParams('?a=1').toString() !== 'a=1')) {
|
||||
polyfillURLSearchParams();
|
||||
}
|
||||
|
||||
// HTMLAnchorElement
|
||||
|
||||
})(
|
||||
(typeof global !== 'undefined') ? global
|
||||
: ((typeof window !== 'undefined') ? window
|
||||
: ((typeof self !== 'undefined') ? self : this))
|
||||
);
|
||||
|
||||
(function(global) {
|
||||
/**
|
||||
* Polyfill URL
|
||||
*
|
||||
* Inspired from : https://github.com/arv/DOM-URL-Polyfill/blob/master/src/url.js
|
||||
*/
|
||||
|
||||
var checkIfURLIsSupported = function() {
|
||||
try {
|
||||
var u = new URL('b', 'http://a');
|
||||
u.pathname = 'c%20d';
|
||||
return (u.href === 'http://a/c%20d') && u.searchParams;
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
var polyfillURL = function() {
|
||||
var _URL = global.URL;
|
||||
|
||||
var URL = function(url, base) {
|
||||
if(typeof url !== 'string') url = String(url);
|
||||
|
||||
var doc = document.implementation.createHTMLDocument('');
|
||||
window.doc = doc;
|
||||
if(base) {
|
||||
var baseElement = doc.createElement('base');
|
||||
baseElement.href = base;
|
||||
doc.head.appendChild(baseElement);
|
||||
}
|
||||
|
||||
var anchorElement = doc.createElement('a');
|
||||
anchorElement.href = url;
|
||||
doc.body.appendChild(anchorElement);
|
||||
anchorElement.href = anchorElement.href; // force href to refresh
|
||||
|
||||
if(anchorElement.protocol === ':' || !/:/.test(anchorElement.href)) {
|
||||
throw new TypeError('Invalid URL');
|
||||
}
|
||||
|
||||
Object.defineProperty(this, '_anchorElement', {
|
||||
value: anchorElement
|
||||
});
|
||||
};
|
||||
|
||||
var proto = URL.prototype;
|
||||
|
||||
var linkURLWithAnchorAttribute = function(attributeName) {
|
||||
Object.defineProperty(proto, attributeName, {
|
||||
get: function() {
|
||||
return this._anchorElement[attributeName];
|
||||
},
|
||||
set: function(value) {
|
||||
this._anchorElement[attributeName] = value;
|
||||
},
|
||||
enumerable: true
|
||||
});
|
||||
};
|
||||
|
||||
['hash', 'host', 'hostname', 'port', 'protocol', 'search']
|
||||
.forEach(function(attributeName) {
|
||||
linkURLWithAnchorAttribute(attributeName);
|
||||
});
|
||||
|
||||
Object.defineProperties(proto, {
|
||||
|
||||
'toString': {
|
||||
get: function() {
|
||||
var _this = this;
|
||||
return function() {
|
||||
return _this.href;
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
'href' : {
|
||||
get: function() {
|
||||
return this._anchorElement.href.replace(/\?$/,'');
|
||||
},
|
||||
set: function(value) {
|
||||
this._anchorElement.href = value;
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
|
||||
'pathname' : {
|
||||
get: function() {
|
||||
return this._anchorElement.pathname.replace(/(^\/?)/,'/');
|
||||
},
|
||||
set: function(value) {
|
||||
this._anchorElement.pathname = value;
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
|
||||
'origin': {
|
||||
get: function() {
|
||||
// get expected port from protocol
|
||||
var expectedPort = {'http:': 80, 'https:': 443, 'ftp:': 21}[this._anchorElement.protocol];
|
||||
// add port to origin if, expected port is different than actual port
|
||||
// and it is not empty f.e http://foo:8080
|
||||
// 8080 != 80 && 8080 != ''
|
||||
var addPortToOrigin = this._anchorElement.port != expectedPort &&
|
||||
this._anchorElement.port !== ''
|
||||
|
||||
return this._anchorElement.protocol +
|
||||
'//' +
|
||||
this._anchorElement.hostname +
|
||||
(addPortToOrigin ? (':' + this._anchorElement.port) : '');
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
|
||||
'password': { // TODO
|
||||
get: function() {
|
||||
return '';
|
||||
},
|
||||
set: function(value) {
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
|
||||
'username': { // TODO
|
||||
get: function() {
|
||||
return '';
|
||||
},
|
||||
set: function(value) {
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
|
||||
'searchParams': {
|
||||
get: function() {
|
||||
var searchParams = new URLSearchParams(this.search);
|
||||
var _this = this;
|
||||
['append', 'delete', 'set'].forEach(function(methodName) {
|
||||
var method = searchParams[methodName];
|
||||
searchParams[methodName] = function() {
|
||||
method.apply(searchParams, arguments);
|
||||
_this.search = searchParams.toString();
|
||||
};
|
||||
});
|
||||
return searchParams;
|
||||
},
|
||||
enumerable: true
|
||||
}
|
||||
});
|
||||
|
||||
URL.createObjectURL = function(blob) {
|
||||
return _URL.createObjectURL.apply(_URL, arguments);
|
||||
};
|
||||
|
||||
URL.revokeObjectURL = function(url) {
|
||||
return _URL.revokeObjectURL.apply(_URL, arguments);
|
||||
};
|
||||
|
||||
global.URL = URL;
|
||||
|
||||
};
|
||||
|
||||
if(!checkIfURLIsSupported()) {
|
||||
polyfillURL();
|
||||
}
|
||||
|
||||
if((global.location !== void 0) && !('origin' in global.location)) {
|
||||
var getOrigin = function() {
|
||||
return global.location.protocol + '//' + global.location.hostname + (global.location.port ? (':' + global.location.port) : '');
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(global.location, 'origin', {
|
||||
get: getOrigin,
|
||||
enumerable: true
|
||||
});
|
||||
} catch(e) {
|
||||
setInterval(function() {
|
||||
global.location.origin = getOrigin();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
})(
|
||||
(typeof global !== 'undefined') ? global
|
||||
: ((typeof window !== 'undefined') ? window
|
||||
: ((typeof self !== 'undefined') ? self : this))
|
||||
);
|
||||
@ -4,6 +4,9 @@ import { I18n } from './services/i18n/i18n';
|
||||
import { UtilRegistry } from './services/util-registry/util-registry';
|
||||
import { isValidUtility } from './core/utility';
|
||||
|
||||
// load window.fetch polyfill
|
||||
import 'whatwg-fetch';
|
||||
|
||||
export class App {
|
||||
httpClient = new HttpClient();
|
||||
htmlHelpers = new HtmlHelpers();
|
||||
|
||||
@ -24,7 +24,7 @@ export class HtmlHelpers {
|
||||
}
|
||||
|
||||
_prefixIds(element, idPrefix) {
|
||||
const idAttrs = ['id', 'for', 'data-conditional-input', 'data-modal-trigger'];
|
||||
const idAttrs = ['id', 'for', 'list', 'data-conditional-input', 'data-modal-trigger'];
|
||||
|
||||
idAttrs.forEach((attr) => {
|
||||
Array.from(element.querySelectorAll('[' + attr + ']')).forEach((input) => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import { Datepicker } from '../form/datepicker';
|
||||
import './async-form.scss';
|
||||
|
||||
const ASYNC_FORM_INITIALIZED_CLASS = 'check-all--initialized';
|
||||
@ -70,7 +71,9 @@ export class AsyncForm {
|
||||
|
||||
const url = this._element.getAttribute('action');
|
||||
const headers = { };
|
||||
const body = new FormData(this._element);
|
||||
|
||||
// create new FormData and format any date values
|
||||
const body = Datepicker.unformatAll(this._element, new FormData(this._element));
|
||||
|
||||
const isModal = this._element.closest(MODAL_SELECTOR);
|
||||
if (isModal) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import { Datepicker } from '../form/datepicker';
|
||||
import { HttpClient } from '../../services/http-client/http-client';
|
||||
import * as debounce from 'lodash.debounce';
|
||||
import './async-table-filter.scss';
|
||||
@ -238,7 +239,9 @@ export class AsyncTable {
|
||||
|
||||
_serializeTableFilterToURL(tableFilterForm) {
|
||||
const url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
|
||||
const formData = new FormData(tableFilterForm);
|
||||
|
||||
// create new FormData and format any date values
|
||||
const formData = Datepicker.unformatAll(this._massInputForm, new FormData(tableFilterForm));
|
||||
|
||||
for (var k of url.searchParams.keys()) {
|
||||
url.searchParams.delete(k);
|
||||
@ -298,7 +301,9 @@ export class AsyncTable {
|
||||
|
||||
_changePagesizeHandler = () => {
|
||||
const url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
|
||||
const formData = new FormData(this._pagesizeForm);
|
||||
|
||||
// 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()) {
|
||||
url.searchParams.delete(k);
|
||||
|
||||
@ -7,7 +7,7 @@ var COURSE_TEASER_EXPANDED_CLASS = 'course-teaser__expanded';
|
||||
var COURSE_TEASER_CHEVRON_CLASS = 'course-teaser__chevron';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-course-teaser]:not(.course-teaser__disabled)',
|
||||
selector: '[uw-course-teaser]:not(.course-teaser__not-expandable)',
|
||||
})
|
||||
export class CourseTeaser {
|
||||
|
||||
@ -28,7 +28,7 @@ export class CourseTeaser {
|
||||
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);
|
||||
|
||||
|
||||
if ((!isExpanded && !isLink) || isChevron) {
|
||||
this._element.classList.toggle(COURSE_TEASER_EXPANDED_CLASS);
|
||||
}
|
||||
|
||||
@ -1,140 +1,276 @@
|
||||
[uw-course-teaser] {
|
||||
display: grid;
|
||||
grid-gap: 5px 7px;
|
||||
grid-template-columns: 50px 120px 1fr;
|
||||
padding: 10px;
|
||||
/* background-color: var(--course-bg-color); */
|
||||
transition: background-color .1s ease-out;
|
||||
--course-border-color: var(--color-grey);
|
||||
--course-padding-hori: 10px;
|
||||
--course-padding-vert: 12px;
|
||||
|
||||
&:not(.course-teaser__disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
display: grid;
|
||||
position: relative;
|
||||
grid-gap: 5px 7px;
|
||||
grid-template-columns: 130px 30px 1fr 60px;
|
||||
grid-template-areas:
|
||||
'shrthnd . title chevron'
|
||||
'shrthnd smstr school chevron'
|
||||
'. . rgstrd . '
|
||||
'tutor tutor name . '
|
||||
'duedate duedate date . '
|
||||
'dscrptn dscrptn dscrptn dscrptn';
|
||||
padding: var(--course-padding-vert) var(--course-padding-hori);
|
||||
transition: background-color .1s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--course-bg-color);
|
||||
}
|
||||
|
||||
+ [uw-course-teaser] {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--course-border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 140px 1fr 30px;
|
||||
grid-template-areas:
|
||||
'shrthnd title chevron'
|
||||
'shrthnd title . '
|
||||
'smstr school school '
|
||||
'. rgstrd rgstrd '
|
||||
'tutor name name '
|
||||
'duedate date date '
|
||||
'dscrptn dscrptn dscrptn';
|
||||
}
|
||||
|
||||
@media (max-width: 426px) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'shrthnd'
|
||||
'title'
|
||||
'smstr'
|
||||
'school'
|
||||
'rgstrd'
|
||||
'tutor'
|
||||
'name'
|
||||
'duedate'
|
||||
'date'
|
||||
'dscrptnlbl'
|
||||
'dscrptn'
|
||||
'chevron';
|
||||
}
|
||||
}
|
||||
|
||||
.course-teaser__not-expandable {
|
||||
cursor: initial;
|
||||
}
|
||||
|
||||
/* chevron */
|
||||
.course-teaser__chevron {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
grid-area: chevron;
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
margin-top: -8px;
|
||||
margin-top: -7.35px;
|
||||
margin-left: -7.35px; /* visually centered */
|
||||
border-width: 0 3px 3px 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border-color: var(--color-fontsec);
|
||||
border-style: solid;
|
||||
transform: rotate(45deg);
|
||||
transition: transform .2s ease-out;
|
||||
transform: rotate(135deg);
|
||||
transform-origin: 7.25px 7.25px; /* rotate about visual center */
|
||||
transition: all .2s ease-out;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
transform: scale(1.4) rotate(45deg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
justify-self: end;
|
||||
width: auto;
|
||||
|
||||
&::before {
|
||||
position: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 426px) {
|
||||
&::before {
|
||||
transform: rotate(45deg);
|
||||
margin-left: -7.35px;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
transform: scale(1.4) rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* semester */
|
||||
.course-teaser__semester {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
grid-area: smstr;
|
||||
justify-self: end;
|
||||
font-size: 1.1rem;
|
||||
a {
|
||||
color: var(--color-fontsec);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
justify-self: initial;
|
||||
}
|
||||
}
|
||||
|
||||
/* shorthand */
|
||||
.course-teaser__shorthand {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
justify-self: end;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
overflow-wrap: anywhere;
|
||||
position: relative;
|
||||
grid-area: shrthnd;
|
||||
font-size: 2rem;
|
||||
line-height: 1.25;
|
||||
min-height: calc(2rem * 1.25);
|
||||
|
||||
> a {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-break: break-any;
|
||||
|
||||
text-decoration: none !important; /* sry. */
|
||||
font-weight: 600;
|
||||
color: var(--color-grey-medium);
|
||||
}
|
||||
|
||||
/* @media (max-width: 768px) {
|
||||
* position: initial;
|
||||
* }
|
||||
*/
|
||||
}
|
||||
|
||||
/* title */
|
||||
.course-teaser__title {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
grid-area: title;
|
||||
font-size: 1.2rem;
|
||||
align-self: baseline;
|
||||
}
|
||||
|
||||
/* registration */
|
||||
/* registration */
|
||||
.course-teaser__registration {
|
||||
grid-column: 4;
|
||||
grid-row: 2;
|
||||
justify-self: end;
|
||||
align-self: baseline;
|
||||
grid-area: rgstrd;
|
||||
color: var(--color-fontsec);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* school */
|
||||
.course-teaser__school-value {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
align-self: end;
|
||||
.course-teaser__school {
|
||||
grid-area: school;
|
||||
a {
|
||||
color: var(--color-fontsec);
|
||||
}
|
||||
}
|
||||
|
||||
/* description */
|
||||
.course-teaser__description {
|
||||
grid-column: 2 / 4;
|
||||
max-height: 1000px;
|
||||
overflow: auto;
|
||||
/* color: var(--color-fontsec); */
|
||||
/* duedate */
|
||||
.course-teaser__duedate {
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
/* lecturer */
|
||||
.course-teaser__lecturer {
|
||||
grid-area: name;
|
||||
}
|
||||
|
||||
/* description */
|
||||
.course-teaser__description {
|
||||
grid-area: dscrptn;
|
||||
max-height: 75vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* show description only as dots (overflow text-overflow) and only show when expanded. No "hidden fiddling" */
|
||||
|
||||
/* labels */
|
||||
.course-teaser__lecturer-label {
|
||||
grid-area: tutor;
|
||||
}
|
||||
|
||||
.course-teaser__duedate-label {
|
||||
grid-area: duedate;
|
||||
}
|
||||
|
||||
.course-teaser__description-label {
|
||||
grid-area: dscrptnlbl;
|
||||
}
|
||||
|
||||
/* subtitle */
|
||||
.course-teaser__lecturer-label,
|
||||
.course-teaser__duedate-label,
|
||||
.course-teaser__school-label {
|
||||
grid-column: 2;
|
||||
.course-teaser__description-label,
|
||||
.course-teaser__duedate-label {
|
||||
justify-self: end;
|
||||
color: var(--color-fontsec);
|
||||
font-style: italic;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
justify-self: initial;
|
||||
}
|
||||
|
||||
@media (max-width: 426px) {
|
||||
margin-top: 7px;
|
||||
font-weight: bold;
|
||||
font-style: initial;
|
||||
}
|
||||
}
|
||||
|
||||
/* hidden in closed state */
|
||||
.course-teaser__description,
|
||||
.course-teaser__registration {
|
||||
.course-teaser__description-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* registered courses */
|
||||
.course-teaser__registered {
|
||||
.course-teaser__registration {
|
||||
display: block;
|
||||
/* expanded courses */
|
||||
.course-teaser__expanded {
|
||||
cursor: initial;
|
||||
|
||||
.course-teaser__chevron {
|
||||
&::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
transform: scale(1.4) rotate(135deg);
|
||||
}
|
||||
|
||||
@media (max-width: 426px) {
|
||||
&::before {
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
transform: scale(1.4) rotate(225deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* expanded courses */
|
||||
.course-teaser__expanded {
|
||||
|
||||
.course-teaser__chevron::before {
|
||||
transform: translateY(7px) rotate(225deg);
|
||||
}
|
||||
|
||||
.course-teaser__school-label,
|
||||
.course-teaser__school-value,
|
||||
.course-teaser__school,
|
||||
.course-teaser__duedate-label,
|
||||
.course-teaser__duedate-value,
|
||||
.course-teaser__duedate,
|
||||
.course-teaser__description {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 426px) {
|
||||
.course-teaser__description-label {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@ -161,12 +297,12 @@ course teaser: header styling
|
||||
text-align: left;
|
||||
border-radius: 20px 20px 20px 20px / 50% 50% 50% 50%;
|
||||
margin-right: 30px;
|
||||
|
||||
|
||||
.course-header-link {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@ -1,30 +1,90 @@
|
||||
import flatpickr from 'flatpickr';
|
||||
import datetime from 'tail.datetime';
|
||||
import { Utility } from '../../core/utility';
|
||||
import moment from 'moment';
|
||||
|
||||
const KEYCODE_ESCAPE = 27;
|
||||
|
||||
// INTERNAL (Uni2work specific) formats for formatting dates and/or times
|
||||
const FORM_DATE_FORMAT = {
|
||||
'date': moment.HTML5_FMT.DATE,
|
||||
'time': moment.HTML5_FMT.TIME_SECONDS,
|
||||
'datetime-local': moment.HTML5_FMT.DATETIME_LOCAL_SECONDS,
|
||||
};
|
||||
|
||||
// FANCY (tail.datetime specific) formats for displaying dates and/or times
|
||||
const FORM_DATE_FORMAT_DATE_DT = 'dd.mm.YYYY';
|
||||
const FORM_DATE_FORMAT_TIME_DT = 'HH:ii:ss';
|
||||
|
||||
// FANCY (moment specific) formats for displaying dates and/or times
|
||||
const FORM_DATE_FORMAT_DATE_MOMENT = 'DD.MM.YYYY';
|
||||
const FORM_DATE_FORMAT_TIME_MOMENT = 'HH:mm:ss';
|
||||
const FORM_DATE_FORMAT_MOMENT = {
|
||||
'date': FORM_DATE_FORMAT_DATE_MOMENT,
|
||||
'time': FORM_DATE_FORMAT_TIME_MOMENT,
|
||||
'datetime-local': `${FORM_DATE_FORMAT_DATE_MOMENT} ${FORM_DATE_FORMAT_TIME_MOMENT}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a string representation of a date and a format string and parses the given date to a Date object.
|
||||
* If the date string is not valid (i.e. cannot be parsed with the given format string), returns undefined.
|
||||
* @param {*} dateStr string representation of a date
|
||||
* @param {*} dateFormat format string of the date
|
||||
*/
|
||||
function parseDateWithFormat(dateStr, dateFormat) {
|
||||
const parsedMomentDate = moment(dateStr, dateFormat);
|
||||
if (parsedMomentDate.isValid()) return parsedMomentDate.toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a string representation of a date, an input ('previous') format and a desired output format and returns a reformatted date string.
|
||||
* If the date string is not valid (i.e. cannot be parsed with the given input format string), returns the original date string;
|
||||
* @param {*} dateStr string representation of a date (needs to be in format formatIn)
|
||||
* @param {*} formatIn input format string
|
||||
* @param {*} formatOut format string of the desired output date string
|
||||
*/
|
||||
function reformatDateString(dateStr, formatIn, formatOut) {
|
||||
const parsedMomentDate = moment(dateStr, formatIn);
|
||||
return parsedMomentDate.isValid() ? parsedMomentDate.format(formatOut) : dateStr;
|
||||
}
|
||||
|
||||
const DATEPICKER_UTIL_SELECTOR = 'input[type="date"], input[type="time"], input[type="datetime-local"]';
|
||||
|
||||
const DATEPICKER_INITIALIZED_CLASS = 'datepicker--initialized';
|
||||
|
||||
const DATEPICKER_CONFIG = {
|
||||
'datetime-local': {
|
||||
enableTime: true,
|
||||
altInput: true,
|
||||
altFormat: 'j. F Y, H:i', // maybe interpolate these formats for locale
|
||||
dateFormat: 'Y-m-dTH:i',
|
||||
time_24hr: true,
|
||||
'global': {
|
||||
// minimize overlaps with other date inputs
|
||||
position: 'right',
|
||||
|
||||
// set default time to 00:00:00
|
||||
timeHours: 0,
|
||||
timeMinutes: 0,
|
||||
timeSeconds: 0,
|
||||
|
||||
// german settings
|
||||
// TODO: hardcoded, get from current language / settings
|
||||
locale: 'de',
|
||||
weekStart: 1,
|
||||
dateFormat: FORM_DATE_FORMAT_DATE_DT,
|
||||
timeFormat: FORM_DATE_FORMAT_TIME_DT,
|
||||
|
||||
// prevent the instance from closing when selecting a date before selecting a time
|
||||
stayOpen: true,
|
||||
|
||||
// hide the close button (we handle closing the datepicker manually by clicking outside)
|
||||
closeButton: false,
|
||||
|
||||
// disable the decades view because nobody will ever need it (i.e. cap the switch to the more relevant year view)
|
||||
viewDecades: false,
|
||||
},
|
||||
'datetime-local': {},
|
||||
'date': {
|
||||
altFormat: 'j. F Y',
|
||||
dateFormat: 'Y-m-d',
|
||||
altInput: true,
|
||||
// disable date picker
|
||||
timeFormat: false,
|
||||
},
|
||||
'time': {
|
||||
enableTime: true,
|
||||
noCalendar: true,
|
||||
altFormat: 'H:i',
|
||||
dateFormat: 'H:i',
|
||||
altInput: true,
|
||||
time_24hr: true,
|
||||
// disable time picker
|
||||
dateFormat: false,
|
||||
},
|
||||
};
|
||||
|
||||
@ -33,7 +93,12 @@ const DATEPICKER_CONFIG = {
|
||||
})
|
||||
export class Datepicker {
|
||||
|
||||
flatpickrInstance;
|
||||
// singleton Map that maps a formID to a Map of Datepicker objects
|
||||
static datepickerCollections;
|
||||
|
||||
datepickerInstance;
|
||||
_element;
|
||||
elementType;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
@ -44,19 +109,140 @@ export class Datepicker {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flatpickrConfig = DATEPICKER_CONFIG[element.getAttribute('type')];
|
||||
// initialize datepickerCollections singleton if not already done
|
||||
if (!Datepicker.datepickerCollections) {
|
||||
Datepicker.datepickerCollections = new Map();
|
||||
}
|
||||
|
||||
if (!flatpickrConfig) {
|
||||
this._element = element;
|
||||
|
||||
// store the previously set type to select the input format
|
||||
this.elementType = this._element.getAttribute('type');
|
||||
|
||||
// get all relevant config options for this datepicker type
|
||||
const datepickerGlobalConfig = DATEPICKER_CONFIG['global'];
|
||||
const datepickerConfig = DATEPICKER_CONFIG[this.elementType];
|
||||
|
||||
// manually set the type attribute to text because datepicker handles displaying the date
|
||||
this._element.setAttribute('type', 'text');
|
||||
|
||||
// additional position config (optional data-datepicker-position attribute in html) that can specialize the global config
|
||||
const datepickerPosition = this._element.dataset.datepickerPosition;
|
||||
if (datepickerPosition) {
|
||||
datepickerGlobalConfig.position = datepickerPosition;
|
||||
}
|
||||
|
||||
if (!datepickerConfig || !FORM_DATE_FORMAT[this.elementType]) {
|
||||
throw new Error('Datepicker utility called on unsupported element!');
|
||||
}
|
||||
|
||||
this.flatpickrInstance = flatpickr(element, flatpickrConfig);
|
||||
// initialize tail.datetime (datepicker) instance
|
||||
this.datepickerInstance = datetime(this._element, { ...datepickerGlobalConfig, ...datepickerConfig });
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(DATEPICKER_INITIALIZED_CLASS);
|
||||
// register this datepicker instance with the formID of the given element in the datepicker collection
|
||||
const formID = this._element.form.id;
|
||||
const elemID = this._element.id;
|
||||
if (!Datepicker.datepickerCollections.has(formID)) {
|
||||
// insert a new key value pair if the formID key is not there already
|
||||
Datepicker.datepickerCollections.set(formID, new Map([[elemID, this]]));
|
||||
} else {
|
||||
// otherwise, insert this instance into the Map
|
||||
Datepicker.datepickerCollections.get(formID).set(elemID, this);
|
||||
}
|
||||
|
||||
// mark the form input element as initialized
|
||||
this._element.classList.add(DATEPICKER_INITIALIZED_CLASS);
|
||||
|
||||
const setDatepickerDate = () => {
|
||||
// try to parse the current input element value with fancy and internal format string
|
||||
const parsedMomentDate = moment(this._element.value, FORM_DATE_FORMAT_MOMENT[this.elementType]);
|
||||
const parsedMomentDateInternal = moment(this._element.value, FORM_DATE_FORMAT[this.elementType]);
|
||||
|
||||
// only set the datepicker date if the input is either in valid fancy format or in valid internal format
|
||||
if (parsedMomentDate.isValid()) {
|
||||
this.datepickerInstance.selectDate(parsedMomentDate.toDate());
|
||||
} else if (parsedMomentDateInternal.isValid()) {
|
||||
this.datepickerInstance.selectDate(parsedMomentDateInternal.toDate());
|
||||
}
|
||||
|
||||
// reregister change event to prevent event loop
|
||||
this._element.addEventListener('change', setDatepickerDate, { once: true });
|
||||
};
|
||||
// change the selected date in the tail.datetime instance if the value of the input element is changed
|
||||
this._element.addEventListener('change', setDatepickerDate, { once: true });
|
||||
|
||||
// close the instance if something other than the instance was clicked (i.e. if the target is not within the datepicker instance and if any previously clicked calendar view was replaced (is not in the window anymore) because it was clicked). YES, I KNOW
|
||||
window.addEventListener('click', event => {
|
||||
if (!this.datepickerInstance.dt.contains(event.target) && window.document.contains(event.target)) {
|
||||
this.datepickerInstance.close();
|
||||
}
|
||||
});
|
||||
|
||||
// close the datepicker on escape keydown events
|
||||
this._element.addEventListener('keydown', event => {
|
||||
if (event.keyCode === KEYCODE_ESCAPE) {
|
||||
this.datepickerInstance.close();
|
||||
}
|
||||
});
|
||||
|
||||
// format the date value of the form input element of this datepicker before form submission
|
||||
this._element.form.addEventListener('submit', () => this.formatElementValue());
|
||||
|
||||
// format any existing dates to fancy display format on pageload
|
||||
this.formatElementValue(true);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.flatpickrInstance.destroy();
|
||||
this.datepickerInstance.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the value of this input element from datepicker format (i.e. DATEPICKER_CONFIG.dateFormat + " " + datetime.defaults.timeFormat) to Uni2work internal date format (i.e. FORM_DATE_FORMAT) required for form submission
|
||||
* @param {*} toFancy optional target format switch (boolean value; default is false). If set to a truthy value, formats the element value to fancy instead of internal date format.
|
||||
*/
|
||||
formatElementValue(toFancy) {
|
||||
const dp = this.datepickerInstance;
|
||||
if (this._element.value) {
|
||||
if (toFancy) {
|
||||
const parsedDate = parseDateWithFormat(this._element.value, FORM_DATE_FORMAT[this.elementType]);
|
||||
if (parsedDate) dp.selectDate();
|
||||
} else {
|
||||
this._element.value = this.unformat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a datestring in internal format from the current state of the input element value.
|
||||
*/
|
||||
unformat() {
|
||||
return reformatDateString(this._element.value, FORM_DATE_FORMAT_MOMENT[this.elementType], FORM_DATE_FORMAT[this.elementType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a Form and a FormData and returns a new FormData with all dates formatted to uni2work date format. This function will not change the value of the date input elements.
|
||||
* @param {*} form Form for which all dates will be formatted in the FormData
|
||||
* @param {*} formData Initial FormData
|
||||
*/
|
||||
static unformatAll(form, formData) {
|
||||
// only proceed if there are any datepickers and if both form and formData are defined
|
||||
if (Datepicker.datepickerCollections && form && formData) {
|
||||
// if the form has no id, assign one randomly
|
||||
if (!form.id) {
|
||||
form.id = `f${Math.floor(Math.random() * 100000)}`;
|
||||
}
|
||||
|
||||
const formId = form.id;
|
||||
|
||||
if (Datepicker.datepickerCollections.has(formId)) {
|
||||
const datepickerInstances = Datepicker.datepickerCollections.get(formId);
|
||||
datepickerInstances.forEach(instance => {
|
||||
formData.set(instance._element.name, instance.unformat());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// return the (possibly changed) FormData
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
@ -6,3 +6,9 @@ Provides UI for entering dates and times
|
||||
|
||||
## Example usage:
|
||||
(any form that uses inputs of type date, time, or datetime-local)
|
||||
|
||||
## Methods
|
||||
|
||||
### static unformatAll(form, formData)
|
||||
|
||||
Call this function on a form and its formData to get back a new FormData object with "unformatted" date values (i.e. all dates formatted from fancy format to backend format).
|
||||
@ -60,7 +60,7 @@ export class InteractiveFieldset {
|
||||
|
||||
// add event listener
|
||||
const observer = new MutationObserver(() => this._updateVisibility());
|
||||
observer.observe(this.conditionalInput, { attributes: true, attributeFilter: ['disabled'] });
|
||||
observer.observe(this.conditionalInput, { attributes: true, attributeFilter: ['data-interactive-fieldset-hidden'] });
|
||||
this.conditionalInput.addEventListener('input', () => this._updateVisibility());
|
||||
|
||||
// initial visibility update
|
||||
@ -76,18 +76,21 @@ export class InteractiveFieldset {
|
||||
}
|
||||
|
||||
_updateVisibility() {
|
||||
const active = this._matchesConditionalValue() && !this.conditionalInput.disabled;
|
||||
const active = this._matchesConditionalValue() && !this.conditionalInput.dataset.interactiveFieldsetHidden;
|
||||
|
||||
this.target.classList.toggle('hidden', !active);
|
||||
|
||||
this.childInputs.forEach((el) => {
|
||||
el.disabled = !active;
|
||||
this.childInputs.forEach((el) => this._updateChildVisibility(el, active));
|
||||
}
|
||||
|
||||
// disable input for flatpickrs added input as well if exists
|
||||
if (el._flatpickr) {
|
||||
el._flatpickr.altInput.disabled = !active;
|
||||
}
|
||||
});
|
||||
_updateChildVisibility(el, active) {
|
||||
el.disabled = !active;
|
||||
|
||||
if (active) {
|
||||
delete el.dataset.interactiveFieldsetHidden;
|
||||
} else {
|
||||
el.dataset.interactiveFieldsetHidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
_matchesConditionalValue() {
|
||||
|
||||
@ -2,6 +2,16 @@ 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';
|
||||
|
||||
/**
|
||||
* Key generator from an arbitrary number of FormData objects.
|
||||
* @param {...any} formDatas FormData objects
|
||||
*/
|
||||
function* generatorFromFormDatas(...formDatas) {
|
||||
for (let formData of formDatas) {
|
||||
yield* formData.keys();
|
||||
}
|
||||
}
|
||||
|
||||
const NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS = 'navigate-away-prompt--initialized';
|
||||
const NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT = '[uw-no-navigate-away-prompt]';
|
||||
|
||||
@ -12,7 +22,7 @@ export class NavigateAwayPrompt {
|
||||
|
||||
_element;
|
||||
|
||||
_touched = false;
|
||||
_initFormData;
|
||||
_unloadDueToSubmit = false;
|
||||
|
||||
constructor(element) {
|
||||
@ -21,6 +31,7 @@ export class NavigateAwayPrompt {
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._initFormData = new FormData(this._element);
|
||||
|
||||
if (this._element.classList.contains(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
@ -40,10 +51,6 @@ export class NavigateAwayPrompt {
|
||||
this._element.addEventListener('submit', () => {
|
||||
this._unloadDueToSubmit = true;
|
||||
});
|
||||
this._element.addEventListener('change', () => {
|
||||
this._touched = true;
|
||||
this._unloadDueToSubmit = false;
|
||||
});
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS);
|
||||
@ -55,9 +62,20 @@ export class NavigateAwayPrompt {
|
||||
}
|
||||
|
||||
_beforeUnloadHandler = (event) => {
|
||||
// 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)) {
|
||||
if (currentFormData.get(key) !== this._initFormData.get(key)) {
|
||||
formDataHasChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// allow the event to happen if the form was not touched by the
|
||||
// user or the unload event was initiated by a form submit
|
||||
if (!this._touched || this._unloadDueToSubmit) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@ -64,9 +64,3 @@
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.radio + .radio {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import { Datepicker } from '../form/datepicker';
|
||||
import './mass-input.scss';
|
||||
|
||||
const MASS_INPUT_CELL_SELECTOR = '.massinput__cell';
|
||||
const MASS_INPUT_ADD_CELL_SELECTOR = '.massinput__cell--add';
|
||||
@ -157,7 +159,8 @@ export class MassInput {
|
||||
}
|
||||
|
||||
_serializeForm(submitButton, enctype) {
|
||||
const formData = new FormData(this._massInputForm);
|
||||
// create new FormData and format any date values
|
||||
const formData = Datepicker.unformatAll(this._massInputForm, new FormData(this._massInputForm));
|
||||
|
||||
// manually add name and value of submit button to formData
|
||||
formData.append(submitButton.name, submitButton.value);
|
||||
|
||||
18
frontend/src/utils/mass-input/mass-input.scss
Normal file
18
frontend/src/utils/mass-input/mass-input.scss
Normal file
@ -0,0 +1,18 @@
|
||||
.massinput-list__wrapper, .massinput-list__cell {
|
||||
display: grid;
|
||||
grid: auto / auto 50px;
|
||||
max-width: 600px;
|
||||
grid-gap: 7px;
|
||||
}
|
||||
|
||||
.massinput-list__field {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.massinput-list__add, .massinput-list__delete {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.massinput-list__cell {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
727
frontend/vendor/datetime.css
vendored
Normal file
727
frontend/vendor/datetime.css
vendored
Normal file
@ -0,0 +1,727 @@
|
||||
@charset "UTF-8";
|
||||
/*
|
||||
| tail.datetime - The vanilla way to select dates and times!
|
||||
| @file ./less/tail.datetime-default-green.less
|
||||
| @author SamBrishes <sam@pytes.net>
|
||||
| @version 0.4.13 - Beta
|
||||
|
|
||||
| @website https://github.com/pytesNET/tail.DateTime
|
||||
| @license X11 / MIT License
|
||||
| @copyright Copyright © 2018 - 2019 SamBrishes, pytesNET <info@pytes.net>
|
||||
*/
|
||||
|
||||
/* @start MAIN CALENDAR */
|
||||
.tail-datetime-calendar, .tail-datetime-calendar *, .tail-datetime-calendar *:before,
|
||||
.tail-datetime-calendar *:after{
|
||||
box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
}
|
||||
.tail-datetime-calendar{
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 275px;
|
||||
height: auto;
|
||||
margin: 15px;
|
||||
padding: 0;
|
||||
z-index: 15;
|
||||
display: block;
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
direction: ltr;
|
||||
border-collapse: separate;
|
||||
font-family: "Open Sans", Calibri, Arial, sans-serif;
|
||||
background-color: white;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3125);
|
||||
-webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3125);
|
||||
}
|
||||
.tail-datetime-calendar:after{
|
||||
clear: both;
|
||||
content: "";
|
||||
display: block;
|
||||
font-size: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.tail-datetime-calendar.calendar-static{
|
||||
top: auto;
|
||||
left: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
position: static;
|
||||
visibility: visible;
|
||||
}
|
||||
.tail-datetime-calendar button.calendar-close{
|
||||
top: 100%;
|
||||
right: 15px;
|
||||
color: #303438;
|
||||
width: 35px;
|
||||
height: 25px;
|
||||
margin: 1px 0 0 0;
|
||||
padding: 5px 10px;
|
||||
opacity: 0.5;
|
||||
outline: none;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
font-size: 14px;
|
||||
line-height: 1.125em;
|
||||
text-shadow: none;
|
||||
background-color: white;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC\
|
||||
9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDEyIDE2Ij48cGF0aCBmaWxsPSIjMzAzNDM4IiBkP\
|
||||
SJNNy40OCA4bDMuNzUgMy43NS0xLjQ4IDEuNDhMNiA5LjQ4bC0zLjc1IDMuNzUtMS40OC0xLjQ4TDQuNTIgOCAuNzcgNC4y\
|
||||
NWwxLjQ4LTEuNDhMNiA2LjUybDMuNzUtMy43NSAxLjQ4IDEuNDhMNy40OCA4eiIvPjwvc3ZnPg==");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 0 0 3px 3px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3125);
|
||||
-webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3125);
|
||||
transition: opacity 142ms linear;
|
||||
-webkit-transition: opacity 142ms linear;
|
||||
}
|
||||
.tail-datetime-calendar button.calendar-close:hover{
|
||||
opacity: 1;
|
||||
}
|
||||
/* @end MAIN CALENDAR */
|
||||
|
||||
/* @start CALENDAR TOOLTIP */
|
||||
.tail-datetime-calendar .calendar-tooltip{
|
||||
color: white;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
position: absolute;
|
||||
background-color: #202428;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-tooltip:before{
|
||||
top: -7px;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0 0 0 -6px;
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-width: 0 7px 7px 7px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent #202428 transparent;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-tooltip .tooltip-inner{
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 4px 7px;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
/* @end CALENDAR TOOLTIP */
|
||||
|
||||
/* @start CALENDAR ACTIONs */
|
||||
.tail-datetime-calendar .calendar-actions{
|
||||
color: white;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: table;
|
||||
overflow: hidden;
|
||||
border-spacing: 0;
|
||||
border-collapse: separate;
|
||||
background-color: var(--color-primary);
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
line-height: 36px;
|
||||
text-shadow: -1px -1px 0 var(--color-dark);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span[data-action]{
|
||||
cursor: pointer;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span.action{
|
||||
width: 36px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span.label{
|
||||
width: auto;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span:first-child:before{
|
||||
right: -1px;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span:last-child:before{
|
||||
left: -1px;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span:first-child:hover:before,
|
||||
.tail-datetime-calendar .calendar-actions span:last-child:hover:before{
|
||||
display: none;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span[data-action]:hover{
|
||||
background-color: var(--color-dark);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span.action-prev{
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC\
|
||||
9zdmciIHdpZHRoPSI2IiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgNiAxNiI+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iT\
|
||||
TYgMkwwIDhsNiA2VjJ6Ii8+PC9zdmc+");
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span.action-next{
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC\
|
||||
9zdmciIHdpZHRoPSI2IiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgNiAxNiI+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iT\
|
||||
TAgMTRsNi02LTYtNnYxMnoiLz48L3N2Zz4=");
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span.action-submit{
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC\
|
||||
9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDEyIDE2Ij48cGF0aCBmaWxsPSIjZmZmZmZmIiBkP\
|
||||
SJNMTIgNWwtOCA4LTQtNCAxLjUtMS41TDQgMTBsNi41LTYuNUwxMiA1eiIvPjwvc3ZnPg==");
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions span.action-cancel{
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC\
|
||||
9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDEyIDE2Ij48cGF0aCBmaWxsPSIjZmZmZmZmIiBkP\
|
||||
SJNNy40OCA4bDMuNzUgMy43NS0xLjQ4IDEuNDhMNiA5LjQ4bC0zLjc1IDMuNzUtMS40OC0xLjQ4TDQuNTIgOCAuNzcgNC4y\
|
||||
NWwxLjQ4LTEuNDhMNiA2LjUybDMuNzUtMy43NSAxLjQ4IDEuNDhMNy40OCA4eiIvPjwvc3ZnPg==");
|
||||
}
|
||||
/* @end CALENDAR ACTIONs */
|
||||
|
||||
/* @start CALENDAR DATEPICKER */
|
||||
.tail-datetime-calendar .calendar-datepicker{
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table{
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-spacing: 0;
|
||||
border-collapse: separate;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td{
|
||||
color: #303438;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
text-shadow: none;
|
||||
line-height: 30px;
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 0px;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th{
|
||||
color: white;
|
||||
background-color: var(--color-lightblack);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td{
|
||||
cursor: pointer;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td span.inner{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.date-disabled{
|
||||
cursor: not-allowed;
|
||||
color: #909498;
|
||||
background-color: #F0F0F0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.date-disabled:after{
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
width: 35px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
background-color: #bfbfbf;
|
||||
transform-origin: 2px -5px;
|
||||
transform: rotate(-45deg);
|
||||
-moz-transform: rotate(-45deg);
|
||||
-webkit-transform: rotate(-45deg);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.date-previous,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.date-next{
|
||||
color: #909498;
|
||||
background-color: #F0F0F0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.date-today:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td .tooltip-tick{
|
||||
top: 5px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 20;
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.date-today:before{
|
||||
left: 5px;
|
||||
background-color: #E67D1E;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td .tooltip-tick{
|
||||
right: 5px;
|
||||
background-color: #202428;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td .tooltip-tick:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td .tooltip-tick:after{
|
||||
display: none;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th.calendar-week,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-day{
|
||||
width: 14.28571429%;
|
||||
height: 35px;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th.calendar-week span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-day span.inner{
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
line-height: 29px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th.calendar-week:hover span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-day:hover span.inner{
|
||||
border-color: #cccccc;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th.calendar-week.date-disabled span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-day.date-disabled span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th.calendar-week.date-disabled:hover span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-day.date-disabled:hover span.inner{
|
||||
border-color: transparent;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th.calendar-week.date-select span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-day.date-select span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr th.calendar-week.date-select:hover span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-day.date-select:hover span.inner{
|
||||
color: var(--color-fontsec);
|
||||
border-color: var(--color-fontsec);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade{
|
||||
width: 33.33333333%;
|
||||
height: 40px;
|
||||
transition: color 142ms linear;
|
||||
-webkit-transition: color 142ms linear;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month.date-today:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year.date-today:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade.date-today:before{
|
||||
left: 50%;
|
||||
margin-left: -2.5px;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year span.inner,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade span.inner{
|
||||
width: auto;
|
||||
height: 31px;
|
||||
line-height: 29px;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month span.inner:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year span.inner:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade span.inner:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month span.inner:after,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year span.inner:after,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade span.inner:after{
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
content: "";
|
||||
z-index: 15;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
transition: all 142ms linear;
|
||||
-webkit-transition: all 142ms linear;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month span.inner:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year span.inner:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade span.inner:before{
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month:hover span.inner:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year:hover span.inner:before,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade:hover span.inner:before{
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
border-top-color: #cccccc;
|
||||
border-left-color: #cccccc;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month span.inner:after,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year span.inner:after,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade span.inner:after{
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-month:hover span.inner:after,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year:hover span.inner:after,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade:hover span.inner:after{
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
border-right-color: #cccccc;
|
||||
border-bottom-color: #cccccc;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-year,
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade{
|
||||
width: 25%;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-datepicker table tr td.calendar-decade span.inner{
|
||||
height: 54px;
|
||||
padding: 7px 15px;
|
||||
text-align: left;
|
||||
line-height: 20px;
|
||||
}
|
||||
/* @end CALENDAR DATEPICKER */
|
||||
|
||||
/* @start CALENDAR TIMEPICKER */
|
||||
.tail-datetime-calendar .calendar-timepicker{
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
text-align: center;
|
||||
border-width: 1px 0 0 0;
|
||||
border-style: solid;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field{
|
||||
width: 28%;
|
||||
margin: 0;
|
||||
padding: 15px 0 7px 0;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field:first-of-type{
|
||||
text-align: right;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field:last-of-type{
|
||||
text-align: left;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input[type="text"]{
|
||||
color: #303438;
|
||||
width: 100%;
|
||||
height: 29px;
|
||||
margin: 0;
|
||||
z-index: 4;
|
||||
padding: 3px 20px 3px 5px;
|
||||
outline: 0;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 23px;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
-webkit-appearance: textfield;
|
||||
background-color: #F0F0F0;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
transition: color 142ms linear, border 142ms linear, background 142ms linear;
|
||||
-webkit-transition: color 142ms linear, border 142ms linear, background 142ms linear;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input[type="text"]:hover{
|
||||
color: #303438;
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input[type="text"]:focus{
|
||||
color: #303438;
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input[type="text"]:disabled{
|
||||
cursor: not-allowed;
|
||||
color: #A0A4A8;
|
||||
background-color: #F6F6F6;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field button.picker-step{
|
||||
min-width: 0px;
|
||||
width: 20px;
|
||||
height: 15px;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 15;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
background-color: #F0F0F0;
|
||||
box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
transition: border 142ms linear, background 142ms linear;
|
||||
-webkit-transition: border 142ms linear, background 142ms linear;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field button.picker-step:before{
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0 0 0 -4px;
|
||||
padding: 0;
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
transition: border 142ms linear;
|
||||
-webkit-transition: border 142ms linear;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field button.picker-step.step-up{
|
||||
top: 15px;
|
||||
border-width: 0 0 1px 1px;
|
||||
border-style: solid;
|
||||
border-color: white;
|
||||
border-radius: 0 2px 0 0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field button.picker-step.step-up:hover{
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field button.picker-step.step-up:before{
|
||||
border-width: 0 4px 5px 4px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent #303438 transparent;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field button.picker-step.step-down{
|
||||
top: 29px;
|
||||
border-width: 1px 0 0 1px;
|
||||
border-style: solid;
|
||||
border-color: white;
|
||||
border-radius: 0 0 2px 0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field button.picker-step.step-down:hover{
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field button.picker-step.step-down:before{
|
||||
border-width: 5px 4px 0 4px;
|
||||
border-style: solid;
|
||||
border-color: #303438 transparent transparent transparent;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:focus + button.step-up{
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:focus + button.step-up:hover{
|
||||
background-color: var(--color-dark);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:focus + button.step-up:before{
|
||||
border-bottom-color: white;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:focus + button + button.step-down{
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:focus + button + button.step-down:hover{
|
||||
background-color: var(--color-dark);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:focus + button + button.step-down:before{
|
||||
border-top-color: white;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:disabled + button.step-up{
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
background-color: #F6F6F6;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:disabled + button.step-up:hover{
|
||||
background-color: #F6F6F6;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:disabled + button.step-up:before{
|
||||
border-bottom-color: #A0A4A8;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:disabled + button + button.step-down{
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
background-color: #F6F6F6;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:disabled + button + button.step-down:hover{
|
||||
background-color: #F6F6F6;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field input:disabled + button + button.step-down:before{
|
||||
border-top-color: #A0A4A8;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker .timepicker-field label{
|
||||
color: #303438;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch{
|
||||
cursor: pointer;
|
||||
margin: 15px 0 -5px 0;
|
||||
display: block;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch:before,
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch:after{
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch:before{
|
||||
content: attr(data-am);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch:after{
|
||||
content: attr(data-pm);
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch input[type="checkbox"]{
|
||||
display: none;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch input[type="checkbox"] + span{
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch input[type="checkbox"] + span:before{
|
||||
width: 50px;
|
||||
height: 16px;
|
||||
content: "";
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-primary);
|
||||
border-radius: 14px;
|
||||
transition: border 284ms linear;
|
||||
-webkit-transition: border 284ms linear;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch input[type="checkbox"] + span:after{
|
||||
top: 3px;
|
||||
left: 4px;
|
||||
right: 30px;
|
||||
width: auto;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 15px;
|
||||
vertical-align: top;
|
||||
transition: left 284ms linear, right 284ms linear 284ms, background 284ms linear;
|
||||
-webkit-transition: left 284ms linear, right 284ms linear 284ms, background 284ms linear;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch input[type="checkbox"]:checked + span:before{
|
||||
border-color: #E67D1E;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-timepicker label.timepicker-switch input[type="checkbox"]:checked + span:after{
|
||||
left: 30px;
|
||||
right: 4px;
|
||||
background-color: #E67D1E;
|
||||
transition: right 284ms linear, left 284ms linear 284ms, background 284ms linear;
|
||||
-webkit-transition: right 284ms linear, left 284ms linear 284ms, background 284ms linear;
|
||||
}
|
||||
.tail-datetime-calendar .calendar-actions + .calendar-timepicker{
|
||||
border-width: 0;
|
||||
}
|
||||
/* @end CALENDAR TIMEPICKER */
|
||||
|
||||
/* @start RTL */
|
||||
.tail-datetime-calendar.rtl{
|
||||
direction: rtl;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-actions span.action-next,
|
||||
.tail-datetime-calendar.rtl .calendar-actions span.action-prev{
|
||||
transform: rotate(180deg);
|
||||
-moz-transform: rotate(180deg);
|
||||
-webkit-transform: rotate(180deg);
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.date-disabled:after{
|
||||
right: 3px;
|
||||
transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-webkit-transform: rotate(45deg);
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.date-today:before{
|
||||
right: 5px;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td .tooltip-tick{
|
||||
left: 5px;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-month.date-today:before,
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-year.date-today:before,
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-decade.date-today:before{
|
||||
right: 50%;
|
||||
margin-right: -2.5px;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-month:hover span.inner:before,
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-year:hover span.inner:before,
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-decade:hover span.inner:before{
|
||||
right: 6px;
|
||||
border-right-color: #cccccc;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-month span.inner:after,
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-year span.inner:after,
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-decade span.inner:after{
|
||||
left: 0;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-month:hover span.inner:after,
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-year:hover span.inner:after,
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-decade:hover span.inner:after{
|
||||
left: 6px;
|
||||
border-left-color: #cccccc;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-datepicker table tr td.calendar-decade span.inner{
|
||||
text-align: right;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-timepicker .timepicker-field:first-child{
|
||||
text-align: left;
|
||||
padding-left: 0;
|
||||
padding-right: 25px;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-timepicker .timepicker-field:last-child{
|
||||
text-align: right;
|
||||
padding-left: 25px;
|
||||
padding-right: 0;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-timepicker .timepicker-field:first-child input[type="text"]{
|
||||
margin-left: -1px;
|
||||
margin-right: 0;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
.tail-datetime-calendar.rtl .calendar-timepicker .timepicker-field:last-child input[type="text"]{
|
||||
margin-left: 0;
|
||||
margin-right: -1px;
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
/* @end RTL */
|
||||
|
||||
/*# sourceMappingURL=tail.datetime-default-green.map */
|
||||
755
frontend/vendor/flatpickr.css
vendored
755
frontend/vendor/flatpickr.css
vendored
@ -1,755 +0,0 @@
|
||||
/*
|
||||
custom code
|
||||
hides the up/down arrows in time (number) inputs
|
||||
*/
|
||||
/* webkit */
|
||||
.flatpickr-calendar input[type=number]::-webkit-inner-spin-button,
|
||||
.flatpickr-calendar input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
/* firefox */
|
||||
.flatpickr-calendar input[type=number] {
|
||||
-moz-appearance:textfield;
|
||||
}
|
||||
/* vendor code */
|
||||
.flatpickr-calendar {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
text-align: center;
|
||||
visibility: hidden;
|
||||
padding: 0;
|
||||
-webkit-animation: none;
|
||||
animation: none;
|
||||
direction: ltr;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
border-radius: 5px;
|
||||
position: absolute;
|
||||
width: 307.875px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-ms-touch-action: manipulation;
|
||||
touch-action: manipulation;
|
||||
background: #fff;
|
||||
-webkit-box-shadow: 1px 0 0 #e6e6e6, -1px 0 0 #e6e6e6, 0 1px 0 #e6e6e6, 0 -1px 0 #e6e6e6, 0 3px 13px rgba(0,0,0,0.08);
|
||||
box-shadow: 1px 0 0 #e6e6e6, -1px 0 0 #e6e6e6, 0 1px 0 #e6e6e6, 0 -1px 0 #e6e6e6, 0 3px 13px rgba(0,0,0,0.08);
|
||||
}
|
||||
.flatpickr-calendar.open,
|
||||
.flatpickr-calendar.inline {
|
||||
opacity: 1;
|
||||
max-height: 640px;
|
||||
visibility: visible;
|
||||
}
|
||||
.flatpickr-calendar.open {
|
||||
display: inline-block;
|
||||
z-index: 99999;
|
||||
}
|
||||
.flatpickr-calendar.animate.open {
|
||||
-webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.flatpickr-calendar.inline {
|
||||
display: block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
.flatpickr-calendar.static {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
}
|
||||
.flatpickr-calendar.static.open {
|
||||
z-index: 999;
|
||||
display: block;
|
||||
}
|
||||
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
|
||||
-webkit-box-shadow: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
|
||||
-webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||
box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||
}
|
||||
.flatpickr-calendar .hasWeeks .dayContainer,
|
||||
.flatpickr-calendar .hasTime .dayContainer {
|
||||
border-bottom: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.flatpickr-calendar .hasWeeks .dayContainer {
|
||||
border-left: 0;
|
||||
}
|
||||
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
|
||||
height: 40px;
|
||||
border-top: 1px solid #e6e6e6;
|
||||
}
|
||||
.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
|
||||
height: auto;
|
||||
}
|
||||
.flatpickr-calendar:before,
|
||||
.flatpickr-calendar:after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
border: solid transparent;
|
||||
content: '';
|
||||
height: 0;
|
||||
width: 0;
|
||||
left: 22px;
|
||||
}
|
||||
.flatpickr-calendar.rightMost:before,
|
||||
.flatpickr-calendar.rightMost:after {
|
||||
left: auto;
|
||||
right: 22px;
|
||||
}
|
||||
.flatpickr-calendar:before {
|
||||
border-width: 5px;
|
||||
margin: 0 -5px;
|
||||
}
|
||||
.flatpickr-calendar:after {
|
||||
border-width: 4px;
|
||||
margin: 0 -4px;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:before,
|
||||
.flatpickr-calendar.arrowTop:after {
|
||||
bottom: 100%;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:before {
|
||||
border-bottom-color: #e6e6e6;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop:after {
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:before,
|
||||
.flatpickr-calendar.arrowBottom:after {
|
||||
top: 100%;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:before {
|
||||
border-top-color: #e6e6e6;
|
||||
}
|
||||
.flatpickr-calendar.arrowBottom:after {
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.flatpickr-calendar:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.flatpickr-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.flatpickr-months {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
}
|
||||
.flatpickr-months .flatpickr-month {
|
||||
background: transparent;
|
||||
color: rgba(0,0,0,0.9);
|
||||
fill: rgba(0,0,0,0.9);
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month,
|
||||
.flatpickr-months .flatpickr-next-month {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
line-height: 16px;
|
||||
height: 28px;
|
||||
padding: 10px;
|
||||
z-index: 3;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month.disabled,
|
||||
.flatpickr-months .flatpickr-next-month.disabled {
|
||||
display: none;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month i,
|
||||
.flatpickr-months .flatpickr-next-month i {
|
||||
position: relative;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
|
||||
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
*/
|
||||
left: 0;
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
/*
|
||||
*/
|
||||
}
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
|
||||
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
*/
|
||||
right: 0;
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
/*
|
||||
*/
|
||||
}
|
||||
/*
|
||||
/*rtl:begin:ignore*/
|
||||
/*
|
||||
/*rtl:end:ignore*/
|
||||
.flatpickr-months .flatpickr-prev-month:hover,
|
||||
.flatpickr-months .flatpickr-next-month:hover {
|
||||
color: #959ea9;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month:hover svg,
|
||||
.flatpickr-months .flatpickr-next-month:hover svg {
|
||||
fill: #f64747;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month svg,
|
||||
.flatpickr-months .flatpickr-next-month svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.flatpickr-months .flatpickr-prev-month svg path,
|
||||
.flatpickr-months .flatpickr-next-month svg path {
|
||||
-webkit-transition: fill 0.1s;
|
||||
transition: fill 0.1s;
|
||||
fill: inherit;
|
||||
}
|
||||
.numInputWrapper {
|
||||
position: relative;
|
||||
height: auto;
|
||||
}
|
||||
.numInputWrapper input,
|
||||
.numInputWrapper span {
|
||||
display: inline-block;
|
||||
}
|
||||
.numInputWrapper input {
|
||||
width: 100%;
|
||||
min-width: auto !important;
|
||||
}
|
||||
.numInputWrapper input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
.numInputWrapper span {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 14px;
|
||||
padding: 0 4px 0 2px;
|
||||
height: 50%;
|
||||
line-height: 50%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(57,57,57,0.15);
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.numInputWrapper span:hover {
|
||||
background: rgba(0,0,0,0.1);
|
||||
}
|
||||
.numInputWrapper span:active {
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.numInputWrapper span:after {
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
.numInputWrapper span.arrowUp {
|
||||
top: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.numInputWrapper span.arrowUp:after {
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 4px solid rgba(57,57,57,0.6);
|
||||
top: 26%;
|
||||
}
|
||||
.numInputWrapper span.arrowDown {
|
||||
top: 50%;
|
||||
}
|
||||
.numInputWrapper span.arrowDown:after {
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid rgba(57,57,57,0.6);
|
||||
top: 40%;
|
||||
}
|
||||
.numInputWrapper span svg {
|
||||
width: inherit;
|
||||
height: auto;
|
||||
}
|
||||
.numInputWrapper span svg path {
|
||||
fill: rgba(0,0,0,0.5);
|
||||
}
|
||||
.numInputWrapper:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
}
|
||||
.numInputWrapper:hover span {
|
||||
opacity: 1;
|
||||
}
|
||||
.flatpickr-current-month {
|
||||
font-size: 135%;
|
||||
line-height: inherit;
|
||||
font-weight: 300;
|
||||
color: inherit;
|
||||
position: absolute;
|
||||
width: 75%;
|
||||
left: 12.5%;
|
||||
padding: 6.16px 0 0 0;
|
||||
line-height: 1;
|
||||
height: 28px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
-webkit-transform: translate3d(0px, 0px, 0px);
|
||||
transform: translate3d(0px, 0px, 0px);
|
||||
}
|
||||
.flatpickr-current-month span.cur-month {
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
display: inline-block;
|
||||
margin-left: 0.5ch;
|
||||
padding: 0;
|
||||
}
|
||||
.flatpickr-current-month span.cur-month:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper {
|
||||
width: 6ch;
|
||||
width: 7ch\0;
|
||||
display: inline-block;
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper span.arrowUp:after {
|
||||
border-bottom-color: rgba(0,0,0,0.9);
|
||||
}
|
||||
.flatpickr-current-month .numInputWrapper span.arrowDown:after {
|
||||
border-top-color: rgba(0,0,0,0.9);
|
||||
}
|
||||
.flatpickr-current-month input.cur-year {
|
||||
background: transparent;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
padding: 0 0 0 0.5ch;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: 300;
|
||||
line-height: inherit;
|
||||
height: auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
vertical-align: initial;
|
||||
}
|
||||
.flatpickr-current-month input.cur-year:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.flatpickr-current-month input.cur-year[disabled],
|
||||
.flatpickr-current-month input.cur-year[disabled]:hover {
|
||||
font-size: 100%;
|
||||
color: rgba(0,0,0,0.5);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.flatpickr-weekdays {
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
}
|
||||
.flatpickr-weekdays .flatpickr-weekdaycontainer {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
span.flatpickr-weekday {
|
||||
cursor: default;
|
||||
font-size: 90%;
|
||||
background: transparent;
|
||||
color: rgba(0,0,0,0.54);
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
display: block;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.dayContainer,
|
||||
.flatpickr-weeks {
|
||||
padding: 1px 0 0 0;
|
||||
}
|
||||
.flatpickr-days {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: start;
|
||||
-webkit-align-items: flex-start;
|
||||
-ms-flex-align: start;
|
||||
align-items: flex-start;
|
||||
width: 307.875px;
|
||||
}
|
||||
.flatpickr-days:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.dayContainer {
|
||||
padding: 0;
|
||||
outline: 0;
|
||||
text-align: left;
|
||||
width: 307.875px;
|
||||
min-width: 307.875px;
|
||||
max-width: 307.875px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
display: -ms-flexbox;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
-ms-flex-wrap: wrap;
|
||||
-ms-flex-pack: justify;
|
||||
-webkit-justify-content: space-around;
|
||||
justify-content: space-around;
|
||||
-webkit-transform: translate3d(0px, 0px, 0px);
|
||||
transform: translate3d(0px, 0px, 0px);
|
||||
opacity: 1;
|
||||
}
|
||||
.dayContainer + .dayContainer {
|
||||
-webkit-box-shadow: -1px 0 0 #e6e6e6;
|
||||
box-shadow: -1px 0 0 #e6e6e6;
|
||||
}
|
||||
.flatpickr-day {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 150px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: #393939;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
width: 14.2857143%;
|
||||
-webkit-flex-basis: 14.2857143%;
|
||||
-ms-flex-preferred-size: 14.2857143%;
|
||||
flex-basis: 14.2857143%;
|
||||
max-width: 39px;
|
||||
height: 39px;
|
||||
line-height: 39px;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.flatpickr-day.inRange,
|
||||
.flatpickr-day.prevMonthDay.inRange,
|
||||
.flatpickr-day.nextMonthDay.inRange,
|
||||
.flatpickr-day.today.inRange,
|
||||
.flatpickr-day.prevMonthDay.today.inRange,
|
||||
.flatpickr-day.nextMonthDay.today.inRange,
|
||||
.flatpickr-day:hover,
|
||||
.flatpickr-day.prevMonthDay:hover,
|
||||
.flatpickr-day.nextMonthDay:hover,
|
||||
.flatpickr-day:focus,
|
||||
.flatpickr-day.prevMonthDay:focus,
|
||||
.flatpickr-day.nextMonthDay:focus {
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
background: #e6e6e6;
|
||||
border-color: #e6e6e6;
|
||||
}
|
||||
.flatpickr-day.today {
|
||||
border-color: #959ea9;
|
||||
}
|
||||
.flatpickr-day.today:hover,
|
||||
.flatpickr-day.today:focus {
|
||||
border-color: #959ea9;
|
||||
background: #959ea9;
|
||||
color: #fff;
|
||||
}
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.startRange,
|
||||
.flatpickr-day.endRange,
|
||||
.flatpickr-day.selected.inRange,
|
||||
.flatpickr-day.startRange.inRange,
|
||||
.flatpickr-day.endRange.inRange,
|
||||
.flatpickr-day.selected:focus,
|
||||
.flatpickr-day.startRange:focus,
|
||||
.flatpickr-day.endRange:focus,
|
||||
.flatpickr-day.selected:hover,
|
||||
.flatpickr-day.startRange:hover,
|
||||
.flatpickr-day.endRange:hover,
|
||||
.flatpickr-day.selected.prevMonthDay,
|
||||
.flatpickr-day.startRange.prevMonthDay,
|
||||
.flatpickr-day.endRange.prevMonthDay,
|
||||
.flatpickr-day.selected.nextMonthDay,
|
||||
.flatpickr-day.startRange.nextMonthDay,
|
||||
.flatpickr-day.endRange.nextMonthDay {
|
||||
background: #569ff7;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
color: #fff;
|
||||
border-color: #569ff7;
|
||||
}
|
||||
.flatpickr-day.selected.startRange,
|
||||
.flatpickr-day.startRange.startRange,
|
||||
.flatpickr-day.endRange.startRange {
|
||||
border-radius: 50px 0 0 50px;
|
||||
}
|
||||
.flatpickr-day.selected.endRange,
|
||||
.flatpickr-day.startRange.endRange,
|
||||
.flatpickr-day.endRange.endRange {
|
||||
border-radius: 0 50px 50px 0;
|
||||
}
|
||||
.flatpickr-day.selected.startRange + .endRange,
|
||||
.flatpickr-day.startRange.startRange + .endRange,
|
||||
.flatpickr-day.endRange.startRange + .endRange {
|
||||
-webkit-box-shadow: -10px 0 0 #569ff7;
|
||||
box-shadow: -10px 0 0 #569ff7;
|
||||
}
|
||||
.flatpickr-day.selected.startRange.endRange,
|
||||
.flatpickr-day.startRange.startRange.endRange,
|
||||
.flatpickr-day.endRange.startRange.endRange {
|
||||
border-radius: 50px;
|
||||
}
|
||||
.flatpickr-day.inRange {
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: -5px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||
box-shadow: -5px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
|
||||
}
|
||||
.flatpickr-day.disabled,
|
||||
.flatpickr-day.disabled:hover,
|
||||
.flatpickr-day.prevMonthDay,
|
||||
.flatpickr-day.nextMonthDay,
|
||||
.flatpickr-day.notAllowed,
|
||||
.flatpickr-day.notAllowed.prevMonthDay,
|
||||
.flatpickr-day.notAllowed.nextMonthDay {
|
||||
color: rgba(57,57,57,0.3);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.flatpickr-day.disabled,
|
||||
.flatpickr-day.disabled:hover {
|
||||
cursor: not-allowed;
|
||||
color: rgba(57,57,57,0.1);
|
||||
}
|
||||
.flatpickr-day.week.selected {
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: -5px 0 0 #569ff7, 5px 0 0 #569ff7;
|
||||
box-shadow: -5px 0 0 #569ff7, 5px 0 0 #569ff7;
|
||||
}
|
||||
.flatpickr-day.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
.rangeMode .flatpickr-day {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.flatpickr-weekwrapper {
|
||||
display: inline-block;
|
||||
float: left;
|
||||
}
|
||||
.flatpickr-weekwrapper .flatpickr-weeks {
|
||||
padding: 0 12px;
|
||||
-webkit-box-shadow: 1px 0 0 #e6e6e6;
|
||||
box-shadow: 1px 0 0 #e6e6e6;
|
||||
}
|
||||
.flatpickr-weekwrapper .flatpickr-weekday {
|
||||
float: none;
|
||||
width: 100%;
|
||||
line-height: 28px;
|
||||
}
|
||||
.flatpickr-weekwrapper span.flatpickr-day,
|
||||
.flatpickr-weekwrapper span.flatpickr-day:hover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
color: rgba(57,57,57,0.3);
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
border: none;
|
||||
}
|
||||
.flatpickr-innerContainer {
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.flatpickr-rContainer {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.flatpickr-time {
|
||||
text-align: center;
|
||||
outline: 0;
|
||||
display: block;
|
||||
height: 0;
|
||||
line-height: 40px;
|
||||
max-height: 40px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
}
|
||||
.flatpickr-time:after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
.flatpickr-time .numInputWrapper {
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
width: 40%;
|
||||
height: 40px;
|
||||
float: left;
|
||||
}
|
||||
.flatpickr-time .numInputWrapper span.arrowUp:after {
|
||||
border-bottom-color: #393939;
|
||||
}
|
||||
.flatpickr-time .numInputWrapper span.arrowDown:after {
|
||||
border-top-color: #393939;
|
||||
}
|
||||
.flatpickr-time.hasSeconds .numInputWrapper {
|
||||
width: 26%;
|
||||
}
|
||||
.flatpickr-time.time24hr .numInputWrapper {
|
||||
width: 49%;
|
||||
}
|
||||
.flatpickr-time input {
|
||||
background: transparent;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: inherit;
|
||||
line-height: inherit;
|
||||
cursor: pointer;
|
||||
color: #393939;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.flatpickr-time input.flatpickr-hour {
|
||||
font-weight: bold;
|
||||
}
|
||||
.flatpickr-time input.flatpickr-minute,
|
||||
.flatpickr-time input.flatpickr-second {
|
||||
font-weight: 400;
|
||||
}
|
||||
.flatpickr-time input:focus {
|
||||
outline: 0;
|
||||
border: 0;
|
||||
}
|
||||
.flatpickr-time .flatpickr-time-separator,
|
||||
.flatpickr-time .flatpickr-am-pm {
|
||||
height: inherit;
|
||||
display: inline-block;
|
||||
float: left;
|
||||
line-height: inherit;
|
||||
color: #393939;
|
||||
font-weight: bold;
|
||||
width: 2%;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-align-self: center;
|
||||
-ms-flex-item-align: center;
|
||||
align-self: center;
|
||||
}
|
||||
.flatpickr-time .flatpickr-am-pm {
|
||||
outline: 0;
|
||||
width: 18%;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
.flatpickr-time .flatpickr-am-pm:hover,
|
||||
.flatpickr-time .flatpickr-am-pm:focus {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.flatpickr-input[readonly] {
|
||||
cursor: pointer;
|
||||
min-width: auto;
|
||||
}
|
||||
@-webkit-keyframes fpFadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, -20px, 0);
|
||||
transform: translate3d(0, -20px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
@keyframes fpFadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, -20px, 0);
|
||||
transform: translate3d(0, -20px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
2
frontend/vendor/main.js
vendored
2
frontend/vendor/main.js
vendored
@ -1,2 +1,2 @@
|
||||
import './fontawesome.css';
|
||||
import './flatpickr.css';
|
||||
import './datetime.css';
|
||||
5
jsconfig.json
Normal file
5
jsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
}
|
||||
@ -465,6 +465,8 @@ CloseAlert: Schliessen
|
||||
|
||||
Name: Name
|
||||
MatrikelNr: Matrikelnummer
|
||||
LdapSynced: LDAP-Synchronisiert
|
||||
LdapSyncedBefore: Letzte LDAP-Synchronisation vor
|
||||
NoMatrikelKnown: Keine Matrikelnummer
|
||||
Theme: Oberflächen Design
|
||||
Favoriten: Anzahl gespeicherter Favoriten
|
||||
@ -581,7 +583,7 @@ RatingFilesUpdated: Korrigierte Dateien überschrieben
|
||||
RatingNotUnicode uexc@UnicodeException: Bewertungsdatei nicht in UTF-8 kodiert: #{tshow uexc}
|
||||
RatingMissingSeparator: Präambel der Bewertungsdatei konnte nicht identifziert werden
|
||||
RatingMultiple: Bewertungen enthält mehrere Punktzahlen für die gleiche Abgabe
|
||||
RatingInvalid parseErr@String: Bewertungspunktzahl konnte nicht als Zahl verstanden werden: #{parseErr}
|
||||
RatingInvalid parseErr@Text: Bewertungspunktzahl konnte nicht als Zahl verstanden werden: #{parseErr}
|
||||
RatingFileIsDirectory: Unerwarteter Fehler: Datei ist unerlaubterweise ein Verzeichnis
|
||||
RatingNegative: Bewertungspunkte dürfen nicht negativ sein
|
||||
RatingExceedsMax: Bewertung übersteigt die erlaubte Maximalpunktzahl
|
||||
@ -615,7 +617,8 @@ TutorsFor n@Int: #{pluralDE n "Tutor" "Tutoren"}
|
||||
CorrectorsFor n@Int: #{pluralDE n "Korrektor" "Korrektoren"}
|
||||
ForSchools n@Int: für #{pluralDE n "Institut" "Institute"}
|
||||
UserListTitle: Komprehensive Benutzerliste
|
||||
AccessRightsSaved: Berechtigungsänderungen wurden gespeichert.
|
||||
AccessRightsSaved: Berechtigungen erfolgreich verändert
|
||||
AccessRightsNotChanged: Berechtigungen wurden nicht verändert
|
||||
|
||||
LecturersForN n@Int: #{pluralDE n "Dozent" "Dozenten"}
|
||||
|
||||
@ -628,6 +631,8 @@ DownloadFilesTip: Wenn gesetzt werden Dateien von Abgaben und Übungsblättern a
|
||||
WarningDays: Fristen-Vorschau
|
||||
WarningDaysTip: Wie viele Tage im Voraus sollen Fristen von Klausuren etc. auf Ihrer Startseite angezeigt werden?
|
||||
NotificationSettings: Erwünschte Benachrichtigungen
|
||||
UserSchools: Relevante Institute
|
||||
UserSchoolsTip: Sie erhalten nur institutweite Benachrichtigungen für Institute, die hier ausgewählt sind.
|
||||
FormNotifications: Benachrichtigungen
|
||||
FormBehaviour: Verhalten
|
||||
FormCosmetics: Oberfläche
|
||||
@ -662,7 +667,8 @@ CampusUserInvalidGivenName: Konnte anhand des Campus-Logins keinen Vornamen ermi
|
||||
CampusUserInvalidSurname: Konnte anhand des Campus-Logins keinen Nachname ermitteln
|
||||
CampusUserInvalidTitle: Konnte anhand des Campus-Logins keinen akademischen Titel ermitteln
|
||||
CampusUserInvalidMatriculation: Konnte anhand des Campus-Logins keine Matrikelnummer ermitteln
|
||||
CampusUserInvalidFeaturesOfStudy parseErr@String: Konnte anhand des Campus-Logins keine Matrikelnummer ermitteln: #{parseErr}
|
||||
CampusUserInvalidFeaturesOfStudy parseErr@Text: Konnte anhand des Campus-Logins keine Studiengänge ermitteln
|
||||
CampusUserInvalidAssociatedSchools parseErr@Text: Konnte anhand des Campus-Logins keine Institute ermitteln
|
||||
|
||||
CorrectorNormal: Normal
|
||||
CorrectorMissing: Abwesend
|
||||
@ -854,6 +860,11 @@ NotificationTriggerCorrectionsNotDistributed: Nicht alle Abgaben eines meiner Ü
|
||||
NotificationTriggerUserRightsUpdate: Meine Berechtigungen wurden geändert
|
||||
NotificationTriggerUserAuthModeUpdate: Mein Anmelde-Modus wurde geändert
|
||||
NotificationTriggerExamResult: Ich kann ein neues Prüfungsergebnis einsehen
|
||||
NotificationTriggerAllocationStaffRegister: Ich kann Kurse bei einer neuen Zentralanmeldung eintragen
|
||||
NotificationTriggerAllocationAllocation: Ich kann Zentralanmeldung-Bewerbungen für einen meiner Kurse bewerten
|
||||
NotificationTriggerAllocationRegister: Ich kann mich bei einer neuen Zentralanmeldung bewerben
|
||||
NotificationTriggerAllocationOutdatedRatings: Zentralanmeldung-Bewerbungen für einen meiner Kurse wurden verändert, nachdem sie bewertet wurden
|
||||
NotificationTriggerAllocationUnratedApplications: Bewertungen zu Zentralanmeldung-Bewerbungen für einen meiner Kurse stehen aus
|
||||
|
||||
NotificationTriggerKindAll: Für alle Benutzer
|
||||
NotificationTriggerKindCourseParticipant: Für Kursteilnehmer
|
||||
@ -861,6 +872,9 @@ NotificationTriggerKindExamParticipant: Für Prüfungsteilnehmer
|
||||
NotificationTriggerKindCorrector: Für Korrektoren
|
||||
NotificationTriggerKindLecturer: Für Dozenten
|
||||
NotificationTriggerKindAdmin: Für Administratoren
|
||||
NotificationTriggerKindExamOffice: Für das Prüfungsamt
|
||||
NotificationTriggerKindEvaluation: Für Vorlesungsumfragen
|
||||
NotificationTriggerKindAllocationStaff: Für Zentralanmeldungen
|
||||
|
||||
CorrCreate: Abgaben erstellen
|
||||
UnknownPseudonymWord pseudonymWord@Text: Unbekanntes Pseudonym-Wort "#{pseudonymWord}"
|
||||
@ -1031,6 +1045,8 @@ MenuExamAddMembers: Prüfungsteilnehmer hinzufügen
|
||||
MenuLecturerInvite: Dozenten hinzufügen
|
||||
MenuAllocationInfo: Hinweise zum Ablauf einer Zentralanmeldung
|
||||
MenuCourseApplicationsFiles: Dateien aller Bewerbungen
|
||||
MenuSchoolList: Institute
|
||||
MenuSchoolNew: Neues Institut anlegen
|
||||
|
||||
AuthPredsInfo: Um eigene Veranstaltungen aus Sicht der Teilnehmer anzusehen, können Veranstalter und Korrektoren hier die Prüfung ihrer erweiterten Berechtigungen temporär deaktivieren. Abgewählte Prädikate schlagen immer fehl. Abgewählte Prädikate werden also nicht geprüft um Zugriffe zu gewähren, welche andernfalls nicht erlaubt wären. Diese Einstellungen gelten nur temporär bis Ihre Sitzung abgelaufen ist, d.h. bis ihr Browser-Cookie abgelaufen ist. Durch Abwahl von Prädikaten kann man sich höchstens temporär aussperren.
|
||||
AuthPredsActive: Aktive Authorisierungsprädikate
|
||||
@ -1425,6 +1441,7 @@ CsvColumnApplicationsRating: Bewertung der Bewerbung; "1.0", "1.3", "1.7", ...,
|
||||
CsvColumnApplicationsComment: Kommentar zur Bewerbung; je nach Kurs-Einstellungen entweder nur als Notiz für die Kursverwalter oder Feedback für den Bewerber
|
||||
|
||||
Action: Aktion
|
||||
ActionNoUsersSelected: Keine Benutzer ausgewählt
|
||||
|
||||
DBCsvDuplicateKey: Zwei Zeilen der CSV-Dateien referenzieren den selben internen Datensatz und können daher nicht verarbeitet werden.
|
||||
DBCsvDuplicateKeyTip: Entfernen Sie eine der unten aufgeführten Zeilen aus Ihren CSV-Dateien und versuchen Sie es erneut.
|
||||
@ -1489,20 +1506,23 @@ PasswordRepeatInvalid: Wiederholung stimmt nicht mit neuem Passwort überein
|
||||
UserPasswordHeadingFor: Passwort ändern für
|
||||
PasswordChangedSuccess: Passwort erfolgreich geändert
|
||||
|
||||
LecturerInviteSchool: Institut
|
||||
LecturerInviteField: Einzuladende EMail Addressen
|
||||
LecturerInviteHeading: Dozenten hinzufügen
|
||||
FunctionaryInviteFunction: Funktion
|
||||
FunctionaryInviteSchool: Institut
|
||||
FunctionaryInviteField: Einzuladende EMail Addressen
|
||||
FunctionaryInviteHeading: Institut-Funktionäre hinzufügen
|
||||
|
||||
LecturersInvited n@Int: #{n} #{pluralDE n "Dozent" "Dozenten"} per EMail eingeladen
|
||||
LecturersAdded n@Int: #{n} #{pluralDE n "Dozent" "Dozenten"} eingetragen
|
||||
FunctionariesInvited n@Int: #{n} #{pluralDE n "Funktionär" "Funktionäre"} per EMail eingeladen
|
||||
FunctionariesAdded n@Int: #{n} #{pluralDE n "Funktionär" "Funktionäre"} eingetragen
|
||||
|
||||
MailSubjectSchoolLecturerInvitation school@SchoolName: Einladung zum Dozent für „#{school}“
|
||||
MailSchoolLecturerInviteHeading school@SchoolName: Einladung zum Dozent für „#{school}“
|
||||
SchoolLecturerInviteExplanation: Sie wurden eingeladen, Dozent für ein Institut zu sein. Sie können, nachdem Sie die Einladung annehmen, eigenständig neue Kurse anlegen.
|
||||
SchoolLecturerInvitationAccepted school@SchoolName: Einladung zum Dozent für „#{school}“ angenommen
|
||||
MailSubjectSchoolFunctionInvitation school@SchoolName renderedFunction@Text: #{renderedFunction}-Einladung für „#{school}“
|
||||
MailSchoolFunctionInviteHeading school@SchoolName renderedFunction@Text: #{renderedFunction}-Einladung für „#{school}“
|
||||
SchoolFunctionInviteExplanation renderedFunction@Text: Sie wurden eingeladen, als #{renderedFunction} für ein Institut zu wirken. Sie erhalten, nachdem Sie die Einladung annehmen, erweiterte Rechte innerhalb des Instituts.
|
||||
SchoolFunctionInvitationAccepted school@SchoolName renderedFunction@Text: #{renderedFunction}-Einladung zum Dozent für „#{school}“ angenommen
|
||||
|
||||
AllocationActive: Aktiv
|
||||
AllocationName: Name
|
||||
AllocationAvailableCourses: Kurse
|
||||
AllocationAppliedCourses: Bewerbungen
|
||||
AllocationTitle termText@Text ssh'@SchoolShorthand allocation@AllocationName: #{termText} - #{ssh'}: #{allocation}
|
||||
AllocationShortTitle termText@Text ssh'@SchoolShorthand ash@AllocationShorthand: #{termText} - #{ssh'} - #{ash}
|
||||
AllocationDescription: Beschreibung
|
||||
@ -1513,7 +1533,7 @@ AllocationRegister: Bewerbung
|
||||
AllocationRegisterClosed: Die Zentralanmeldung ist aktuell geschlossen.
|
||||
AllocationRegisterOpensIn difftime@Text: Die Zentralanmeldung öffnet voraussichtlich in #{difftime}
|
||||
AllocationStaffAllocationFrom: Bewertung der Bewerbungen ab
|
||||
AllocationStaffAllocation: Bewerbungsbewertung
|
||||
AllocationStaffAllocation: Bewertung der Bewerbungen
|
||||
AllocationProcess: Platzvergabe
|
||||
AllocationNoApplication: Keine Bewerbung
|
||||
AllocationPriority: Priorität
|
||||
@ -1562,4 +1582,46 @@ CourseApplicationNoRatingPoints: Keine Bewertung
|
||||
CourseApplicationNoRatingComment: Kein Kommentar
|
||||
|
||||
UserDisplayName: Voller Name
|
||||
UserMatriculation: Matrikelnummer
|
||||
UserMatriculation: Matrikelnummer
|
||||
|
||||
SchoolShort: Kürzel
|
||||
SchoolName: Name
|
||||
SchoolLdapOrganisations: Assoziierte LDAP-Fragmente
|
||||
SchoolLdapOrganisationsTip: Beim Login via LDAP werden dem Nutzer alle Institute zugeordnet deren assoziierte LDAP-Fragmente im Eintrag des Nutzer gefunden werden
|
||||
|
||||
SchoolUpdated ssh@SchoolId: #{ssh} erfolgreich angepasst
|
||||
SchoolTitle ssh@SchoolId: Institut „#{ssh}“
|
||||
TitleSchoolNew: Neues Institut anlegen
|
||||
SchoolCreated ssh@SchoolId: #{ssh} erfolgreich angelegt
|
||||
SchoolExists ssh@SchoolId: Institut „#{ssh}“ existiert bereits
|
||||
|
||||
SchoolAdmin: Admin
|
||||
SchoolLecturer: Dozent
|
||||
SchoolEvaluation: Kursumfragenverwaltung
|
||||
SchoolExamOffice: Prüfungsamt
|
||||
|
||||
ApplicationEditTip: Während des Bewerbungszeitraums können eigene Bewerbungen beliebig angepasst und auch wieder zurückgezogen werden.
|
||||
|
||||
UserLdapSync: LDAP-Synchronisieren
|
||||
SynchroniseLdapUserQueued n@Int: LDAP-Synchronisation von #{n} #{pluralDE n "Benutzer" "Benutzern"} angestoßen
|
||||
UserHijack: Sitzung übernehmen
|
||||
|
||||
MailSubjectAllocationStaffRegister allocation@AllocationName: Sie können nun Kurse für die Zentralameldung „#{allocation}“ registrieren
|
||||
MailAllocationStaffRegisterNewCourse: Sie können auf der unten aufgeführten Seite neue Kurse in Uni2work anlegen. Hierbei haben Sie die Möglichkeit anzugeben, dass der Kurs an der Zentralanmeldung teilnimmt.
|
||||
MailAllocationStaffRegisterDeadline deadline@Text: Bitte beachten Sie, dass alle Kurse, die an der Zentralanmeldung teilnehmen, bis #{deadline} eingetragen sein müssen.
|
||||
|
||||
MailSubjectAllocationRegister allocation@AllocationName: Sie können sich nun für Kurse der Zentralameldung „#{allocation}“ bewerben
|
||||
MailAllocationRegister: Sie können sich, auf der unten aufgeführten Seite, für alle Kurse der Zentralanmeldung jeweils einzeln bewerben.
|
||||
MailAllocationRegisterDeadline deadline@Text: Bitte beachten Sie, dass alle Bewerbungen bis #{deadline} eingegangen sein müssen.
|
||||
|
||||
MailSubjectAllocationAllocation allocation@AllocationName: Sie können nun Bewerbungen für ihre Kurse in der Zentralanmeldung „#{allocation}“ bewerten
|
||||
MailAllocationAllocation: Sie können nun auf den unten aufgeführten Seiten Bewerbungen, die im Rahmen der Zentralanmeldung an ihre Kurse gestellt wurden, bewerten. Die Bewertungen werden bei der Vergabe der Plätze berücksichtigt.
|
||||
MailAllocationApplicationsMayChange deadline@Text: Bitte beachten Sie, dass Studierende noch bis #{deadline} Bewerbungen stellen, verändern und zurückziehen können. Bewerbungen, die sich nach ihrer Bewertung noch verändern, müssen neu bewertet werden.
|
||||
MailAllocationAllocationDeadline deadline@Text: Bitte beachten Sie, dass alle Bewertungen bis #{deadline} erfolgt sein müssen.
|
||||
|
||||
MailSubjectAllocationUnratedApplications allocation@AllocationName: Es stehen noch Bewertungen zu Bewerbungen für ihre Kurse in der Zentralanmeldung „#{allocation}“ aus
|
||||
MailAllocationUnratedApplications: Für die unten aufgeführten Kurse liegen Bewerbungen vor, die im Rahmen der Zentralanmeldung an den jeweiligen Kurs gestellt wurden, die noch nicht bewertet wurden.
|
||||
|
||||
MailSubjectAllocationOutdatedRatings allocation@AllocationName: Bereits bewertete Bewerbungen für ihre Kurse in der Zentralanmeldung „#{allocation}“ haben sich geändert
|
||||
MailAllocationOutdatedRatings: Für die unten aufgeführten Kurse liegen Bewerbungen vor, die im Rahmen der Zentralanmeldung an den jeweiligen Kurs gestellt wurden, die sich verändert haben, seit sie zuletzt bewertet wurden.
|
||||
MailAllocationOutdatedRatingsWarning: Bewerbungen deren Bewertung veraltet ist (d.h. die Bewerbung wurde nach der Bewertung verändert) zählen als nicht bewertet.
|
||||
@ -2,4 +2,5 @@ Invitation
|
||||
email UserEmail
|
||||
for Value
|
||||
data Value
|
||||
expiresAt UTCTime Maybe
|
||||
UniqueInvitation email for
|
||||
@ -6,4 +6,11 @@ School json
|
||||
UniqueSchool name
|
||||
UniqueSchoolShorthand shorthand -- required for Normalisation of CI Text
|
||||
Primary shorthand -- newtype Key School = SchoolKey { unSchoolKey :: SchoolShorthand }
|
||||
deriving Eq Show Generic
|
||||
deriving Ord Eq Show Generic
|
||||
SchoolLdap
|
||||
school SchoolId Maybe
|
||||
orgUnit (CI Text)
|
||||
UniqueOrgUnit orgUnit
|
||||
SchoolTerms
|
||||
school SchoolId
|
||||
terms StudyTermsId
|
||||
24
models/users
24
models/users
@ -14,6 +14,8 @@ User json -- Each Uni2work user has a corresponding row in this table; create
|
||||
ident (CI Text) -- Case-insensitive user-identifier
|
||||
authentication AuthenticationMode -- 'AuthLDAP' or ('AuthPWHash'+password-hash)
|
||||
lastAuthentication UTCTime Maybe -- last login date
|
||||
created UTCTime default=now()
|
||||
lastLdapSynchronisation UTCTime 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
|
||||
@ -30,14 +32,20 @@ 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
|
||||
UserAdmin -- Each row in this table grants school-specific administrator-rights to a specific user
|
||||
user UserId
|
||||
school SchoolId
|
||||
UniqueUserAdmin user school -- combination of user+school must be unique, i.e. no duplicate rows
|
||||
UserLecturer -- Each row in this table grants school-specific lecturer-rights to a specific user
|
||||
user UserId
|
||||
school SchoolId
|
||||
UniqueSchoolLecturer user school -- combination of user+school must be unique, i.e. no duplicate rows
|
||||
UserFunction -- Administratively assigned functions (lecturer, admin, evaluation, ...)
|
||||
user UserId
|
||||
school SchoolId
|
||||
function SchoolFunction
|
||||
UniqueUserFunction user school function
|
||||
UserExamOffice
|
||||
user UserId
|
||||
field StudyTermsId
|
||||
UniqueUserExamOffice user field
|
||||
UserSchool -- Managed by users themselves, encodes "schools of interest"
|
||||
user UserId
|
||||
school SchoolId
|
||||
isOptOut Bool -- true if this a marker, that the user manually deleted this entry; it should not be recreated automatically
|
||||
UniqueUserSchool user school
|
||||
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.
|
||||
|
||||
1784
package-lock.json
generated
1784
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uni2work",
|
||||
"version": "5.5.0",
|
||||
"version": "6.4.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@ -49,16 +49,17 @@
|
||||
"defaults"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.4.4",
|
||||
"@babel/core": "^7.4.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.4.4",
|
||||
"@babel/cli": "^7.5.5",
|
||||
"@babel/core": "^7.5.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.5.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.4.4",
|
||||
"@babel/preset-env": "^7.4.5",
|
||||
"@babel/plugin-transform-runtime": "^7.5.5",
|
||||
"@babel/preset-env": "^7.5.5",
|
||||
"@commitlint/cli": "^8.1.0",
|
||||
"@commitlint/config-conventional": "^8.0.0",
|
||||
"autoprefixer": "^9.6.0",
|
||||
"@commitlint/config-conventional": "^8.1.0",
|
||||
"autoprefixer": "^9.6.1",
|
||||
"babel-core": "^6.26.3",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.5",
|
||||
@ -66,9 +67,9 @@
|
||||
"css-loader": "^2.1.1",
|
||||
"eslint": "^5.16.0",
|
||||
"extract-text-webpack-plugin": "^4.0.0-beta.0",
|
||||
"husky": "^2.4.1",
|
||||
"husky": "^2.7.0",
|
||||
"jasmine-core": "^3.4.0",
|
||||
"karma": "^4.1.0",
|
||||
"karma": "^4.3.0",
|
||||
"karma-chrome-launcher": "^2.2.0",
|
||||
"karma-cli": "^2.0.0",
|
||||
"karma-jasmine": "^2.0.1",
|
||||
@ -81,15 +82,19 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"null-loader": "^2.0.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"sass-loader": "^7.1.0",
|
||||
"sass-loader": "^7.3.1",
|
||||
"standard-version": "^6.0.1",
|
||||
"style-loader": "^0.23.1",
|
||||
"uglifyjs-webpack-plugin": "^2.1.3",
|
||||
"webpack": "^4.34.0",
|
||||
"webpack-cli": "^3.3.4"
|
||||
"uglifyjs-webpack-plugin": "^2.2.0",
|
||||
"webpack": "^4.39.3",
|
||||
"webpack-cli": "^3.3.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"flatpickr": "^4.5.7",
|
||||
"npm": "^6.10.1"
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"core-js": "^3.2.1",
|
||||
"moment": "^2.24.0",
|
||||
"npm": "^6.11.2",
|
||||
"tail.datetime": "git+https://github.com/uni2work/tail.DateTime.git#master",
|
||||
"whatwg-fetch": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
name: uniworx
|
||||
version: 5.5.0
|
||||
version: 6.4.0
|
||||
|
||||
dependencies:
|
||||
# Due to a bug in GHC 8.0.1, we block its usage
|
||||
@ -135,6 +135,7 @@ dependencies:
|
||||
- cassava-conduit
|
||||
- constraints
|
||||
- memory
|
||||
- pqueue
|
||||
|
||||
other-extensions:
|
||||
- GeneralizedNewtypeDeriving
|
||||
|
||||
12
routes
12
routes
@ -43,14 +43,14 @@
|
||||
/robots.txt RobotsR GET !free
|
||||
|
||||
/ HomeR GET !free
|
||||
/users UsersR GET -- no tags, i.e. admins only
|
||||
/users UsersR GET POST -- no tags, i.e. admins only
|
||||
/users/#CryptoUUIDUser AdminUserR GET POST
|
||||
/users/#CryptoUUIDUser/delete AdminUserDeleteR POST
|
||||
/users/#CryptoUUIDUser/hijack AdminHijackUserR POST !adminANDno-escalation
|
||||
/users/#CryptoUUIDUser/notifications UserNotificationR GET POST !self
|
||||
/users/#CryptoUUIDUser/password UserPasswordR GET POST !selfANDis-pw-hash
|
||||
!/users/lecturer-invite/new AdminNewLecturerInviteR GET POST
|
||||
!/users/lecturer-invite AdminLecturerInviteR GET POST
|
||||
!/users/functionary-invite/new AdminNewFunctionaryInviteR GET POST
|
||||
!/users/functionary-invite AdminFunctionaryInviteR GET POST
|
||||
/admin AdminR GET
|
||||
/admin/features AdminFeaturesR GET POST
|
||||
/admin/test AdminTestR GET POST
|
||||
@ -78,8 +78,10 @@
|
||||
!/term/#TermId TermCourseListR GET !free
|
||||
!/term/#TermId/#SchoolId TermSchoolCourseListR GET !free
|
||||
|
||||
/school SchoolListR GET !development
|
||||
/school/#SchoolId SchoolShowR GET !development
|
||||
/school SchoolListR GET
|
||||
!/school/new SchoolNewR GET POST
|
||||
/school/#SchoolId SchoolR:
|
||||
/ SchoolEditR GET POST
|
||||
|
||||
/allocation/ AllocationListR GET !free
|
||||
/allocation/#TermId/#SchoolId/#AllocationShorthand AllocationR:
|
||||
|
||||
@ -2,14 +2,15 @@ module Auth.LDAP
|
||||
( apLdap
|
||||
, campusLogin
|
||||
, CampusUserException(..)
|
||||
, campusUser
|
||||
, campusUser, campusUser'
|
||||
, CampusMessage(..)
|
||||
, ldapUserPrincipalName, ldapUserEmail, ldapUserDisplayName
|
||||
, ldapUserMatriculation, ldapUserFirstName, ldapUserSurname
|
||||
, ldapUserTitle, ldapUserStudyFeatures, ldapUserFieldName
|
||||
, ldapUserSchoolAssociation
|
||||
) where
|
||||
|
||||
import Import.NoFoundation hiding (userEmail, userDisplayName)
|
||||
import Import.NoFoundation
|
||||
import Network.Connection
|
||||
|
||||
import Data.CaseInsensitive (CI)
|
||||
@ -58,16 +59,17 @@ findUser LdapConf{..} ldap ident retAttrs = fromMaybe [] <$> findM (assertM (not
|
||||
, Ldap.derefAliases Ldap.DerefAlways
|
||||
]
|
||||
|
||||
ldapUserPrincipalName, ldapUserEmail, ldapUserDisplayName, ldapUserMatriculation, ldapUserFirstName, ldapUserSurname, ldapUserTitle, ldapUserStudyFeatures, ldapUserFieldName :: Ldap.Attr
|
||||
ldapUserPrincipalName = Ldap.Attr "userPrincipalName"
|
||||
ldapUserEmail = Ldap.Attr "mail"
|
||||
ldapUserDisplayName = Ldap.Attr "displayName"
|
||||
ldapUserMatriculation = Ldap.Attr "LMU-Stud-Matrikelnummer"
|
||||
ldapUserFirstName = Ldap.Attr "givenName"
|
||||
ldapUserSurname = Ldap.Attr "sn"
|
||||
ldapUserTitle = Ldap.Attr "title"
|
||||
ldapUserStudyFeatures = Ldap.Attr "dfnEduPersonFeaturesOfStudy"
|
||||
ldapUserFieldName = Ldap.Attr "dfnEduPersonFieldOfStudyString"
|
||||
ldapUserPrincipalName, ldapUserEmail, ldapUserDisplayName, ldapUserMatriculation, ldapUserFirstName, ldapUserSurname, ldapUserTitle, ldapUserStudyFeatures, ldapUserFieldName, ldapUserSchoolAssociation :: Ldap.Attr
|
||||
ldapUserPrincipalName = Ldap.Attr "userPrincipalName"
|
||||
ldapUserEmail = Ldap.Attr "mail"
|
||||
ldapUserDisplayName = Ldap.Attr "displayName"
|
||||
ldapUserMatriculation = Ldap.Attr "LMU-Stud-Matrikelnummer"
|
||||
ldapUserFirstName = Ldap.Attr "givenName"
|
||||
ldapUserSurname = Ldap.Attr "sn"
|
||||
ldapUserTitle = Ldap.Attr "title"
|
||||
ldapUserStudyFeatures = Ldap.Attr "dfnEduPersonFeaturesOfStudy"
|
||||
ldapUserFieldName = Ldap.Attr "dfnEduPersonFieldOfStudyString"
|
||||
ldapUserSchoolAssociation = Ldap.Attr "LMU-IFI-eduPersonOrgUnitDNString"
|
||||
|
||||
|
||||
data CampusUserException = CampusUserLdapError LdapPoolError
|
||||
@ -80,6 +82,8 @@ data CampusUserException = CampusUserLdapError LdapPoolError
|
||||
|
||||
instance Exception CampusUserException
|
||||
|
||||
makePrisms ''CampusUserException
|
||||
|
||||
campusUser :: (MonadBaseControl IO m, MonadThrow m, MonadIO 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
|
||||
@ -105,6 +109,10 @@ campusUser conf@LdapConf{..} pool Creds{..} = liftIO . (`catches` errHandlers) $
|
||||
, Exc.Handler $ \(HostCannotConnect host excs) -> throwM $ CampusUserHostCannotConnect host excs
|
||||
]
|
||||
|
||||
campusUser' :: (MonadBaseControl IO m, MonadCatch m, MonadIO 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) [])
|
||||
|
||||
|
||||
campusForm :: ( RenderMessage site FormMessage
|
||||
, RenderMessage site CampusMessage
|
||||
|
||||
@ -15,6 +15,7 @@ import qualified Data.Binary as Binary
|
||||
|
||||
import Data.Time.Clock
|
||||
import Data.Time.Calendar.Instances ()
|
||||
import Web.PathPieces
|
||||
|
||||
|
||||
instance Hashable DiffTime where
|
||||
@ -31,6 +32,10 @@ instance PersistFieldSql NominalDiffTime where
|
||||
deriving instance Generic UTCTime
|
||||
instance Hashable UTCTime
|
||||
|
||||
instance PathPiece UTCTime where
|
||||
toPathPiece = pack . formatTime defaultTimeLocale "%0Y-%m-%dT%H:%M:%S%Q%z"
|
||||
fromPathPiece = parseTimeM False defaultTimeLocale "%Y-%m-%dT%H:%M:%S%Q%z" . unpack
|
||||
|
||||
|
||||
instance Binary DiffTime where
|
||||
get = fromRational <$> Binary.get
|
||||
|
||||
@ -64,6 +64,8 @@ false = E.val False
|
||||
isJust :: (E.Esqueleto query expr backend, PersistField typ) => expr (E.Value (Maybe typ)) -> expr (E.Value Bool)
|
||||
isJust = E.not_ . E.isNothing
|
||||
|
||||
infix 4 `isInfixOf`, `hasInfix`
|
||||
|
||||
-- | Check if the first string is contained in the text derived from the second argument
|
||||
isInfixOf :: ( E.Esqueleto query expr backend
|
||||
, E.SqlString s1
|
||||
|
||||
@ -21,3 +21,6 @@ instance PersistEntity record => Binary (Key record) where
|
||||
put = Binary.put . toPersistValue
|
||||
putList = Binary.putList . map toPersistValue
|
||||
get = either (fail . unpack) return . fromPersistValue =<< Binary.get
|
||||
|
||||
instance PersistEntity record => NFData (Key record) where
|
||||
rnf = rnf . keyToValues
|
||||
|
||||
@ -20,3 +20,4 @@ deriving instance Typeable PersistValue
|
||||
|
||||
instance Hashable PersistValue
|
||||
instance Binary PersistValue
|
||||
instance NFData PersistValue
|
||||
|
||||
@ -65,6 +65,7 @@ import Control.Monad.Memo.Class (MonadMemo(..), for4)
|
||||
import qualified Control.Monad.Catch as C
|
||||
|
||||
import Handler.Utils.StudyFeatures
|
||||
import Handler.Utils.SchoolLdap
|
||||
import Utils.Form
|
||||
import Utils.Sheet
|
||||
import Utils.SystemMessage
|
||||
@ -152,6 +153,7 @@ deriving instance Generic TutorialR
|
||||
deriving instance Generic ExamR
|
||||
deriving instance Generic CourseApplicationR
|
||||
deriving instance Generic AllocationR
|
||||
deriving instance Generic SchoolR
|
||||
deriving instance Generic (Route UniWorX)
|
||||
|
||||
-- | Convenient Type Synonyms:
|
||||
@ -310,6 +312,7 @@ embedRenderMessage ''UniWorX ''SubmissionModeDescr
|
||||
embedRenderMessage ''UniWorX ''UploadModeDescr id
|
||||
embedRenderMessage ''UniWorX ''SecretJSONFieldException id
|
||||
embedRenderMessage ''UniWorX ''AFormMessage $ concat . drop 2 . splitCamel
|
||||
embedRenderMessage ''UniWorX ''SchoolFunction id
|
||||
|
||||
embedRenderMessage ''UniWorX ''AuthenticationMode id
|
||||
|
||||
@ -606,8 +609,9 @@ tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of
|
||||
CourseR tid ssh csh _ -> $cachedHereBinary (mAuthId, tid, ssh, csh) . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
isAdmin <- lift . E.selectExists . E.from $ \(course `E.InnerJoin` userAdmin) -> do
|
||||
E.on $ course E.^. CourseSchool E.==. userAdmin E.^. UserAdminSchool
|
||||
E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val authId
|
||||
E.on $ course E.^. CourseSchool E.==. userAdmin E.^. UserFunctionSchool
|
||||
E.where_ $ userAdmin E.^. UserFunctionUser E.==. E.val authId
|
||||
E.&&. userAdmin E.^. UserFunctionFunction E.==. E.val SchoolAdmin
|
||||
E.&&. course E.^. CourseTerm E.==. E.val tid
|
||||
E.&&. course E.^. CourseSchool E.==. E.val ssh
|
||||
E.&&. course E.^. CourseShorthand E.==. E.val csh
|
||||
@ -617,17 +621,24 @@ tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of
|
||||
AllocationR tid ssh ash _ -> $cachedHereBinary (mAuthId, tid, ssh, ash) . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
isAdmin <- lift . E.selectExists . E.from $ \(allocation `E.InnerJoin` userAdmin) -> do
|
||||
E.on $ allocation E.^. AllocationSchool E.==. userAdmin E.^. UserAdminSchool
|
||||
E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val authId
|
||||
E.on $ allocation E.^. AllocationSchool E.==. userAdmin E.^. UserFunctionSchool
|
||||
E.where_ $ userAdmin E.^. UserFunctionUser E.==. E.val authId
|
||||
E.&&. userAdmin E.^. UserFunctionFunction E.==. E.val SchoolAdmin
|
||||
E.&&. allocation E.^. AllocationTerm E.==. E.val tid
|
||||
E.&&. allocation E.^. AllocationSchool E.==. E.val ssh
|
||||
E.&&. allocation E.^. AllocationShorthand E.==. E.val ash
|
||||
guardMExceptT isAdmin (unauthorizedI MsgUnauthorizedSchoolAdmin)
|
||||
return Authorized
|
||||
-- Schools: access only to school admins
|
||||
SchoolR ssh _ -> $cachedHereBinary (mAuthId, ssh) . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
isAdmin <- lift $ exists [UserFunctionUser ==. authId, UserFunctionFunction ==. SchoolAdmin]
|
||||
guardMExceptT isAdmin (unauthorizedI MsgUnauthorizedSchoolAdmin)
|
||||
return Authorized
|
||||
-- other routes: access to any admin is granted here
|
||||
_other -> $cachedHereBinary mAuthId . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
adrights <- lift $ selectFirst [UserAdminUser ==. authId] []
|
||||
adrights <- lift $ selectFirst [UserFunctionUser ==. authId, UserFunctionFunction ==. SchoolAdmin] []
|
||||
guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedSiteAdmin)
|
||||
return Authorized
|
||||
tagAccessPredicate AuthToken = APDB $ \mAuthId route isWrite -> exceptT return return $
|
||||
@ -636,10 +647,9 @@ tagAccessPredicate AuthNoEscalation = APDB $ \mAuthId route _ -> case route of
|
||||
AdminHijackUserR cID -> exceptT return return $ do
|
||||
myUid <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
uid <- decrypt cID
|
||||
otherSchoolsAdmin <- lift $ Set.fromList . map (userAdminSchool . entityVal) <$> selectList [UserAdminUser ==. uid] []
|
||||
otherSchoolsLecturer <- lift $ Set.fromList . map (userLecturerSchool . entityVal) <$> selectList [UserLecturerUser ==. uid] []
|
||||
mySchools <- lift $ Set.fromList . map (userAdminSchool . entityVal) <$> selectList [UserAdminUser ==. myUid] []
|
||||
guardMExceptT ((otherSchoolsAdmin `Set.union` otherSchoolsLecturer) `Set.isSubsetOf` mySchools) (unauthorizedI MsgUnauthorizedAdminEscalation)
|
||||
otherSchoolsFunctions <- lift $ Set.fromList . map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid] []
|
||||
mySchools <- lift $ Set.fromList . map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. myUid, UserFunctionFunction ==. SchoolAdmin] []
|
||||
guardMExceptT (otherSchoolsFunctions `Set.isSubsetOf` mySchools) (unauthorizedI MsgUnauthorizedAdminEscalation)
|
||||
return Authorized
|
||||
r -> $unsupportedAuthPredicate AuthNoEscalation r
|
||||
tagAccessPredicate AuthDeprecated = APHandler $ \_ r _ -> do
|
||||
@ -680,7 +690,7 @@ tagAccessPredicate AuthLecturer = APDB $ \mAuthId route _ -> case route of
|
||||
-- lecturer for any school will do
|
||||
_ -> $cachedHereBinary mAuthId . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
void . maybeMExceptT (unauthorizedI MsgUnauthorizedSchoolLecturer) $ selectFirst [UserLecturerUser ==. authId] []
|
||||
void . maybeMExceptT (unauthorizedI MsgUnauthorizedSchoolLecturer) $ selectFirst [UserFunctionUser ==. authId, UserFunctionFunction ==. SchoolLecturer] []
|
||||
return Authorized
|
||||
tagAccessPredicate AuthCorrector = APDB $ \mAuthId route _ -> exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
@ -734,6 +744,20 @@ tagAccessPredicate AuthTutor = APDB $ \mAuthId route _ -> exceptT return return
|
||||
guardMExceptT (not $ Map.null resMap) (unauthorizedI MsgUnauthorizedTutor)
|
||||
return Authorized
|
||||
tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
|
||||
CApplicationR tid ssh csh _ _ -> maybeT (unauthorizedI MsgUnauthorizedApplicationTime) $ do
|
||||
course <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
allocationCourse <- $cachedHereBinary course . lift . getBy $ UniqueAllocationCourse course
|
||||
allocation <- for allocationCourse $ \(Entity _ AllocationCourse{..}) -> $cachedHereBinary allocationCourseAllocation . MaybeT $ get allocationCourseAllocation
|
||||
|
||||
case allocation of
|
||||
Nothing -> return ()
|
||||
Just Allocation{..} -> do
|
||||
cTime <- liftIO getCurrentTime
|
||||
guard $ NTop allocationStaffAllocationFrom <= NTop (Just cTime)
|
||||
guard $ NTop (Just cTime) <= NTop allocationStaffAllocationTo
|
||||
|
||||
return Authorized
|
||||
|
||||
CExamR tid ssh csh examn subRoute -> maybeT (unauthorizedI MsgUnauthorizedExamTime) $ do
|
||||
course <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity eId Exam{..} <- $cachedHereBinary (course, examn) . MaybeT . getBy $ UniqueExam course examn
|
||||
@ -1679,7 +1703,6 @@ siteLayout' headingOverride widget = do
|
||||
addStylesheet $ StaticR bundles_css_vendor_css
|
||||
addStylesheet $ StaticR bundles_css_main_css
|
||||
-- JavaScript
|
||||
addScript $ StaticR bundles_js_polyfills_js
|
||||
addScript $ StaticR bundles_js_vendor_js
|
||||
addScript $ StaticR bundles_js_main_js
|
||||
toWidget $(juliusFile "templates/i18n.julius")
|
||||
@ -1723,6 +1746,10 @@ instance YesodBreadcrumbs UniWorX where
|
||||
breadcrumb AdminFeaturesR = return ("Test" , Just AdminR)
|
||||
breadcrumb AdminTestR = return ("Test" , Just AdminR)
|
||||
breadcrumb AdminErrMsgR = return ("Test" , Just AdminR)
|
||||
|
||||
breadcrumb SchoolListR = return ("Institute" , Just AdminR)
|
||||
breadcrumb (SchoolR ssh SchoolEditR) = return (original (unSchoolKey ssh), Just SchoolListR)
|
||||
breadcrumb SchoolNewR = return ("Neu" , Just SchoolListR)
|
||||
|
||||
breadcrumb InfoR = return ("Information" , Nothing)
|
||||
breadcrumb InfoLecturerR = return ("Veranstalter" , Just InfoR)
|
||||
@ -1996,6 +2023,14 @@ pageActions (HomeR) =
|
||||
]
|
||||
pageActions (AdminR) =
|
||||
[ MenuItem
|
||||
{ menuItemType = PageActionPrime
|
||||
, menuItemLabel = MsgMenuSchoolList
|
||||
, menuItemIcon = Nothing
|
||||
, menuItemRoute = SomeRoute SchoolListR
|
||||
, menuItemModal = False
|
||||
, menuItemAccessCallback' = return True
|
||||
}
|
||||
, MenuItem
|
||||
{ menuItemType = PageActionPrime
|
||||
, menuItemLabel = MsgAdminFeaturesHeading
|
||||
, menuItemIcon = Nothing
|
||||
@ -2028,12 +2063,22 @@ pageActions (AdminR) =
|
||||
, menuItemAccessCallback' = return True
|
||||
}
|
||||
]
|
||||
pageActions (SchoolListR) =
|
||||
[ MenuItem
|
||||
{ menuItemType = PageActionPrime
|
||||
, menuItemLabel = MsgMenuSchoolNew
|
||||
, menuItemIcon = Nothing
|
||||
, menuItemRoute = SomeRoute SchoolNewR
|
||||
, menuItemModal = False
|
||||
, menuItemAccessCallback' = return True
|
||||
}
|
||||
]
|
||||
pageActions (UsersR) =
|
||||
[ MenuItem
|
||||
{ menuItemType = PageActionPrime
|
||||
, menuItemLabel = MsgMenuLecturerInvite
|
||||
, menuItemIcon = Nothing
|
||||
, menuItemRoute = SomeRoute AdminNewLecturerInviteR
|
||||
, menuItemRoute = SomeRoute AdminNewFunctionaryInviteR
|
||||
, menuItemModal = True
|
||||
, menuItemAccessCallback' = return True
|
||||
}
|
||||
@ -2861,13 +2906,6 @@ pageHeading (TermSchoolCourseListR tid ssh)
|
||||
School{schoolName=school} <- handlerToWidget $ runDB $ get404 ssh
|
||||
i18nHeading $ MsgTermSchoolCourseListHeading tid school
|
||||
|
||||
pageHeading (SchoolListR)
|
||||
= Just $ i18nHeading MsgSchoolListHeading
|
||||
pageHeading (SchoolShowR ssh)
|
||||
= Just $ do
|
||||
School{schoolName=school} <- handlerToWidget $ runDB $ get404 ssh
|
||||
i18nHeading $ MsgSchoolHeading school
|
||||
|
||||
pageHeading (CourseListR)
|
||||
= Just $ i18nHeading $ MsgCourseListTitle
|
||||
pageHeading CourseNewR
|
||||
@ -3006,7 +3044,8 @@ data CampusUserConversionException
|
||||
| CampusUserInvalidSurname
|
||||
| CampusUserInvalidTitle
|
||||
| CampusUserInvalidMatriculation
|
||||
| CampusUserInvalidFeaturesOfStudy String
|
||||
| CampusUserInvalidFeaturesOfStudy Text
|
||||
| CampusUserInvalidAssociatedSchools Text
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
instance Exception CampusUserConversionException
|
||||
|
||||
@ -3084,12 +3123,15 @@ upsertCampusUser ldapData Creds{..} = do
|
||||
, userNotificationSettings = def
|
||||
, userMailLanguages = def
|
||||
, userTokensIssuedAfter = Nothing
|
||||
, userCreated = now
|
||||
, userLastLdapSynchronisation = Just now
|
||||
, ..
|
||||
}
|
||||
userUpdate = [ UserMatrikelnummer =. userMatrikelnummer
|
||||
, UserDisplayName =. userDisplayName
|
||||
, UserSurname =. userSurname
|
||||
, UserEmail =. userEmail
|
||||
userUpdate = [ UserMatrikelnummer =. userMatrikelnummer
|
||||
, UserDisplayName =. userDisplayName
|
||||
, UserSurname =. userSurname
|
||||
, UserEmail =. userEmail
|
||||
, UserLastLdapSynchronisation =. Just now
|
||||
] ++
|
||||
[ UserLastAuthentication =. Just now | not isDummy ]
|
||||
|
||||
@ -3111,7 +3153,7 @@ upsertCampusUser ldapData Creds{..} = do
|
||||
Right str <- return $ Text.decodeUtf8' v'
|
||||
return str
|
||||
|
||||
fs <- either (throwM . CampusUserInvalidFeaturesOfStudy . unpack) return userStudyFeatures
|
||||
fs <- either (throwM . CampusUserInvalidFeaturesOfStudy . tshow) return userStudyFeatures
|
||||
|
||||
let
|
||||
studyTermCandidates = Set.fromList $ do
|
||||
@ -3141,13 +3183,56 @@ upsertCampusUser ldapData Creds{..} = do
|
||||
insertMaybe studyFeaturesDegree $ StudyDegree (unStudyDegreeKey studyFeaturesDegree) Nothing Nothing
|
||||
insertMaybe studyFeaturesField $ StudyTerms (unStudyTermsKey studyFeaturesField) Nothing Nothing
|
||||
void $ upsert f [StudyFeaturesUpdated =. now, StudyFeaturesValid =. True]
|
||||
associateUserSchoolsByTerms userId
|
||||
|
||||
let
|
||||
userAssociatedSchools = fmap concat $ forM userAssociatedSchools' parseLdapSchools
|
||||
userAssociatedSchools' = do
|
||||
(k, v) <- ldapData
|
||||
guard $ k == ldapUserSchoolAssociation
|
||||
v' <- v
|
||||
Right str <- return $ Text.decodeUtf8' v'
|
||||
return str
|
||||
|
||||
ss <- either (throwM . CampusUserInvalidAssociatedSchools . tshow) return userAssociatedSchools
|
||||
|
||||
forM_ ss $ \frag -> void . runMaybeT $ do
|
||||
let
|
||||
exactMatch = MaybeT . getBy $ UniqueOrgUnit frag
|
||||
infixMatch = (hoistMaybe . preview _head =<<) . lift . E.select . E.from $ \schoolLdap -> do
|
||||
E.where_ $ E.val frag `E.isInfixOf` schoolLdap E.^. SchoolLdapOrgUnit
|
||||
E.&&. E.not_ (E.isNothing $ schoolLdap E.^. SchoolLdapSchool)
|
||||
return schoolLdap
|
||||
Entity _ SchoolLdap{..} <- exactMatch <|> infixMatch
|
||||
ssh <- hoistMaybe schoolLdapSchool
|
||||
|
||||
lift . void $ insertUnique UserSchool
|
||||
{ userSchoolUser = userId
|
||||
, userSchoolSchool = ssh
|
||||
, userSchoolIsOptOut = False
|
||||
}
|
||||
|
||||
forM_ ss $ void . insertUnique . SchoolLdap Nothing
|
||||
|
||||
return user
|
||||
where
|
||||
insertMaybe key val = get key >>= maybe (insert_ val) (\_ -> return ())
|
||||
isDummy = credsPlugin == "dummy"
|
||||
isPWHash = credsPlugin == "PWHash"
|
||||
|
||||
associateUserSchoolsByTerms :: UserId -> DB ()
|
||||
associateUserSchoolsByTerms uid = do
|
||||
sfs <- selectList [StudyFeaturesUser ==. uid] []
|
||||
|
||||
forM_ sfs $ \(Entity _ StudyFeatures{..}) -> do
|
||||
schoolTerms <- selectList [SchoolTermsTerms ==. studyFeaturesField] []
|
||||
forM_ schoolTerms $ \(Entity _ SchoolTerms{..}) ->
|
||||
void $ insertUnique UserSchool
|
||||
{ userSchoolUser = uid
|
||||
, userSchoolSchool = schoolTermsSchool
|
||||
, userSchoolIsOptOut = False
|
||||
}
|
||||
|
||||
|
||||
instance YesodAuth UniWorX where
|
||||
type AuthId UniWorX = UserId
|
||||
@ -3209,6 +3294,11 @@ instance YesodAuth UniWorX where
|
||||
|
||||
acceptExisting = do
|
||||
res <- maybe (UserError $ IdentifierNotFound credsIdent) (Authenticated . entityKey) <$> getBy uAuth
|
||||
case res of
|
||||
Authenticated uid
|
||||
-> associateUserSchoolsByTerms uid
|
||||
_other
|
||||
-> return ()
|
||||
case res of
|
||||
Authenticated uid
|
||||
| not isDummy -> res <$ update uid [ UserLastAuthentication =. Just now ]
|
||||
|
||||
@ -289,6 +289,7 @@ instance Button UniWorX ButtonAdminStudyTerms where
|
||||
getAdminFeaturesR, postAdminFeaturesR :: Handler Html
|
||||
getAdminFeaturesR = postAdminFeaturesR
|
||||
postAdminFeaturesR = do
|
||||
uid <- requireAuthId
|
||||
((btnResult, btnWdgt), btnEnctype) <- runFormPost $ identifyForm ("infer-button" :: Text) (buttonForm :: Form ButtonAdminStudyTerms)
|
||||
let btnForm = wrapForm btnWdgt def
|
||||
{ formAction = Just $ SomeRoute AdminFeaturesR
|
||||
@ -322,11 +323,21 @@ postAdminFeaturesR = do
|
||||
newStudyTermKeys <- fromMaybe [] <$> lookupSessionJson SessionNewStudyTerms
|
||||
( (degreeResult,degreeTable)
|
||||
, (studyTermsResult,studytermsTable)
|
||||
, ((), candidateTable)) <- runDB $ (,,)
|
||||
<$> mkDegreeTable
|
||||
<*> mkStudytermsTable (Set.fromList newStudyTermKeys)
|
||||
(Set.fromList $ map entityKey infConflicts)
|
||||
<*> mkCandidateTable
|
||||
, ((), candidateTable)
|
||||
, userSchools) <- runDB $ do
|
||||
schools <- E.select . E.from $ \school -> do
|
||||
E.where_ . E.exists . E.from $ \schoolFunction ->
|
||||
E.where_ $ schoolFunction E.^. UserFunctionSchool E.==. school E.^. SchoolId
|
||||
E.&&. schoolFunction E.^. UserFunctionUser E.==. E.val uid
|
||||
E.&&. schoolFunction E.^. UserFunctionFunction E.==. E.val SchoolAdmin
|
||||
return school
|
||||
(,,,)
|
||||
<$> mkDegreeTable
|
||||
<*> mkStudytermsTable (Set.fromList newStudyTermKeys)
|
||||
(Set.fromList $ map entityKey infConflicts)
|
||||
(Set.fromList schools)
|
||||
<*> mkCandidateTable
|
||||
<*> pure schools
|
||||
|
||||
-- This needs to happen after calls to `dbTable` so they can short-circuit correctly
|
||||
unless (null infConflicts) $ addMessageI Warning MsgStudyFeatureConflict
|
||||
@ -341,12 +352,16 @@ postAdminFeaturesR = do
|
||||
void . runDB $ Map.traverseWithKey updateDegree res
|
||||
addMessageI Success MsgStudyDegreeChangeSuccess
|
||||
|
||||
let studyTermsResult' :: FormResult (Map (Key StudyTerms) (Maybe Text, Maybe Text))
|
||||
let studyTermsResult' :: FormResult (Map (Key StudyTerms) (Maybe Text, Maybe Text, Set SchoolId))
|
||||
studyTermsResult' = studyTermsResult <&> getDBFormResult
|
||||
(\row -> ( row ^. _dbrOutput . _entityVal . _studyTermsName
|
||||
, row ^. _dbrOutput . _entityVal . _studyTermsShorthand
|
||||
(\row -> ( row ^. _dbrOutput . _1 . _entityVal . _studyTermsName
|
||||
, row ^. _dbrOutput . _1 . _entityVal . _studyTermsShorthand
|
||||
, row ^. _dbrOutput . _2
|
||||
))
|
||||
updateStudyTerms studyTermsKey (name,short) = update studyTermsKey [StudyTermsName =. name, StudyTermsShorthand =. short]
|
||||
updateStudyTerms studyTermsKey (name,short,schools) = do
|
||||
update studyTermsKey [StudyTermsName =. name, StudyTermsShorthand =. short]
|
||||
forM_ schools $ \ssh -> void . insertUnique $ SchoolTerms ssh studyTermsKey
|
||||
deleteWhere [SchoolTermsTerms ==. studyTermsKey, SchoolTermsSchool /<-. Set.toList schools, SchoolTermsSchool <-. toListOf (folded . _entityKey) userSchools]
|
||||
formResult studyTermsResult' $ \res -> do
|
||||
void . runDB $ Map.traverseWithKey updateStudyTerms res
|
||||
addMessageI Success MsgStudyTermsChangeSuccess
|
||||
@ -355,24 +370,41 @@ postAdminFeaturesR = do
|
||||
setTitleI MsgAdminFeaturesHeading
|
||||
$(widgetFile "adminFeatures")
|
||||
where
|
||||
textInputCell lensRes lensDefault = formCell id (return . view (_dbrOutput . _entityKey))
|
||||
textInputCell :: Ord i
|
||||
=> Lens' a (Maybe Text)
|
||||
-> Getter (DBRow r) (Maybe Text)
|
||||
-> Getter (DBRow r) i
|
||||
-> DBRow r
|
||||
-> DBCell (MForm (HandlerT UniWorX IO)) (FormResult (DBFormResult i a (DBRow r)))
|
||||
textInputCell lensRes lensDefault lensIndex = formCell id (return . view lensIndex)
|
||||
(\row _mkUnique -> (\(res,fieldView) -> (set lensRes . assertM (not . Text.null) <$> res, fvInput fieldView))
|
||||
<$> mopt textField "" (Just $ row ^. lensDefault)
|
||||
)
|
||||
|
||||
checkboxCell :: Ord i
|
||||
=> Lens' a Bool
|
||||
-> Getter (DBRow r) Bool
|
||||
-> Getter (DBRow r) i
|
||||
-> DBRow r
|
||||
-> DBCell (MForm (HandlerT UniWorX IO)) (FormResult (DBFormResult i a (DBRow r)))
|
||||
checkboxCell lensRes lensDefault lensIndex = formCell id (return . view lensIndex)
|
||||
( \row _mkUnique -> (\(res, fieldView) -> (set lensRes <$> res, fvInput fieldView))
|
||||
<$> mpopt checkBoxField "" (Just $ row ^. lensDefault)
|
||||
)
|
||||
|
||||
|
||||
mkDegreeTable :: DB (FormResult (DBFormResult (Key StudyDegree) (Maybe Text, Maybe Text) (DBRow (Entity StudyDegree))), Widget)
|
||||
mkDegreeTable =
|
||||
let dbtIdent = "admin-studydegrees" :: Text
|
||||
dbtStyle = def
|
||||
dbtSQLQuery :: E.SqlExpr (Entity StudyDegree) -> E.SqlQuery ( E.SqlExpr (Entity StudyDegree))
|
||||
dbtSQLQuery :: E.SqlExpr (Entity StudyDegree) -> E.SqlQuery (E.SqlExpr (Entity StudyDegree))
|
||||
dbtSQLQuery = return
|
||||
dbtRowKey = (E.^. StudyDegreeKey)
|
||||
dbtProj = return
|
||||
dbtColonnade = formColonnade $ mconcat
|
||||
[ sortable (Just "key") (i18nCell MsgGenericKey) (numCell . view (_dbrOutput . _entityVal . _studyDegreeKey))
|
||||
, sortable (Just "name") (i18nCell MsgDegreeName) (textInputCell _1 (_dbrOutput . _entityVal . _studyDegreeName))
|
||||
, sortable (Just "short") (i18nCell MsgDegreeShort) (textInputCell _2 (_dbrOutput . _entityVal . _studyDegreeShorthand))
|
||||
, sortable (Just "name") (i18nCell MsgDegreeName) (textInputCell _1 (_dbrOutput . _entityVal . _studyDegreeName) (_dbrOutput . _entityKey))
|
||||
, sortable (Just "short") (i18nCell MsgDegreeShort) (textInputCell _2 (_dbrOutput . _entityVal . _studyDegreeShorthand) (_dbrOutput . _entityKey))
|
||||
, dbRow
|
||||
]
|
||||
dbtSorting = Map.fromList
|
||||
@ -390,20 +422,29 @@ postAdminFeaturesR = do
|
||||
dbtCsvDecode = Nothing
|
||||
in dbTable psValidator DBTable{..}
|
||||
|
||||
mkStudytermsTable :: Set (Key StudyTerms) -> Set (Key StudyTerms) -> DB (FormResult (DBFormResult (Key StudyTerms) (Maybe Text, Maybe Text) (DBRow (Entity StudyTerms))), Widget)
|
||||
mkStudytermsTable newKeys badKeys =
|
||||
mkStudytermsTable :: Set (Key StudyTerms) -> Set (Key StudyTerms) -> Set (Entity School) -> DB (FormResult (DBFormResult (Key StudyTerms) (Maybe Text, Maybe Text, Set SchoolId) (DBRow (Entity StudyTerms, Set SchoolId))), Widget)
|
||||
mkStudytermsTable newKeys badKeys schools =
|
||||
let dbtIdent = "admin-studyterms" :: Text
|
||||
dbtStyle = def
|
||||
dbtSQLQuery :: E.SqlExpr (Entity StudyTerms) -> E.SqlQuery ( E.SqlExpr (Entity StudyTerms))
|
||||
dbtSQLQuery :: E.SqlExpr (Entity StudyTerms) -> E.SqlQuery (E.SqlExpr (Entity StudyTerms))
|
||||
dbtSQLQuery = return
|
||||
dbtRowKey = (E.^. StudyTermsKey)
|
||||
dbtProj = return
|
||||
dbtProj field = do
|
||||
fieldSchools <- fmap (setOf $ folded . _Value) . lift . E.select . E.from $ \school -> do
|
||||
E.where_ . E.exists . E.from $ \schoolTerms ->
|
||||
E.where_ $ schoolTerms E.^. SchoolTermsSchool E.==. school E.^. SchoolId
|
||||
E.&&. schoolTerms E.^. SchoolTermsTerms E.==. E.val (field ^. _dbrOutput . _entityKey)
|
||||
E.where_ $ school E.^. SchoolShorthand `E.in_` E.valList (toListOf (folded . _entityKey . _SchoolId) schools)
|
||||
return $ school E.^. SchoolId
|
||||
return $ field & _dbrOutput %~ (, fieldSchools)
|
||||
dbtColonnade = formColonnade $ mconcat
|
||||
[ sortable (Just "key") (i18nCell MsgGenericKey) (numCell . view (_dbrOutput . _entityVal . _studyTermsKey))
|
||||
, sortable (Just "isnew") (i18nCell MsgGenericIsNew) (isNewCell . flip Set.member newKeys . view (_dbrOutput . _entityKey))
|
||||
, sortable (Just "isbad") (i18nCell MsgGenericHasConflict) (isBadCell . flip Set.member badKeys . view (_dbrOutput . _entityKey))
|
||||
, sortable (Just "name") (i18nCell MsgStudyTermsName) (textInputCell _1 (_dbrOutput . _entityVal . _studyTermsName))
|
||||
, sortable (Just "short") (i18nCell MsgStudyTermsShort) (textInputCell _2 (_dbrOutput . _entityVal . _studyTermsShorthand))
|
||||
[ sortable (Just "key") (i18nCell MsgGenericKey) (numCell . view (_dbrOutput . _1 . _entityVal . _studyTermsKey))
|
||||
, sortable (Just "isnew") (i18nCell MsgGenericIsNew) (isNewCell . flip Set.member newKeys . view (_dbrOutput . _1 . _entityKey))
|
||||
, sortable (Just "isbad") (i18nCell MsgGenericHasConflict) (isBadCell . flip Set.member badKeys . view (_dbrOutput . _1 . _entityKey))
|
||||
, sortable (Just "name") (i18nCell MsgStudyTermsName) (textInputCell _1 (_dbrOutput . _1 . _entityVal . _studyTermsName) (_dbrOutput . _1 . _entityKey))
|
||||
, sortable (Just "short") (i18nCell MsgStudyTermsShort) (textInputCell _2 (_dbrOutput . _1 . _entityVal . _studyTermsShorthand) (_dbrOutput . _1 . _entityKey))
|
||||
, flip foldMap schools $ \(Entity ssh School{schoolName}) ->
|
||||
sortable Nothing (cell $ toWidget schoolName) (checkboxCell (_3 . at ssh . _Maybe) (_dbrOutput . _2 . at ssh . _Maybe) (_dbrOutput . _1 . _entityKey))
|
||||
, dbRow
|
||||
]
|
||||
dbtSorting = Map.fromList
|
||||
|
||||
@ -5,12 +5,13 @@ module Handler.Allocation.List
|
||||
import Import
|
||||
|
||||
import qualified Database.Esqueleto as E
|
||||
import qualified Database.Esqueleto.Utils as E
|
||||
import Handler.Utils.Table.Columns
|
||||
import Handler.Utils.Table.Pagination
|
||||
|
||||
|
||||
type AllocationTableExpr = E.SqlExpr (Entity Allocation)
|
||||
type AllocationTableData = DBRow (Entity Allocation)
|
||||
type AllocationTableData = DBRow (Entity Allocation, Natural, Natural)
|
||||
|
||||
allocationListIdent :: Text
|
||||
allocationListIdent = "allocations"
|
||||
@ -18,8 +19,34 @@ allocationListIdent = "allocations"
|
||||
queryAllocation :: Getter AllocationTableExpr (E.SqlExpr (Entity Allocation))
|
||||
queryAllocation = id
|
||||
|
||||
|
||||
countCourses :: (Num n, PersistField n)
|
||||
=> (E.SqlExpr (Entity AllocationCourse) -> E.SqlExpr (E.Value Bool))
|
||||
-> E.SqlExpr (Entity Allocation)
|
||||
-> E.SqlExpr (E.Value n)
|
||||
countCourses addWhere allocation = E.sub_select . E.from $ \allocationCourse -> do
|
||||
E.where_ $ allocationCourse E.^. AllocationCourseAllocation E.==. allocation E.^. AllocationId
|
||||
E.&&. addWhere allocationCourse
|
||||
return E.countRows
|
||||
|
||||
queryAvailable :: Getter AllocationTableExpr (E.SqlExpr (E.Value Natural))
|
||||
queryAvailable = queryAllocation . to (countCourses $ const E.true)
|
||||
|
||||
queryApplied :: UserId -> Getter AllocationTableExpr (E.SqlExpr (E.Value Natural))
|
||||
queryApplied uid = queryAllocation . to (\allocation -> countCourses (addWhere allocation) allocation)
|
||||
where
|
||||
addWhere allocation allocationCourse
|
||||
= E.exists . E.from $ \courseApplication ->
|
||||
E.where_ $ courseApplication E.^. CourseApplicationCourse E.==. allocationCourse E.^. AllocationCourseCourse
|
||||
E.&&. courseApplication E.^. CourseApplicationAllocation E.==. E.just (allocation E.^. AllocationId)
|
||||
E.&&. courseApplication E.^. CourseApplicationUser E.==. E.val uid
|
||||
|
||||
resultAllocation :: Lens' AllocationTableData (Entity Allocation)
|
||||
resultAllocation = _dbrOutput
|
||||
resultAllocation = _dbrOutput . _1
|
||||
|
||||
resultAvailable, resultApplied :: Lens' AllocationTableData Natural
|
||||
resultAvailable = _dbrOutput . _2
|
||||
resultApplied = _dbrOutput . _3
|
||||
|
||||
allocationTermLink :: TermId -> SomeRoute UniWorX
|
||||
allocationTermLink tid = SomeRoute (AllocationListR, [(dbFilterKey allocationListIdent "term", toPathPiece tid)])
|
||||
@ -32,13 +59,17 @@ allocationLink Allocation{..} = SomeRoute $ AllocationR allocationTerm allocatio
|
||||
|
||||
getAllocationListR :: Handler Html
|
||||
getAllocationListR = do
|
||||
muid <- maybeAuthId
|
||||
now <- liftIO getCurrentTime
|
||||
let
|
||||
dbtSQLQuery :: AllocationTableExpr -> E.SqlQuery _
|
||||
dbtSQLQuery = return
|
||||
dbtSQLQuery = runReaderT $ (,,)
|
||||
<$> view queryAllocation
|
||||
<*> view queryAvailable
|
||||
<*> view (maybe (to . const $ E.val 0) queryApplied muid)
|
||||
|
||||
dbtProj :: DBRow _ -> MaybeT (YesodDB UniWorX) AllocationTableData
|
||||
dbtProj = return
|
||||
dbtProj = return . over (_dbrOutput . _2) E.unValue . over (_dbrOutput . _3) E.unValue
|
||||
|
||||
dbtRowKey = view $ queryAllocation . to (E.^. AllocationId)
|
||||
|
||||
@ -47,12 +78,24 @@ getAllocationListR = do
|
||||
[ anchorColonnade (views (resultAllocation . _entityVal . _allocationTerm) allocationTermLink) $ colTermShort (resultAllocation . _entityVal . _allocationTerm)
|
||||
, anchorColonnade (views (resultAllocation . _entityVal . _allocationSchool) allocationSchoolLink) $ colSchoolShort (resultAllocation . _entityVal . _allocationSchool)
|
||||
, anchorColonnade (views (resultAllocation . _entityVal) allocationLink) $ colAllocationName (resultAllocation . _entityVal . _allocationName)
|
||||
, sortable (Just "available") (i18nCell MsgAllocationAvailableCourses) $ views resultAvailable i18nCell
|
||||
, if
|
||||
| Just _ <- muid
|
||||
-> sortable (Just "applied") (i18nCell MsgAllocationAppliedCourses) . views resultApplied $ maybe mempty i18nCell . assertM' (> 0)
|
||||
| otherwise
|
||||
-> mempty
|
||||
]
|
||||
|
||||
dbtSorting = mconcat
|
||||
[ sortTerm $ queryAllocation . to (E.^. AllocationTerm)
|
||||
, sortSchool $ queryAllocation . to (E.^. AllocationSchool)
|
||||
, sortAllocationName $ queryAllocation . to (E.^. AllocationName)
|
||||
, singletonMap "available" . SortColumn $ view queryAvailable
|
||||
, if
|
||||
| Just uid <- muid
|
||||
-> singletonMap "applied" . SortColumn . view $ queryApplied uid
|
||||
| otherwise
|
||||
-> mempty
|
||||
]
|
||||
|
||||
dbtFilter = mconcat
|
||||
|
||||
@ -46,8 +46,8 @@ getAShowR tid ssh ash = do
|
||||
let title = MsgAllocationTitle (mr . ShortTermIdentifier $ unTermKey allocationTerm) (unSchoolKey allocationSchool) allocationName
|
||||
shortTitle = MsgAllocationShortTitle (mr . ShortTermIdentifier $ unTermKey allocationTerm) (unSchoolKey allocationSchool) allocationShorthand
|
||||
|
||||
staffInformation <- anyM courses $ \(view $ resultCourse . _entityVal -> Course{..}) ->
|
||||
hasReadAccessTo $ CourseR courseTerm courseSchool courseShorthand CApplicationsR
|
||||
-- staffInformation <- anyM courses $ \(view $ resultCourse . _entityVal -> Course{..}) ->
|
||||
-- hasReadAccessTo $ CourseR courseTerm courseSchool courseShorthand CApplicationsR
|
||||
mayRegister <- hasWriteAccessTo $ AllocationR tid ssh ash ARegisterR
|
||||
(registerForm, registerEnctype) <- generateFormPost . renderAForm FormStandard . allocationRegisterForm $ allocationUserToForm . entityVal <$> registration
|
||||
let
|
||||
|
||||
@ -13,27 +13,30 @@ getCAEditR = postCAEditR
|
||||
postCAEditR tid ssh csh cID = do
|
||||
uid <- requireAuthId
|
||||
appId <- decrypt cID
|
||||
(mAlloc, Entity cid Course{..}, CourseApplication{..}, isAdmin, User{..}) <- runDB $ do
|
||||
(mAlloc, Entity cid Course{..}, CourseApplication{..}, User{..}) <- runDB $ do
|
||||
course <- getBy404 $ TermSchoolCourseShort tid ssh csh
|
||||
app <- get404 appId
|
||||
mAlloc <- traverse getEntity404 $ courseApplicationAllocation app
|
||||
appUser <- get404 $ courseApplicationUser app
|
||||
isAdmin <- case mAlloc of
|
||||
Just alloc -> exists [UserAdminUser ==. uid, UserAdminSchool ==. alloc ^. _entityVal . _allocationSchool]
|
||||
Nothing -> exists [UserAdminUser ==. uid, UserAdminSchool ==. course ^. _entityVal . _courseSchool]
|
||||
return (mAlloc, course, app, isAdmin, appUser)
|
||||
return (mAlloc, course, app, appUser)
|
||||
|
||||
isAdmin <- case mAlloc of
|
||||
Just (Entity _ Allocation{..})
|
||||
-> hasWriteAccessTo $ SchoolR allocationSchool SchoolEditR
|
||||
Nothing
|
||||
-> hasWriteAccessTo $ SchoolR courseSchool SchoolEditR
|
||||
let afmApplicant = uid == courseApplicationUser || isAdmin
|
||||
afmLecturer <- hasWriteAccessTo $ CourseR courseTerm courseSchool courseShorthand CEditR
|
||||
afmApplicantEdit <- hasWriteAccessTo $ CApplicationR tid ssh csh cID CAEditR
|
||||
mayEdit <- hasWriteAccessTo $ CApplicationR tid ssh csh cID CAEditR
|
||||
courseCID <- encrypt cid :: Handler CryptoUUIDCourse
|
||||
|
||||
let afMode = ApplicationFormMode
|
||||
{ afmApplicant = uid == courseApplicationUser || isAdmin
|
||||
, afmApplicantEdit
|
||||
{ afmApplicant
|
||||
, afmApplicantEdit = afmApplicant && mayEdit
|
||||
, afmLecturer
|
||||
}
|
||||
|
||||
(ApplicationFormView{..}, appEnc) <- editApplicationR (entityKey <$> mAlloc) uid cid (Just appId) afMode (/= BtnAllocationApply) $ if
|
||||
(ApplicationFormView{..}, appEnc) <- editApplicationR (entityKey <$> mAlloc) courseApplicationUser cid (Just appId) afMode (/= BtnAllocationApply) $ if
|
||||
| uid == courseApplicationUser
|
||||
, Just (Entity _ Allocation{..}) <- mAlloc
|
||||
-> SomeRoute $ AllocationR allocationTerm allocationSchool allocationShorthand AShowR :#: courseCID
|
||||
|
||||
@ -221,6 +221,11 @@ postCApplicationsR tid ssh csh = do
|
||||
participantLink uid = do
|
||||
cID <- encrypt uid
|
||||
return . SomeRoute . CourseR tid ssh csh $ CUserR cID
|
||||
|
||||
applicationLink :: MonadCrypto m => CourseApplicationId -> m (SomeRoute UniWorX)
|
||||
applicationLink appId = do
|
||||
cID <- encrypt appId
|
||||
return . SomeRoute $ CApplicationR tid ssh csh cID CAEditR
|
||||
|
||||
dbtSQLQuery :: CourseApplicationsTableExpr -> E.SqlQuery _
|
||||
dbtSQLQuery = runReaderT $ do
|
||||
@ -256,7 +261,7 @@ postCApplicationsR tid ssh csh = do
|
||||
dbtColonnade :: Colonnade Sortable _ _
|
||||
dbtColonnade = mconcat
|
||||
[ emptyOpticColonnade (resultAllocation . _entityVal) $ \l -> anchorColonnade (views l allocationLink) $ colAllocationShorthand (l . _allocationShorthand)
|
||||
, colApplicationId (resultCourseApplication . _entityKey)
|
||||
, anchorColonnadeM (views (resultCourseApplication . _entityKey) applicationLink) $ colApplicationId (resultCourseApplication . _entityKey)
|
||||
, anchorColonnadeM (views (resultUser . _entityKey) participantLink) $ colUserDisplayName (resultUser . _entityVal . $(multifocusL 2) _userDisplayName _userSurname)
|
||||
, colUserMatriculation (resultUser . _entityVal . _userMatrikelnummer)
|
||||
, emptyOpticColonnade (resultStudyTerms . _entityVal) colStudyTerms
|
||||
@ -525,6 +530,7 @@ postCApplicationsR tid ssh csh = do
|
||||
|
||||
psValidator :: PSValidator _ _
|
||||
psValidator = def
|
||||
& defaultSorting [SortAscBy "user-name"]
|
||||
|
||||
dbTableWidget' psValidator DBTable{..}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import qualified Data.Map as Map
|
||||
import Control.Monad.Trans.Writer (execWriterT)
|
||||
|
||||
import qualified Database.Esqueleto as E
|
||||
import qualified Database.Esqueleto.Utils as E
|
||||
|
||||
import Jobs.Queue
|
||||
|
||||
@ -105,10 +106,12 @@ makeCourseForm miButtonAction template = identifyForm FIDcourse $ \html -> do
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
|
||||
uid <- liftHandlerT requireAuthId
|
||||
(lecSchools, admSchools) <- liftHandlerT . runDB $ (,)
|
||||
<$> (map (userLecturerSchool . entityVal) <$> selectList [UserLecturerUser ==. uid] [] )
|
||||
<*> (map (userAdminSchool . entityVal) <$> selectList [UserAdminUser ==. uid] [] )
|
||||
let userSchools = lecSchools ++ admSchools
|
||||
(lecturerSchools, adminSchools) <- liftHandlerT . runDB $ do
|
||||
lecturerSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolLecturer]] []
|
||||
protoAdminSchools <- map (userFunctionSchool . entityVal) <$> selectList [UserFunctionUser ==. uid, UserFunctionFunction <-. [SchoolAdmin]] []
|
||||
adminSchools <- filterM (hasWriteAccessTo . flip SchoolR SchoolEditR) protoAdminSchools
|
||||
return (lecturerSchools, adminSchools)
|
||||
let userSchools = nub $ lecturerSchools ++ adminSchools
|
||||
|
||||
termsField <- case template of
|
||||
-- Change of term is only allowed if user may delete the course (i.e. no participants) or admin
|
||||
@ -200,32 +203,55 @@ makeCourseForm miButtonAction template = identifyForm FIDcourse $ \html -> do
|
||||
allocationForm = wFormToAForm $ do
|
||||
availableAllocations' <- liftHandlerT . runDB . E.select . E.from $ \(allocation `E.InnerJoin` term) -> do
|
||||
E.on $ allocation E.^. AllocationTerm E.==. term E.^. TermId
|
||||
|
||||
let alreadyParticipates = flip (maybe E.false) (template >>= cfCourseId) $ \cid ->
|
||||
E.exists . E.from $ \allocationCourse ->
|
||||
E.where_ $ allocationCourse E.^. AllocationCourseCourse E.==. E.val cid
|
||||
E.&&. allocationCourse E.^. AllocationCourseAllocation E.==. allocation E.^. AllocationId
|
||||
|
||||
E.where_ $ term E.^. TermActive
|
||||
return allocation
|
||||
E.||. alreadyParticipates
|
||||
E.||. allocation E.^. AllocationSchool `E.in_` E.valList adminSchools
|
||||
return (allocation, alreadyParticipates)
|
||||
|
||||
now <- liftIO getCurrentTime
|
||||
let
|
||||
allocationEnabled :: Entity Allocation -> Bool
|
||||
allocationEnabled (Entity _ Allocation{..}) = NTop allocationStaffRegisterFrom <= NTop (Just now)
|
||||
&& NTop (Just now) <= NTop allocationStaffRegisterTo
|
||||
availableAllocations = filter allocationEnabled availableAllocations'
|
||||
allocationEnabled (Entity _ Allocation{..})
|
||||
= ( NTop allocationStaffRegisterFrom <= NTop (Just now)
|
||||
&& NTop (Just now) <= NTop allocationStaffRegisterTo
|
||||
) || allocationSchool `elem` adminSchools
|
||||
availableAllocations = availableAllocations' ^.. folded . filtered (allocationEnabled . view _1) . _1
|
||||
activeAllocations = availableAllocations' ^.. folded . filtered ((&&) <$> (not <$> allocationEnabled . view _1) <*> view (_2 . _Value)) . _1
|
||||
|
||||
mkAllocationOption :: forall m. (MonadHandler m, HandlerSite m ~ UniWorX) => Entity Allocation -> m (Option AllocationId)
|
||||
mkAllocationOption (Entity aId Allocation{..}) = liftHandlerT $ do
|
||||
cID <- encrypt aId :: Handler CryptoUUIDAllocation
|
||||
return . Option (mr . MsgCourseAllocationOption (mr . ShortTermIdentifier $ unTermKey allocationTerm) $ CI.original allocationName) aId $ toPathPiece cID
|
||||
|
||||
case availableAllocations of
|
||||
[] -> wforced (convertField (const Nothing) (const False) checkBoxField) (fslI MsgCourseAllocationParticipate & setTooltip MsgCourseNoAllocationsAvailable) Nothing
|
||||
currentAllocationAvailable = (\alloc -> any ((== alloc) . entityKey) availableAllocations) . acfAllocation <$> (template >>= cfAllocation)
|
||||
|
||||
case (currentAllocationAvailable, availableAllocations) of
|
||||
(Nothing, []) -> wforced (convertField (const Nothing) (const False) checkBoxField) (fslI MsgCourseAllocationParticipate & setTooltip MsgCourseNoAllocationsAvailable) Nothing
|
||||
_ -> do
|
||||
allocationOptions <- mkOptionList <$> mapM mkAllocationOption availableAllocations
|
||||
allocationOptions <- mkOptionList <$> mapM mkAllocationOption (availableAllocations ++ activeAllocations)
|
||||
|
||||
let
|
||||
allocationForm' = AllocationCourseForm
|
||||
<$> apreq (selectField' Nothing $ return allocationOptions) (fslI MsgCourseAllocation) (fmap acfAllocation $ template >>= cfAllocation)
|
||||
<*> apreq (natFieldI MsgCourseAllocationMinCapacityMustBeNonNegative) (fslI MsgCourseAllocationMinCapacity & setTooltip MsgCourseAllocationMinCapacityTip) (fmap acfMinCapacity $ template >>= cfAllocation)
|
||||
|
||||
optionalActionW allocationForm' (fslI MsgCourseAllocationParticipate & setTooltip MsgCourseAllocationParticipateTip) (is _Just . cfAllocation <$> template)
|
||||
userAdmin = not $ null adminSchools
|
||||
mayChange = fromMaybe True $ (|| userAdmin) <$> currentAllocationAvailable
|
||||
|
||||
allocationForm' =
|
||||
let ainp :: Field Handler a -> FieldSettings UniWorX -> Maybe a -> AForm Handler a
|
||||
ainp
|
||||
| mayChange
|
||||
= apreq
|
||||
| otherwise
|
||||
= aforcedJust
|
||||
in AllocationCourseForm
|
||||
<$> ainp (selectField' Nothing $ return allocationOptions) (fslI MsgCourseAllocation) (fmap acfAllocation $ template >>= cfAllocation)
|
||||
<*> ainp (natFieldI MsgCourseAllocationMinCapacityMustBeNonNegative) (fslI MsgCourseAllocationMinCapacity & setTooltip MsgCourseAllocationMinCapacityTip) (fmap acfMinCapacity $ template >>= cfAllocation)
|
||||
|
||||
optionalActionW' (bool mforcedJust mpopt mayChange) allocationForm' (fslI MsgCourseAllocationParticipate & setTooltip MsgCourseAllocationParticipateTip) (is _Just . cfAllocation <$> template)
|
||||
|
||||
(result, widget) <- flip (renderAForm FormStandard) html $ CourseForm
|
||||
<$> pure (cfCourseId =<< template)
|
||||
@ -278,11 +304,11 @@ makeCourseForm miButtonAction template = identifyForm FIDcourse $ \html -> do
|
||||
_ -> (result, widget)
|
||||
|
||||
|
||||
validateCourse :: (MonadHandler m, HandlerSite m ~ UniWorX) => CourseForm -> m [Text]
|
||||
validateCourse :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => CourseForm -> m [Text]
|
||||
validateCourse CourseForm{..} = do
|
||||
now <- liftIO getCurrentTime
|
||||
uid <- liftHandlerT requireAuthId
|
||||
userAdmin <- liftHandlerT . runDB . getBy $ UniqueUserAdmin uid cfSchool -- FIXME: This /needs/ to be a call to `isAuthorized` on a route
|
||||
userAdmin <- hasWriteAccessTo $ SchoolR cfSchool SchoolEditR
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
allocationTerm <- for (acfAllocation <$> cfAllocation) $ fmap allocationTerm . liftHandlerT . runDB . getJust
|
||||
|
||||
@ -291,7 +317,7 @@ validateCourse CourseForm{..} = do
|
||||
prevAllocation <- fmap join . traverse get $ allocationCourseAllocation . entityVal <$> prevAllocationCourse
|
||||
|
||||
fmap join . for prevAllocation $ \Allocation{allocationStaffRegisterTo} -> if
|
||||
| is _Just userAdmin
|
||||
| userAdmin
|
||||
-> return Nothing
|
||||
| NTop allocationStaffRegisterTo <= NTop (Just now)
|
||||
-> Just . courseCapacity <$> getJust cid
|
||||
@ -309,7 +335,7 @@ validateCourse CourseForm{..} = do
|
||||
( NTop cfRegFrom <= NTop cfDeRegUntil
|
||||
, MsgCourseDeregistrationEndMustBeAfterStart
|
||||
)
|
||||
, ( maybe (anyOf (traverse . _Right . _1) (== uid) cfLecturers) (\(Entity _ UserAdmin{}) -> True) userAdmin
|
||||
, ( bool (anyOf (traverse . _Right . _1) (== uid) cfLecturers) True userAdmin
|
||||
, MsgCourseUserMustBeLecturer
|
||||
)
|
||||
, ( is _Nothing cfAllocation || is _Just cfCapacity
|
||||
@ -357,8 +383,9 @@ getCourseNewR = do
|
||||
E.&&. lecturer E.^. LecturerCourse E.==. course E.^. CourseId
|
||||
let lecturersSchool =
|
||||
E.exists $ E.from $ \user ->
|
||||
E.where_ $ user E.^. UserLecturerUser E.==. E.val uid
|
||||
E.&&. user E.^. UserLecturerSchool E.==. course E.^. CourseSchool
|
||||
E.where_ $ user E.^. UserFunctionUser E.==. E.val uid
|
||||
E.&&. user E.^. UserFunctionSchool E.==. course E.^. CourseSchool
|
||||
E.&&. user E.^. UserFunctionFunction E.==. E.val SchoolLecturer
|
||||
let courseCreated c =
|
||||
E.sub_select . E.from $ \edit -> do -- oldest edit must be creation
|
||||
E.where_ $ edit E.^. CourseEditCourse E.==. c E.^. CourseId
|
||||
@ -527,17 +554,16 @@ courseEditHandler miButtonAction mbCourseForm = do
|
||||
, formEncoding = formEnctype
|
||||
}
|
||||
|
||||
upsertAllocationCourse :: (MonadHandler m, HandlerSite m ~ UniWorX) => CourseId -> Maybe AllocationCourseForm -> ReaderT SqlBackend m ()
|
||||
upsertAllocationCourse :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => CourseId -> Maybe AllocationCourseForm -> ReaderT SqlBackend m ()
|
||||
upsertAllocationCourse cid cfAllocation = do
|
||||
now <- liftIO getCurrentTime
|
||||
uid <- liftHandlerT requireAuthId
|
||||
Course{..} <- getJust cid
|
||||
prevAllocationCourse <- getBy $ UniqueAllocationCourse cid
|
||||
prevAllocation <- fmap join . traverse get $ allocationCourseAllocation . entityVal <$> prevAllocationCourse
|
||||
userAdmin <- liftHandlerT . runDB . getBy $ UniqueUserAdmin uid courseSchool -- FIXME: This /needs/ to be a call to `isAuthorized` on a route
|
||||
userAdmin <- fromMaybe False <$> for prevAllocation (\Allocation{..} -> hasWriteAccessTo $ SchoolR allocationSchool SchoolEditR)
|
||||
|
||||
doEdit <- if
|
||||
| is _Just userAdmin
|
||||
| userAdmin
|
||||
-> return True
|
||||
| Just Allocation{allocationStaffRegisterTo} <- prevAllocation
|
||||
, NTop allocationStaffRegisterTo <= NTop (Just now)
|
||||
|
||||
@ -61,7 +61,7 @@ lecturerInvitationConfig = InvitationConfig{..}
|
||||
getKeyBy404 $ TermSchoolCourseShort tid csh ssh
|
||||
invitationSubject (Entity _ Course{..}) _ = return . SomeMessage $ MsgMailSubjectLecturerInvitation courseTerm courseSchool courseShorthand
|
||||
invitationHeading (Entity _ Course{..}) _ = return . SomeMessage $ MsgCourseLecInviteHeading $ CI.original courseName
|
||||
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgCourseLecInviteExplanation}|]
|
||||
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgCourseLecInviteExplanation}|]
|
||||
invitationTokenConfig _ _ = do
|
||||
itAuthority <- liftHandlerT requireAuthId
|
||||
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
|
||||
|
||||
@ -74,7 +74,7 @@ participantInvitationConfig = InvitationConfig{..}
|
||||
getKeyBy404 $ TermSchoolCourseShort tid csh ssh
|
||||
invitationSubject (Entity _ Course{..}) _ = return . SomeMessage $ MsgMailSubjectParticipantInvitation courseTerm courseSchool courseShorthand
|
||||
invitationHeading (Entity _ Course{..}) _ = return . SomeMessage $ MsgCourseParticipantInviteHeading $ CI.original courseName
|
||||
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgCourseParticipantInviteExplanation}|]
|
||||
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgCourseParticipantInviteExplanation}|]
|
||||
invitationTokenConfig _ _ = do
|
||||
itAuthority <- liftHandlerT requireAuthId
|
||||
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
|
||||
|
||||
@ -206,11 +206,10 @@ makeCourseUserTable cid restrict colChoices psValidator = do
|
||||
, dbParamsFormAction = Just $ SomeRoute currentRoute
|
||||
, dbParamsFormAttrs = []
|
||||
, dbParamsFormSubmit = FormSubmit
|
||||
, dbParamsFormAdditional = \csrf -> do
|
||||
(res,vw) <- mreq (selectField optionsFinite) "" Nothing
|
||||
let formWgt = toWidget csrf <> fvInput vw
|
||||
formRes = (, mempty) . First . Just <$> res
|
||||
return (formRes,formWgt)
|
||||
, dbParamsFormAdditional
|
||||
= renderAForm FormStandard
|
||||
$ (, mempty) . First . Just
|
||||
<$> areq (selectField optionsFinite) (fslI MsgAction) Nothing
|
||||
, dbParamsFormEvaluate = liftHandlerT . runFormPost
|
||||
, dbParamsFormResult = id
|
||||
, dbParamsFormIdent = def
|
||||
|
||||
@ -61,7 +61,7 @@ examCorrectorInvitationConfig = InvitationConfig{..}
|
||||
Course{..} <- get404 examCourse
|
||||
return . SomeMessage $ MsgMailSubjectExamCorrectorInvitation courseTerm courseSchool courseShorthand examName
|
||||
invitationHeading (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamCorrectorInviteHeading examName
|
||||
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgExamCorrectorInviteExplanation}|]
|
||||
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgExamCorrectorInviteExplanation}|]
|
||||
invitationTokenConfig _ _ = do
|
||||
itAuthority <- liftHandlerT requireAuthId
|
||||
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
|
||||
|
||||
@ -162,8 +162,8 @@ examOccurrenceForm prev = wFormToAForm $ do
|
||||
(eofNameRes, eofNameView) <- mpreq ciField ("" & addName (nudge "name")) (eofName <$> mPrev)
|
||||
(eofRoomRes, eofRoomView) <- mpreq textField ("" & addName (nudge "room")) (eofRoom <$> mPrev)
|
||||
(eofCapacityRes, eofCapacityView) <- mpreq (natFieldI MsgExamRoomCapacityNegative) ("" & addName (nudge "capacity")) (eofCapacity <$> mPrev)
|
||||
(eofStartRes, eofStartView) <- mpreq utcTimeField ("" & addName (nudge "start")) (eofStart <$> mPrev)
|
||||
(eofEndRes, eofEndView) <- mopt utcTimeField ("" & addName (nudge "end")) (eofEnd <$> mPrev)
|
||||
(eofStartRes, eofStartView) <- mpreq utcTimeField ("" & addName (nudge "start") & addDatepickerPositionAttr DPBottom) (eofStart <$> mPrev)
|
||||
(eofEndRes, eofEndView) <- mopt utcTimeField ("" & addName (nudge "end") & addDatepickerPositionAttr DPBottom) (eofEnd <$> mPrev)
|
||||
(eofDescRes, eofDescView) <- mopt htmlFieldSmall ("" & addName (nudge "description")) (eofDescription <$> mPrev)
|
||||
|
||||
return ( ExamOccurrenceForm
|
||||
|
||||
@ -69,7 +69,7 @@ examRegistrationInvitationConfig = InvitationConfig{..}
|
||||
Course{..} <- get404 examCourse
|
||||
return . SomeMessage $ MsgMailSubjectExamRegistrationInvitation courseTerm courseSchool courseShorthand examName
|
||||
invitationHeading (Entity _ Exam{..}) _ = return . SomeMessage $ MsgExamRegistrationInviteHeading examName
|
||||
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgExamRegistrationInviteExplanation}|]
|
||||
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgExamRegistrationInviteExplanation}|]
|
||||
invitationTokenConfig _ (InvDBDataExamRegistration{..}, _) = do
|
||||
itAuthority <- liftHandlerT requireAuthId
|
||||
let itExpiresAt = Just $ Just invDBExamRegistrationDeadline
|
||||
|
||||
@ -15,6 +15,7 @@ import qualified Database.Esqueleto as E
|
||||
import qualified Database.Esqueleto.Utils as E
|
||||
-- import Database.Esqueleto ((^.))
|
||||
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
|
||||
|
||||
data SettingsForm = SettingsForm
|
||||
@ -25,15 +26,33 @@ data SettingsForm = SettingsForm
|
||||
, stgTime :: DateTimeFormat
|
||||
, stgDownloadFiles :: Bool
|
||||
, stgWarningDays :: NominalDiffTime
|
||||
, stgSchools :: Set SchoolId
|
||||
, stgNotificationSettings :: NotificationSettings
|
||||
}
|
||||
|
||||
data NotificationTriggerKind = NTKAll | NTKCourseParticipant | NTKExamParticipant | NTKCorrector | NTKLecturer | NTKAdmin
|
||||
deriving (Eq, Ord, Enum, Bounded, Generic, Typeable)
|
||||
instance Universe NotificationTriggerKind
|
||||
instance Finite NotificationTriggerKind
|
||||
data NotificationTriggerKind
|
||||
= NTKAll
|
||||
| NTKCourseParticipant
|
||||
| NTKExamParticipant
|
||||
| NTKCorrector
|
||||
| NTKAllocationStaff
|
||||
| NTKFunctionary SchoolFunction
|
||||
deriving (Eq, Ord, Generic, Typeable)
|
||||
deriveFinite ''NotificationTriggerKind
|
||||
|
||||
embedRenderMessage ''UniWorX ''NotificationTriggerKind $ ("NotificationTriggerKind" <>) . mconcat . drop 1 . splitCamel
|
||||
instance RenderMessage UniWorX NotificationTriggerKind where
|
||||
renderMessage f ls = \case
|
||||
NTKAll -> mr MsgNotificationTriggerKindAll
|
||||
NTKCourseParticipant -> mr MsgNotificationTriggerKindCourseParticipant
|
||||
NTKExamParticipant -> mr MsgNotificationTriggerKindExamParticipant
|
||||
NTKCorrector -> mr MsgNotificationTriggerKindCorrector
|
||||
NTKAllocationStaff -> mr MsgNotificationTriggerKindAllocationStaff
|
||||
NTKFunctionary SchoolAdmin -> mr MsgNotificationTriggerKindAdmin
|
||||
NTKFunctionary SchoolLecturer -> mr MsgNotificationTriggerKindLecturer
|
||||
NTKFunctionary SchoolExamOffice -> mr MsgNotificationTriggerKindExamOffice
|
||||
NTKFunctionary SchoolEvaluation -> mr MsgNotificationTriggerKindEvaluation
|
||||
where
|
||||
mr = renderMessage f ls
|
||||
|
||||
|
||||
makeSettingForm :: Maybe SettingsForm -> Form SettingsForm
|
||||
@ -55,38 +74,36 @@ makeSettingForm template html = do
|
||||
& setTooltip MsgWarningDaysTip
|
||||
) (stgWarningDays <$> template)
|
||||
<* aformSection MsgFormNotifications
|
||||
<*> schoolsForm (stgSchools <$> template)
|
||||
<*> notificationForm (stgNotificationSettings <$> template)
|
||||
return (result, widget) -- no validation required here
|
||||
where
|
||||
themeList = [Option (toMessage t) t (toPathPiece t) | t <- universeF]
|
||||
--
|
||||
-- Version with proper grouping:
|
||||
--
|
||||
-- makeSettingForm :: Maybe SettingsForm -> Form SettingsForm
|
||||
-- makeSettingForm template = identForm FIDsettings $ \html -> do
|
||||
-- (result, widget) <- flip (renderAForm FormStandard) html $ settingsFormT5T2
|
||||
-- <$> aFormGroup "Cosmetics" cosmeticsForm
|
||||
-- <*> aFormGroup "Notifications" notificationsForm
|
||||
-- <* submitButton
|
||||
-- return (result, widget) -- no validation required here
|
||||
-- where
|
||||
-- settingsFormT5T2 :: (Int,Theme,DateTimeFormat,DateTimeFormat,DateTimeFormat) -> (Bool,NotificationSettings) -> SettingsForm
|
||||
-- settingsFormT5T2 = $(uncurryN 2) . $(uncurryN 5) SettingsForm
|
||||
-- themeList = [Option (display t) t (toPathPiece t) | t <- universeF]
|
||||
-- cosmeticsForm = (,,,,)
|
||||
-- <$> areq (natFieldI $ MsgNatField "Favoriten") -- TODO: natFieldI not working here
|
||||
-- (fslpI MsgFavoriten "Anzahl Favoriten") (stgMaxFavourties <$> template)
|
||||
-- <*> areq (selectField . return $ mkOptionList themeList)
|
||||
-- (fslI MsgTheme) { fsId = Just "theme-select" } (stgTheme <$> template)
|
||||
-- <*> areq (selectField $ dateTimeFormatOptions SelFormatDateTime) (fslI MsgDateTimeFormat) (stgDateTime <$> template)
|
||||
-- <*> areq (selectField $ dateTimeFormatOptions SelFormatDate) (fslI MsgDateFormat) (stgDate <$> template)
|
||||
-- <*> areq (selectField $ dateTimeFormatOptions SelFormatTime) (fslI MsgTimeFormat) (stgTime <$> template)
|
||||
-- notificationsForm = (,)
|
||||
-- <$> areq checkBoxField (fslI MsgDownloadFiles
|
||||
-- & setTooltip MsgDownloadFilesTip
|
||||
-- ) (stgDownloadFiles <$> template)
|
||||
-- <*> (NotificationSettings <$> funcForm nsForm (fslI MsgNotificationSettings) True)
|
||||
-- nsForm nt = fromMaybe False <$> aopt checkBoxField (fslI nt) (Just $ flip notificationAllowed nt . stgNotificationSettings <$> template)
|
||||
|
||||
schoolsForm :: Maybe (Set SchoolId) -> AForm Handler (Set SchoolId)
|
||||
schoolsForm template = formToAForm $ schoolsFormView =<< renderWForm FormStandard schoolsForm' mempty
|
||||
where
|
||||
schoolsForm' :: WForm Handler (FormResult (Set SchoolId))
|
||||
schoolsForm' = do
|
||||
allSchools <- liftHandlerT . runDB $ selectList [] [Asc SchoolName]
|
||||
|
||||
let
|
||||
schoolForm (Entity ssh School{schoolName})
|
||||
= fmap (bool Set.empty $ Set.singleton ssh) <$> wpopt checkBoxField (fsl $ CI.original schoolName) (Set.member ssh <$> template)
|
||||
|
||||
fold <$> mapM schoolForm allSchools
|
||||
|
||||
schoolsFormView :: (FormResult (Set SchoolId), Widget) -> MForm Handler (FormResult (Set SchoolId), [FieldView UniWorX])
|
||||
schoolsFormView (res, fvInput) = do
|
||||
mr <- getMessageRender
|
||||
let fvLabel = toHtml $ mr MsgUserSchools
|
||||
fvTooltip = Just . toHtml $ mr MsgUserSchoolsTip
|
||||
fvRequired = False
|
||||
fvErrors
|
||||
| FormFailure (err : _) <- res = Just $ toHtml err
|
||||
| otherwise = Nothing
|
||||
fvId <- newIdent
|
||||
return (res, pure FieldView{..})
|
||||
|
||||
notificationForm :: Maybe NotificationSettings -> AForm Handler NotificationSettings
|
||||
notificationForm template = wFormToAForm $ do
|
||||
@ -99,13 +116,10 @@ notificationForm template = wFormToAForm $ do
|
||||
| isAdmin
|
||||
= return False
|
||||
| Just uid <- mbUid
|
||||
, NTKAdmin <- nt
|
||||
= fmap not . E.selectExists . E.from $ \userAdmin ->
|
||||
E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val uid
|
||||
| Just uid <- mbUid
|
||||
, NTKLecturer <- nt
|
||||
= fmap not . E.selectExists . E.from $ \userLecturer ->
|
||||
E.where_ $ userLecturer E.^. UserLecturerUser E.==. E.val uid
|
||||
, NTKFunctionary f <- nt
|
||||
= fmap not . E.selectExists . E.from $ \userFunction ->
|
||||
E.where_ $ userFunction E.^. UserFunctionUser E.==. E.val uid
|
||||
E.&&. userFunction E.^. UserFunctionFunction E.==. E.val f
|
||||
| Just uid <- mbUid
|
||||
, NTKCorrector <- nt
|
||||
= fmap not . E.selectExists . E.from $ \sheetCorrector ->
|
||||
@ -137,17 +151,22 @@ notificationForm template = wFormToAForm $ do
|
||||
= apopt checkBoxField (fslI nt) (flip notificationAllowed nt <$> template)
|
||||
|
||||
ntSection = \case
|
||||
NTSubmissionRatedGraded -> Just NTKCourseParticipant
|
||||
NTSubmissionRated -> Just NTKCourseParticipant
|
||||
NTSheetActive -> Just NTKCourseParticipant
|
||||
NTSheetSoonInactive -> Just NTKCourseParticipant
|
||||
NTSheetInactive -> Just NTKLecturer
|
||||
NTCorrectionsAssigned -> Just NTKCorrector
|
||||
NTCorrectionsNotDistributed -> Just NTKLecturer
|
||||
NTUserRightsUpdate -> Just NTKAll
|
||||
NTUserAuthModeUpdate -> Just NTKAll
|
||||
NTExamResult -> Just NTKExamParticipant
|
||||
-- _other -> Nothing
|
||||
NTSubmissionRatedGraded -> Just NTKCourseParticipant
|
||||
NTSubmissionRated -> Just NTKCourseParticipant
|
||||
NTSheetActive -> Just NTKCourseParticipant
|
||||
NTSheetSoonInactive -> Just NTKCourseParticipant
|
||||
NTSheetInactive -> Just $ NTKFunctionary SchoolLecturer
|
||||
NTCorrectionsAssigned -> Just NTKCorrector
|
||||
NTCorrectionsNotDistributed -> Just $ NTKFunctionary SchoolLecturer
|
||||
NTUserRightsUpdate -> Just NTKAll
|
||||
NTUserAuthModeUpdate -> Just NTKAll
|
||||
NTExamResult -> Just NTKExamParticipant
|
||||
NTAllocationStaffRegister -> Just $ NTKFunctionary SchoolLecturer
|
||||
NTAllocationAllocation -> Just NTKAllocationStaff
|
||||
NTAllocationRegister -> Just NTKAll
|
||||
NTAllocationOutdatedRatings -> Just NTKAllocationStaff
|
||||
NTAllocationUnratedApplications -> Just NTKAllocationStaff
|
||||
-- _other -> Nothing
|
||||
|
||||
forcedTriggers = [NTUserRightsUpdate, NTUserAuthModeUpdate]
|
||||
|
||||
@ -177,6 +196,12 @@ getProfileR, postProfileR :: Handler Html
|
||||
getProfileR = postProfileR
|
||||
postProfileR = do
|
||||
(uid, User{..}) <- requireAuthPair
|
||||
userSchools <- fmap (setOf $ folded . _Value) . runDB . E.select . E.from $ \school -> do
|
||||
E.where_ . E.exists . E.from $ \userSchool ->
|
||||
E.where_ $ E.not_ (userSchool E.^. UserSchoolIsOptOut)
|
||||
E.&&. userSchool E.^. UserSchoolUser E.==. E.val uid
|
||||
E.&&. userSchool E.^. UserSchoolSchool E.==. school E.^. SchoolId
|
||||
return $ school E.^. SchoolId
|
||||
let settingsTemplate = Just SettingsForm
|
||||
{ stgMaxFavourties = userMaxFavourites
|
||||
, stgTheme = userTheme
|
||||
@ -184,6 +209,7 @@ postProfileR = do
|
||||
, stgDate = userDateFormat
|
||||
, stgTime = userTimeFormat
|
||||
, stgDownloadFiles = userDownloadFiles
|
||||
, stgSchools = userSchools
|
||||
, stgNotificationSettings = userNotificationSettings
|
||||
, stgWarningDays = userWarningDays
|
||||
}
|
||||
@ -207,6 +233,25 @@ postProfileR = do
|
||||
, OffsetBy stgMaxFavourties
|
||||
]
|
||||
mapM_ delete oldFavs
|
||||
let
|
||||
symDiff = (stgSchools `Set.difference` userSchools) `Set.union` (userSchools `Set.difference` stgSchools)
|
||||
forM_ symDiff $ \ssh -> if
|
||||
| ssh `Set.member` stgSchools
|
||||
-> void $ upsert UserSchool
|
||||
{ userSchoolSchool = ssh
|
||||
, userSchoolUser = uid
|
||||
, userSchoolIsOptOut = False
|
||||
}
|
||||
[ UserSchoolIsOptOut =. False
|
||||
]
|
||||
| otherwise
|
||||
-> void $ upsert UserSchool
|
||||
{ userSchoolSchool = ssh
|
||||
, userSchoolUser = uid
|
||||
, userSchoolIsOptOut = True
|
||||
}
|
||||
[ UserSchoolIsOptOut =. True
|
||||
]
|
||||
addMessageI Info MsgSettingsUpdate
|
||||
redirect $ ProfileR :#: ProfileSettings
|
||||
|
||||
@ -255,14 +300,7 @@ getProfileDataR = do
|
||||
makeProfileData :: Entity User -> DB Widget
|
||||
makeProfileData (Entity uid User{..}) = do
|
||||
-- MsgRenderer mr <- getMsgRenderer
|
||||
admin_rights <- E.select $ E.from $ \(adright `E.InnerJoin` school) -> do
|
||||
E.where_ $ adright E.^. UserAdminUser E.==. E.val uid
|
||||
E.on $ adright E.^. UserAdminSchool E.==. school E.^. SchoolId
|
||||
return (school E.^. SchoolShorthand)
|
||||
lecturer_rights <- E.select $ E.from $ \(lecright `E.InnerJoin` school) -> do
|
||||
E.where_ $ lecright E.^. UserLecturerUser E.==. E.val uid
|
||||
E.on $ lecright E.^. UserLecturerSchool E.==. school E.^. SchoolId
|
||||
return (school E.^. SchoolShorthand)
|
||||
functions <- Map.fromListWith Set.union . map (\(Entity _ UserFunction{..}) -> (userFunctionFunction, Set.singleton userFunctionSchool)) <$> selectList [UserFunctionUser ==. uid] []
|
||||
lecture_corrector <- E.select $ E.distinct $ E.from $ \(sheet `E.InnerJoin` corrector `E.InnerJoin` course) -> do
|
||||
E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId
|
||||
E.on $ sheet E.^. SheetId E.==. corrector E.^. SheetCorrectorSheet
|
||||
@ -314,7 +352,7 @@ mkOwnedCoursesTable =
|
||||
return $ indicatorCell -- return True if one cell is produced here
|
||||
`mappend` termCell tid
|
||||
, sortable (Just "school") (i18nCell MsgCourseSchool) $
|
||||
schoolCell <$> view (_dbrOutput . _1 . re _Just)
|
||||
schoolCell <$> view (_dbrOutput . _1)
|
||||
<*> view (_dbrOutput . _2 )
|
||||
, sortable (Just "course") (i18nCell MsgCourse) $
|
||||
courseCellCL <$> view _dbrOutput
|
||||
@ -362,8 +400,8 @@ mkEnrolledCoursesTable =
|
||||
, sortable (Just "term") (i18nCell MsgTerm) $
|
||||
termCell <$> view (_dbrOutput . _1 . _entityVal . _courseTerm)
|
||||
, sortable (Just "school") (i18nCell MsgCourseSchool) . magnify (_dbrOutput . _1 . _entityVal) $
|
||||
schoolCell <$> view ( _courseTerm . re _Just)
|
||||
<*> view _courseSchool
|
||||
schoolCell <$> view _courseTerm
|
||||
<*> view _courseSchool
|
||||
, sortable (Just "course") (i18nCell MsgCourse) $
|
||||
courseCell <$> view (_dbrOutput . _1 . _entityVal)
|
||||
, sortable (Just "time") (i18nCell MsgRegistered) $ do
|
||||
@ -430,7 +468,7 @@ mkSubmissionTable =
|
||||
, sortable (Just "term") (i18nCell MsgTerm) $
|
||||
termCell <$> view (_dbrOutput . _1 . _1)
|
||||
, sortable (Just "school") (i18nCell MsgCourseSchool) . magnify (_dbrOutput . _1 ) $
|
||||
schoolCell <$> view ( _1. re _Just)
|
||||
schoolCell <$> view _1
|
||||
<*> view _2
|
||||
, sortable (Just "course") (i18nCell MsgCourse) $
|
||||
courseCellCL <$> view (_dbrOutput . _1)
|
||||
@ -512,7 +550,7 @@ mkSubmissionGroupTable =
|
||||
, sortable (Just "term") (i18nCell MsgTerm) $
|
||||
termCell <$> view (_dbrOutput . _1 . _1)
|
||||
, sortable (Just "school") (i18nCell MsgCourseSchool) . magnify (_dbrOutput . _1 ) $
|
||||
schoolCell <$> view ( _1. re _Just)
|
||||
schoolCell <$> view _1
|
||||
<*> view _2
|
||||
, sortable (Just "course") (i18nCell MsgCourse) $
|
||||
courseCellCL <$> view (_dbrOutput . _1)
|
||||
|
||||
@ -1,10 +1,169 @@
|
||||
module Handler.School where
|
||||
|
||||
import Import
|
||||
import Handler.Utils
|
||||
import Handler.Utils.Table.Columns
|
||||
|
||||
import qualified Database.Esqueleto as E
|
||||
|
||||
import qualified Data.Set as Set
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import qualified Data.Text as Text
|
||||
|
||||
|
||||
getSchoolListR :: Handler Html
|
||||
getSchoolListR = error "getSchoolListR: Not implemented"
|
||||
getSchoolListR = do
|
||||
let
|
||||
schoolLink :: SchoolId -> SomeRoute UniWorX
|
||||
schoolLink ssh = SomeRoute $ SchoolR ssh SchoolEditR
|
||||
|
||||
dbtSQLQuery :: E.SqlExpr (Entity School) -> E.SqlQuery _
|
||||
dbtSQLQuery = return
|
||||
|
||||
getSchoolShowR :: SchoolId -> Handler Html
|
||||
getSchoolShowR = error "getSchoolShowR: Not implemented"
|
||||
dbtProj :: DBRow _ -> MaybeT (YesodDB UniWorX) (DBRow (Entity School))
|
||||
dbtProj = return
|
||||
|
||||
dbtRowKey = (E.^. SchoolId)
|
||||
|
||||
dbtColonnade :: Colonnade Sortable _ _
|
||||
dbtColonnade = mconcat
|
||||
[ colSchoolShort $ _dbrOutput . _entityKey
|
||||
, anchorColonnade (views (_dbrOutput . _entityKey) schoolLink) $ colSchoolName (_dbrOutput . _entityVal . _schoolName)
|
||||
]
|
||||
|
||||
dbtSorting = mconcat
|
||||
[ sortSchoolShort $ to (E.^. SchoolId)
|
||||
, sortSchoolName $ to (E.^. SchoolName)
|
||||
]
|
||||
|
||||
dbtFilter = mempty
|
||||
dbtFilterUI = mempty
|
||||
|
||||
dbtStyle = def
|
||||
dbtParams = def
|
||||
|
||||
dbtCsvEncode = noCsvEncode
|
||||
dbtCsvDecode = Nothing
|
||||
|
||||
dbtIdent :: Text
|
||||
dbtIdent = "schools"
|
||||
|
||||
psValidator = def
|
||||
& defaultSorting [SortAscBy "school-name"]
|
||||
|
||||
|
||||
table <- runDB $ dbTableWidget' psValidator DBTable{..}
|
||||
|
||||
let title = MsgMenuSchoolList
|
||||
siteLayoutMsg title $ do
|
||||
setTitleI title
|
||||
table
|
||||
|
||||
data SchoolForm = SchoolForm
|
||||
{ sfShorthand :: CI Text
|
||||
, sfName :: CI Text
|
||||
, sfOrgUnits :: Set (CI Text)
|
||||
}
|
||||
|
||||
mkSchoolForm :: Maybe SchoolId -> Maybe SchoolForm -> Form SchoolForm
|
||||
mkSchoolForm mSsh template = renderAForm FormStandard $ SchoolForm
|
||||
<$> maybe (\f fs -> areq f fs (sfShorthand <$> template)) (\ssh f fs -> aforced f fs (unSchoolKey ssh)) mSsh ciField (fslI MsgSchoolShort)
|
||||
<*> areq ciField (fslI MsgSchoolName) (sfName <$> template)
|
||||
<*> (Set.fromList . mapMaybe (fmap CI.mk . assertM' (not . Text.null) . Text.strip) <$> massInputListA (textField & addDatalist ldapOrgs) (const "") (const Nothing) ("ldap-organisations" :: Text) (fslI MsgSchoolLdapOrganisations & setTooltip MsgSchoolLdapOrganisationsTip) False (fmap CI.original . Set.toList . sfOrgUnits <$> template))
|
||||
where
|
||||
ldapOrgs :: WidgetT UniWorX IO (Set (CI Text))
|
||||
ldapOrgs = liftHandlerT . runDB $
|
||||
setOf (folded . _entityVal . _schoolLdapOrgUnit) <$> selectList [] []
|
||||
|
||||
schoolToForm :: SchoolId -> DB (Form SchoolForm)
|
||||
schoolToForm ssh = do
|
||||
School{..} <- get404 ssh
|
||||
ldapFrags <- selectList [SchoolLdapSchool ==. Just ssh] []
|
||||
return . mkSchoolForm (Just ssh) $ Just SchoolForm
|
||||
{ sfShorthand = schoolShorthand
|
||||
, sfName = schoolName
|
||||
, sfOrgUnits = setOf (folded . _entityVal . _schoolLdapOrgUnit) ldapFrags
|
||||
}
|
||||
|
||||
|
||||
getSchoolEditR, postSchoolEditR :: SchoolId -> Handler Html
|
||||
getSchoolEditR = postSchoolEditR
|
||||
postSchoolEditR ssh = do
|
||||
sForm <- runDB $ schoolToForm ssh
|
||||
|
||||
((sfResult, sfView), sfEnctype) <- runFormPost sForm
|
||||
|
||||
formResult sfResult $ \SchoolForm{..} -> do
|
||||
runDB $ do
|
||||
update ssh [ SchoolName =. sfName ]
|
||||
forM_ sfOrgUnits $ \schoolLdapOrgUnit ->
|
||||
void $ upsert SchoolLdap
|
||||
{ schoolLdapSchool = Just ssh
|
||||
, ..
|
||||
}
|
||||
[ SchoolLdapSchool =. Just ssh
|
||||
]
|
||||
deleteWhere [SchoolLdapSchool ==. Just ssh, SchoolLdapOrgUnit /<-. Set.toList sfOrgUnits]
|
||||
addMessageI Success $ MsgSchoolUpdated ssh
|
||||
redirect $ SchoolR ssh SchoolEditR
|
||||
|
||||
let sfView' = wrapForm sfView FormSettings
|
||||
{ formMethod = POST
|
||||
, formAction = Just . SomeRoute $ SchoolR ssh SchoolEditR
|
||||
, formEncoding = sfEnctype
|
||||
, formAttrs = []
|
||||
, formSubmit = FormSubmit
|
||||
, formAnchor = Nothing :: Maybe Text
|
||||
}
|
||||
|
||||
siteLayoutMsg (MsgSchoolTitle ssh) $ do
|
||||
setTitleI $ MsgSchoolTitle ssh
|
||||
sfView'
|
||||
|
||||
getSchoolNewR, postSchoolNewR :: Handler Html
|
||||
getSchoolNewR = postSchoolNewR
|
||||
postSchoolNewR = do
|
||||
uid <- requireAuthId
|
||||
((sfResult, sfView), sfEnctype) <- runFormPost $ mkSchoolForm Nothing Nothing
|
||||
|
||||
formResult sfResult $ \SchoolForm{..} -> do
|
||||
let ssh = SchoolKey sfShorthand
|
||||
insertOkay <- runDB $ do
|
||||
didInsert <- is _Just <$> insertUnique School
|
||||
{ schoolShorthand = sfShorthand
|
||||
, schoolName = sfName
|
||||
}
|
||||
when didInsert $ do
|
||||
insert_ UserFunction
|
||||
{ userFunctionUser = uid
|
||||
, userFunctionSchool = ssh
|
||||
, userFunctionFunction = SchoolAdmin
|
||||
}
|
||||
forM_ sfOrgUnits $ \schoolLdapOrgUnit ->
|
||||
void $ upsert SchoolLdap
|
||||
{ schoolLdapSchool = Just ssh
|
||||
, ..
|
||||
}
|
||||
[ SchoolLdapSchool =. Just ssh
|
||||
]
|
||||
return didInsert
|
||||
|
||||
if
|
||||
| insertOkay -> do
|
||||
addMessageI Success $ MsgSchoolCreated ssh
|
||||
redirect $ SchoolR ssh SchoolEditR
|
||||
| otherwise
|
||||
-> addMessageI Error $ MsgSchoolExists ssh
|
||||
|
||||
let sfView' = wrapForm sfView FormSettings
|
||||
{ formMethod = POST
|
||||
, formAction = Just $ SomeRoute SchoolNewR
|
||||
, formEncoding = sfEnctype
|
||||
, formAttrs = []
|
||||
, formSubmit = FormSubmit
|
||||
, formAnchor = Nothing :: Maybe Text
|
||||
}
|
||||
|
||||
siteLayoutMsg MsgTitleSchoolNew $ do
|
||||
setTitleI MsgTitleSchoolNew
|
||||
sfView'
|
||||
|
||||
@ -902,7 +902,7 @@ correctorInvitationConfig = InvitationConfig{..}
|
||||
Course{..} <- get404 sheetCourse
|
||||
return . SomeMessage $ MsgMailSubjectCorrectorInvitation courseTerm courseSchool courseShorthand sheetName
|
||||
invitationHeading (Entity _ Sheet{..}) _ = return . SomeMessage $ MsgSheetCorrInviteHeading sheetName
|
||||
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgSheetCorrInviteExplanation}|]
|
||||
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgSheetCorrInviteExplanation}|]
|
||||
invitationTokenConfig _ _ = do
|
||||
itAuthority <- liftHandlerT requireAuthId
|
||||
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
|
||||
|
||||
@ -100,7 +100,7 @@ submissionUserInvitationConfig = InvitationConfig{..}
|
||||
invitationHeading (Entity _ Submission{..}) _ = do
|
||||
Sheet{..} <- getJust submissionSheet
|
||||
return . SomeMessage $ MsgSubmissionUserInviteHeading sheetName
|
||||
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgSubmissionUserInviteExplanation}|]
|
||||
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgSubmissionUserInviteExplanation}|]
|
||||
invitationTokenConfig (Entity _ Submission{..}) _ = do
|
||||
Sheet{..} <- getJust submissionSheet
|
||||
Course{..} <- getJust sheetCourse
|
||||
|
||||
@ -257,7 +257,14 @@ newTermForm template html = do
|
||||
= aforced termNewField (fslpI MsgTerm (mr MsgTermPlaceholder)) tid
|
||||
| otherwise
|
||||
= areq termNewField (fslpI MsgTerm (mr MsgTermPlaceholder)) Nothing
|
||||
holidayForm = formToAForm . over (mapped._2) pure $ massInputList dayField (const $ "" & addPlaceholder (mr MsgTermHolidayPlaceholder)) (const Nothing) ("holidays" :: Text) (fslI MsgTermHolidays & setTooltip MsgMassInputTip) True (tftHolidays template) mempty
|
||||
holidayForm = massInputListA
|
||||
dayField
|
||||
(const $ "" & addPlaceholder (mr MsgTermHolidayPlaceholder))
|
||||
(const Nothing)
|
||||
("holidays" :: Text)
|
||||
(fslI MsgTermHolidays & setTooltip MsgMassInputTip)
|
||||
True
|
||||
(tftHolidays template)
|
||||
(result, widget) <- flip (renderAForm FormStandard) html $ Term
|
||||
<$> tidForm
|
||||
<*> areq dayField (fslI MsgTermStartDay & setTooltip MsgTermStartDayTooltip) (tftStart template)
|
||||
|
||||
@ -259,7 +259,7 @@ tutorInvitationConfig = InvitationConfig{..}
|
||||
Course{..} <- get404 tutorialCourse
|
||||
return . SomeMessage $ MsgMailSubjectTutorInvitation courseTerm courseSchool courseShorthand tutorialName
|
||||
invitationHeading (Entity _ Tutorial{..}) _ = return . SomeMessage $ MsgTutorInviteHeading tutorialName
|
||||
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgTutorInviteExplanation}|]
|
||||
invitationExplanation _ _ = return [ihamlet|_{SomeMessage MsgTutorInviteExplanation}|]
|
||||
invitationTokenConfig _ _ = do
|
||||
itAuthority <- liftHandlerT requireAuthId
|
||||
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
|
||||
|
||||
@ -10,6 +10,7 @@ import Handler.Utils
|
||||
import Handler.Utils.Tokens
|
||||
import Handler.Utils.Users
|
||||
import Handler.Utils.Invitations
|
||||
import Handler.Utils.Table.Cells
|
||||
|
||||
import qualified Auth.LDAP as Auth
|
||||
|
||||
@ -31,11 +32,10 @@ import Text.Hamlet (ihamlet)
|
||||
import Data.Aeson hiding (Result(..))
|
||||
|
||||
|
||||
hijackUserForm :: CryptoUUIDUser -> Form ()
|
||||
hijackUserForm cID csrf = do
|
||||
(uidResult, uidView) <- mforced hiddenField "" (cID :: CryptoUUIDUser)
|
||||
(btnResult, btnView) <- mreq (buttonField BtnHijack) "" Nothing
|
||||
return (() <$ uidResult <* btnResult, mconcat [toWidget csrf, fvInput uidView, fvInput btnView])
|
||||
hijackUserForm :: Form ()
|
||||
hijackUserForm csrf = do
|
||||
(btnResult, btnView) <- mopt (buttonField BtnHijack) "" Nothing
|
||||
return (btnResult >>= guard . is _Just, mconcat [toWidget csrf, fvInput btnView])
|
||||
|
||||
-- In case of refactoring, use this:
|
||||
-- instance HasEntity (DBRow (Entity User)) User where
|
||||
@ -43,11 +43,21 @@ hijackUserForm cID csrf = do
|
||||
-- instance HasUser (DBRow (Entity USer)) where
|
||||
-- hasUser = _entityVal
|
||||
|
||||
getUsersR :: Handler Html
|
||||
getUsersR = do
|
||||
data UserAction = UserLdapSync | UserHijack
|
||||
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
|
||||
|
||||
instance Universe UserAction
|
||||
instance Finite UserAction
|
||||
nullaryPathPiece ''UserAction $ camelToPathPiece' 1
|
||||
embedRenderMessage ''UniWorX ''UserAction id
|
||||
|
||||
getUsersR, postUsersR :: Handler Html
|
||||
getUsersR = postUsersR
|
||||
postUsersR = do
|
||||
let
|
||||
dbtColonnade = dbColonnade . mconcat $
|
||||
dbtColonnade = mconcat $
|
||||
[ dbRow
|
||||
, dbSelect (applying _2) id (return . view (_dbrOutput . _entityKey))
|
||||
, sortable (Just "name") (i18nCell MsgName) $ \DBRow{ dbrOutput = Entity uid User{..} } -> anchorCellM
|
||||
(AdminUserR <$> encrypt uid)
|
||||
(nameWidget userDisplayName userSurname)
|
||||
@ -58,53 +68,58 @@ getUsersR = do
|
||||
-- (AdminUserR <$> encrypt uid)
|
||||
-- (toWidget . display $ last $ impureNonNull $ words $ userDisplayName)
|
||||
, sortable (Just "auth-ldap") (i18nCell MsgAuthMode) $ \DBRow{ dbrOutput = Entity _ User{..} } -> i18nCell userAuthentication
|
||||
, sortable Nothing (i18nCell MsgAdminFor) $ \DBRow{ dbrOutput = Entity uid _ } -> flip (set' cellContents) mempty $ do
|
||||
schools <- lift . E.select . E.from $ \(school `E.InnerJoin` userAdmin) -> do
|
||||
E.on $ school E.^. SchoolId E.==. userAdmin E.^. UserAdminSchool
|
||||
E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val uid
|
||||
E.orderBy [E.asc $ school E.^. SchoolShorthand]
|
||||
return $ school E.^. SchoolShorthand
|
||||
return [whamlet|
|
||||
$newline never
|
||||
<ul .list--inline .list--comma-separated>
|
||||
$forall (E.Value sh) <- schools
|
||||
<li>#{sh}
|
||||
|]
|
||||
, sortable Nothing (i18nCell MsgLecturerFor) $ \DBRow{ dbrOutput = Entity uid _ } -> flip (set' cellContents) mempty $ do
|
||||
schools <- lift . E.select . E.from $ \(school `E.InnerJoin` userLecturer) -> do
|
||||
E.on $ school E.^. SchoolId E.==. userLecturer E.^. UserLecturerSchool
|
||||
E.where_ $ userLecturer E.^. UserLecturerUser E.==. E.val uid
|
||||
E.orderBy [E.asc $ school E.^. SchoolShorthand]
|
||||
return $ school E.^. SchoolShorthand
|
||||
return [whamlet|
|
||||
$newline never
|
||||
<ul .list--inline .list--comma-separated>
|
||||
$forall (E.Value sh) <- schools
|
||||
<li>#{sh}
|
||||
|]
|
||||
, sortable Nothing mempty $ \DBRow{ dbrOutput = Entity uid _ } -> cell $ do
|
||||
cID <- encrypt uid
|
||||
mayHijack <- (== Authorized) <$> evalAccess (AdminHijackUserR cID) True
|
||||
myUid <- liftHandlerT maybeAuthId
|
||||
when (mayHijack && Just uid /= myUid) $ do
|
||||
(hijackView, hijackEnctype) <- liftHandlerT . generateFormPost $ hijackUserForm cID
|
||||
wrapForm hijackView FormSettings
|
||||
{ formMethod = POST
|
||||
, formAction = Just . SomeRoute $ AdminHijackUserR cID
|
||||
, formEncoding = hijackEnctype
|
||||
, formAttrs = []
|
||||
, formSubmit = FormNoSubmit
|
||||
, formAnchor = Nothing :: Maybe Text
|
||||
}
|
||||
, sortable (Just "ldap-sync") (i18nCell MsgLdapSynced) $ \DBRow{ dbrOutput = Entity _ User{..} } -> maybe mempty dateTimeCell userLastLdapSynchronisation
|
||||
, flip foldMap universeF $ \function ->
|
||||
sortable Nothing (i18nCell function) $ \DBRow{ dbrOutput = Entity uid _ } -> flip (set' cellContents) mempty $ do
|
||||
schools <- liftHandlerT . runDB . E.select . E.from $ \(school `E.InnerJoin` userFunction) -> do
|
||||
E.on $ school E.^. SchoolId E.==. userFunction E.^. UserFunctionSchool
|
||||
E.where_ $ userFunction E.^. UserFunctionUser E.==. E.val uid
|
||||
E.&&. userFunction E.^. UserFunctionFunction E.==. E.val function
|
||||
E.orderBy [E.asc $ school E.^. SchoolShorthand]
|
||||
return $ school E.^. SchoolShorthand
|
||||
return [whamlet|
|
||||
$newline never
|
||||
<ul .list--inline .list--comma-separated>
|
||||
$forall (E.Value sh) <- schools
|
||||
<li>#{sh}
|
||||
|]
|
||||
, sortable Nothing mempty $ \inp@DBRow{ dbrOutput = Entity uid _ } -> FormCell
|
||||
{ formCellAttrs = []
|
||||
, formCellLens = id
|
||||
, formCellContents = do
|
||||
cID <- encrypt uid
|
||||
mayHijack <- (== Authorized) <$> evalAccess (AdminHijackUserR cID) True
|
||||
myUid <- liftHandlerT maybeAuthId
|
||||
if
|
||||
| mayHijack
|
||||
, Just uid /= myUid
|
||||
-> lift $ do
|
||||
let
|
||||
postprocess :: FormResult () -> FormResult (First UserAction, DBFormResult UserId Bool (DBRow (Entity User)))
|
||||
postprocess (FormSuccess ()) = FormSuccess (First $ Just UserHijack, DBFormResult $ Map.singleton uid (inp, const True))
|
||||
postprocess FormMissing = FormSuccess mempty
|
||||
postprocess (FormFailure errs) = FormFailure errs
|
||||
|
||||
over _1 postprocess <$> hijackUserForm mempty
|
||||
| otherwise
|
||||
-> return mempty
|
||||
}
|
||||
]
|
||||
psValidator = def
|
||||
& defaultSorting [SortAscBy "name", SortAscBy "display-name"]
|
||||
|
||||
((), userList) <- runDB $ do
|
||||
(usersRes, userList) <- runDB $ do
|
||||
schoolOptions <- map (CI.original . schoolName . entityVal &&& CI.original . unSchoolKey . entityKey)
|
||||
<$> selectList [] [Asc SchoolName]
|
||||
|
||||
dbTable psValidator DBTable
|
||||
let
|
||||
postprocess :: FormResult (First UserAction, DBFormResult UserId Bool (DBRow (Entity User))) -> FormResult (UserAction, Set UserId)
|
||||
postprocess inp = do
|
||||
(First (Just act), usrMap) <- inp
|
||||
let usrSet = Map.keysSet . Map.filter id $ getDBFormResult (const False) usrMap
|
||||
return (act, usrSet)
|
||||
|
||||
over _1 postprocess <$> dbTable psValidator DBTable
|
||||
{ dbtSQLQuery = return :: E.SqlExpr (Entity User) -> E.SqlQuery (E.SqlExpr (Entity User))
|
||||
, dbtRowKey = (E.^. UserId)
|
||||
, dbtColonnade
|
||||
@ -122,6 +137,9 @@ getUsersR = do
|
||||
, ( "auth-ldap"
|
||||
, SortColumn $ \user -> user E.^. UserAuthentication E.!=. E.val AuthLDAP
|
||||
)
|
||||
, ( "ldap-sync"
|
||||
, SortColumn $ \user -> user E.^. UserLastLdapSynchronisation
|
||||
)
|
||||
]
|
||||
, dbtFilter = Map.fromList -- OverloadedLists does not work with the templates
|
||||
[ ( "user-search", FilterColumn $ \user (criteria :: Set.Set Text) ->
|
||||
@ -142,42 +160,71 @@ getUsersR = do
|
||||
, ( "school", FilterColumn $ \user criterion -> if
|
||||
| Set.null criterion -> E.val True :: E.SqlExpr (E.Value Bool)
|
||||
| otherwise -> let schools = E.valList (Set.toList criterion) in
|
||||
E.exists ( E.from $ \ulectr -> do
|
||||
E.where_ $ ulectr E.^. UserLecturerUser E.==. user E.^. UserId
|
||||
E.where_ $ ulectr E.^. UserLecturerSchool `E.in_` schools
|
||||
) E.||.
|
||||
E.exists ( E.from $ \uadmin -> do
|
||||
E.where_ $ uadmin E.^. UserAdminUser E.==. user E.^. UserId
|
||||
E.where_ $ uadmin E.^. UserAdminSchool `E.in_` schools
|
||||
)
|
||||
E.exists . E.from $ \ufunc -> E.where_ $ ufunc E.^. UserFunctionUser E.==. user E.^. UserId
|
||||
E.&&. ufunc E.^. UserFunctionFunction `E.in_` schools
|
||||
)
|
||||
, ( "ldap-sync", FilterColumn $ \user criteria -> if
|
||||
| Just criteria' <- fromNullable criteria
|
||||
-> let minTime = minimum (criteria' :: NonNull (Set UTCTime))
|
||||
in E.maybe E.true (E.<=. E.val minTime) $ user E.^. UserLastLdapSynchronisation
|
||||
| otherwise -> E.val True :: E.SqlExpr (E.Value Bool)
|
||||
)
|
||||
]
|
||||
, dbtFilterUI = \mPrev -> mconcat
|
||||
[ prismAForm (singletonFilter "user-search") mPrev $ aopt textField (fslI MsgName)
|
||||
[ prismAForm (singletonFilter "user-search") mPrev $ aopt textField (fslI MsgName)
|
||||
-- , prismAForm (singletonFilter "matriculation" ) mPrev $ aopt textField (fslI MsgMatrikelNr)
|
||||
, prismAForm (singletonFilter "matriculation" ) mPrev $ aopt matriculationField (fslI MsgMatrikelNr)
|
||||
, prismAForm (singletonFilter "auth-ldap" . maybePrism _PathPiece) mPrev $ aopt (lift `hoistField` radioFieldList [(MsgAuthPWHash "", False), (MsgAuthLDAP, True)]) (fslI MsgAuthMode)
|
||||
, prismAForm (singletonFilter "school" ) mPrev $ aopt (lift `hoistField` selectFieldList schoolOptions) (fslI MsgCourseSchool)
|
||||
, prismAForm (singletonFilter "matriculation") mPrev $ aopt matriculationField (fslI MsgMatrikelNr)
|
||||
, prismAForm (singletonFilter "auth-ldap" . maybePrism _PathPiece) mPrev $ aopt (lift `hoistField` selectFieldList [(MsgAuthPWHash "", False), (MsgAuthLDAP, True)]) (fslI MsgAuthMode)
|
||||
, prismAForm (singletonFilter "school") mPrev $ aopt (lift `hoistField` selectFieldList schoolOptions) (fslI MsgCourseSchool)
|
||||
, prismAForm (singletonFilter "ldap-sync" . maybePrism _PathPiece) mPrev $ aopt utcTimeField (fslI MsgLdapSyncedBefore)
|
||||
]
|
||||
, dbtStyle = def { dbsFilterLayout = defaultDBSFilterLayout }
|
||||
, dbtParams = def
|
||||
, dbtParams = DBParamsForm
|
||||
{ dbParamsFormMethod = POST
|
||||
, dbParamsFormAction = Just $ SomeRoute UsersR
|
||||
, dbParamsFormAttrs = []
|
||||
, dbParamsFormSubmit = FormSubmit
|
||||
, dbParamsFormAdditional
|
||||
= renderAForm FormStandard
|
||||
$ (, mempty) . First . Just
|
||||
<$> areq (selectField $ optionsF [UserLdapSync]) (fslI MsgAction) Nothing
|
||||
, dbParamsFormEvaluate = liftHandlerT . runFormPost
|
||||
, dbParamsFormResult = id
|
||||
, dbParamsFormIdent = def
|
||||
}
|
||||
, dbtIdent = "users" :: Text
|
||||
, dbtCsvEncode = noCsvEncode
|
||||
, dbtCsvDecode = Nothing
|
||||
}
|
||||
|
||||
formResult usersRes $ \case
|
||||
(_, usersSet)
|
||||
| Set.null usersSet -> do
|
||||
addMessageI Info MsgActionNoUsersSelected
|
||||
redirect UsersR
|
||||
(UserLdapSync, userSet) -> do
|
||||
runDBJobs . forM_ userSet $ \uid -> queueDBJob $ JobSynchroniseLdapUser uid
|
||||
addMessageI Success . MsgSynchroniseLdapUserQueued $ Set.size userSet
|
||||
redirect UsersR
|
||||
(UserHijack, Set.minView -> Just (uid, _)) ->
|
||||
hijackUser uid >>= sendResponse
|
||||
_other -> error "Should not be possible"
|
||||
|
||||
defaultLayout $ do
|
||||
setTitleI MsgUserListTitle
|
||||
$(widgetFile "users")
|
||||
|
||||
hijackUser :: UserId -> Handler TypedContent
|
||||
hijackUser uid = do
|
||||
User{userIdent} <- runDB $ get404 uid
|
||||
setCredsRedirect $ Creds "dummy" (CI.original userIdent) []
|
||||
|
||||
postAdminHijackUserR :: CryptoUUIDUser -> Handler TypedContent
|
||||
postAdminHijackUserR cID = do
|
||||
uid <- decrypt cID
|
||||
((hijackRes, _), _) <- runFormPost $ hijackUserForm cID
|
||||
((hijackRes, _), _) <- runFormPost hijackUserForm
|
||||
|
||||
ret <- formResultMaybe hijackRes $ \() -> Just <$> do
|
||||
User{userIdent} <- runDB $ get404 uid
|
||||
setCredsRedirect $ Creds "dummy" (CI.original userIdent) []
|
||||
ret <- formResultMaybe hijackRes $ \() -> Just <$> hijackUser uid
|
||||
|
||||
maybe (redirect UsersR) return ret
|
||||
|
||||
@ -199,56 +246,57 @@ getAdminUserR = postAdminUserR
|
||||
postAdminUserR uuid = do
|
||||
adminId <- requireAuthId
|
||||
uid <- decrypt uuid
|
||||
let fromSchoolList = Set.fromList . map (userAdminSchool . entityVal)
|
||||
let unValueRights (school, E.Value isAdmin, E.Value isLecturer) = (school,isAdmin,isLecturer)
|
||||
(user@User{..}, fromSchoolList -> adminSchools, fmap unValueRights -> userRights) <- runDB $ (,,)
|
||||
<$> get404 uid
|
||||
<*> selectList [UserAdminUser ==. adminId] []
|
||||
<*> E.select ( E.from $ \school -> do
|
||||
E.orderBy [E.asc $ school E.^. SchoolName]
|
||||
let schAdmin = E.exists $ E.from $ \userAdmin -> do
|
||||
E.where_ $ userAdmin E.^. UserAdminSchool E.==. school E.^. SchoolId
|
||||
E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val uid
|
||||
let schLecturer = E.exists $ E.from $ \userLecturer -> do
|
||||
E.where_ $ userLecturer E.^. UserLecturerSchool E.==. school E.^. SchoolId
|
||||
E.where_ $ userLecturer E.^. UserLecturerUser E.==. E.val uid
|
||||
return (school,schAdmin,schLecturer)
|
||||
)
|
||||
(user@User{..}, adminSchools, functions, schools) <- runDB $ do
|
||||
user <- get404 uid
|
||||
|
||||
schools <- E.select . E.from $ \(school `E.LeftOuterJoin` userFunction) -> do
|
||||
E.on $ userFunction E.?. UserFunctionSchool E.==. E.just (school E.^. SchoolId)
|
||||
E.&&. userFunction E.?. UserFunctionUser E.==. E.just (E.val uid)
|
||||
let isAdmin = E.exists . E.from $ \adminFunction ->
|
||||
E.where_ $ adminFunction E.^. UserFunctionUser E.==. E.val adminId
|
||||
E.&&. adminFunction E.^. UserFunctionSchool E.==. school E.^. SchoolId
|
||||
E.&&. adminFunction E.^. UserFunctionFunction E.==. E.val SchoolAdmin
|
||||
return (school, userFunction E.?. UserFunctionFunction, isAdmin)
|
||||
|
||||
return ( user
|
||||
, setOf (folded . filtered (view $ _3 . _Value) . _1 . _entityKey) schools
|
||||
, setOf (folded . folding (\x -> (,) <$> preview (_2 . _Value . _Just) x <*> preview (_1 . _entityKey) x)) schools
|
||||
, setOf (folded . _1) schools
|
||||
)
|
||||
let allFunctions = Set.fromList universeF
|
||||
allSchools = Set.mapMonotonic entityKey schools
|
||||
|
||||
-- above data is needed for both form generation and result evaluation
|
||||
let userRightsForm :: Form [(SchoolId, Bool, Bool)]
|
||||
let userRightsForm :: Form (Set (SchoolFunction, SchoolId))
|
||||
userRightsForm = identifyForm FIDuserRights $ \csrf -> do
|
||||
boxRights <- forM userRights $ \(school@(Entity sid _), isAdmin, isLecturer) ->
|
||||
if Set.member sid adminSchools
|
||||
then do
|
||||
cbAdmin <- mreq checkBoxField "" (Just isAdmin)
|
||||
cbLecturer <- mreq checkBoxField "" (Just isLecturer)
|
||||
return (school, cbAdmin, cbLecturer)
|
||||
else do
|
||||
cbAdmin <- mforced checkBoxField "" isAdmin
|
||||
cbLecturer <- mforced checkBoxField "" isLecturer
|
||||
return (school, cbAdmin, cbLecturer)
|
||||
let result = forM boxRights $ \(Entity sid _, (resAdmin,_), (resLecturer, _)) ->
|
||||
(,,) <$> pure sid <*> resAdmin <*> resLecturer
|
||||
return (result,$(widgetFile "widgets/user-rights-form/user-rights-form"))
|
||||
boxRights <- sequence . flip Map.fromSet (allFunctions `setProduct` allSchools) $ \(function, sid) -> if
|
||||
| sid `Set.member` adminSchools
|
||||
-> mpopt checkBoxField "" . Just $ (function, sid) `Set.member` functions
|
||||
| otherwise
|
||||
-> mforced checkBoxField "" $ (function, sid) `Set.member` functions
|
||||
let result = Map.keysSet . Map.filter id <$> mapM (view _1) boxRights
|
||||
return (result, $(widgetFile "widgets/user-rights-form/user-rights-form"))
|
||||
userAuthenticationForm :: Form ButtonAuthMode
|
||||
userAuthenticationForm = buttonForm' $ if
|
||||
| userAuthentication == AuthLDAP -> [BtnAuthPWHash]
|
||||
| otherwise -> [BtnAuthLDAP, BtnPasswordReset]
|
||||
let userRightsAction changes = do
|
||||
runDBJobs $ do
|
||||
forM_ changes $ \(sid, userAdmin, userLecturer) ->
|
||||
if Set.notMember sid adminSchools
|
||||
then return ()
|
||||
else do
|
||||
if userAdmin
|
||||
then void . insertUnique $ UserAdmin uid sid
|
||||
else deleteBy $ UniqueUserAdmin uid sid
|
||||
if userLecturer
|
||||
then void . insertUnique $ UserLecturer uid sid
|
||||
else deleteBy $ UniqueSchoolLecturer uid sid
|
||||
-- Note: deleteWhere would not work well here since we filter by adminSchools
|
||||
queueDBJob . JobQueueNotification $ NotificationUserRightsUpdate uid (over _1 (schoolShorthand . entityVal) <$> userRights) -- original rights to check for difference
|
||||
addMessageI Info MsgAccessRightsSaved
|
||||
let symDiff = (changes `Set.difference` functions) `Set.union` (functions `Set.difference` changes)
|
||||
updates = (allFunctions `setProduct` adminSchools) `Set.intersection` symDiff
|
||||
if
|
||||
| not $ Set.null updates -> runDBJobs $ do
|
||||
$logInfoS "user-rights-update" $ tshow updates
|
||||
forM_ updates $ \(function, sid) -> do
|
||||
$logDebugS "user-rights-update" [st|#{tshow (function, sid)}: #{tshow (Set.member (function, sid) functions)} → #{tshow (Set.member (function,sid) changes)}|]
|
||||
if
|
||||
| (function, sid) `Set.member` changes
|
||||
-> void . insertUnique $ UserFunction uid sid function
|
||||
| otherwise
|
||||
-> deleteBy $ UniqueUserFunction uid sid function
|
||||
queueDBJob . JobQueueNotification . NotificationUserRightsUpdate uid $ Set.mapMonotonic (over _2 unSchoolKey) functions -- original rights to check for difference
|
||||
addMessageI Success MsgAccessRightsSaved
|
||||
| otherwise
|
||||
-> addMessageI Info MsgAccessRightsNotChanged
|
||||
redirect $ AdminUserR uuid
|
||||
|
||||
userAuthenticationAction = \case
|
||||
@ -435,54 +483,76 @@ postUserPasswordR cID = do
|
||||
}
|
||||
|
||||
|
||||
instance IsInvitableJunction UserLecturer where
|
||||
type InvitationFor UserLecturer = School
|
||||
data InvitableJunction UserLecturer = JunctionUserLecturer
|
||||
instance IsInvitableJunction UserFunction where
|
||||
type InvitationFor UserFunction = School
|
||||
data InvitableJunction UserFunction = JunctionUserFunction
|
||||
{ jFunction :: SchoolFunction
|
||||
}
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
data InvitationDBData UserLecturer = InvDBDataUserLecturer
|
||||
data InvitationDBData UserFunction = InvDBDataUserFunction
|
||||
{ invDBUserFunctionDeadline :: UTCTime
|
||||
}
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
data InvitationTokenData UserLecturer = InvTokenDataUserLecturer
|
||||
{ invTokenUserLecturerSchool :: SchoolShorthand
|
||||
data InvitationTokenData UserFunction = InvTokenDataUserFunction
|
||||
{ invTokenUserFunctionSchool :: SchoolShorthand
|
||||
, invTokenUserFunctionFunction :: SchoolFunction
|
||||
}
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
|
||||
_InvitableJunction = iso
|
||||
(\UserLecturer{..} -> (userLecturerUser, userLecturerSchool, JunctionUserLecturer))
|
||||
(\(userLecturerUser, userLecturerSchool, JunctionUserLecturer) -> UserLecturer{..})
|
||||
(\UserFunction{..} -> (userFunctionUser, userFunctionSchool, JunctionUserFunction userFunctionFunction))
|
||||
(\(userFunctionUser, userFunctionSchool, JunctionUserFunction userFunctionFunction) -> UserFunction{..})
|
||||
|
||||
instance ToJSON (InvitableJunction UserLecturer) where
|
||||
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1 }
|
||||
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 1 }
|
||||
instance FromJSON (InvitableJunction UserLecturer) where
|
||||
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1 }
|
||||
instance ToJSON (InvitableJunction UserFunction) where
|
||||
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1
|
||||
, fieldLabelModifier = camelToPathPiece' 1
|
||||
}
|
||||
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 1
|
||||
, fieldLabelModifier = camelToPathPiece' 1
|
||||
}
|
||||
instance FromJSON (InvitableJunction UserFunction) where
|
||||
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 1
|
||||
, fieldLabelModifier = camelToPathPiece' 1
|
||||
}
|
||||
|
||||
instance ToJSON (InvitationDBData UserLecturer) where
|
||||
toJSON = genericToJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
|
||||
toEncoding = genericToEncoding defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
|
||||
instance FromJSON (InvitationDBData UserLecturer) where
|
||||
parseJSON = genericParseJSON defaultOptions { constructorTagModifier = camelToPathPiece' 3 }
|
||||
instance ToJSON (InvitationDBData UserFunction) where
|
||||
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
|
||||
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
|
||||
instance FromJSON (InvitationDBData UserFunction) where
|
||||
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
|
||||
|
||||
instance ToJSON (InvitationTokenData UserLecturer) where
|
||||
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
|
||||
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
|
||||
instance FromJSON (InvitationTokenData UserLecturer) where
|
||||
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 3 }
|
||||
instance ToJSON (InvitationTokenData UserFunction) where
|
||||
toJSON = genericToJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
|
||||
toEncoding = genericToEncoding defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
|
||||
instance FromJSON (InvitationTokenData UserFunction) where
|
||||
parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = camelToPathPiece' 4 }
|
||||
|
||||
lecturerInvitationConfig :: InvitationConfig UserLecturer
|
||||
lecturerInvitationConfig = InvitationConfig{..}
|
||||
functionInvitationConfig :: InvitationConfig UserFunction
|
||||
functionInvitationConfig = InvitationConfig{..}
|
||||
where
|
||||
invitationRoute _ _ = return AdminLecturerInviteR
|
||||
invitationResolveFor InvTokenDataUserLecturer{..} = return $ SchoolKey invTokenUserLecturerSchool
|
||||
invitationSubject (Entity _ School{..}) _ = return . SomeMessage $ MsgMailSubjectSchoolLecturerInvitation schoolName
|
||||
invitationHeading (Entity _ School{..}) _ = return . SomeMessage $ MsgMailSchoolLecturerInviteHeading schoolName
|
||||
invitationExplanation _ _ = [ihamlet|_{SomeMessage MsgSchoolLecturerInviteExplanation}|]
|
||||
invitationTokenConfig _ _ = do
|
||||
invitationRoute _ _ = return AdminFunctionaryInviteR
|
||||
invitationResolveFor InvTokenDataUserFunction{..} = return $ SchoolKey invTokenUserFunctionSchool
|
||||
invitationSubject (Entity _ School{..}) (_, InvTokenDataUserFunction{..}) = do
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
return . SomeMessage . MsgMailSubjectSchoolFunctionInvitation schoolName $ mr invTokenUserFunctionFunction
|
||||
invitationHeading (Entity _ School{..}) (_, InvTokenDataUserFunction{..}) = do
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
return . SomeMessage . MsgMailSchoolFunctionInviteHeading schoolName $ mr invTokenUserFunctionFunction
|
||||
invitationExplanation _ (_, InvTokenDataUserFunction{..}) = do
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
return [ihamlet|_{SomeMessage $ MsgSchoolFunctionInviteExplanation (mr $ SomeMessage invTokenUserFunctionFunction)}|]
|
||||
invitationTokenConfig _ (InvDBDataUserFunction{..}, _) = do
|
||||
itAuthority <- liftHandlerT requireAuthId
|
||||
return $ InvitationTokenConfig itAuthority Nothing Nothing Nothing
|
||||
let itExpiresAt = Just $ Just invDBUserFunctionDeadline
|
||||
itAddAuth = Nothing
|
||||
itStartsAt = Nothing
|
||||
return InvitationTokenConfig{..}
|
||||
invitationRestriction _ _ = return Authorized
|
||||
invitationForm _ _ _ = pure $ (JunctionUserLecturer, ())
|
||||
invitationForm _ (_, InvTokenDataUserFunction{..}) _ = pure $ (JunctionUserFunction invTokenUserFunctionFunction, ())
|
||||
invitationInsertHook _ _ _ _ = id
|
||||
invitationSuccessMsg (Entity _ School{..}) _ = return . SomeMessage $ MsgSchoolLecturerInvitationAccepted schoolName
|
||||
invitationSuccessMsg (Entity _ School{..}) (Entity _ UserFunction{..}) = do
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
return . SomeMessage . MsgSchoolFunctionInvitationAccepted schoolName $ mr userFunctionFunction
|
||||
invitationUltDest (Entity ssh _) _ = do
|
||||
currentTerm <- E.select . E.from $ \term -> do
|
||||
E.where_ $ term E.^. TermActive
|
||||
@ -494,39 +564,50 @@ lecturerInvitationConfig = InvitationConfig{..}
|
||||
_other -> CourseListR
|
||||
|
||||
|
||||
getAdminNewLecturerInviteR, postAdminNewLecturerInviteR :: Handler Html
|
||||
getAdminNewLecturerInviteR = postAdminNewLecturerInviteR
|
||||
postAdminNewLecturerInviteR = do
|
||||
getAdminNewFunctionaryInviteR, postAdminNewFunctionaryInviteR :: Handler Html
|
||||
getAdminNewFunctionaryInviteR = postAdminNewFunctionaryInviteR
|
||||
postAdminNewFunctionaryInviteR = do
|
||||
uid <- requireAuthId
|
||||
userSchools <- runDB . E.select . E.from $ \userAdmin -> do
|
||||
E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val uid
|
||||
return $ userAdmin E.^. UserAdminSchool
|
||||
E.where_ $ userAdmin E.^. UserFunctionUser E.==. E.val uid
|
||||
E.&&. userAdmin E.^. UserFunctionFunction E.==. E.val SchoolAdmin
|
||||
return $ userAdmin E.^. UserFunctionSchool
|
||||
|
||||
((invitesResult, invitesWgt), invitesEncoding) <- runFormPost . renderWForm FormStandard $ do
|
||||
school <- wreq (schoolFieldFor $ map E.unValue userSchools) (fslI MsgLecturerInviteSchool) Nothing
|
||||
users <- wreq (multiUserField False Nothing) (fslI MsgLecturerInviteField & setTooltip MsgMultiEmailFieldTip) Nothing
|
||||
return $ (,) <$> school <*> users
|
||||
now <- liftIO getCurrentTime
|
||||
let
|
||||
localNow = utcToLocalTime now
|
||||
beginToday = case localTimeToUTC (LocalTime (localDay localNow) midnight) of
|
||||
LTUUnique utc' _ -> utc'
|
||||
_other -> UTCTime (utctDay now) 0
|
||||
defDeadline = beginToday{ utctDay = 14 `addDays` utctDay beginToday }
|
||||
|
||||
formResultModal invitesResult UsersR $ \(schoolId, users) -> do
|
||||
function <- wreq (selectField optionsFinite) (fslI MsgFunctionaryInviteFunction) Nothing
|
||||
school <- wreq (schoolFieldFor $ map E.unValue userSchools) (fslI MsgFunctionaryInviteSchool) Nothing
|
||||
deadline <- wreq utcTimeField (fslI MsgExamRegistrationInviteDeadline) (Just defDeadline)
|
||||
users <- wreq (multiUserField False Nothing) (fslI MsgFunctionaryInviteField & setTooltip MsgMultiEmailFieldTip) Nothing
|
||||
return $ (,,,) <$> function <*> school <*> deadline <*> users
|
||||
|
||||
formResultModal invitesResult UsersR $ \(function, schoolId, deadline, users) -> do
|
||||
let (emails, uids) = partitionEithers $ Set.toList users
|
||||
lift . runDBJobs $ do
|
||||
forM_ uids $ \lecId ->
|
||||
void . insertUnique $ UserLecturer lecId schoolId
|
||||
void . insertUnique $ UserFunction lecId schoolId function
|
||||
|
||||
sinkInvitationsF lecturerInvitationConfig [ (mail, schoolId, (InvDBDataUserLecturer, InvTokenDataUserLecturer $ unSchoolKey schoolId)) | mail <- emails ]
|
||||
sinkInvitationsF functionInvitationConfig [ (mail, schoolId, (InvDBDataUserFunction deadline, InvTokenDataUserFunction (unSchoolKey schoolId) function)) | mail <- emails ]
|
||||
|
||||
unless (null emails) $
|
||||
tell . pure <=< messageI Success . MsgLecturersInvited $ length emails
|
||||
tell . pure <=< messageI Success . MsgFunctionariesInvited $ length emails
|
||||
unless (null uids) $
|
||||
tell . pure <=< messageI Success . MsgLecturersAdded $ length uids
|
||||
tell . pure <=< messageI Success . MsgFunctionariesAdded $ length uids
|
||||
|
||||
siteLayoutMsg MsgLecturerInviteHeading $ do
|
||||
setTitleI MsgLecturerInviteHeading
|
||||
siteLayoutMsg MsgFunctionaryInviteHeading $ do
|
||||
setTitleI MsgFunctionaryInviteHeading
|
||||
wrapForm invitesWgt def
|
||||
{ formEncoding = invitesEncoding
|
||||
, formAction = Just $ SomeRoute AdminNewLecturerInviteR
|
||||
, formAction = Just $ SomeRoute AdminNewFunctionaryInviteR
|
||||
}
|
||||
|
||||
getAdminLecturerInviteR, postAdminLecturerInviteR :: Handler Html
|
||||
getAdminLecturerInviteR = postAdminLecturerInviteR
|
||||
postAdminLecturerInviteR = invitationR lecturerInvitationConfig
|
||||
getAdminFunctionaryInviteR, postAdminFunctionaryInviteR :: Handler Html
|
||||
getAdminFunctionaryInviteR = postAdminFunctionaryInviteR
|
||||
postAdminFunctionaryInviteR = invitationR functionInvitationConfig
|
||||
|
||||
@ -12,6 +12,7 @@ module Handler.Utils.DateTime
|
||||
, weeksToAdd
|
||||
, setYear
|
||||
, ceilingQuarterHour
|
||||
, formatGregorianW
|
||||
) where
|
||||
|
||||
import Import
|
||||
@ -263,3 +264,7 @@ formatTimeRangeW s t t' = toWidget =<< formatTimeRange s t t'
|
||||
|
||||
formatTimeRangeMail :: (MonadMail m, HasLocalTime t, HasLocalTime t') => SelDateTimeFormat -> t -> Maybe t' -> m Text
|
||||
formatTimeRangeMail = formatTimeRange' formatTimeMail
|
||||
|
||||
|
||||
formatGregorianW :: Integer -> Int -> Int -> Widget
|
||||
formatGregorianW year month day = formatTimeW SelFormatDate $ fromGregorian year month day
|
||||
|
||||
@ -170,8 +170,15 @@ optionalAction :: AForm Handler a
|
||||
-> FieldSettings UniWorX
|
||||
-> Maybe Bool
|
||||
-> (Html -> MForm Handler (FormResult (Maybe a), [FieldView UniWorX]))
|
||||
optionalAction justAct fs@FieldSettings{..} defActive csrf = do
|
||||
(doRes, doView) <- mpopt checkBoxField fs defActive
|
||||
optionalAction = optionalAction' mpopt
|
||||
|
||||
optionalAction' :: (Field Handler Bool -> FieldSettings UniWorX -> Maybe Bool -> MForm Handler (FormResult Bool, FieldView UniWorX))
|
||||
-> AForm Handler a
|
||||
-> FieldSettings UniWorX
|
||||
-> Maybe Bool
|
||||
-> (Html -> MForm Handler (FormResult (Maybe a), [FieldView UniWorX]))
|
||||
optionalAction' minp justAct fs@FieldSettings{..} defActive csrf = do
|
||||
(doRes, doView) <- minp checkBoxField fs defActive
|
||||
(actionRes, actionViews') <- over _2 ($ []) <$> aFormToForm justAct
|
||||
|
||||
let actionViews = over (mapped . _fvInput) (\w -> $(widgetFile "widgets/multi-action/optional-action")) actionViews'
|
||||
@ -182,13 +189,28 @@ optionalActionA :: AForm Handler a
|
||||
-> FieldSettings UniWorX
|
||||
-> Maybe Bool
|
||||
-> AForm Handler (Maybe a)
|
||||
optionalActionA justAct fs defActive = formToAForm $ optionalAction justAct fs defActive mempty
|
||||
optionalActionA = optionalActionA' mpopt
|
||||
|
||||
optionalActionA' :: (Field Handler Bool -> FieldSettings UniWorX -> Maybe Bool -> MForm Handler (FormResult Bool, FieldView UniWorX))
|
||||
-> AForm Handler a
|
||||
-> FieldSettings UniWorX
|
||||
-> Maybe Bool
|
||||
-> AForm Handler (Maybe a)
|
||||
optionalActionA' minp justAct fs defActive = formToAForm $ optionalAction' minp justAct fs defActive mempty
|
||||
|
||||
optionalActionW :: AForm Handler a
|
||||
-> FieldSettings UniWorX
|
||||
-> Maybe Bool
|
||||
-> WForm Handler (FormResult (Maybe a))
|
||||
optionalActionW justAct fs defAction = aFormToWForm $ optionalActionA justAct fs defAction
|
||||
optionalActionW = optionalActionW' mpopt
|
||||
|
||||
|
||||
optionalActionW' :: (Field Handler Bool -> FieldSettings UniWorX -> Maybe Bool -> MForm Handler (FormResult Bool, FieldView UniWorX))
|
||||
-> AForm Handler a
|
||||
-> FieldSettings UniWorX
|
||||
-> Maybe Bool
|
||||
-> WForm Handler (FormResult (Maybe a))
|
||||
optionalActionW' minp justAct fs defAction = aFormToWForm $ optionalActionA' minp justAct fs defAction
|
||||
|
||||
|
||||
multiAction :: forall action a.
|
||||
@ -848,7 +870,7 @@ localTimeField = Field
|
||||
where
|
||||
fieldTimeFormat :: String
|
||||
--fieldTimeFormat = "%e.%m.%y %k:%M"
|
||||
fieldTimeFormat = "%Y-%m-%dT%H:%M"
|
||||
fieldTimeFormat = "%Y-%m-%dT%H:%M:%S"
|
||||
|
||||
-- `defaultTimeLocale` is okay here, since `fieldTimeFormat` does not contain any
|
||||
readTime :: Text -> Either UniWorXMessage LocalTime
|
||||
|
||||
@ -7,7 +7,7 @@ module Handler.Utils.Form.MassInput
|
||||
, massInput
|
||||
, module Handler.Utils.Form.MassInput.Liveliness
|
||||
, massInputA, massInputW
|
||||
, massInputList
|
||||
, massInputList, massInputListA
|
||||
, massInputAccum, massInputAccumA, massInputAccumW
|
||||
, massInputAccumEdit, massInputAccumEditA, massInputAccumEditW
|
||||
, ListLength(..), ListPosition(..), miDeleteList
|
||||
@ -486,6 +486,22 @@ massInputList field fieldSettings miButtonAction miIdent miSettings miRequired m
|
||||
miRequired
|
||||
(Map.fromList . zip [0..] . map ((), ) <$> miPrevResult)
|
||||
|
||||
massInputListA :: forall handler cellResult ident.
|
||||
( MonadHandler handler, HandlerSite handler ~ UniWorX
|
||||
, MonadLogger handler
|
||||
, PathPiece ident
|
||||
)
|
||||
=> Field handler cellResult
|
||||
-> (ListPosition -> FieldSettings UniWorX)
|
||||
-> (forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX))
|
||||
-> ident
|
||||
-> FieldSettings UniWorX
|
||||
-> Bool
|
||||
-> Maybe [cellResult]
|
||||
-> AForm handler [cellResult]
|
||||
massInputListA field fieldSettings miButtonAction miIdent miSettings miRequired miPrevResult = formToAForm . fmap (over _2 pure) $ massInputList field fieldSettings miButtonAction miIdent miSettings miRequired miPrevResult mempty
|
||||
|
||||
|
||||
-- | Wrapper around `massInput` for the common case, that we just want a list of data with no option to modify it except deletion and addition
|
||||
massInputAccum :: forall handler cellData ident.
|
||||
( MonadHandler handler, HandlerSite handler ~ UniWorX
|
||||
|
||||
@ -124,7 +124,7 @@ data InvitationConfig junction = forall formCtx. InvitationConfig
|
||||
-- ^ Subject of the e-mail which sends the token to the user
|
||||
, invitationHeading :: Entity (InvitationFor junction) -> InvitationData junction -> DB (SomeMessage UniWorX)
|
||||
-- ^ Heading of the page which allows the invitee to accept/decline the invitation (`invitationR`
|
||||
, invitationExplanation :: Entity (InvitationFor junction) -> InvitationData junction -> HtmlUrlI18n (SomeMessage UniWorX) (Route UniWorX)
|
||||
, invitationExplanation :: Entity (InvitationFor junction) -> InvitationData junction -> DB (HtmlUrlI18n (SomeMessage UniWorX) (Route UniWorX))
|
||||
-- ^ Explanation of what kind of invitation this is (used both in the e-mail and in `invitationR`)
|
||||
, invitationTokenConfig :: Entity (InvitationFor junction) -> InvitationData junction -> DB InvitationTokenConfig
|
||||
-- ^ Parameters for creating the invitation token (`InvitationTokenData` is handled transparently)
|
||||
@ -177,7 +177,7 @@ sinkInvitations :: forall junction.
|
||||
-- updated, instead.
|
||||
--
|
||||
-- For new junctions an invitation is sent by e-mail.
|
||||
sinkInvitations InvitationConfig{..} = determineExists .| C.foldMap pure >>= lift . sinkInvitations'
|
||||
sinkInvitations InvitationConfig{..} = determineExists .| sinkInvitations'
|
||||
where
|
||||
determineExists :: Conduit (Invitation' junction)
|
||||
(YesodJobDB UniWorX)
|
||||
@ -203,13 +203,9 @@ sinkInvitations InvitationConfig{..} = determineExists .| C.foldMap pure >>= lif
|
||||
JSON.Success dbData -> return dbData
|
||||
JSON.Error str -> fail $ "Could not decode invitationData: " <> str
|
||||
|
||||
sinkInvitations' :: [Invitation' junction]
|
||||
-> YesodJobDB UniWorX ()
|
||||
sinkInvitations' new = do
|
||||
when (is _Nothing (ephemeralInvitation @junction)) $ do
|
||||
insertMany_ $ map (\(email, fid, dat) -> Invitation email (invRef @junction fid) (toJSON $ dat ^. _invitationDBData)) new
|
||||
-- forM_ existing $ \(iid, oldDat) -> update iid [ InvitationData =. toJSON (dat ^. _invitationDBData) ]
|
||||
forM_ new $ \(jInvitee, fid, dat) -> do
|
||||
sinkInvitations' :: Sink (Invitation' junction) (YesodJobDB UniWorX) ()
|
||||
sinkInvitations' = do
|
||||
C.mapM_ $ \(jInvitee, fid, dat) -> do
|
||||
app <- getYesod
|
||||
let mr = renderMessage app $ NonEmpty.toList appLanguages
|
||||
ur <- getUrlRenderParams
|
||||
@ -224,7 +220,14 @@ sinkInvitations InvitationConfig{..} = determineExists .| C.foldMap pure >>= lif
|
||||
jwt <- encodeToken token
|
||||
jInvitationUrl <- toTextUrl (route, [(toPathPiece GetBearer, toPathPiece jwt)])
|
||||
jInvitationSubject <- fmap mr . mapReaderT liftHandlerT $ invitationSubject fEnt dat
|
||||
let jInvitationExplanation = invitationExplanation fEnt dat (toHtml . mr) ur
|
||||
jInvitationExplanation <- (\ihtml -> ihtml (toHtml . mr) ur) <$> mapReaderT liftHandlerT (invitationExplanation fEnt dat)
|
||||
|
||||
when (is _Nothing (ephemeralInvitation @junction)) $ insert_ $ Invitation
|
||||
{ invitationEmail = jInvitee
|
||||
, invitationFor = invRef @junction fid
|
||||
, invitationData = toJSON $ dat ^. _invitationDBData
|
||||
, invitationExpiresAt = tokenExpiresAt token
|
||||
}
|
||||
|
||||
queueDBJob JobInvitation{..}
|
||||
|
||||
@ -247,9 +250,9 @@ sourceInvitations :: forall junction.
|
||||
-> Source (YesodDB UniWorX) (UserEmail, InvitationDBData junction)
|
||||
sourceInvitations forKey = selectSource [InvitationFor ==. invRef @junction forKey] [] .| C.mapM decode
|
||||
where
|
||||
decode (Entity _ (Invitation email _ invitationData))
|
||||
decode (Entity _ (Invitation{invitationEmail, invitationData}))
|
||||
= case fromJSON invitationData of
|
||||
JSON.Success dbData -> return (email, dbData)
|
||||
JSON.Success dbData -> return (invitationEmail, dbData)
|
||||
JSON.Error str -> fail $ "Could not decode invitationData: " <> str
|
||||
|
||||
sourceInvitationsList :: forall junction.
|
||||
@ -322,7 +325,7 @@ invitationR' InvitationConfig{..} = liftHandlerT $ do
|
||||
MsgRenderer mr <- getMsgRenderer
|
||||
ur <- getUrlRenderParams
|
||||
heading <- invitationHeading fEnt iData
|
||||
let explanation = invitationExplanation fEnt iData (toHtml . mr) ur
|
||||
explanation <- (\ihtml -> ihtml (toHtml . mr) ur) <$> invitationExplanation fEnt iData
|
||||
|
||||
fmap (, (dataWidget, dataEnctype), heading, explanation) . formResultMaybe dataRes $ \case
|
||||
Nothing -> do
|
||||
|
||||
@ -157,7 +157,7 @@ parseRating File{ fileContent = Just input, .. } = do
|
||||
ratingStr = Text.unpack $ Text.strip ratingLine
|
||||
ratingPoints <- case () of
|
||||
_ | null ratingStr -> return Nothing
|
||||
| otherwise -> either (throw . RatingInvalid) return $ Just <$> readEither ratingStr
|
||||
| otherwise -> either (throw . RatingInvalid . pack) return $ Just <$> readEither ratingStr
|
||||
return Rating'{ ratingTime = Just fileModified, .. }
|
||||
parseRating _ = throwM RatingFileIsDirectory
|
||||
|
||||
|
||||
32
src/Handler/Utils/SchoolLdap.hs
Normal file
32
src/Handler/Utils/SchoolLdap.hs
Normal file
@ -0,0 +1,32 @@
|
||||
module Handler.Utils.SchoolLdap
|
||||
( parseLdapSchools
|
||||
) where
|
||||
|
||||
import Import.NoFoundation hiding (try, (<|>), choice)
|
||||
|
||||
import Text.Parsec
|
||||
import Text.Parsec.Text
|
||||
|
||||
import Data.CaseInsensitive (CI)
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
|
||||
import qualified Data.Set as Set
|
||||
|
||||
|
||||
parseLdapSchools :: Text -> Either ParseError (Set (CI Text))
|
||||
parseLdapSchools = parse pLdapSchools ""
|
||||
|
||||
pLdapSchools :: Parser (Set (CI Text))
|
||||
pLdapSchools = Set.fromList . map CI.mk <$> pSegment `sepBy` char ','
|
||||
|
||||
pSegment :: Parser Text
|
||||
pSegment = do
|
||||
let
|
||||
fragStart = flip label "fragment start" $ do
|
||||
void . choice . map (try . string) $ sortOn Down
|
||||
[ "l", "st", "o", "ou", "c", "street", "dc" ]
|
||||
void $ char '='
|
||||
|
||||
fragStart
|
||||
pack <$> manyTill anyChar (try (lookAhead $ char ',' >> fragStart) <|> eof)
|
||||
|
||||
@ -8,8 +8,8 @@ import Text.Parsec
|
||||
import Text.Parsec.Text
|
||||
|
||||
|
||||
parseStudyFeatures :: UserId -> UTCTime -> Text -> Either Text [StudyFeatures]
|
||||
parseStudyFeatures uId now = first tshow . parse (pStudyFeatures uId now <* eof) ""
|
||||
parseStudyFeatures :: UserId -> UTCTime -> Text -> Either ParseError [StudyFeatures]
|
||||
parseStudyFeatures uId now = parse (pStudyFeatures uId now <* eof) ""
|
||||
|
||||
|
||||
pStudyFeatures :: UserId -> UTCTime -> Parser [StudyFeatures]
|
||||
|
||||
@ -234,18 +234,14 @@ termCell tid = anchorCell link name
|
||||
termCellCL :: IsDBTable m a => CourseLink -> DBCell m a
|
||||
termCellCL (tid,_,_) = termCell tid
|
||||
|
||||
schoolCell :: IsDBTable m a => Maybe TermId -> SchoolId -> DBCell m a
|
||||
schoolCell (Just tid) ssh = anchorCell link name
|
||||
schoolCell :: IsDBTable m a => TermId -> SchoolId -> DBCell m a
|
||||
schoolCell tid ssh = anchorCell link name
|
||||
where
|
||||
link = TermSchoolCourseListR tid ssh
|
||||
name = toWgt ssh
|
||||
schoolCell Nothing ssh = anchorCell link name
|
||||
where
|
||||
link = SchoolShowR ssh
|
||||
name = toWgt ssh
|
||||
|
||||
schoolCellCL :: IsDBTable m a => CourseLink -> DBCell m a
|
||||
schoolCellCL (tid,ssh,_) = schoolCell (Just tid) ssh
|
||||
schoolCellCL (tid,ssh,_) = schoolCell tid ssh
|
||||
|
||||
courseCellCL :: IsDBTable m a => CourseLink -> DBCell m a
|
||||
courseCellCL (tid,ssh,csh) = anchorCell link name
|
||||
|
||||
@ -102,8 +102,8 @@ fltrTermUI mPrev = prismAForm (singletonFilter "term" . maybePrism _PathPiece) m
|
||||
-- Schools --
|
||||
-------------
|
||||
|
||||
colSchoolShort :: OpticColonnade SchoolId
|
||||
colSchoolShort resultSsh = Colonnade.singleton (fromSortable header) body
|
||||
colSchool :: OpticColonnade SchoolId
|
||||
colSchool resultSsh = Colonnade.singleton (fromSortable header) body
|
||||
where
|
||||
header = Sortable (Just "school") (i18nCell MsgSchool)
|
||||
body = i18nCell . unSchoolKey . view resultSsh
|
||||
@ -111,6 +111,24 @@ colSchoolShort resultSsh = Colonnade.singleton (fromSortable header) body
|
||||
sortSchool :: OpticSortColumn SchoolId
|
||||
sortSchool querySsh = singletonMap "school" . SortColumn $ view querySsh
|
||||
|
||||
colSchoolShort :: OpticColonnade SchoolId
|
||||
colSchoolShort resultSsh = Colonnade.singleton (fromSortable header) body
|
||||
where
|
||||
header = Sortable (Just "school-short") (i18nCell MsgSchoolShort)
|
||||
body = i18nCell . unSchoolKey . view resultSsh
|
||||
|
||||
sortSchoolShort :: OpticSortColumn SchoolId
|
||||
sortSchoolShort querySsh = singletonMap "school-short" . SortColumn $ view querySsh
|
||||
|
||||
colSchoolName :: OpticColonnade SchoolName
|
||||
colSchoolName resultSn = Colonnade.singleton (fromSortable header) body
|
||||
where
|
||||
header = Sortable (Just "school-name") (i18nCell MsgSchoolName)
|
||||
body = i18nCell . view resultSn
|
||||
|
||||
sortSchoolName :: OpticSortColumn SchoolName
|
||||
sortSchoolName querySn = singletonMap "school-name" . SortColumn $ view querySn
|
||||
|
||||
fltrSchool :: OpticFilterColumn t SchoolId
|
||||
fltrSchool querySsh = singletonMap "school" . FilterColumn $ mkExactFilter (view querySsh)
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ module Handler.Utils.Table.Pagination
|
||||
, linkEitherCell, linkEitherCellM, linkEitherCellM'
|
||||
, cellTooltip
|
||||
, listCell
|
||||
, formCell, DBFormResult, getDBFormResult
|
||||
, formCell, DBFormResult(..), getDBFormResult
|
||||
, dbRow, dbSelect
|
||||
, (&)
|
||||
, module Control.Monad.Trans.Maybe
|
||||
|
||||
@ -57,7 +57,7 @@ import Data.Hashable as Import
|
||||
import Data.List.NonEmpty as Import (NonEmpty(..), nonEmpty)
|
||||
import Data.Text.Encoding.Error as Import(UnicodeException(..))
|
||||
import Data.Semigroup as Import (Semigroup, Min(..), Max(..))
|
||||
import Data.Monoid as Import (Last(..), First(..), Any(..), All(..), Sum(..), Endo(..))
|
||||
import Data.Monoid as Import (Last(..), First(..), Any(..), All(..), Sum(..), Endo(..), Alt(..))
|
||||
import Data.Binary as Import (Binary)
|
||||
|
||||
import Numeric.Natural as Import (Natural)
|
||||
|
||||
45
src/Jobs.hs
45
src/Jobs.hs
@ -66,6 +66,8 @@ import Jobs.Handler.SendCourseCommunication
|
||||
import Jobs.Handler.Invitation
|
||||
import Jobs.Handler.SendPasswordReset
|
||||
import Jobs.Handler.TransactionLog
|
||||
import Jobs.Handler.SynchroniseLdap
|
||||
import Jobs.Handler.PruneInvitations
|
||||
|
||||
import Jobs.HealthReport
|
||||
|
||||
@ -143,7 +145,7 @@ manageJobPool foundation@UniWorX{..}
|
||||
endo <- execWriterT . replicateM_ missing $ do
|
||||
workerId <- newWorkerId
|
||||
let logIdent = mkLogIdent workerId
|
||||
(bChan, chan) <- atomically $ newBroadcastTChan >>= (\c -> (c, ) <$> dupTChan c)
|
||||
chan <- liftIO $ newTVarIO mempty
|
||||
let
|
||||
streamChan = join . atomically $ do
|
||||
shouldTerminate <- readTMVar appJobState >>= fmap not . isEmptyTMVar . jobShutdown
|
||||
@ -151,7 +153,10 @@ manageJobPool foundation@UniWorX{..}
|
||||
| shouldTerminate ->
|
||||
return $ return ()
|
||||
| otherwise -> do
|
||||
nextVal <- readTChan chan
|
||||
queue <- readTVar chan
|
||||
nextVal <- case jqDequeue queue of
|
||||
Nothing -> retry
|
||||
Just (j, q) -> j <$ writeTVar chan q
|
||||
return $ yield nextVal >> streamChan
|
||||
runWorker = unsafeHandler foundation . flip runReaderT (jobContext oldState) $ do
|
||||
$logInfoS logIdent "Started"
|
||||
@ -160,7 +165,7 @@ manageJobPool foundation@UniWorX{..}
|
||||
worker <- allocateLinkedAsync runWorker
|
||||
|
||||
tell . Endo $ \cSt -> cSt
|
||||
{ jobWorkers = Map.insert worker bChan $ jobWorkers cSt
|
||||
{ jobWorkers = Map.insert worker chan $ jobWorkers cSt
|
||||
, jobWorkerName = \a -> bool (jobWorkerName cSt a) workerId $ a == worker
|
||||
}
|
||||
atomically . putTMVar appJobState $ endo `appEndo` oldState
|
||||
@ -179,12 +184,14 @@ manageJobPool foundation@UniWorX{..}
|
||||
void . lift . allocateLinkedAsync $
|
||||
let go = do
|
||||
next <- evalRandTIO . mapRandT (liftIO . atomically) . runMaybeT $ do
|
||||
nextVal <- MaybeT . lift . tryReadTChan $ jobWorkers oldState ! jobAsync
|
||||
let chan = jobWorkers oldState ! jobAsync
|
||||
(nextVal, newQueue) <- MaybeT . lift . fmap jqDequeue $ readTVar chan
|
||||
lift . lift $ writeTVar chan newQueue
|
||||
jobWorkers' <- lift . lift $ jobWorkers <$> readTMVar appJobState
|
||||
receiver <- maybe (lift $ lift retry) return =<< uniformMay jobWorkers'
|
||||
return (nextVal, receiver)
|
||||
whenIsJust next $ \(nextVal, receiver) -> do
|
||||
atomically $ writeTChan receiver nextVal
|
||||
atomically . modifyTVar' receiver $ jqInsert nextVal
|
||||
go
|
||||
in go
|
||||
|
||||
@ -214,15 +221,15 @@ execCrontab :: RWST JobContext () (HashMap JobCtl (Max UTCTime)) (HandlerT UniWo
|
||||
execCrontab = do
|
||||
mapRWST (liftHandlerT . runDB . setSerializable) $ do
|
||||
let
|
||||
mergeLastExec (Entity leId CronLastExec{..})
|
||||
mergeLastExec (Entity _leId CronLastExec{..})
|
||||
| Just job <- Aeson.parseMaybe parseJSON cronLastExecJob
|
||||
= State.modify $ HashMap.insertWith (<>) (JobCtlQueue job) (Max cronLastExecTime)
|
||||
| otherwise = lift $ delete leId
|
||||
| otherwise = return ()
|
||||
|
||||
mergeQueued (Entity qjId QueuedJob{..})
|
||||
mergeQueued (Entity _qjId QueuedJob{..})
|
||||
| Just job <- Aeson.parseMaybe parseJSON queuedJobContent
|
||||
= State.modify $ HashMap.insertWith (<>) (JobCtlQueue job) (Max queuedJobCreationTime)
|
||||
| otherwise = lift $ delete qjId
|
||||
| otherwise = return ()
|
||||
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ mergeLastExec
|
||||
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ mergeQueued
|
||||
|
||||
@ -339,7 +346,7 @@ handleJobs' wNum = C.mapM_ $ \jctl -> do
|
||||
handleQueueException (JNonexistant jId) = $logInfoS logIdent $ "Saw nonexistant queue id: " ++ tshow (fromSqlKey jId)
|
||||
handleQueueException (JLocked jId lInstance lTime) = $logDebugS logIdent $ "Saw locked QueuedJob: " ++ tshow (fromSqlKey jId, lInstance, lTime)
|
||||
|
||||
handleCmd JobCtlNoOp = return ()
|
||||
handleCmd JobCtlTest = return ()
|
||||
handleCmd JobCtlFlush = void . lift . runDB . runConduit $ selectKeys [] [ Asc QueuedJobCreationTime ] .| C.mapM_ (lift . writeJobCtl . JobCtlPerform)
|
||||
handleCmd (JobCtlQueue job) = lift $ queueJob' job
|
||||
handleCmd (JobCtlPerform jId) = lift . handle handleQueueException . jLocked jId $ \j@QueuedJob{..} -> do
|
||||
@ -428,11 +435,19 @@ jLocked jId act = do
|
||||
pruneLastExecs :: Crontab JobCtl -> DB ()
|
||||
pruneLastExecs crontab = runConduit $ selectSource [] [] .| C.mapM_ ensureCrontab
|
||||
where
|
||||
ensureCrontab (Entity leId CronLastExec{..})
|
||||
| Just job <- Aeson.parseMaybe parseJSON cronLastExecJob
|
||||
, HashMap.member (JobCtlQueue job) crontab
|
||||
= return ()
|
||||
| otherwise = delete leId
|
||||
ensureCrontab (Entity leId CronLastExec{..}) = void . runMaybeT $ do
|
||||
now <- liftIO getCurrentTime
|
||||
flushInterval <- MaybeT . getsYesod . view $ appSettings . _appJobFlushInterval
|
||||
|
||||
|
||||
if
|
||||
| abs (now `diffUTCTime` cronLastExecTime) > flushInterval * 2
|
||||
-> return ()
|
||||
| Just job <- Aeson.parseMaybe parseJSON cronLastExecJob
|
||||
, not $ HashMap.member (JobCtlQueue job) crontab
|
||||
-> lift $ delete leId
|
||||
| otherwise
|
||||
-> return ()
|
||||
|
||||
determineCrontab' :: DB (Crontab JobCtl)
|
||||
determineCrontab' = (\ct -> ct <$ pruneLastExecs ct) =<< determineCrontab
|
||||
|
||||
@ -11,6 +11,9 @@ import qualified Data.Map as Map
|
||||
import Data.Semigroup (Max(..))
|
||||
|
||||
import Data.Time.Zones
|
||||
import Data.Time.Clock.POSIX
|
||||
|
||||
import Handler.Utils.DateTime
|
||||
|
||||
import Control.Monad.Trans.Writer (WriterT, execWriterT)
|
||||
import Control.Monad.Writer.Class (MonadWriter(..))
|
||||
@ -23,7 +26,7 @@ import qualified Database.Esqueleto as E
|
||||
determineCrontab :: DB (Crontab JobCtl)
|
||||
-- ^ Extract all future jobs from the database (sheet deadlines, ...)
|
||||
determineCrontab = execWriterT $ do
|
||||
AppSettings{..} <- getsYesod appSettings'
|
||||
UniWorX{ appSettings' = AppSettings{..}, .. } <- getYesod
|
||||
|
||||
case appJobFlushInterval of
|
||||
Just interval -> tell $ HashMap.singleton
|
||||
@ -44,6 +47,16 @@ determineCrontab = execWriterT $ do
|
||||
, cronRateLimit = appJobCronInterval
|
||||
, cronNotAfter = Right CronNotScheduled
|
||||
}
|
||||
|
||||
oldestInvitationMUTC <- lift $ preview (_head . _entityVal . _invitationExpiresAt . _Just) <$> selectList [InvitationExpiresAt !=. Nothing] [Asc InvitationExpiresAt, LimitTo 1]
|
||||
whenIsJust oldestInvitationMUTC $ \oldestInvUTC -> tell $ HashMap.singleton
|
||||
(JobCtlQueue JobPruneInvitations)
|
||||
Cron
|
||||
{ cronInitial = CronTimestamp $ utcToLocalTime oldestInvUTC
|
||||
, cronRepeat = CronRepeatOnChange
|
||||
, cronRateLimit = nominalDay
|
||||
, cronNotAfter = Right CronNotScheduled
|
||||
}
|
||||
|
||||
tell . flip foldMap universeF $ \kind ->
|
||||
case appHealthCheckInterval kind of
|
||||
@ -84,6 +97,49 @@ determineCrontab = execWriterT $ do
|
||||
, cronNotAfter = Right CronNotScheduled
|
||||
}
|
||||
|
||||
|
||||
if
|
||||
| is _Just appLdapConf
|
||||
, is _Just appLdapConf
|
||||
, Just syncWithin <- appSynchroniseLdapUsersWithin
|
||||
-> do
|
||||
now <- liftIO getPOSIXTime
|
||||
let
|
||||
epochInterval = syncWithin / 2
|
||||
interval = appSynchroniseLdapUsersInterval
|
||||
|
||||
(ldapEpoch, epochNow) = now `divMod'` epochInterval
|
||||
ldapInterval = epochNow `div'` interval
|
||||
numIntervals = floor $ epochInterval / interval
|
||||
|
||||
nextIntervals = do
|
||||
let
|
||||
n = ceiling $ 4 * appJobCronInterval / appSynchroniseLdapUsersInterval
|
||||
i <- [negate (ceiling $ n % 2) .. ceiling $ n % 2]
|
||||
let
|
||||
((+ ldapEpoch) -> nextEpoch, nextInterval) = (ldapInterval + i) `divMod` numIntervals
|
||||
nextIntervalTime
|
||||
= posixSecondsToUTCTime $ fromInteger nextEpoch * epochInterval + fromInteger nextInterval * interval
|
||||
return (nextEpoch, nextInterval, nextIntervalTime)
|
||||
|
||||
forM_ nextIntervals $ \(nextEpoch, nextInterval, nextIntervalTime) -> do
|
||||
$logDebugS "SynchroniseLdap" [st|currentTime: #{tshow ldapEpoch}.#{tshow epochNow}; upcomingSync: #{tshow nextEpoch}.#{tshow (fromInteger nextInterval * interval)}; upcomingData: #{tshow (numIntervals, nextEpoch, nextInterval)}|]
|
||||
tell $ HashMap.singleton
|
||||
(JobCtlQueue JobSynchroniseLdap
|
||||
{ jEpoch = fromInteger nextEpoch
|
||||
, jNumIterations = fromInteger numIntervals
|
||||
, jIteration = fromInteger nextInterval
|
||||
})
|
||||
Cron
|
||||
{ cronInitial = CronTimestamp $ utcToLocalTimeTZ appTZ nextIntervalTime
|
||||
, cronRepeat = CronRepeatNever
|
||||
, cronRateLimit = appSynchroniseLdapUsersInterval
|
||||
, cronNotAfter = Left syncWithin
|
||||
}
|
||||
| otherwise
|
||||
-> return ()
|
||||
|
||||
|
||||
let
|
||||
sheetJobs (Entity nSheet Sheet{..}) = do
|
||||
tell $ HashMap.singleton
|
||||
@ -168,3 +224,57 @@ determineCrontab = execWriterT $ do
|
||||
_other -> return ()
|
||||
|
||||
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ examJobs
|
||||
|
||||
let
|
||||
allocationJobs (Entity nAllocation Allocation{..}) = do
|
||||
whenIsJust allocationStaffRegisterFrom $ \staffRegisterFrom ->
|
||||
tell $ HashMap.singleton
|
||||
(JobCtlQueue $ JobQueueNotification NotificationAllocationStaffRegister{..})
|
||||
Cron
|
||||
{ cronInitial = CronTimestamp $ utcToLocalTimeTZ appTZ staffRegisterFrom
|
||||
, cronRepeat = CronRepeatOnChange
|
||||
, cronRateLimit = appNotificationRateLimit
|
||||
, cronNotAfter = maybe (Right CronNotScheduled) (Right . CronTimestamp . utcToLocalTimeTZ appTZ) allocationStaffRegisterTo
|
||||
}
|
||||
whenIsJust allocationRegisterFrom $ \registerFrom ->
|
||||
tell $ HashMap.singleton
|
||||
(JobCtlQueue $ JobQueueNotification NotificationAllocationRegister{..})
|
||||
Cron
|
||||
{ cronInitial = CronTimestamp $ utcToLocalTimeTZ appTZ registerFrom
|
||||
, cronRepeat = CronRepeatOnChange
|
||||
, cronRateLimit = appNotificationRateLimit
|
||||
, cronNotAfter = maybe (Right CronNotScheduled) (Right . CronTimestamp . utcToLocalTimeTZ appTZ) allocationRegisterTo
|
||||
}
|
||||
whenIsJust allocationStaffAllocationFrom $ \allocationFrom ->
|
||||
tell $ HashMap.singleton
|
||||
(JobCtlQueue $ JobQueueNotification NotificationAllocationAllocation{..})
|
||||
Cron
|
||||
{ cronInitial = CronTimestamp $ utcToLocalTimeTZ appTZ allocationFrom
|
||||
, cronRepeat = CronRepeatOnChange
|
||||
, cronRateLimit = appNotificationRateLimit
|
||||
, cronNotAfter = maybe (Right CronNotScheduled) (Right . CronTimestamp . utcToLocalTimeTZ appTZ) allocationStaffAllocationTo
|
||||
}
|
||||
case allocationRegisterTo of
|
||||
Just registerTo
|
||||
| maybe True (> registerTo) allocationStaffAllocationTo
|
||||
-> do
|
||||
tell $ HashMap.singleton
|
||||
(JobCtlQueue $ JobQueueNotification NotificationAllocationUnratedApplications{..})
|
||||
Cron
|
||||
{ cronInitial = CronTimestamp $ utcToLocalTimeTZ appTZ registerTo
|
||||
, cronRepeat = CronRepeatOnChange
|
||||
, cronRateLimit = appNotificationRateLimit
|
||||
, cronNotAfter = maybe (Right CronNotScheduled) (Right . CronTimestamp . utcToLocalTimeTZ appTZ) allocationStaffAllocationTo
|
||||
}
|
||||
tell $ HashMap.singleton
|
||||
(JobCtlQueue $ JobQueueNotification NotificationAllocationOutdatedRatings{..})
|
||||
Cron
|
||||
{ cronInitial = CronTimestamp $ utcToLocalTimeTZ appTZ registerTo
|
||||
, cronRepeat = CronRepeatOnChange
|
||||
, cronRateLimit = appNotificationRateLimit
|
||||
, cronNotAfter = maybe (Right CronNotScheduled) (Right . CronTimestamp . utcToLocalTimeTZ appTZ) allocationStaffAllocationTo
|
||||
}
|
||||
_other
|
||||
-> return ()
|
||||
|
||||
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ allocationJobs
|
||||
|
||||
13
src/Jobs/Handler/PruneInvitations.hs
Normal file
13
src/Jobs/Handler/PruneInvitations.hs
Normal file
@ -0,0 +1,13 @@
|
||||
module Jobs.Handler.PruneInvitations
|
||||
( dispatchJobPruneInvitations
|
||||
) where
|
||||
|
||||
import Import
|
||||
|
||||
import Database.Persist.Sql (deleteWhereCount)
|
||||
|
||||
dispatchJobPruneInvitations :: Handler ()
|
||||
dispatchJobPruneInvitations = do
|
||||
now <- liftIO getCurrentTime
|
||||
n <- runDB $ deleteWhereCount [ InvitationExpiresAt <. Just now ]
|
||||
$logInfoS "PruneInvitations" [st|Deleted #{n} expired invitations|]
|
||||
@ -2,16 +2,19 @@ module Jobs.Handler.QueueNotification
|
||||
( dispatchJobQueueNotification
|
||||
) where
|
||||
|
||||
import Import hiding ((\\))
|
||||
import Import
|
||||
|
||||
import Data.List (nub, (\\))
|
||||
import Data.List (nub)
|
||||
|
||||
import Jobs.Types
|
||||
|
||||
import qualified Database.Esqueleto as E
|
||||
import qualified Database.Esqueleto.Utils as E
|
||||
import Utils.Sql
|
||||
import Jobs.Queue
|
||||
|
||||
import qualified Data.Set as Set
|
||||
|
||||
|
||||
dispatchJobQueueNotification :: Notification -> Handler ()
|
||||
dispatchJobQueueNotification jNotification = runDBJobs . setSerializable $ do
|
||||
@ -59,14 +62,15 @@ determineNotificationCandidates NotificationUserRightsUpdate{..} = do
|
||||
-- always send to affected user
|
||||
affectedUser <- selectList [UserId ==. nUser] []
|
||||
-- send to same-school admins only if there was an update
|
||||
currentAdminSchools <- fmap (userAdminSchool . entityVal) <$> selectList [UserAdminUser ==. nUser] []
|
||||
let oldAdminSchools = [ SchoolKey ssh | (ssh, True, _) <- nOriginalRights ]
|
||||
newAdminSchools = currentAdminSchools \\ oldAdminSchools
|
||||
currentAdminSchools <- setOf (folded . _entityVal . _userFunctionSchool) <$> selectList [UserFunctionUser ==. nUser, UserFunctionFunction ==. SchoolAdmin] []
|
||||
let oldAdminSchools = setOf (folded . filtered ((== SchoolAdmin) . view _1) . _2 . from _SchoolId) nOriginalRights
|
||||
newAdminSchools = currentAdminSchools `Set.difference` oldAdminSchools
|
||||
affectedAdmins <- E.select . E.from $ \(user `E.InnerJoin` admin) -> do
|
||||
E.on $ admin E.^. UserAdminUser E.==. user E.^. UserId
|
||||
E.where_ $ admin E.^. UserAdminSchool `E.in_` E.valList newAdminSchools
|
||||
E.on $ admin E.^. UserFunctionUser E.==. user E.^. UserId
|
||||
E.where_ $ admin E.^. UserFunctionSchool `E.in_` E.valList (Set.toList newAdminSchools)
|
||||
E.&&. admin E.^. UserFunctionFunction E.==. E.val SchoolAdmin
|
||||
return user
|
||||
return $ nub $ affectedUser <> affectedAdmins
|
||||
return . nub $ affectedUser <> affectedAdmins
|
||||
determineNotificationCandidates NotificationUserAuthModeUpdate{..}
|
||||
= selectList [UserId ==. nUser] []
|
||||
determineNotificationCandidates notif@NotificationExamResult{..} = do
|
||||
@ -77,6 +81,86 @@ determineNotificationCandidates notif@NotificationExamResult{..} = do
|
||||
whenIsJust lastExec $ \lastExec' ->
|
||||
E.where_ $ examResult E.^. ExamResultLastChanged E.>. E.val lastExec'
|
||||
return user
|
||||
determineNotificationCandidates NotificationAllocationStaffRegister{..} = do
|
||||
Allocation{..} <- getJust nAllocation
|
||||
E.select . E.from $ \(user `E.InnerJoin` userFunction) -> do
|
||||
E.on $ user E.^. UserId E.==. userFunction E.^. UserFunctionUser
|
||||
E.&&. userFunction E.^. UserFunctionSchool E.==. E.val allocationSchool
|
||||
E.&&. userFunction E.^. UserFunctionFunction E.==. E.val SchoolLecturer
|
||||
|
||||
E.where_ . E.exists . E.from $ \userSchool ->
|
||||
E.where_ $ userSchool E.^. UserSchoolUser E.==. user E.^. UserId
|
||||
E.&&. userSchool E.^. UserSchoolSchool E.==. E.val allocationSchool
|
||||
E.&&. E.not_ (userSchool E.^. UserSchoolIsOptOut)
|
||||
|
||||
E.where_ . E.not_ . E.exists . E.from $ \(course `E.InnerJoin` lecturer) -> do
|
||||
E.on $ lecturer E.^. LecturerCourse E.==. course E.^. CourseId
|
||||
E.&&. lecturer E.^. LecturerUser E.==. user E.^. UserId
|
||||
E.where_ . E.exists . E.from $ \allocationCourse ->
|
||||
E.where_ $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId
|
||||
E.&&. allocationCourse E.^. AllocationCourseAllocation E.==. E.val nAllocation
|
||||
|
||||
return user
|
||||
determineNotificationCandidates NotificationAllocationAllocation{..} =
|
||||
E.select . E.from $ \(user `E.InnerJoin` lecturer `E.InnerJoin` course `E.InnerJoin` allocationCourse) -> do
|
||||
E.on $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId
|
||||
E.&&. allocationCourse E.^. AllocationCourseAllocation E.==. E.val nAllocation
|
||||
E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse
|
||||
E.on $ lecturer E.^. LecturerUser E.==. user E.^. UserId
|
||||
|
||||
E.where_ . E.not_ . E.exists . E.from $ \application ->
|
||||
E.where_ $ application E.^. CourseApplicationAllocation E.==. E.val (Just nAllocation)
|
||||
E.&&. application E.^. CourseApplicationCourse E.==. course E.^. CourseId
|
||||
E.&&. application E.^. CourseApplicationUser E.!=. user E.^. UserId
|
||||
E.&&. E.not_ (E.isNothing $ application E.^. CourseApplicationRatingTime)
|
||||
|
||||
E.where_ . E.exists . E.from $ \application ->
|
||||
E.where_ $ application E.^. CourseApplicationAllocation E.==. E.val (Just nAllocation)
|
||||
E.&&. application E.^. CourseApplicationCourse E.==. course E.^. CourseId
|
||||
E.&&. application E.^. CourseApplicationUser E.!=. user E.^. UserId
|
||||
|
||||
return user
|
||||
determineNotificationCandidates NotificationAllocationUnratedApplications{..} =
|
||||
E.select . E.from $ \(user `E.InnerJoin` lecturer `E.InnerJoin` course `E.InnerJoin` allocationCourse) -> do
|
||||
E.on $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId
|
||||
E.&&. allocationCourse E.^. AllocationCourseAllocation E.==. E.val nAllocation
|
||||
E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse
|
||||
E.on $ lecturer E.^. LecturerUser E.==. user E.^. UserId
|
||||
|
||||
E.where_ . E.exists . E.from $ \application ->
|
||||
E.where_ $ application E.^. CourseApplicationAllocation E.==. E.val (Just nAllocation)
|
||||
E.&&. application E.^. CourseApplicationCourse E.==. course E.^. CourseId
|
||||
E.&&. application E.^. CourseApplicationUser E.!=. user E.^. UserId
|
||||
E.&&. E.isNothing (application E.^. CourseApplicationRatingTime)
|
||||
|
||||
return user
|
||||
determineNotificationCandidates NotificationAllocationRegister{..} = do
|
||||
Allocation{..} <- getJust nAllocation
|
||||
E.select . E.from $ \user -> do
|
||||
E.where_ . E.exists . E.from $ \userSchool ->
|
||||
E.where_ $ userSchool E.^. UserSchoolUser E.==. user E.^. UserId
|
||||
E.&&. userSchool E.^. UserSchoolSchool E.==. E.val allocationSchool
|
||||
E.&&. E.not_ (userSchool E.^. UserSchoolIsOptOut)
|
||||
|
||||
E.where_ . E.not_ . E.exists . E.from $ \application ->
|
||||
E.where_ $ application E.^. CourseApplicationAllocation E.==. E.val (Just nAllocation)
|
||||
E.&&. application E.^. CourseApplicationUser E.==. user E.^. UserId
|
||||
|
||||
return user
|
||||
determineNotificationCandidates NotificationAllocationOutdatedRatings{..} =
|
||||
E.select . E.from $ \(user `E.InnerJoin` lecturer `E.InnerJoin` course `E.InnerJoin` allocationCourse) -> do
|
||||
E.on $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId
|
||||
E.&&. allocationCourse E.^. AllocationCourseAllocation E.==. E.val nAllocation
|
||||
E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse
|
||||
E.on $ lecturer E.^. LecturerUser E.==. user E.^. UserId
|
||||
|
||||
E.where_ . E.exists . E.from $ \application ->
|
||||
E.where_ $ application E.^. CourseApplicationAllocation E.==. E.val (Just nAllocation)
|
||||
E.&&. application E.^. CourseApplicationCourse E.==. course E.^. CourseId
|
||||
E.&&. application E.^. CourseApplicationUser E.!=. user E.^. UserId
|
||||
E.&&. E.maybe E.false (E.<. application E.^. CourseApplicationTime) (application E.^. CourseApplicationRatingTime)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
classifyNotification :: Notification -> DB NotificationTrigger
|
||||
@ -93,3 +177,8 @@ classifyNotification NotificationCorrectionsNotDistributed{} = return NTCorrecti
|
||||
classifyNotification NotificationUserRightsUpdate{} = return NTUserRightsUpdate
|
||||
classifyNotification NotificationUserAuthModeUpdate{} = return NTUserAuthModeUpdate
|
||||
classifyNotification NotificationExamResult{} = return NTExamResult
|
||||
classifyNotification NotificationAllocationStaffRegister{} = return NTAllocationStaffRegister
|
||||
classifyNotification NotificationAllocationAllocation{} = return NTAllocationAllocation
|
||||
classifyNotification NotificationAllocationRegister{} = return NTAllocationRegister
|
||||
classifyNotification NotificationAllocationOutdatedRatings{} = return NTAllocationOutdatedRatings
|
||||
classifyNotification NotificationAllocationUnratedApplications{} = return NTAllocationUnratedApplications
|
||||
|
||||
@ -15,6 +15,7 @@ import Jobs.Handler.SendNotification.CorrectionsNotDistributed
|
||||
import Jobs.Handler.SendNotification.UserRightsUpdate
|
||||
import Jobs.Handler.SendNotification.UserAuthModeUpdate
|
||||
import Jobs.Handler.SendNotification.ExamResult
|
||||
import Jobs.Handler.SendNotification.Allocation
|
||||
|
||||
|
||||
dispatchJobSendNotification :: UserId -> Notification -> Handler ()
|
||||
|
||||
159
src/Jobs/Handler/SendNotification/Allocation.hs
Normal file
159
src/Jobs/Handler/SendNotification/Allocation.hs
Normal file
@ -0,0 +1,159 @@
|
||||
{-# OPTIONS_GHC -fno-warn-unused-do-bind #-} -- ihamletFile discards do results
|
||||
|
||||
module Jobs.Handler.SendNotification.Allocation
|
||||
( dispatchNotificationAllocationStaffRegister
|
||||
, dispatchNotificationAllocationRegister
|
||||
, dispatchNotificationAllocationAllocation
|
||||
, dispatchNotificationAllocationUnratedApplications
|
||||
, dispatchNotificationAllocationOutdatedRatings
|
||||
) where
|
||||
|
||||
import Import
|
||||
|
||||
import Handler.Utils
|
||||
import Jobs.Handler.SendNotification.Utils
|
||||
|
||||
import Text.Hamlet
|
||||
|
||||
import qualified Database.Esqueleto as E
|
||||
import qualified Database.Esqueleto.Utils as E
|
||||
|
||||
|
||||
dispatchNotificationAllocationStaffRegister :: AllocationId -> UserId -> Handler ()
|
||||
dispatchNotificationAllocationStaffRegister nAllocation jRecipient = userMailT jRecipient $ do
|
||||
Allocation{..} <- liftHandlerT . runDB $ getJust nAllocation
|
||||
|
||||
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
|
||||
setSubjectI $ MsgMailSubjectAllocationStaffRegister allocationName
|
||||
editNotifications <- mkEditNotifications jRecipient
|
||||
registerDeadline <- traverse (formatTime SelFormatDateTime) allocationStaffRegisterTo
|
||||
addAlternatives $
|
||||
providePreferredAlternative $(ihamletFile "templates/mail/allocationStaffRegister.hamlet")
|
||||
|
||||
dispatchNotificationAllocationRegister :: AllocationId -> UserId -> Handler ()
|
||||
dispatchNotificationAllocationRegister nAllocation jRecipient = userMailT jRecipient $ do
|
||||
Allocation{..} <- liftHandlerT . runDB $ getJust nAllocation
|
||||
|
||||
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
|
||||
setSubjectI $ MsgMailSubjectAllocationRegister allocationName
|
||||
editNotifications <- mkEditNotifications jRecipient
|
||||
registerDeadline <- traverse (formatTime SelFormatDateTime) allocationRegisterTo
|
||||
addAlternatives $
|
||||
providePreferredAlternative $(ihamletFile "templates/mail/allocationRegister.hamlet")
|
||||
|
||||
dispatchNotificationAllocationAllocation :: AllocationId -> UserId -> Handler ()
|
||||
dispatchNotificationAllocationAllocation nAllocation jRecipient = do
|
||||
(Allocation{..}, courses) <- liftHandlerT . runDB $ do
|
||||
allocation <- getJust nAllocation
|
||||
courses <- E.select . E.from $ \(course `E.InnerJoin` allocationCourse `E.InnerJoin` lecturer) -> do
|
||||
E.on $ lecturer E.^. LecturerCourse E.==. course E.^. CourseId
|
||||
E.&&. lecturer E.^. LecturerUser E.==. E.val jRecipient
|
||||
E.on $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId
|
||||
E.&&. allocationCourse E.^. AllocationCourseAllocation E.==. E.val nAllocation
|
||||
return ( course E.^. CourseTerm
|
||||
, course E.^. CourseSchool
|
||||
, course E.^. CourseShorthand
|
||||
, course E.^. CourseName
|
||||
)
|
||||
let courses' = courses
|
||||
& over (traverse . _1) E.unValue
|
||||
& over (traverse . _2) E.unValue
|
||||
& over (traverse . _3) E.unValue
|
||||
& over (traverse . _4) E.unValue
|
||||
return (allocation, courses')
|
||||
|
||||
unless (null courses) . userMailT jRecipient $ do
|
||||
now <- liftIO getCurrentTime
|
||||
|
||||
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
|
||||
setSubjectI $ MsgMailSubjectAllocationAllocation allocationName
|
||||
editNotifications <- mkEditNotifications jRecipient
|
||||
allocationDeadline <- traverse (formatTime SelFormatDateTime) allocationStaffAllocationTo
|
||||
registerDeadline <- traverse (formatTime SelFormatDateTime) $ assertM (> now) allocationRegisterTo
|
||||
addAlternatives $
|
||||
providePreferredAlternative $(ihamletFile "templates/mail/allocationAllocation.hamlet")
|
||||
|
||||
dispatchNotificationAllocationUnratedApplications :: AllocationId -> UserId -> Handler ()
|
||||
dispatchNotificationAllocationUnratedApplications nAllocation jRecipient = do
|
||||
(Allocation{..}, courses) <- liftHandlerT . runDB $ do
|
||||
allocation <- getJust nAllocation
|
||||
courses <- E.select . E.from $ \(course `E.InnerJoin` allocationCourse `E.InnerJoin` lecturer) -> do
|
||||
E.on $ lecturer E.^. LecturerCourse E.==. course E.^. CourseId
|
||||
E.&&. lecturer E.^. LecturerUser E.==. E.val jRecipient
|
||||
E.on $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId
|
||||
E.&&. allocationCourse E.^. AllocationCourseAllocation E.==. E.val nAllocation
|
||||
|
||||
let
|
||||
unratedAppCount :: E.SqlExpr (E.Value Natural)
|
||||
unratedAppCount = E.sub_select . E.from $ \application -> do
|
||||
E.where_ $ application E.^. CourseApplicationCourse E.==. course E.^. CourseId
|
||||
E.&&. application E.^. CourseApplicationAllocation E.==. E.val (Just nAllocation)
|
||||
E.&&. E.isNothing (application E.^. CourseApplicationRatingTime)
|
||||
return E.countRows
|
||||
|
||||
return ( course E.^. CourseTerm
|
||||
, course E.^. CourseSchool
|
||||
, course E.^. CourseShorthand
|
||||
, course E.^. CourseName
|
||||
, unratedAppCount
|
||||
)
|
||||
let courses' = courses
|
||||
& over (traverse . _1) E.unValue
|
||||
& over (traverse . _2) E.unValue
|
||||
& over (traverse . _3) E.unValue
|
||||
& over (traverse . _4) E.unValue
|
||||
& over (traverse . _5) E.unValue
|
||||
& filter ((> 0) . view _5)
|
||||
return (allocation, courses')
|
||||
|
||||
|
||||
unless (null courses) . userMailT jRecipient $ do
|
||||
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
|
||||
setSubjectI $ MsgMailSubjectAllocationUnratedApplications allocationName
|
||||
editNotifications <- mkEditNotifications jRecipient
|
||||
allocationDeadline <- traverse (formatTime SelFormatDateTime) allocationStaffAllocationTo
|
||||
addAlternatives $
|
||||
providePreferredAlternative $(ihamletFile "templates/mail/allocationUnratedApplications.hamlet")
|
||||
|
||||
dispatchNotificationAllocationOutdatedRatings :: AllocationId -> UserId -> Handler ()
|
||||
dispatchNotificationAllocationOutdatedRatings nAllocation jRecipient = do
|
||||
(Allocation{..}, courses) <- liftHandlerT . runDB $ do
|
||||
allocation <- getJust nAllocation
|
||||
courses <- E.select . E.from $ \(course `E.InnerJoin` allocationCourse `E.InnerJoin` lecturer) -> do
|
||||
E.on $ lecturer E.^. LecturerCourse E.==. course E.^. CourseId
|
||||
E.&&. lecturer E.^. LecturerUser E.==. E.val jRecipient
|
||||
E.on $ allocationCourse E.^. AllocationCourseCourse E.==. course E.^. CourseId
|
||||
E.&&. allocationCourse E.^. AllocationCourseAllocation E.==. E.val nAllocation
|
||||
|
||||
let
|
||||
outdatedRatingsAppCount :: E.SqlExpr (E.Value Natural)
|
||||
outdatedRatingsAppCount = E.sub_select . E.from $ \application -> do
|
||||
E.where_ $ application E.^. CourseApplicationCourse E.==. course E.^. CourseId
|
||||
E.&&. application E.^. CourseApplicationAllocation E.==. E.val (Just nAllocation)
|
||||
E.&&. E.maybe E.false (E.<. application E.^. CourseApplicationTime) (application E.^. CourseApplicationRatingTime)
|
||||
return E.countRows
|
||||
|
||||
return ( course E.^. CourseTerm
|
||||
, course E.^. CourseSchool
|
||||
, course E.^. CourseShorthand
|
||||
, course E.^. CourseName
|
||||
, outdatedRatingsAppCount
|
||||
)
|
||||
let courses' = courses
|
||||
& over (traverse . _1) E.unValue
|
||||
& over (traverse . _2) E.unValue
|
||||
& over (traverse . _3) E.unValue
|
||||
& over (traverse . _4) E.unValue
|
||||
& over (traverse . _5) E.unValue
|
||||
& filter ((> 0) . view _5)
|
||||
return (allocation, courses')
|
||||
|
||||
|
||||
unless (null courses) . userMailT jRecipient $ do
|
||||
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
|
||||
setSubjectI $ MsgMailSubjectAllocationOutdatedRatings allocationName
|
||||
editNotifications <- mkEditNotifications jRecipient
|
||||
allocationDeadline <- traverse (formatTime SelFormatDateTime) allocationStaffAllocationTo
|
||||
addAlternatives $
|
||||
providePreferredAlternative $(ihamletFile "templates/mail/allocationOutdatedRatings.hamlet")
|
||||
|
||||
@ -6,24 +6,25 @@ module Jobs.Handler.SendNotification.UserRightsUpdate
|
||||
|
||||
import Import
|
||||
|
||||
import Handler.Utils.Database
|
||||
import Handler.Utils.Mail
|
||||
import Jobs.Handler.SendNotification.Utils
|
||||
|
||||
import qualified Data.Map as Map
|
||||
import qualified Data.Set as Set
|
||||
|
||||
import Text.Hamlet
|
||||
-- import qualified Data.CaseInsensitive as CI
|
||||
|
||||
dispatchNotificationUserRightsUpdate :: UserId -> [(SchoolShorthand,Bool,Bool)]-> UserId -> Handler ()
|
||||
dispatchNotificationUserRightsUpdate :: UserId -> Set (SchoolFunction, SchoolShorthand) -> UserId -> Handler ()
|
||||
dispatchNotificationUserRightsUpdate nUser _originalRights jRecipient = userMailT jRecipient $ do
|
||||
(User{..}, adminSchools, lecturerSchools) <- liftHandlerT . runDB $ do
|
||||
user <-getJust nUser
|
||||
adminSchools <- getSchoolsOf nUser UserAdminSchool UserAdminUser
|
||||
lecturerSchools <- getSchoolsOf nUser UserLecturerSchool UserLecturerUser
|
||||
return (user,adminSchools,lecturerSchools)
|
||||
(User{..}, functions) <- liftHandlerT . runDB $ do
|
||||
user <- getJust nUser
|
||||
functions <- Map.fromListWith Set.union . map (\(Entity _ UserFunction{..}) -> (userFunctionFunction, Set.singleton userFunctionSchool)) <$> selectList [UserFunctionUser ==. nUser] []
|
||||
return (user, functions)
|
||||
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
|
||||
setSubjectI $ MsgMailSubjectUserRightsUpdate userDisplayName
|
||||
-- MsgRenderer mr <- getMailMsgRenderer
|
||||
editNotifications <- mkEditNotifications jRecipient
|
||||
addAlternatives $
|
||||
providePreferredAlternative ($(ihamletFile "templates/mail/userRightsUpdate.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))
|
||||
providePreferredAlternative ($(ihamletFile "templates/mail/userRightsUpdate.hamlet") :: HtmlUrlI18n (SomeMessage UniWorX) (Route UniWorX))
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
module Jobs.Handler.SendNotification.Utils
|
||||
( mkEditNotifications
|
||||
, ihamletSomeMessage
|
||||
) where
|
||||
|
||||
import Import
|
||||
@ -9,6 +10,9 @@ import Text.Hamlet
|
||||
import qualified Data.HashSet as HashSet
|
||||
|
||||
|
||||
ihamletSomeMessage :: HtmlUrlI18n UniWorXMessage (Route UniWorX) -> HtmlUrlI18n (SomeMessage UniWorX) (Route UniWorX)
|
||||
ihamletSomeMessage f trans = f $ trans . SomeMessage
|
||||
|
||||
mkEditNotifications :: (MonadHandler m, HandlerSite m ~ UniWorX) => UserId -> m (HtmlUrlI18n UniWorXMessage (Route UniWorX))
|
||||
mkEditNotifications uid = liftHandlerT $ do
|
||||
cID <- encrypt uid
|
||||
|
||||
61
src/Jobs/Handler/SynchroniseLdap.hs
Normal file
61
src/Jobs/Handler/SynchroniseLdap.hs
Normal file
@ -0,0 +1,61 @@
|
||||
module Jobs.Handler.SynchroniseLdap
|
||||
( dispatchJobSynchroniseLdap, dispatchJobSynchroniseLdapUser
|
||||
, SynchroniseLdapException(..)
|
||||
) where
|
||||
|
||||
import Import
|
||||
|
||||
import qualified Data.Conduit.List as C
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
|
||||
import Auth.LDAP
|
||||
|
||||
import Jobs.Queue
|
||||
|
||||
|
||||
data SynchroniseLdapException
|
||||
= SynchroniseLdapNoLdap
|
||||
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
|
||||
instance Exception SynchroniseLdapException
|
||||
|
||||
dispatchJobSynchroniseLdap :: Natural -> Natural -> Natural -> Handler ()
|
||||
dispatchJobSynchroniseLdap numIterations epoch iteration
|
||||
= runDBJobs . runConduit $
|
||||
readUsers .| filterIteration .| sinkDBJobs
|
||||
where
|
||||
readUsers :: Source (YesodJobDB UniWorX) UserId
|
||||
readUsers = selectKeys [] []
|
||||
|
||||
filterIteration :: Conduit UserId (YesodJobDB UniWorX) Job
|
||||
filterIteration = C.mapMaybeM $ \userId -> runMaybeT $ do
|
||||
let
|
||||
userIteration, currentIteration :: Integer
|
||||
userIteration = toInteger (hash epoch `hashWithSalt` userId) `mod` toInteger numIterations
|
||||
currentIteration = toInteger iteration `mod` toInteger numIterations
|
||||
$logDebugS "SynchroniseLdap" [st|User ##{tshow (fromSqlKey userId)}: sync on #{tshow userIteration}/#{tshow numIterations}, now #{tshow currentIteration}|]
|
||||
guard $ userIteration == currentIteration
|
||||
|
||||
return $ JobSynchroniseLdapUser userId
|
||||
|
||||
dispatchJobSynchroniseLdapUser :: UserId -> Handler ()
|
||||
dispatchJobSynchroniseLdapUser jUser = do
|
||||
UniWorX{ appSettings' = AppSettings{..}, .. } <- getYesod
|
||||
case (,) <$> appLdapConf <*> appLdapPool of
|
||||
Just (ldapConf, ldapPool) ->
|
||||
runDB . void . runMaybeT . handleExc $ do
|
||||
user@User{userIdent} <- MaybeT $ get jUser
|
||||
|
||||
$logInfoS "SynchroniseLdap" [st|Synchronising #{userIdent}|]
|
||||
|
||||
ldapAttrs <- MaybeT $ campusUser' ldapConf ldapPool user
|
||||
void . lift $ upsertCampusUser ldapAttrs Creds
|
||||
{ credsIdent = CI.original userIdent
|
||||
, credsPlugin = "dummy"
|
||||
, credsExtra = []
|
||||
}
|
||||
Nothing ->
|
||||
throwM SynchroniseLdapNoLdap
|
||||
where
|
||||
handleExc
|
||||
= catchMPlus (Proxy @CampusUserException)
|
||||
. catchMPlus (Proxy @CampusUserConversionException)
|
||||
@ -159,7 +159,7 @@ dispatchHealthCheckActiveJobExecutors = HealthActiveJobExecutors <$> do
|
||||
in fromInteger micro
|
||||
$logDebugS "HealthCheckActiveJobExecutors" . tshow . map showWorkerId $ Map.elems workers'
|
||||
responders <- fmap (getSum . fold) . liftIO . forConcurrently (Map.toList workers) $ \(_, wName)
|
||||
-> fromMaybe (Sum 0) <$> timeout timeoutMicro (runReaderT ?? app $ Sum 1 <$ writeJobCtlBlock' (writeJobCtl' wName) JobCtlNoOp)
|
||||
-> fromMaybe (Sum 0) <$> timeout timeoutMicro (runReaderT ?? app $ Sum 1 <$ writeJobCtlBlock' (writeJobCtl' wName) JobCtlTest)
|
||||
if
|
||||
| Map.null workers -> return Nothing
|
||||
| otherwise -> return . Just $ responders % fromIntegral (Map.size workers)
|
||||
|
||||
@ -44,7 +44,7 @@ writeJobCtl' target cmd = do
|
||||
| null jobWorkers
|
||||
-> throwM JobQueuePoolEmpty
|
||||
| [(_, chan)] <- filter ((== target) . jobWorkerName . view _1) $ Map.toList jobWorkers
|
||||
-> atomically $ writeTChan chan cmd
|
||||
-> atomically . modifyTVar' chan $ jqInsert cmd
|
||||
| otherwise
|
||||
-> throwM JobQueueWorkerNotFound
|
||||
|
||||
@ -80,6 +80,7 @@ writeJobCtlBlock = writeJobCtlBlock' writeJobCtl
|
||||
|
||||
queueJobUnsafe :: Bool -> Job -> YesodDB UniWorX QueuedJobId
|
||||
queueJobUnsafe queuedJobWriteLastExec job = do
|
||||
$logInfoS "queueJob" $ tshow job
|
||||
queuedJobCreationTime <- liftIO getCurrentTime
|
||||
queuedJobCreationInstance <- getsYesod appInstanceID
|
||||
insert QueuedJob
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
|
||||
module Jobs.Types
|
||||
( Job(..), Notification(..)
|
||||
, JobCtl(..)
|
||||
@ -6,6 +8,8 @@ module Jobs.Types
|
||||
, jobWorkerNames
|
||||
, JobWorkerId
|
||||
, showWorkerId, newWorkerId
|
||||
, JobQueue, jqInsert, jqDequeue
|
||||
, JobPriority(..), prioritiseJob
|
||||
) where
|
||||
|
||||
import Import.NoFoundation hiding (Unique)
|
||||
@ -20,6 +24,9 @@ import Data.Unique
|
||||
import qualified Data.Map.Strict as Map
|
||||
import qualified Data.Set as Set
|
||||
|
||||
import Data.PQueue.Prio.Max (MaxPQueue)
|
||||
import qualified Data.PQueue.Prio.Max as PQ
|
||||
|
||||
|
||||
data Job = JobSendNotification { jRecipient :: UserId, jNotification :: Notification }
|
||||
| JobSendTestEmail { jEmail :: Email, jMailContext :: MailContext }
|
||||
@ -49,7 +56,14 @@ data Job = JobSendNotification { jRecipient :: UserId, jNotification :: Notifica
|
||||
| JobSendPasswordReset { jRecipient :: UserId
|
||||
}
|
||||
| JobTruncateTransactionLog
|
||||
| JobPruneInvitations
|
||||
| JobDeleteTransactionLogIPs
|
||||
| JobSynchroniseLdap { jNumIterations
|
||||
, jEpoch
|
||||
, jIteration :: Natural
|
||||
}
|
||||
| JobSynchroniseLdapUser { jUser :: UserId
|
||||
}
|
||||
deriving (Eq, Ord, Show, Read, Generic, Typeable)
|
||||
data Notification = NotificationSubmissionRated { nSubmission :: SubmissionId }
|
||||
| NotificationSheetActive { nSheet :: SheetId }
|
||||
@ -57,13 +71,20 @@ data Notification = NotificationSubmissionRated { nSubmission :: SubmissionId }
|
||||
| NotificationSheetInactive { nSheet :: SheetId }
|
||||
| NotificationCorrectionsAssigned { nUser :: UserId, nSheet :: SheetId }
|
||||
| NotificationCorrectionsNotDistributed { nSheet :: SheetId }
|
||||
| NotificationUserRightsUpdate { nUser :: UserId, nOriginalRights :: [(SchoolShorthand,Bool,Bool)] } -- User rights (admin, lecturer,...) were changed somehow
|
||||
| NotificationUserRightsUpdate { nUser :: UserId, nOriginalRights :: Set (SchoolFunction, SchoolShorthand) }
|
||||
| NotificationUserAuthModeUpdate { nUser :: UserId, nOriginalAuthMode :: AuthenticationMode }
|
||||
| NotificationExamResult { nExam :: ExamId }
|
||||
| NotificationAllocationStaffRegister { nAllocation :: AllocationId }
|
||||
| NotificationAllocationRegister { nAllocation :: AllocationId }
|
||||
| NotificationAllocationAllocation { nAllocation :: AllocationId }
|
||||
| NotificationAllocationUnratedApplications { nAllocation :: AllocationId }
|
||||
| NotificationAllocationOutdatedRatings { nAllocation :: AllocationId }
|
||||
deriving (Eq, Ord, Show, Read, Generic, Typeable)
|
||||
|
||||
instance Hashable Job
|
||||
instance NFData Job
|
||||
instance Hashable Notification
|
||||
instance NFData Notification
|
||||
|
||||
deriveJSON defaultOptions
|
||||
{ constructorTagModifier = camelToPathPiece' 1
|
||||
@ -85,10 +106,11 @@ data JobCtl = JobCtlFlush
|
||||
| JobCtlDetermineCrontab
|
||||
| JobCtlQueue Job
|
||||
| JobCtlGenerateHealthReport HealthCheck
|
||||
| JobCtlNoOp
|
||||
| JobCtlTest
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
|
||||
instance Hashable JobCtl
|
||||
instance NFData JobCtl
|
||||
|
||||
|
||||
newtype JobWorkerId = JobWorkerId { jobWorkerUnique :: Unique }
|
||||
@ -106,8 +128,35 @@ data JobContext = JobContext
|
||||
, jobConfirm :: TVar (HashMap JobCtl (NonEmpty (TMVar (Maybe SomeException))))
|
||||
}
|
||||
|
||||
|
||||
data JobPriority = JobPrioBatch | JobPrioRealtime
|
||||
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
|
||||
instance Universe JobPriority
|
||||
instance Finite JobPriority
|
||||
instance NFData JobPriority
|
||||
|
||||
prioritiseJob :: JobCtl -> JobPriority
|
||||
prioritiseJob JobCtlTest = JobPrioRealtime
|
||||
prioritiseJob (JobCtlGenerateHealthReport _) = JobPrioRealtime
|
||||
prioritiseJob JobCtlDetermineCrontab = JobPrioRealtime
|
||||
prioritiseJob _ = JobPrioBatch
|
||||
|
||||
|
||||
newtype JobQueue = JobQueue { getJobQueue :: MaxPQueue JobPriority JobCtl }
|
||||
deriving (Eq, Ord, Read, Show)
|
||||
deriving newtype (Monoid, NFData)
|
||||
|
||||
makePrisms ''JobQueue
|
||||
|
||||
jqInsert :: JobCtl -> JobQueue -> JobQueue
|
||||
jqInsert job = force . over _JobQueue $ PQ.insertBehind (prioritiseJob job) job
|
||||
|
||||
jqDequeue :: JobQueue -> Maybe (JobCtl, JobQueue)
|
||||
jqDequeue = fmap ((\r@(_, q) -> q `deepseq` r) . over _2 JobQueue) . PQ.maxView . getJobQueue
|
||||
|
||||
|
||||
data JobState = JobState
|
||||
{ jobWorkers :: Map (Async ()) (TChan JobCtl)
|
||||
{ jobWorkers :: Map (Async ()) (TVar JobQueue)
|
||||
, jobWorkerName :: Async () -> JobWorkerId
|
||||
, jobContext :: JobContext
|
||||
, jobPoolManager :: Async ()
|
||||
|
||||
@ -103,7 +103,7 @@ createLdapPool host port stripes timeoutConn (round . (* 1e6) -> timeoutAct) lim
|
||||
setup <- liftIO newEmptyTMVarIO
|
||||
|
||||
ldapAsync <- allocateAsync . flip runLoggingT logFunc $ do
|
||||
$logInfoS "LdapExecutor" "Starting"
|
||||
$logDebugS "LdapExecutor" "Starting"
|
||||
res <- liftIO . Ldap.with host port $ flip runLoggingT logFunc . go (Just setup)
|
||||
case res of
|
||||
Left exc -> do
|
||||
|
||||
@ -160,6 +160,7 @@ instance Default MailLanguages where
|
||||
def = MailLanguages []
|
||||
|
||||
instance Hashable MailLanguages
|
||||
instance NFData MailLanguages
|
||||
|
||||
|
||||
data MailContext = MailContext
|
||||
@ -172,10 +173,12 @@ deriveJSON defaultOptions
|
||||
} ''MailContext
|
||||
|
||||
instance Hashable MailContext
|
||||
instance NFData MailContext
|
||||
instance Default MailContext where
|
||||
def = MailContext { mcLanguages = def
|
||||
, mcDateTimeFormat = def
|
||||
}
|
||||
def = MailContext
|
||||
{ mcLanguages = def
|
||||
, mcDateTimeFormat = def
|
||||
}
|
||||
|
||||
makeLenses_ ''MailContext
|
||||
|
||||
|
||||
@ -454,6 +454,37 @@ customMigrations = Map.fromListWith (>>)
|
||||
whenM (tableExists "allocation_deregister") $ do
|
||||
[executeQQ|ALTER TABLE allocation_deregister DROP COLUMN IF EXISTS allocation;|]
|
||||
)
|
||||
, ( AppliedMigrationKey [migrationVersion|18.0.0|] [version|19.0.0|]
|
||||
, do
|
||||
[executeQQ|
|
||||
CREATe TABLE IF NOT EXISTS "user_function" ( "id" serial8 primary key, "user" bigint, "school" citext, "function" text );
|
||||
|]
|
||||
|
||||
whenM (tableExists "user_admin") $ do
|
||||
let getAdminEntries = rawQuery [st|SELECT user_admin.id, user_admin.user, user_admin.school FROM user_admin;|] []
|
||||
moveAdminEntry [fromPersistValue -> Right (eId :: Int64), fromPersistValue -> Right (uid :: UserId), fromPersistValue -> Right (sid :: SchoolId)] =
|
||||
[executeQQ|
|
||||
INSERT INTO "user_function" ("user", "school", "function") VALUES (#{uid}, #{sid}, #{SchoolAdmin});
|
||||
DELETE FROM "user_admin" WHERE "id" = #{eId};
|
||||
|]
|
||||
moveAdminEntry _ = return ()
|
||||
runConduit $ getAdminEntries .| C.mapM_ moveAdminEntry
|
||||
tableDropEmpty "user_admin"
|
||||
whenM (tableExists "user_lecturer") $ do
|
||||
let getLecturerEntries = rawQuery [st|SELECT user_lecturer.id, user_lecturer.user, user_lecturer.school FROM user_lecturer;|] []
|
||||
moveLecturerEntry [fromPersistValue -> Right (eId :: Int64), fromPersistValue -> Right (uid :: UserId), fromPersistValue -> Right (sid :: SchoolId)] =
|
||||
[executeQQ|
|
||||
INSERT INTO "user_function" ("user", "school", "function") VALUES (#{uid}, #{sid}, #{SchoolLecturer});
|
||||
DELETE FROM "user_lecturer" WHERE "id" = #{eId};
|
||||
|]
|
||||
moveLecturerEntry _ = return ()
|
||||
runConduit $ getLecturerEntries .| C.mapM_ moveLecturerEntry
|
||||
tableDropEmpty "user_lecturer"
|
||||
whenM (tableExists "invitation") $ do
|
||||
[executeQQ|
|
||||
DELETE FROM "invitation" WHERE "for"->'junction' = '"UserLecturer"';
|
||||
|]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ data Rating' = Rating'
|
||||
data RatingException = RatingNotUnicode UnicodeException -- ^ Rating failed to parse as unicode
|
||||
| RatingMissingSeparator -- ^ Could not split rating header from comments
|
||||
| RatingMultiple -- ^ Encountered multiple point values in rating
|
||||
| RatingInvalid String -- ^ Failed to parse rating point value
|
||||
| RatingInvalid Text -- ^ Failed to parse rating point value
|
||||
| RatingFileIsDirectory -- ^ We do not expect this to, it's included for totality
|
||||
| RatingNegative -- ^ Rating points must be non-negative
|
||||
| RatingExceedsMax -- ^ Rating point must not exceed maximum points
|
||||
|
||||
@ -12,3 +12,4 @@ import Model.Types.Security as Types
|
||||
import Model.Types.Sheet as Types
|
||||
import Model.Types.Submission as Types
|
||||
import Model.Types.Misc as Types
|
||||
import Model.Types.School as Types
|
||||
|
||||
@ -20,6 +20,7 @@ data HealthCheck
|
||||
instance Universe HealthCheck
|
||||
instance Finite HealthCheck
|
||||
instance Hashable HealthCheck
|
||||
instance NFData HealthCheck
|
||||
|
||||
deriveJSON defaultOptions
|
||||
{ constructorTagModifier = camelToPathPiece' 2
|
||||
|
||||
@ -14,6 +14,7 @@ import Import.NoModel
|
||||
|
||||
import qualified Data.Aeson.Types as Aeson
|
||||
|
||||
import qualified Data.HashSet as HashSet
|
||||
import qualified Data.HashMap.Strict as HashMap
|
||||
|
||||
|
||||
@ -31,6 +32,11 @@ data NotificationTrigger
|
||||
| NTUserRightsUpdate
|
||||
| NTUserAuthModeUpdate
|
||||
| NTExamResult
|
||||
| NTAllocationStaffRegister
|
||||
| NTAllocationAllocation
|
||||
| NTAllocationRegister
|
||||
| NTAllocationOutdatedRatings
|
||||
| NTAllocationUnratedApplications
|
||||
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
|
||||
|
||||
instance Universe NotificationTrigger
|
||||
@ -54,17 +60,12 @@ newtype NotificationSettings = NotificationSettings { notificationAllowed :: Not
|
||||
deriving newtype (Eq, Ord, Read, Show)
|
||||
|
||||
instance Default NotificationSettings where
|
||||
def = NotificationSettings $ \case
|
||||
NTSubmissionRatedGraded -> True
|
||||
NTSubmissionRated -> True
|
||||
NTSheetActive -> True
|
||||
NTSheetSoonInactive -> False
|
||||
NTSheetInactive -> True
|
||||
NTCorrectionsAssigned -> True
|
||||
NTCorrectionsNotDistributed -> True
|
||||
NTUserRightsUpdate -> True
|
||||
NTUserAuthModeUpdate -> True
|
||||
NTExamResult -> True
|
||||
def = NotificationSettings $ not . flip HashSet.member defaultOff
|
||||
where
|
||||
defaultOff :: HashSet NotificationTrigger
|
||||
defaultOff = HashSet.fromList
|
||||
[ NTSheetSoonInactive
|
||||
]
|
||||
|
||||
instance ToJSON NotificationSettings where
|
||||
toJSON v = toJSON . HashMap.fromList $ map (id &&& notificationAllowed v) universeF
|
||||
|
||||
20
src/Model/Types/School.hs
Normal file
20
src/Model/Types/School.hs
Normal file
@ -0,0 +1,20 @@
|
||||
module Model.Types.School where
|
||||
|
||||
import Import.NoModel
|
||||
import Model.Types.TH.PathPiece
|
||||
|
||||
data SchoolFunction
|
||||
= SchoolAdmin
|
||||
| SchoolLecturer
|
||||
| SchoolEvaluation
|
||||
| SchoolExamOffice
|
||||
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
|
||||
instance Universe SchoolFunction
|
||||
instance Finite SchoolFunction
|
||||
instance Hashable SchoolFunction
|
||||
instance NFData SchoolFunction
|
||||
|
||||
nullaryPathPiece ''SchoolFunction $ camelToPathPiece' 1
|
||||
pathPieceJSON ''SchoolFunction
|
||||
pathPieceJSONKey ''SchoolFunction
|
||||
derivePersistFieldPathPiece ''SchoolFunction
|
||||
@ -27,6 +27,7 @@ data AuthenticationMode = AuthLDAP
|
||||
deriving (Eq, Ord, Read, Show, Generic)
|
||||
|
||||
instance Hashable AuthenticationMode
|
||||
instance NFData AuthenticationMode
|
||||
|
||||
deriveJSON defaultOptions
|
||||
{ constructorTagModifier = intercalate "-" . map toLower . drop 1 . splitCamel
|
||||
|
||||
47
src/Model/Types/TH/PathPiece.hs
Normal file
47
src/Model/Types/TH/PathPiece.hs
Normal file
@ -0,0 +1,47 @@
|
||||
module Model.Types.TH.PathPiece
|
||||
( derivePersistFieldPathPiece
|
||||
) where
|
||||
|
||||
import ClassyPrelude.Yesod
|
||||
import Data.List (foldl)
|
||||
import Database.Persist.Sql
|
||||
|
||||
import qualified Data.Text.Encoding as Text
|
||||
|
||||
import Language.Haskell.TH
|
||||
import Language.Haskell.TH.Datatype
|
||||
|
||||
|
||||
derivePersistFieldPathPiece :: Name -> DecsQ
|
||||
derivePersistFieldPathPiece tName = do
|
||||
DatatypeInfo{..} <- reifyDatatype tName
|
||||
vars <- forM datatypeVars (const $ newName "a")
|
||||
let t = foldl (\t' n' -> t' `appT` varT n') (conT tName) vars
|
||||
iCxt
|
||||
| null vars = cxt []
|
||||
| otherwise = cxt [[t|PathPiece|] `appT` t]
|
||||
sqlCxt
|
||||
| null vars = cxt []
|
||||
| otherwise = cxt [[t|PersistField|] `appT` t]
|
||||
sequence
|
||||
[ instanceD iCxt ([t|PersistField|] `appT` t)
|
||||
[ funD 'toPersistValue
|
||||
[ clause [] (normalB [e|PersistText . toPathPiece|]) []
|
||||
]
|
||||
, funD 'fromPersistValue
|
||||
[ do
|
||||
bs <- newName "bs"
|
||||
clause [[p|PersistByteString $(varP bs)|]] (normalB [e|maybe (Left "Could not decode PathPiece from PersistByteString") Right $ fromPathPiece =<< either (const Nothing) Just (Text.decodeUtf8' $(varE bs))|]) []
|
||||
, do
|
||||
text <- newName "text"
|
||||
clause [[p|PersistText $(varP text)|]] (normalB [e|maybe (Left "Could not decode PathPiece from PersistTetx") Right $ fromPathPiece $(varE text)|]) []
|
||||
, clause [wildP] (normalB [e|Left "PathPiece values must be converted from PersistText or PersistByteString"|]) []
|
||||
]
|
||||
]
|
||||
, instanceD sqlCxt ([t|PersistFieldSql|] `appT` t)
|
||||
[ funD 'sqlType
|
||||
[ clause [wildP] (normalB [e|SqlString|]) []
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
@ -21,9 +21,10 @@ deriving instance Ord Address
|
||||
deriving instance Generic Address
|
||||
|
||||
instance Hashable Address
|
||||
instance NFData Address
|
||||
|
||||
deriveToJSON defaultOptions
|
||||
{ fieldLabelModifier = intercalate "-" . map toLower . drop 1 . splitCamel
|
||||
{ fieldLabelModifier = camelToPathPiece' 1
|
||||
} ''Address
|
||||
|
||||
instance FromJSON Address where
|
||||
|
||||
@ -118,6 +118,9 @@ data AppSettings = AppSettings
|
||||
, appHealthCheckHTTP :: Bool
|
||||
, appHealthCheckActiveJobExecutorsTimeout :: NominalDiffTime
|
||||
|
||||
, appSynchroniseLdapUsersWithin :: Maybe NominalDiffTime
|
||||
, appSynchroniseLdapUsersInterval :: NominalDiffTime
|
||||
|
||||
, appInitialLogSettings :: LogSettings
|
||||
|
||||
, appTransactionLogIPRetentionTime :: NominalDiffTime
|
||||
@ -152,8 +155,11 @@ data LogDestination = LogDestStderr | LogDestStdout | LogDestFile { logDestFile
|
||||
|
||||
deriving instance Generic LogLevel
|
||||
instance Hashable LogLevel
|
||||
instance NFData LogLevel
|
||||
instance Hashable LogSettings
|
||||
instance NFData LogSettings
|
||||
instance Hashable LogDestination
|
||||
instance NFData LogDestination
|
||||
|
||||
data UserDefaultConf = UserDefaultConf
|
||||
{ userDefaultTheme :: Theme
|
||||
@ -396,6 +402,9 @@ instance FromJSON AppSettings where
|
||||
|
||||
appSessionTimeout <- o .: "session-timeout"
|
||||
|
||||
appSynchroniseLdapUsersWithin <- o .:? "synchronise-ldap-users-within"
|
||||
appSynchroniseLdapUsersInterval <- o .: "synchronise-ldap-users-interval"
|
||||
|
||||
appMaximumContentLength <- o .: "maximum-content-length"
|
||||
|
||||
appReloadTemplates <- o .:? "reload-templates" .!= defaultDev
|
||||
|
||||
@ -43,3 +43,6 @@ instance Csv.ToField Markup where
|
||||
|
||||
instance Csv.FromField Markup where
|
||||
parseField = fmap preEscapedText . Csv.parseField
|
||||
|
||||
instance NFData Markup where
|
||||
rnf = rnf . Text.renderMarkup
|
||||
|
||||
11
src/Utils.hs
11
src/Utils.hs
@ -51,7 +51,7 @@ import Control.Arrow as Utils ((>>>))
|
||||
import Control.Monad.Trans.Except (ExceptT(..), throwE, runExceptT)
|
||||
import Control.Monad.Except (MonadError(..))
|
||||
import Control.Monad.Trans.Maybe as Utils (MaybeT(..))
|
||||
import Control.Monad.Catch hiding (throwM)
|
||||
import Control.Monad.Catch (catchIf)
|
||||
|
||||
import Language.Haskell.TH
|
||||
import Language.Haskell.TH.Instances ()
|
||||
@ -399,6 +399,9 @@ setMapMaybe f = Set.fromList . mapMaybe f . Set.toList
|
||||
setSymmDiff :: Ord a => Set a -> Set a -> Set a
|
||||
setSymmDiff x y = (x `Set.difference` y) `Set.union` (y `Set.difference` x)
|
||||
|
||||
setProduct :: (Ord a, Ord b) => Set a -> Set b -> Set (a, b)
|
||||
setProduct (Set.toAscList -> as) (Set.toAscList -> bs) = Set.fromDistinctAscList $ (,) <$> as <*> bs
|
||||
|
||||
----------
|
||||
-- Maps --
|
||||
----------
|
||||
@ -498,6 +501,12 @@ hoistMaybe = maybe mzero return
|
||||
catchIfMaybeT :: (MonadCatch m, Exception e) => (e -> Bool) -> m a -> MaybeT m a
|
||||
catchIfMaybeT p act = catchIf p (lift act) (const mzero)
|
||||
|
||||
catchMaybeT :: forall p m e a. (MonadCatch m, Exception e) => p e -> m a -> MaybeT m a
|
||||
catchMaybeT _ act = catch (lift act) (const mzero :: e -> MaybeT m a)
|
||||
|
||||
catchMPlus :: forall p m e a. (MonadPlus m, MonadCatch m, Exception e) => p e -> m a -> m a
|
||||
catchMPlus _ = handle (const mzero :: e -> m a)
|
||||
|
||||
mcons :: Maybe a -> [a] -> [a]
|
||||
mcons Nothing xs = xs
|
||||
mcons (Just x) xs = x:xs
|
||||
|
||||
@ -108,6 +108,16 @@ addAttrs attr valus fs = fs { fsAttrs = insertAttr attr valu $ fsAttrs fs }
|
||||
where
|
||||
valu = T.intercalate " " valus
|
||||
|
||||
data DatepickerPosition = DPLeft | DPRight | DPTop | DPBottom deriving (Eq,Ord,Enum,Bounded,Read,Show)
|
||||
|
||||
instance Universe DatepickerPosition
|
||||
instance Finite DatepickerPosition
|
||||
|
||||
nullaryPathPiece ''DatepickerPosition $ camelToPathPiece' 1
|
||||
|
||||
addDatepickerPositionAttr :: DatepickerPosition -> FieldSettings site -> FieldSettings site
|
||||
addDatepickerPositionAttr = addAttr "data-datepicker-position" . toPathPiece
|
||||
|
||||
addPlaceholder :: Text -> FieldSettings site -> FieldSettings site
|
||||
addPlaceholder placeholder fs = fs { fsAttrs = (placeholderAttr, placeholder) : filter ((/= placeholderAttr) . fst) (fsAttrs fs) }
|
||||
where
|
||||
@ -479,14 +489,14 @@ reorderField optList = Field{..}
|
||||
withNum t n = tshow n <> "." <> t
|
||||
$(widgetFile "widgets/permutation/permutation")
|
||||
|
||||
optionsFinite :: ( MonadHandler m
|
||||
, Finite a
|
||||
, RenderMessage site a
|
||||
, HandlerSite m ~ site
|
||||
, PathPiece a
|
||||
)
|
||||
=> m (OptionList a)
|
||||
optionsFinite = do
|
||||
optionsF :: ( MonadHandler m
|
||||
, RenderMessage site (Element mono)
|
||||
, HandlerSite m ~ site
|
||||
, PathPiece (Element mono)
|
||||
, MonoFoldable mono
|
||||
)
|
||||
=> mono -> m (OptionList (Element mono))
|
||||
optionsF (otoList -> opts) = do
|
||||
mr <- getMessageRender
|
||||
let
|
||||
mkOption a = Option
|
||||
@ -494,7 +504,17 @@ optionsFinite = do
|
||||
, optionInternalValue = a
|
||||
, optionExternalValue = toPathPiece a
|
||||
}
|
||||
return . mkOptionList $ mkOption <$> universeF
|
||||
return . mkOptionList $ mkOption <$> opts
|
||||
|
||||
|
||||
optionsFinite :: ( MonadHandler m
|
||||
, Finite a
|
||||
, RenderMessage site a
|
||||
, HandlerSite m ~ site
|
||||
, PathPiece a
|
||||
)
|
||||
=> m (OptionList a)
|
||||
optionsFinite = optionsF universeF
|
||||
|
||||
fractionalField :: forall m a.
|
||||
( RealFrac a
|
||||
@ -1059,6 +1079,22 @@ wforced :: (RenderMessage site FormMessage, HandlerSite m ~ site, MonadHandler m
|
||||
=> Field m a -> FieldSettings site -> a -> WForm m (FormResult a)
|
||||
wforced field settings val = mFormToWForm $ mforced field settings val
|
||||
|
||||
mforcedJust :: (site ~ HandlerSite m, MonadHandler m)
|
||||
=> Field m a -> FieldSettings site -> Maybe a -> MForm m (FormResult a, FieldView site)
|
||||
mforcedJust f fs (Just fDef) = mforced f fs fDef
|
||||
mforcedJust _ _ Nothing = error "mforcedJust called with Nothing"
|
||||
|
||||
aforcedJust :: (RenderMessage site FormMessage, site ~ HandlerSite m, MonadHandler m)
|
||||
=> Field m a -> FieldSettings site -> Maybe a -> AForm m a
|
||||
aforcedJust f fs (Just fDef) = aforced f fs fDef
|
||||
aforcedJust _ _ Nothing = error "aforcedJust called with Nothing"
|
||||
|
||||
wforcedJust :: (RenderMessage site FormMessage, site ~ HandlerSite m, MonadHandler m)
|
||||
=> Field m a -> FieldSettings site -> Maybe a -> WForm m (FormResult a)
|
||||
wforcedJust f fs (Just fDef) = wforced f fs fDef
|
||||
wforcedJust _ _ Nothing = error "wforcedJust called with Nothing"
|
||||
|
||||
|
||||
mpreq :: (RenderMessage site FormMessage, HandlerSite m ~ site, MonadHandler m)
|
||||
=> Field m a -> FieldSettings site -> Maybe a -> MForm m (FormResult a, FieldView site)
|
||||
-- ^ Pseudo required
|
||||
|
||||
@ -43,6 +43,9 @@ _nullable = prism' toNullable fromNullable
|
||||
_SchoolId :: Iso' SchoolId SchoolShorthand
|
||||
_SchoolId = iso unSchoolKey SchoolKey
|
||||
|
||||
_Maybe :: Iso' (Maybe ()) Bool
|
||||
_Maybe = iso (is _Just) (bool Nothing (Just ()))
|
||||
|
||||
|
||||
-----------------------------------
|
||||
-- Lens Definitions for our Types
|
||||
@ -149,6 +152,8 @@ makeLenses_ ''Occurrences
|
||||
|
||||
makeLenses_ ''PredDNF
|
||||
|
||||
makeLenses_ ''Invitation
|
||||
|
||||
makeLenses_ ''ExamBonusRule
|
||||
makeLenses_ ''ExamGradingRule
|
||||
makeLenses_ ''ExamResult
|
||||
@ -168,9 +173,13 @@ makeLenses_ ''Allocation
|
||||
makeLenses_ ''File
|
||||
|
||||
makeLenses_ ''Submission
|
||||
|
||||
makeLenses_ ''SubmissionUser
|
||||
|
||||
makeLenses_ ''School
|
||||
makeLenses_ ''SchoolLdap
|
||||
|
||||
makeLenses_ ''UserFunction
|
||||
|
||||
|
||||
-- makeClassy_ ''Load
|
||||
|
||||
|
||||
6
start.sh
6
start.sh
@ -4,13 +4,15 @@ set -e
|
||||
|
||||
[ "${FLOCKER}" != "$0" ] && exec env FLOCKER="$0" flock -en .stack-work.lock "$0" "$@" || :
|
||||
|
||||
unset HOST
|
||||
__HOST=${HOST:-$(hostname -s | awk '{ print $0; }')}
|
||||
|
||||
export DETAILED_LOGGING=${DETAILED_LOGGIN:-true}
|
||||
export LOG_ALL=${LOG_ALL:-false}
|
||||
export LOGLEVEL=${LOGLEVEL:-info}
|
||||
export DUMMY_LOGIN=${DUMMY_LOGIN:-true}
|
||||
export ALLOW_DEPRECATED=${ALLOW_DEPRECATED:-true}
|
||||
export RIBBON=${RIBBON:-Localhost}
|
||||
export RIBBON=${RIBBON:-${__HOST:-localhost}}
|
||||
unset HOST
|
||||
|
||||
move-back() {
|
||||
mv -v .stack-work .stack-work-run
|
||||
|
||||
@ -24,15 +24,14 @@ $newline never
|
||||
_{MsgAllocationRegisterFrom}
|
||||
<dd .deflist__dd>
|
||||
^{formatTimeRangeW SelFormatDateTime fromT allocationRegisterTo}
|
||||
$if staffInformation
|
||||
$maybe fromT <- allocationStaffAllocationFrom
|
||||
<dt .deflist__dt>
|
||||
$maybe _ <- allocationStaffAllocationTo
|
||||
_{MsgAllocationStaffAllocation}
|
||||
$nothing
|
||||
_{MsgAllocationStaffAllocationFrom}
|
||||
<dd .deflist__dd>
|
||||
^{formatTimeRangeW SelFormatDateTime fromT allocationStaffAllocationTo}
|
||||
$maybe fromT <- allocationStaffAllocationFrom
|
||||
<dt .deflist__dt>
|
||||
$maybe _ <- allocationStaffAllocationTo
|
||||
_{MsgAllocationStaffAllocation}
|
||||
$nothing
|
||||
_{MsgAllocationStaffAllocationFrom}
|
||||
<dd .deflist__dd>
|
||||
^{formatTimeRangeW SelFormatDateTime fromT allocationStaffAllocationTo}
|
||||
|
||||
$# TODO show datetime of automatic allocation
|
||||
$#
|
||||
@ -78,6 +77,7 @@ $if not (null courseWidgets)
|
||||
<div .allocation__explanation .allocation__label>
|
||||
<p>_{MsgAllocationPriorityTip}
|
||||
<p>_{MsgAllocationPriorityRelative}
|
||||
<p>_{MsgApplicationEditTip}
|
||||
<div .allocation__courses>
|
||||
$forall courseWgt <- courseWidgets
|
||||
^{courseWgt}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
.allocation__label {
|
||||
.allocation__label, .allocation__explanation {
|
||||
color: var(--color-fontsec);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
if (window.App) {
|
||||
window.App.i18n.addMany(#{frontendI18n});
|
||||
// window.App.i18n.setLang(lang); TODO: set language string for datepicker config
|
||||
} else {
|
||||
throw new Error('I18n JavaScript service is missing!');
|
||||
}
|
||||
|
||||
@ -33,6 +33,8 @@ $newline text
|
||||
zuweisen, zwischen "dieser Kurs wäre meine erste Wahl"
|
||||
und "diesen Kurs besuche ich auf keinen Fall".
|
||||
Es kann auch mehreren Kursen die gleiche Priorität eingeräumt werden.
|
||||
<p>
|
||||
Bewerbungen für und Prioritisierung der Kurse können innerhalb des Bewerbungszeitraums beliebig angepasst und zurückgezogen werden.
|
||||
<p>
|
||||
Studierende können auch mehr als einen Platz
|
||||
in verschiedenen Kursen einer Zentralanmeldung anfordern,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user