feat(allocations): upload of priorities

This commit is contained in:
Gregor Kleen 2020-02-27 16:31:38 +01:00
parent 2735d465eb
commit a590f45cc1
29 changed files with 558 additions and 104 deletions

View File

@ -33,3 +33,35 @@ fieldset
.label-pagesize .label-pagesize
margin-right: 13px margin-right: 13px
.explained-selection-field
display: flex
flex-flow: row wrap
margin: -5px
.explained-selection-field__option
display: grid
grid-gap: 5px 7px
grid-template-columns: 30px 1fr
grid-template-rows: 2em 1fr
grid-template-areas: 'radiobox title' '. explanation'
margin: 5px
max-width: 500px
.explained-selection-field__input
grid-area: radiobox
place-self: center center
width: 20px
height: 20px
.explained-selection-field__label
grid-area: title
place-self: center stretch
font-weight: 600
.explained-selection-field__explanation
grid-area: explanation
place-self: stretch stretch
font-weight: 600
font-size: .9rem
color: var(--color-fontsec)

View File

@ -2,25 +2,33 @@ import { Utility } from '../../core/utility';
import './checkbox.sass'; import './checkbox.sass';
var CHECKBOX_CLASS = 'checkbox'; var CHECKBOX_CLASS = 'checkbox';
var RADIOBOX_CLASS = 'radiobox';
var CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized'; var CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized';
@Utility({ @Utility({
selector: 'input[type="checkbox"]:not([uw-no-checkbox])', selector: 'input[type="checkbox"]:not([uw-no-checkbox]), input[type="radio"]:not([uw-no-radiobox])',
}) })
export class Checkbox { export class Checkbox {
constructor(element) { constructor(element) {
if (!element) { if (!element) {
throw new Error('Checkbox utility cannot be setup without an element!'); throw new Error('Checkbox utility cannot be setup without an element!');
} }
const isRadio = element.type === 'radio';
const box_class = isRadio ? RADIOBOX_CLASS : CHECKBOX_CLASS;
if (isRadio && element.closest('.radio-group')) {
// Don't initialize radiobox, if radio is part of a group
return false;
}
if (element.classList.contains(CHECKBOX_INITIALIZED_CLASS)) { if (element.classList.contains(CHECKBOX_INITIALIZED_CLASS)) {
// throw new Error('Checkbox utility already initialized!'); // throw new Error('Checkbox utility already initialized!');
return false; return false;
} }
if (element.parentElement.classList.contains(CHECKBOX_CLASS)) { if (element.parentElement.classList.contains(box_class)) {
// throw new Error('Checkbox element\'s wrapper already has class '' + CHECKBOX_CLASS + ''!'); // throw new Error('Checkbox element\'s wrapper already has class '' + box_class + ''!');
return false; return false;
} }
@ -28,7 +36,7 @@ export class Checkbox {
var parentEl = element.parentElement; var parentEl = element.parentElement;
var wrapperEl = document.createElement('div'); var wrapperEl = document.createElement('div');
wrapperEl.classList.add(CHECKBOX_CLASS); wrapperEl.classList.add(box_class);
var labelEl = document.createElement('label'); var labelEl = document.createElement('label');
labelEl.setAttribute('for', element.id); labelEl.setAttribute('for', element.id);

View File

@ -1,6 +1,6 @@
// CUSTOM CHECKBOXES // CUSTOM CHECKBOXES
// Completely replaces legacy checkbox // Completely replaces legacy checkbox
.checkbox [type='checkbox'], #lang-checkbox .checkbox [type='checkbox'], .radiobox [type='radio'], #lang-checkbox
position: fixed position: fixed
top: -1px top: -1px
left: -1px left: -1px
@ -9,7 +9,7 @@
overflow: hidden overflow: hidden
display: none display: none
.checkbox .checkbox, .radiobox
position: relative position: relative
display: inline-block display: inline-block
@ -17,47 +17,77 @@
display: block display: block
height: 20px height: 20px
width: 20px width: 20px
background-color: #f3f3f3 background-color: var(--color-grey-lighter)
box-shadow: inset 0 1px 2px 1px rgba(50, 50, 50, 0.05) box-shadow: inset 0 1px 2px 1px rgba(50, 50, 50, 0.05)
border: 2px solid var(--color-primary) border: 2px solid var(--color-primary)
border-radius: 4px border-radius: 4px
color: white color: white
cursor: pointer cursor: pointer
label::before, &.radiobox label
label::after border-radius: 10px
position: absolute
display: block
top: 12px
left: 8px
height: 2px
width: 8px
background-color: var(--color-font)
\:checked + label
background-color: var(--color-primary)
[type='checkbox']:focus + label &.checkbox
border-color: #3273dc label::before,
box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25) label::after
outline: 0 position: absolute
display: block
top: 12px
left: 8px
height: 2px
width: 8px
background-color: var(--color-font)
\:checked + label::before, \:checked + label
:checked + label::after background-color: var(--color-primary)
content: ''
\:checked + label::before [type='checkbox']:focus + label
background-color: white border-color: #3273dc
transform: rotate(45deg) box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25)
left: 2px outline: 0
top: 11px
\:checked + label::after \:checked + label::before,
background-color: white :checked + label::after
transform: rotate(-45deg) content: ''
top: 9px
width: 12px \:checked + label::before
left: 7px background-color: white
transform: rotate(45deg)
left: 2px
top: 11px
\:checked + label::after
background-color: white
transform: rotate(-45deg)
top: 9px
width: 12px
left: 7px
&.radiobox
label::before
position: absolute
display: block
top: 6.5px
left: 6.5px
height: 7px
width: 7px
border-radius: 3.5px
background-color: var(--color-font)
\:checked + label
background-color: var(--color-primary)
[type='radio']:focus + label
border-color: #3273dc
box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25)
outline: 0
\:checked + label::before
content: ''
\:checked + label::before
background-color: white
[disabled] + label [disabled] + label
pointer-events: none pointer-events: none

View File

@ -2,7 +2,7 @@ import { Checkbox } from './checkbox';
import { FileInput } from './file-input'; import { FileInput } from './file-input';
import './inputs.sass'; import './inputs.sass';
import './radio.sass'; import './radio-group.sass';
export const InputUtils = [ export const InputUtils = [
Checkbox, Checkbox,

View File

@ -0,0 +1,55 @@
// CUSTOM RADIO BOXES
// Completely replaces native radiobox
.radio-group
display: flex
.radio
position: relative
display: inline-block
[type='radio']
position: fixed
top: -1px
left: -1px
width: 1px
height: 1px
overflow: hidden
label
display: block
height: 34px
min-width: 42px
line-height: 34px
text-align: center
padding: 0 13px
background-color: #f3f3f3
box-shadow: inset 2px 1px 2px 1px rgba(50, 50, 50, 0.05)
color: var(--color-font)
cursor: pointer
\:checked + label
background-color: var(--color-primary)
color: var(--color-lightwhite)
box-shadow: inset -2px -1px 2px 1px rgba(255, 255, 255, 0.15)
\:focus + label
border-color: #3273dc
box-shadow: 0 0 0.125em 0 rgba(50, 115, 220, 0.8)
outline: 0
[disabled] + label
pointer-events: none
border: none
opacity: 0.6
filter: grayscale(1)
.radio:first-child
label
border-top-left-radius: 4px
border-bottom-left-radius: 4px
.radio:last-child
label
border-top-right-radius: 4px
border-bottom-right-radius: 4px

View File

@ -0,0 +1,47 @@
import { Utility } from '../../core/utility';
import './radio.sass';
var RADIO_CLASS = 'radiobox';
var RADIO_INITIALIZED_CLASS = 'radio--initialized';
@Utility({
selector: 'input[type="radio"]:not([uw-no-radio])',
})
export class Radio {
constructor(element) {
if (!element) {
throw new Error('Radio utility cannot be setup without an element!');
}
if (element.closest('.radio-group')) {
return false;
}
if (element.classList.contains(RADIO_INITIALIZED_CLASS)) {
// throw new Error('Radio utility already initialized!');
return false;
}
if (element.parentElement.classList.contains(RADIO_CLASS)) {
// throw new Error('Radio element\'s wrapper already has class '' + RADIO_CLASS + ''!');
return false;
}
var siblingEl = element.nextSibling;
var parentEl = element.parentElement;
var wrapperEl = document.createElement('div');
wrapperEl.classList.add(RADIO_CLASS);
var labelEl = document.createElement('label');
labelEl.setAttribute('for', element.id);
wrapperEl.appendChild(element);
wrapperEl.appendChild(labelEl);
parentEl.insertBefore(wrapperEl, siblingEl);
element.classList.add(RADIO_INITIALIZED_CLASS);
}
}

View File

@ -1,55 +1,55 @@
// CUSTOM RADIO BOXES // GROUPS OF RADIO BUTTONS
// Completely replaces native radiobox // Completely replaces native radiobox
.radio-group .radio-group
display: flex display: flex
.radio & > .radio
position: relative position: relative
display: inline-block display: inline-block
[type='radio'] [type='radio']
position: fixed position: fixed
top: -1px top: -1px
left: -1px left: -1px
width: 1px width: 1px
height: 1px height: 1px
overflow: hidden overflow: hidden
label label
display: block display: block
height: 34px height: 34px
min-width: 42px min-width: 42px
line-height: 34px line-height: 34px
text-align: center text-align: center
padding: 0 13px padding: 0 13px
background-color: #f3f3f3 background-color: #f3f3f3
box-shadow: inset 2px 1px 2px 1px rgba(50, 50, 50, 0.05) box-shadow: inset 2px 1px 2px 1px rgba(50, 50, 50, 0.05)
color: var(--color-font) color: var(--color-font)
cursor: pointer cursor: pointer
\:checked + label \:checked + label
background-color: var(--color-primary) background-color: var(--color-primary)
color: var(--color-lightwhite) color: var(--color-lightwhite)
box-shadow: inset -2px -1px 2px 1px rgba(255, 255, 255, 0.15) box-shadow: inset -2px -1px 2px 1px rgba(255, 255, 255, 0.15)
\:focus + label \:focus + label
border-color: #3273dc border-color: #3273dc
box-shadow: 0 0 0.125em 0 rgba(50, 115, 220, 0.8) box-shadow: 0 0 0.125em 0 rgba(50, 115, 220, 0.8)
outline: 0 outline: 0
[disabled] + label [disabled] + label
pointer-events: none pointer-events: none
border: none border: none
opacity: 0.6 opacity: 0.6
filter: grayscale(1) filter: grayscale(1)
.radio:first-child &:first-child
label label
border-top-left-radius: 4px border-top-left-radius: 4px
border-bottom-left-radius: 4px border-bottom-left-radius: 4px
.radio:last-child &:last-child
label label
border-top-right-radius: 4px border-top-right-radius: 4px
border-bottom-right-radius: 4px border-bottom-right-radius: 4px

View File

@ -1213,6 +1213,7 @@ MenuExternalExamList: Externe Prüfungen
MenuParticipantsList: Kursteilnehmerlisten MenuParticipantsList: Kursteilnehmerlisten
MenuParticipantsIntersect: Überschneidung von Kursteilnehmern MenuParticipantsIntersect: Überschneidung von Kursteilnehmern
MenuAllocationUsers: Bewerber MenuAllocationUsers: Bewerber
MenuAllocationPriorities: Zentrale Dringlichkeiten
BreadcrumbSubmissionFile: Datei BreadcrumbSubmissionFile: Datei
BreadcrumbSubmissionUserInvite: Einladung zur Abgabe BreadcrumbSubmissionUserInvite: Einladung zur Abgabe
@ -1279,6 +1280,7 @@ BreadcrumbParticipants: Kursteilnehmerliste
BreadcrumbExamAutoOccurrence: Automatische Termin-/Raumverteilung BreadcrumbExamAutoOccurrence: Automatische Termin-/Raumverteilung
BreadcrumbStorageKey: Lokalen Schlüssel generieren BreadcrumbStorageKey: Lokalen Schlüssel generieren
BreadcrumbAllocationUsers: Bewerber BreadcrumbAllocationUsers: Bewerber
BreadcrumbAllocationPriorities: Zentrale Dringlichkeiten
ExternalExamEdit coursen@CourseName examn@ExamName: Bearbeiten: #{coursen}, #{examn} ExternalExamEdit coursen@CourseName examn@ExamName: Bearbeiten: #{coursen}, #{examn}
ExternalExamGrades coursen@CourseName examn@ExamName: Prüfungsleistungen: #{coursen}, #{examn} ExternalExamGrades coursen@CourseName examn@ExamName: Prüfungsleistungen: #{coursen}, #{examn}
@ -2357,4 +2359,12 @@ CsvColumnAllocationUserRequested: Maximale Anzahl von Plätzen, die der Bewerber
CsvColumnAllocationUserApplied: Anzahl von Bewerbungen, die der Bewerber eingereicht hat CsvColumnAllocationUserApplied: Anzahl von Bewerbungen, die der Bewerber eingereicht hat
CsvColumnAllocationUserVetos: Anzahl von Bewerbungen, die von Kursverwaltern ein Veto oder eine Note erhalten haben, die äquivalent ist zu "Nicht Bestanden" (5.0) CsvColumnAllocationUserVetos: Anzahl von Bewerbungen, die von Kursverwaltern ein Veto oder eine Note erhalten haben, die äquivalent ist zu "Nicht Bestanden" (5.0)
CsvColumnAllocationUserAssigned: Anzahl von Plätzen, die der Bewerber durch diese Zentralanmeldung bereits erhalten hat CsvColumnAllocationUserAssigned: Anzahl von Plätzen, die der Bewerber durch diese Zentralanmeldung bereits erhalten hat
AllocationUsersCsvName tid@TermId ssh@SchoolId ash@AllocationShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase ash}-bewerber AllocationUsersCsvName tid@TermId ssh@SchoolId ash@AllocationShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase ash}-bewerber
AllocationPrioritiesMode: Modus
AllocationPrioritiesNumeric: Numerische Dringlichkeiten
AllocationPrioritiesOrdinal: Dringlichkeiten durch Sortierung
AllocationPrioritiesTitle tid@TermId ssh@SchoolId ash@AllocationShorthand: #{tid}-#{ssh}-#{ash}: Zentrale Dringlichkeiten
AllocationPrioritiesFile: CSV-Datei
AllocationPrioritiesSunk num@Int64: Zentrale Prioritäten für #{num} Bewerber erfolgreich hinterlegt
AllocationPrioritiesMissing num@Int64: Für #{num} Bewerber ist keine zentrale Priorität hinterlegt, da in der hochgeladenen CSV-Datei die #{pluralDE num "entsprechende Matrikelnummer" "entsprechenden Matrikelnummern"} nicht gefunden #{pluralDE num "wurde" "wurden"}

View File

@ -802,5 +802,31 @@
"usedIds": [] "usedIds": []
} }
} }
],
"mini-css-extract-plugin node_modules/css-loader/dist/cjs.js??ref--6-1!node_modules/postcss-loader/src/index.js??ref--6-2!node_modules/resolve-url-loader/index.js??ref--6-3!node_modules/sass-loader/dist/cjs.js??ref--6-4!frontend/src/utils/inputs/radio-group.sass": [
{
"modules": {
"byIdentifier": {},
"usedIds": {}
},
"chunks": {
"byName": {},
"bySource": {},
"usedIds": []
}
}
],
"mini-css-extract-plugin node_modules/css-loader/dist/cjs.js??ref--6-1!node_modules/postcss-loader/src/index.js??ref--6-2!node_modules/resolve-url-loader/index.js??ref--6-3!node_modules/sass-loader/dist/cjs.js??ref--6-4!frontend/src/utils/inputs/radiobox.sass": [
{
"modules": {
"byIdentifier": {},
"usedIds": {}
},
"chunks": {
"byName": {},
"bySource": {},
"usedIds": []
}
}
] ]
} }

1
routes
View File

@ -109,6 +109,7 @@
/register ARegisterR POST !time /register ARegisterR POST !time
/course/#CryptoUUIDCourse/apply AApplyR POST !timeANDallocation-registered /course/#CryptoUUIDCourse/apply AApplyR POST !timeANDallocation-registered
/users AUsersR GET POST !allocation-admin /users AUsersR GET POST !allocation-admin
/priorities APriosR GET POST !allocation-admin
/participants ParticipantsListR GET !evaluation /participants ParticipantsListR GET !evaluation
/participants/#TermId/#SchoolId ParticipantsR GET !evaluation /participants/#TermId/#SchoolId ParticipantsR GET !evaluation

View File

@ -21,6 +21,7 @@ module Database.Esqueleto.Utils
, SqlProject(..) , SqlProject(..)
, (->.) , (->.)
, fromSqlKey , fromSqlKey
, selectCountRows
, module Database.Esqueleto.Utils.TH , module Database.Esqueleto.Utils.TH
) where ) where
@ -257,3 +258,13 @@ instance (PersistEntity val, PersistField typ) => SqlProject val typ (Maybe (E.E
fromSqlKey :: (ToBackendKey SqlBackend entity, PersistField (Key entity)) => E.SqlExpr (E.Value (Key entity)) -> E.SqlExpr (E.Value Int64) fromSqlKey :: (ToBackendKey SqlBackend entity, PersistField (Key entity)) => E.SqlExpr (E.Value (Key entity)) -> E.SqlExpr (E.Value Int64)
fromSqlKey = E.veryUnsafeCoerceSqlExprValue fromSqlKey = E.veryUnsafeCoerceSqlExprValue
selectCountRows :: (Num a, PersistField a, MonadIO m) => E.SqlQuery ignored -> E.SqlReadT m a
selectCountRows q = do
res <- E.select $ E.countRows <$ q
case res of
[E.Value res']
-> return res'
_other
-> error "E.countRows did not return exactly one result"

View File

@ -2029,6 +2029,7 @@ instance YesodBreadcrumbs UniWorX where
MaybeT $ get cid MaybeT $ get cid
return (CI.original courseName, Just $ AllocationR tid ssh ash AShowR) return (CI.original courseName, Just $ AllocationR tid ssh ash AShowR)
AUsersR -> i18nCrumb MsgBreadcrumbAllocationUsers . Just $ AllocationR tid ssh ash AShowR AUsersR -> i18nCrumb MsgBreadcrumbAllocationUsers . Just $ AllocationR tid ssh ash AShowR
APriosR -> i18nCrumb MsgBreadcrumbAllocationPriorities . Just $ AllocationR tid ssh ash AUsersR
breadcrumb ParticipantsListR = i18nCrumb MsgBreadcrumbParticipantsList $ Just CourseListR breadcrumb ParticipantsListR = i18nCrumb MsgBreadcrumbParticipantsList $ Just CourseListR
breadcrumb (ParticipantsR _ _) = i18nCrumb MsgBreadcrumbParticipants $ Just ParticipantsListR breadcrumb (ParticipantsR _ _) = i18nCrumb MsgBreadcrumbParticipants $ Just ParticipantsListR
@ -3021,6 +3022,19 @@ pageActions (AllocationR tid ssh ash AShowR) = return
, navChildren = [] , navChildren = []
} }
] ]
pageActions (AllocationR tid ssh ash AUsersR) = return
[ NavPageActionPrimary
{ navLink = NavLink
{ navLabel = MsgMenuAllocationPriorities
, navRoute = AllocationR tid ssh ash APriosR
, navAccess' = return True
, navType = NavTypeLink { navModal = True }
, navQuick' = mempty
, navForceActive = False
}
, navChildren = []
}
]
pageActions CourseListR = do pageActions CourseListR = do
participantsSecondary <- pageQuickActions NavQuickViewPageActionSecondary ParticipantsListR participantsSecondary <- pageQuickActions NavQuickViewPageActionSecondary ParticipantsListR
return return

View File

@ -8,3 +8,4 @@ import Handler.Allocation.Application as Handler.Allocation
import Handler.Allocation.Register as Handler.Allocation import Handler.Allocation.Register as Handler.Allocation
import Handler.Allocation.List as Handler.Allocation import Handler.Allocation.List as Handler.Allocation
import Handler.Allocation.Users as Handler.Allocation import Handler.Allocation.Users as Handler.Allocation
import Handler.Allocation.Prios as Handler.Allocation

View File

@ -0,0 +1,82 @@
module Handler.Allocation.Prios
( getAPriosR, postAPriosR
) where
import Import
import Handler.Utils
import Handler.Utils.Csv
import Handler.Utils.Allocation
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Utils as E
import qualified Data.Conduit.List as C
import qualified Data.Csv as Csv
data AllocationPrioritiesMode
= AllocationPrioritiesNumeric
| AllocationPrioritiesOrdinal
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
instance Universe AllocationPrioritiesMode
instance Finite AllocationPrioritiesMode
nullaryPathPiece ''AllocationPrioritiesMode $ camelToPathPiece' 2
embedRenderMessage ''UniWorX ''AllocationPrioritiesMode id
getAPriosR, postAPriosR :: TermId -> SchoolId -> AllocationShorthand -> Handler Html
getAPriosR = postAPriosR
postAPriosR tid ssh ash = do
doNumericPrios <- runDB $ do
Entity aId _ <- getBy404 $ TermSchoolAllocationShort tid ssh ash
numericPrios <- E.selectCountRows . E.from $ \allocationUser -> do
E.where_ $ allocationUser E.^. AllocationUserAllocation E.==. E.val aId
E.where_ . E.maybe E.false sqlAllocationPriorityNumeric $ allocationUser E.^. AllocationUserPriority
ordinalPrios <- E.selectCountRows . E.from $ \allocationUser -> do
E.where_ $ allocationUser E.^. AllocationUserAllocation E.==. E.val aId
E.where_ . E.maybe E.false (E.not_ . sqlAllocationPriorityNumeric) $ allocationUser E.^. AllocationUserPriority
let doNumericPrios = ((>=) :: Int64 -> Int64 -> Bool) numericPrios ordinalPrios
return doNumericPrios
let explainAllocationPrioMode = \case
AllocationPrioritiesNumeric -> return $(i18nWidgetFile "allocation-priority-explanation/numeric")
AllocationPrioritiesOrdinal -> return $(i18nWidgetFile "allocation-priority-explanation/ordinal")
((priosRes, priosView), priosEnctype) <- runFormPost . renderAForm FormStandard $ (,)
<$> apopt (explainedSelectionField Nothing (explainOptionList optionsFinite explainAllocationPrioMode)) (fslI MsgAllocationPrioritiesMode) (Just $ bool AllocationPrioritiesOrdinal AllocationPrioritiesNumeric doNumericPrios)
<*> areq fileField (fslI MsgAllocationPrioritiesFile) Nothing
formResult priosRes $ \(mode, fInfo) -> do
let sourcePrios = case mode of
AllocationPrioritiesNumeric -> fileSourceCsvPositional Csv.NoHeader fInfo
AllocationPrioritiesOrdinal -> fileSourceCsvPositional Csv.NoHeader fInfo .| C.map Csv.fromOnly .| ordinalPriorities
(matrSunk, matrMissing) <- runDB $ do
Entity aId _ <- getBy404 $ TermSchoolAllocationShort tid ssh ash
updateWhere
[ AllocationUserAllocation ==. aId ]
[ AllocationUserPriority =. Nothing ]
matrSunk <- runConduit $ sourcePrios .| sinkAllocationPriorities aId
matrMissing <- fromIntegral <$> count [ AllocationUserAllocation ==. aId, AllocationUserPriority ==. Nothing ]
return (matrSunk, matrMissing)
when (matrSunk > 0) $
addMessageI Success $ MsgAllocationPrioritiesSunk matrSunk
when (matrMissing > 0) $
addMessageI Error $ MsgAllocationPrioritiesMissing matrMissing
redirect $ AllocationR tid ssh ash AUsersR
siteLayoutMsg MsgMenuAllocationPriorities $ do
setTitleI $ MsgAllocationPrioritiesTitle tid ssh ash
wrapForm priosView def
{ formEncoding = priosEnctype
, formAction = Just . SomeRoute $ AllocationR tid ssh ash APriosR
}

View File

@ -46,9 +46,9 @@ ordinalPriorities :: Monad m => ConduitT UserMatriculation (Map UserMatriculatio
ordinalPriorities = evalStateC 0 . C.mapM $ \matr -> singletonMap matr <$> (AllocationPriorityOrdinal <$> State.get <* State.modify' succ) ordinalPriorities = evalStateC 0 . C.mapM $ \matr -> singletonMap matr <$> (AllocationPriorityOrdinal <$> State.get <* State.modify' succ)
sinkAllocationPriorities :: AllocationId sinkAllocationPriorities :: AllocationId
-> ConduitT (Map UserMatriculation AllocationPriority) Void DB () -> ConduitT (Map UserMatriculation AllocationPriority) Void DB Int64
sinkAllocationPriorities allocId = C.mapM_ . imapM_ $ \matr prio -> sinkAllocationPriorities allocId = fmap getSum . C.foldMapM . ifoldMapM $ \matr prio ->
E.update $ \allocationUser -> do fmap Sum . E.updateCount $ \allocationUser -> do
E.set allocationUser [ AllocationUserPriority E.=. E.val (Just prio) ] E.set allocationUser [ AllocationUserPriority E.=. E.val (Just prio) ]
E.where_ $ allocationUser E.^. AllocationUserAllocation E.==. E.val allocId E.where_ $ allocationUser E.^. AllocationUserAllocation E.==. E.val allocId
E.where_ . E.exists . E.from $ \user -> E.where_ . E.exists . E.from $ \user ->

View File

@ -7,7 +7,7 @@ module Handler.Utils.Csv
, encodeDefaultOrderedCsv , encodeDefaultOrderedCsv
, respondCsv, respondCsvDB , respondCsv, respondCsvDB
, respondDefaultOrderedCsv, respondDefaultOrderedCsvDB , respondDefaultOrderedCsv, respondDefaultOrderedCsvDB
, fileSourceCsv , fileSourceCsv, fileSourceCsvPositional
, partIsAttachmentCsv , partIsAttachmentCsv
, CsvParseError(..) , CsvParseError(..)
, ToNamedRecord(..), FromNamedRecord(..) , ToNamedRecord(..), FromNamedRecord(..)
@ -210,6 +210,16 @@ fileSourceCsv :: ( FromNamedRecord csv
-> ConduitT () csv m () -> ConduitT () csv m ()
fileSourceCsv = (.| decodeCsv) . fileSource fileSourceCsv = (.| decodeCsv) . fileSource
fileSourceCsvPositional :: ( MonadHandler m
, HandlerSite m ~ UniWorX
, MonadThrow m
, FromRecord csv
)
=> HasHeader
-> FileInfo
-> ConduitT () csv m ()
fileSourceCsvPositional hdr = (.| decodeCsvPositional hdr) . fileSource
instance ToWidget UniWorX CsvRendered where instance ToWidget UniWorX CsvRendered where
toWidget CsvRendered{..} = liftWidget $(widgetFile "widgets/csvRendered") toWidget CsvRendered{..} = liftWidget $(widgetFile "widgets/csvRendered")

View File

@ -1479,3 +1479,46 @@ csvOptionsForm :: forall m.
csvOptionsForm mPrev = hoistAForm liftHandler $ CsvOptions csvOptionsForm mPrev = hoistAForm liftHandler $ CsvOptions
<$> csvFormatOptionsForm (fslI MsgCsvFormatOptions & setTooltip MsgCsvOptionsTip) (csvFormat <$> mPrev) <$> csvFormatOptionsForm (fslI MsgCsvFormatOptions & setTooltip MsgCsvOptionsTip) (csvFormat <$> mPrev)
<*> apopt checkBoxField (fslI MsgCsvTimestamp & setTooltip MsgCsvTimestampTip) (csvTimestamp <$> mPrev) <*> apopt checkBoxField (fslI MsgCsvTimestamp & setTooltip MsgCsvTimestampTip) (csvTimestamp <$> mPrev)
explainedSelectionField :: forall m a.
( MonadHandler m
, HandlerSite m ~ UniWorX
, Eq a
)
=> Maybe (SomeMessage UniWorX, Maybe Widget) -- ^ Label for none option
-> Handler ([(Option a, Maybe Widget)], Text -> Maybe a)
-> Field m a
explainedSelectionField optMsg' mkOpts = Field{..}
where
fieldEnctype = UrlEncoded
fieldParse ts _ = do
(_, parser) <- liftHandler mkOpts
if
| t : _ <- ts
, Just t' <- parser t
-> return . Right $ Just t'
| t : _ <- ts
, null t
-> return $ Right Nothing
| t : _ <- ts
-> return . Left . SomeMessage $ MsgInvalidEntry t
| otherwise
-> return $ Right Nothing
fieldView theId name attrs val isReq = do
(opts, _) <- liftHandler mkOpts
let optMsg = guardOnM (not isReq) optMsg'
inputId optExternal = [st|#{theId}__input--#{optExternal}|]
matchesVal Nothing = is _Left val
matchesVal (Just x) = val == Right x
$(widgetFile "widgets/explained-selection-field")
explainOptionList :: forall a.
Handler (OptionList a)
-> (a -> MaybeT Handler Widget)
-> Handler ([(Option a, Maybe Widget)], Text -> Maybe a)
explainOptionList ol mkExplanation = do
OptionList{..} <- ol
olOptions' <- forM olOptions $ \opt@Option{..} -> (opt, ) <$> runMaybeT (mkExplanation optionInternalValue)
return (olOptions', olReadExternal)

View File

@ -17,7 +17,7 @@ import ClassyPrelude.Yesod as Import
, HasHttpManager(..) , HasHttpManager(..)
, embed , embed
, try, embed, catches, handle, catch, bracket, bracketOnError, bracket_, catchJust, finally, handleJust, mask, mask_, onException, tryJust, uninterruptibleMask, uninterruptibleMask_ , try, embed, catches, handle, catch, bracket, bracketOnError, bracket_, catchJust, finally, handleJust, mask, mask_, onException, tryJust, uninterruptibleMask, uninterruptibleMask_
, htmlField , htmlField, fileField
) )
import UnliftIO.Async.Utils as Import import UnliftIO.Async.Utils as Import

View File

@ -1,5 +1,6 @@
module Model.Types.Allocation module Model.Types.Allocation
( AllocationPriority(..) ( AllocationPriority(..)
, sqlAllocationPriorityNumeric
, AllocationPriorityComparison(..) , AllocationPriorityComparison(..)
, AllocationFingerprint , AllocationFingerprint
, module Utils.Allocation , module Utils.Allocation
@ -16,6 +17,10 @@ import qualified Data.Map.Strict as Map
import Crypto.Hash (SHAKE128) import Crypto.Hash (SHAKE128)
import qualified Database.Esqueleto as E
import qualified Database.Esqueleto.Internal.Sql as E
import qualified Database.Esqueleto.PostgreSQL.JSON as E
{-# ANN module ("HLint: ignore Use newtype instead of data"::String) #-} {-# ANN module ("HLint: ignore Use newtype instead of data"::String) #-}
@ -31,7 +36,9 @@ deriveJSON defaultOptions
, unwrapUnaryRecords = False , unwrapUnaryRecords = False
, tagSingleConstructors = True , tagSingleConstructors = True
} ''AllocationPriority } ''AllocationPriority
derivePersistFieldJSON ''AllocationPriority
deriving via E.JSONB AllocationPriority instance E.PersistField AllocationPriority
deriving via E.JSONB AllocationPriority instance E.PersistFieldSql AllocationPriority
instance Binary AllocationPriority instance Binary AllocationPriority
@ -43,6 +50,10 @@ instance Csv.FromRecord (Map UserMatriculation AllocationPriority) where
| otherwise = mzero | otherwise = mzero
sqlAllocationPriorityNumeric :: E.SqlExpr (E.Value AllocationPriority) -> E.SqlExpr (E.Value Bool)
sqlAllocationPriorityNumeric prio = E.veryUnsafeCoerceSqlExprValue prio E.->. "mode" E.==. E.jsonbVal ("numeric" :: Text)
data AllocationPriorityComparison data AllocationPriorityComparison
= AllocationPriorityComparisonNumeric { allocationGradeScale :: Rational } = AllocationPriorityComparisonNumeric { allocationGradeScale :: Rational }
| AllocationPriorityComparisonOrdinal { allocationCloneIndex :: Down Natural, allocationOrdinalScale :: Rational } | AllocationPriorityComparisonOrdinal { allocationCloneIndex :: Down Natural, allocationOrdinalScale :: Rational }

View File

@ -670,7 +670,10 @@ ofoldl1M f (otoList -> x:xs) = foldlM f x xs
ofoldl1M _ _ = error "otoList of NonNull is empty" ofoldl1M _ _ = error "otoList of NonNull is empty"
foldMapM :: (Foldable f, Monad m, Monoid b) => (a -> m b) -> f a -> m b foldMapM :: (Foldable f, Monad m, Monoid b) => (a -> m b) -> f a -> m b
foldMapM f = foldrM (\x xs -> (<>) <$> f x <*> pure xs) mempty foldMapM f = foldrM (\x xs -> (<> xs) <$> f x) mempty
ifoldMapM :: (FoldableWithIndex i f, Monad m, Monoid b) => (i -> a -> m b) -> f a -> m b
ifoldMapM f = ifoldrM (\i x xs -> (<> xs) <$> f i x) mempty
partitionM :: forall mono m . partitionM :: forall mono m .
( MonoFoldable mono ( MonoFoldable mono

View File

@ -639,17 +639,30 @@ secretJsonField :: forall m a.
secretJsonField = secretJsonField' $ fieldView (hiddenField :: Field m Text) secretJsonField = secretJsonField' $ fieldView (hiddenField :: Field m Text)
fileFieldMultiple :: Monad m => Field m [FileInfo] fileFieldMultiple :: Monad m => Field m [FileInfo]
fileFieldMultiple = Field fileFieldMultiple = Field{..}
{ fieldParse = \_ files -> return $ case files of where
fieldEnctype = Multipart
fieldParse _ files = return $ case files of
[] -> Right Nothing [] -> Right Nothing
fs -> Right $ Just fs fs -> Right $ Just fs
, fieldView = \id' name attrs _ isReq -> fieldView id' name attrs _ isReq =
[whamlet| [whamlet|
$newline never $newline never
<input type="file" uw-file-input id=#{id'} name=#{name} *{attrs} multiple :isReq:required="required"> <input type="file" uw-file-input ##{id'} name=#{name} *{attrs} multiple :isReq:required>
|]
fileField :: Monad m => Field m FileInfo
fileField = Field{..}
where
fieldEnctype = Multipart
fieldParse _ files = return $ case files of
[] -> Right Nothing
f : _ -> Right $ Just f
fieldView id' name attrs _ isReq =
[whamlet|
$newline never
<input type=file uw-file-input ##{id'} name=#{name} *{attrs} :isReq:required>
|] |]
, fieldEnctype = Multipart
}
guardField :: Functor m => (a -> Bool) -> Field m a -> Field m a guardField :: Functor m => (a -> Bool) -> Field m a -> Field m a
guardField p field = field { fieldParse = \ts fs -> fieldParse field ts fs <&> \case guardField p field = field { fieldParse = \ts fs -> fieldParse field ts fs <&> \case

View File

@ -2,9 +2,9 @@ $newline never
$if not isModal $if not isModal
$with containers <- filter isNavHeaderContainer nav $with containers <- filter isNavHeaderContainer nav
$if not (null containers) $if not (null containers)
<input name=nav-container type=radio .navbar__container-radio--none checked #container-radio-none> <input name=nav-container type=radio .navbar__container-radio--none checked #container-radio-none uw-no-radiobox>
$forall (_, containerIdent, _, _) <- containers $forall (_, containerIdent, _, _) <- containers
<input name=nav-container type=radio .navbar__container-radio ##{containerIdent}-radio> <input name=nav-container type=radio .navbar__container-radio ##{containerIdent}-radio uw-no-radiobox>
<!-- secondary navigation at the side --> <!-- secondary navigation at the side -->
^{asidenav} ^{asidenav}

View File

@ -0,0 +1,19 @@
$newline never
Es wird erwartet, dass die erste Spalte der hochgeladenen CSV-Datei die #
Matrikelnummer der Zentralanmeldungs-Bewerber enthält.
<br />
Alle weiteren Spalten werden als ganze Zahlen interpretiert und #
kodieren die jeweilige zentrale Dringlichkeit bei der Vergabe der #
Plätze. #
Hierbei wird die erste Dringlichkeits-Spalte verwendet zur Vergabe des #
jeweils ersten Platzes, die zweite Spalte für den zweiten Platz, usw. #
Größere Zahlen kodieren eine höhere Dringlichkeit.
<br />
Die CSV-Datei darf keine Spaltenüberschriften enthalten.

View File

@ -0,0 +1,17 @@
$newline never
Es wird erwartet, dass die hochgeladene CSV-Datei genau eine Spalte #
mit den Matrikelnummern der Zentralanmeldungs-Bewerber enthält.
<br />
Die zentrale Dringlichkeit ergibt sich ausschließlich anhand der #
Sortierung der CSV-Datei. #
Bewerber, deren Matrikelnummer später in der Datei vorkommt, erhalten #
für alle ihre Plätze eine höhere Dringlichkeit, als Bewerber, deren #
Matrikelnummern in der Datei früher vorkommen. #
<br />
Die CSV-Datei darf keine Spaltenüberschriften enthalten.

View File

@ -7,7 +7,7 @@ $newline never
<li> <li>
Alle HTML-Eingabefelder akzeptieren nun stattdessen Markdown Alle HTML-Eingabefelder akzeptieren nun stattdessen Markdown
<li> <li>
Alle ausgehenden HTML E-Mails haben nun auch einen \ Alle ausgehenden HTML E-Mails haben nun auch einen #
Markdown-Teil Markdown-Teil
<dt .deflist__dt> <dt .deflist__dt>
@ -15,8 +15,8 @@ $newline never
<dd .deflist__dd> <dd .deflist__dd>
<ul> <ul>
<li> <li>
Prüfungen können nun angeben in welchem Format Leistungen \ Prüfungen können nun angeben in welchem Format Leistungen #
eingetragen werden dürfen (Bestanden/Nicht Bestanden, \ eingetragen werden dürfen (Bestanden/Nicht Bestanden, #
Numerische Noten oder Gemischt) Numerische Noten oder Gemischt)
<dt .deflist__dt> <dt .deflist__dt>

View File

@ -14,7 +14,7 @@ $newline never
<dd .deflist__dd> <dd .deflist__dd>
<ul> <ul>
<li> <li>
Exams may now specify in which format results are expected to \ Exams may now specify in which format results are expected to #
entered (passed/failed, numeric grades, or mixed) entered (passed/failed, numeric grades, or mixed)
<dt .deflist__dt> <dt .deflist__dt>

View File

@ -0,0 +1,21 @@
$newline never
<div ##{theId} *{attrs} .explained-selection-field>
$maybe (msg, wgt) <- optMsg
<div .explained-selection-field__option>
<label .explained-selection-field__input .explained-selection-field__input--none for=#{inputId "none"}>
<input ##{inputId "none"} type=radio name=#{name} value="" :matchesVal Nothing:checked>
<label .explained-selection-field__label .explained-selection-field__label--none for=#{inputId "none"}>
_{msg}
$maybe w <- wgt
<div .explained-selection-field__explanation .explained-selection-field__explanation--none>
^{w}
$forall (Option{..}, optionExplanation) <- opts
<div .explained-selection-field__option>
<label .explained-selection-field__input for=#{inputId optionExternalValue}>
<input ##{inputId optionExternalValue} type=radio name=#{name} value=#{optionExternalValue} :matchesVal (Just optionInternalValue):checked>
<label .explained-selection-field__label for=#{inputId optionExternalValue}>
#{optionDisplay}
$maybe w <- optionExplanation
<div .explained-selection-field__explanation>
^{w}

View File

@ -1,13 +1,13 @@
$newline never $newline never
$if hasSecondaryPageActions || hasPrimarySubActions $if hasSecondaryPageActions || hasPrimarySubActions
<input .pagenav-item__expand-radio name=pagenav-item__expand-radio id=pageaction-item__expand-none type=radio checked> <input .pagenav-item__expand-radio name=pagenav-item__expand-radio id=pageaction-item__expand-none type=radio checked uw-no-radiobox>
<ul .pagenav> <ul .pagenav>
$forall n <- filter isPageActionPrimary nav $forall n <- filter isPageActionPrimary nav
<li .pagenav__list-item> <li .pagenav__list-item>
^{navWidget n} ^{navWidget n}
$if hasSecondaryPageActions $if hasSecondaryPageActions
<li .pagenav__list-item .pagenav-secondary> <li .pagenav__list-item .pagenav-secondary>
<input .pagenav-item__expand-radio name=pagenav-item__expand-radio id=pageaction-item__expand-secondary type=radio> <input .pagenav-item__expand-radio name=pagenav-item__expand-radio id=pageaction-item__expand-secondary type=radio uw-no-radiobox>
<label .pagenav-item__expand-label for=pageaction-item__expand-secondary> <label .pagenav-item__expand-label for=pageaction-item__expand-secondary>
<i .fas .fa-fw .fa-#{iconText IconPageActionSecondary}> <i .fas .fa-fw .fa-#{iconText IconPageActionSecondary}>
<div .pagenav-item__children-wrapper> <div .pagenav-item__children-wrapper>

View File

@ -1,7 +1,7 @@
$newline never $newline never
^{pWidget} ^{pWidget}
$if not (null sWidgets) $if not (null sWidgets)
<input .pagenav-item__expand-radio name=pagenav-item__expand-radio id=pageaction-item__expand-#{navIdent} type=radio> <input .pagenav-item__expand-radio name=pagenav-item__expand-radio id=pageaction-item__expand-#{navIdent} type=radio uw-no-radiobox>
<label .pagenav-item__expand-label for=pageaction-item__expand-#{navIdent}> <label .pagenav-item__expand-label for=pageaction-item__expand-#{navIdent}>
<i .fas .fa-fw .fa-#{iconText IconPageActionPrimaryExpand}> <i .fas .fa-fw .fa-#{iconText IconPageActionPrimaryExpand}>
<div .pagenav-item__children-wrapper> <div .pagenav-item__children-wrapper>