Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • koma/feature/preference-polling-form
  • main
  • renovate/django-5.x
3 results

Target

Select target project
  • konstantin/akplanning
  • matedealer/akplanning
  • kif/akplanning
  • mirco/akplanning
  • lordofthevoid/akplanning
  • voidptr/akplanning
  • xayomer/akplanning-fork
  • mollux/akplanning
  • neumantm/akplanning
  • mmarx/akplanning
  • nerf/akplanning
  • felix_bonn/akplanning
  • sebastian.uschmann/akplanning
13 results
Select Git revision
  • 520-akowner
  • 520-fix-event-wizard-datepicker
  • 520-fix-scheduling
  • 520-improve-scheduling
  • 520-improve-scheduling-2
  • 520-improve-submission
  • 520-improve-trackmanager
  • 520-improve-wall
  • 520-message-resolved
  • 520-status
  • 520-upgrades
  • add_express_interest_to_ak_overview
  • admin-production-color
  • bugfixes
  • csp
  • featire-ical-export
  • feature-ak-requirement-lists
  • feature-akslide-export-better-filename
  • feature-akslides
  • feature-better-admin
  • feature-better-cv-list
  • feature-colors
  • feature-constraint-checking
  • feature-constraint-checking-wip
  • feature-dashboard-history-button
  • feature-event-status
  • feature-event-wizard
  • feature-export-flag
  • feature-improve-admin
  • feature-improve-filters
  • feature-improved-user-creation-workflow
  • feature-interest-view
  • feature-mails
  • feature-modular-status
  • feature-plan-autoreload
  • feature-present-default
  • feature-register-link
  • feature-remaining-constraint-validation
  • feature-room-import
  • feature-scheduler-improve
  • feature-scheduling-2.0
  • feature-special-attention
  • feature-time-input
  • feature-tracker
  • feature-wiki-wishes
  • feature-wish-slots
  • feature-wizard-buttons
  • features-availabilities
  • fix-ak-times-above-folg
  • fix-api
  • fix-constraint-violation-string
  • fix-cv-checking
  • fix-default-slot-length
  • fix-default-slot-localization
  • fix-doc-minor
  • fix-duration-display
  • fix-event-tz-pytz-update
  • fix-history-interest
  • fix-interest-view
  • fix-js
  • fix-pipeline
  • fix-plan-timezone-now
  • fix-room-add
  • fix-scheduling-drag
  • fix-slot-defaultlength
  • fix-timezone
  • fix-translation-scheduling
  • fix-virtual-room-admin
  • fix-wizard-csp
  • font-locally
  • improve-admin
  • improve-online
  • improve-slides
  • improve-submission-coupling
  • interest_restriction
  • main
  • master
  • meta-debug-toolbar
  • meta-export
  • meta-makemessages
  • meta-performance
  • meta-tests
  • meta-tests-gitlab-test
  • meta-upgrades
  • mollux-master-patch-02906
  • port-availabilites-fullcalendar
  • qs
  • remove-tags
  • renovate/configure
  • renovate/django-4.x
  • renovate/django-5.x
  • renovate/django-bootstrap-datepicker-plus-5.x
  • renovate/django-bootstrap5-23.x
  • renovate/django-bootstrap5-24.x
  • renovate/django-compressor-4.x
  • renovate/django-debug-toolbar-4.x
  • renovate/django-registration-redux-2.x
  • renovate/django-simple-history-3.x
  • renovate/django-split-settings-1.x
  • renovate/django-timezone-field-5.x
100 results
Show changes
Showing
with 1134 additions and 25 deletions
.gu-mirror{position:fixed!important;margin:0!important;z-index:9999!important;opacity:.8}.gu-hide{display:none!important}.gu-unselectable{-webkit-user-select:none!important;-moz-user-select:none!important;-ms-user-select:none!important;user-select:none!important}.gu-transit{opacity:.2}
\ No newline at end of file
!function(e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).dragula=e()}(function(){return function o(r,i,u){function c(t,e){if(!i[t]){if(!r[t]){var n="function"==typeof require&&require;if(!e&&n)return n(t,!0);if(a)return a(t,!0);throw(n=new Error("Cannot find module '"+t+"'")).code="MODULE_NOT_FOUND",n}n=i[t]={exports:{}},r[t][0].call(n.exports,function(e){return c(r[t][1][e]||e)},n,n.exports,o,r,i,u)}return i[t].exports}for(var a="function"==typeof require&&require,e=0;e<u.length;e++)c(u[e]);return c}({1:[function(e,t,n){"use strict";var o={},r="(?:^|\\s)",i="(?:\\s|$)";function u(e){var t=o[e];return t?t.lastIndex=0:o[e]=t=new RegExp(r+e+i,"g"),t}t.exports={add:function(e,t){var n=e.className;n.length?u(t).test(n)||(e.className+=" "+t):e.className=t},rm:function(e,t){e.className=e.className.replace(u(t)," ").trim()}}},{}],2:[function(e,t,n){(function(r){"use strict";var M=e("contra/emitter"),k=e("crossvent"),j=e("./classes"),R=document,q=R.documentElement;function U(e,t,n,o){r.navigator.pointerEnabled?k[t](e,{mouseup:"pointerup",mousedown:"pointerdown",mousemove:"pointermove"}[n],o):r.navigator.msPointerEnabled?k[t](e,{mouseup:"MSPointerUp",mousedown:"MSPointerDown",mousemove:"MSPointerMove"}[n],o):(k[t](e,{mouseup:"touchend",mousedown:"touchstart",mousemove:"touchmove"}[n],o),k[t](e,n,o))}function K(e){if(void 0!==e.touches)return e.touches.length;if(void 0!==e.which&&0!==e.which)return e.which;if(void 0!==e.buttons)return e.buttons;e=e.button;return void 0!==e?1&e?1:2&e?3:4&e?2:0:void 0}function z(e,t){return void 0!==r[t]?r[t]:(q.clientHeight?q:R.body)[e]}function H(e,t,n){var o=(e=e||{}).className||"";return e.className+=" gu-hide",n=R.elementFromPoint(t,n),e.className=o,n}function V(){return!1}function $(){return!0}function G(e){return e.width||e.right-e.left}function J(e){return e.height||e.bottom-e.top}function Q(e){return e.parentNode===R?null:e.parentNode}function W(e){return"INPUT"===e.tagName||"TEXTAREA"===e.tagName||"SELECT"===e.tagName||function e(t){if(!t)return!1;if("false"===t.contentEditable)return!1;if("true"===t.contentEditable)return!0;return e(Q(t))}(e)}function Z(t){return t.nextElementSibling||function(){var e=t;for(;e=e.nextSibling,e&&1!==e.nodeType;);return e}()}function ee(e,t){var t=(n=t).targetTouches&&n.targetTouches.length?n.targetTouches[0]:n.changedTouches&&n.changedTouches.length?n.changedTouches[0]:n,n={pageX:"clientX",pageY:"clientY"};return e in n&&!(e in t)&&n[e]in t&&(e=n[e]),t[e]}t.exports=function(e,t){var l,f,s,d,m,o,r,v,p,h,n;1===arguments.length&&!1===Array.isArray(e)&&(t=e,e=[]);var i,g=null,y=t||{};void 0===y.moves&&(y.moves=$),void 0===y.accepts&&(y.accepts=$),void 0===y.invalid&&(y.invalid=function(){return!1}),void 0===y.containers&&(y.containers=e||[]),void 0===y.isContainer&&(y.isContainer=V),void 0===y.copy&&(y.copy=!1),void 0===y.copySortSource&&(y.copySortSource=!1),void 0===y.revertOnSpill&&(y.revertOnSpill=!1),void 0===y.removeOnSpill&&(y.removeOnSpill=!1),void 0===y.direction&&(y.direction="vertical"),void 0===y.ignoreInputTextSelection&&(y.ignoreInputTextSelection=!0),void 0===y.mirrorContainer&&(y.mirrorContainer=R.body);var w=M({containers:y.containers,start:function(e){e=S(e);e&&C(e)},end:O,cancel:L,remove:X,destroy:function(){c(!0),N({})},canMove:function(e){return!!S(e)},dragging:!1});return!0===y.removeOnSpill&&w.on("over",function(e){j.rm(e,"gu-hide")}).on("out",function(e){w.dragging&&j.add(e,"gu-hide")}),c(),w;function u(e){return-1!==w.containers.indexOf(e)||y.isContainer(e)}function c(e){e=e?"remove":"add";U(q,e,"mousedown",E),U(q,e,"mouseup",N)}function a(e){U(q,e?"remove":"add","mousemove",x)}function b(e){e=e?"remove":"add";k[e](q,"selectstart",T),k[e](q,"click",T)}function T(e){i&&e.preventDefault()}function E(e){var t,n;o=e.clientX,r=e.clientY,1!==K(e)||e.metaKey||e.ctrlKey||(n=S(t=e.target))&&(i=n,a(),"mousedown"===e.type&&(W(t)?t.focus():e.preventDefault()))}function x(e){if(i)if(0!==K(e)){if(!(void 0!==e.clientX&&Math.abs(e.clientX-o)<=(y.slideFactorX||0)&&void 0!==e.clientY&&Math.abs(e.clientY-r)<=(y.slideFactorY||0))){if(y.ignoreInputTextSelection){var t=ee("clientX",e)||0,n=ee("clientY",e)||0;if(W(R.elementFromPoint(t,n)))return}n=i;a(!0),b(),O(),C(n);n=function(e){e=e.getBoundingClientRect();return{left:e.left+z("scrollLeft","pageXOffset"),top:e.top+z("scrollTop","pageYOffset")}}(s);d=ee("pageX",e)-n.left,m=ee("pageY",e)-n.top,j.add(h||s,"gu-transit"),function(){if(l)return;var e=s.getBoundingClientRect();(l=s.cloneNode(!0)).style.width=G(e)+"px",l.style.height=J(e)+"px",j.rm(l,"gu-transit"),j.add(l,"gu-mirror"),y.mirrorContainer.appendChild(l),U(q,"add","mousemove",P),j.add(y.mirrorContainer,"gu-unselectable"),w.emit("cloned",l,s,"mirror")}(),P(e)}}else N({})}function S(e){if(!(w.dragging&&l||u(e))){for(var t=e;Q(e)&&!1===u(Q(e));){if(y.invalid(e,t))return;if(!(e=Q(e)))return}var n=Q(e);if(n)if(!y.invalid(e,t))if(y.moves(e,n,t,Z(e)))return{item:e,source:n}}}function C(e){var t,n;t=e.item,n=e.source,("boolean"==typeof y.copy?y.copy:y.copy(t,n))&&(h=e.item.cloneNode(!0),w.emit("cloned",h,e.item,"copy")),f=e.source,s=e.item,v=p=Z(e.item),w.dragging=!0,w.emit("drag",s,f)}function O(){var e;w.dragging&&_(e=h||s,Q(e))}function I(){a(!(i=!1)),b(!0)}function N(e){var t,n;I(),w.dragging&&(t=h||s,n=ee("clientX",e)||0,e=ee("clientY",e)||0,(e=B(H(l,n,e),n,e))&&(h&&y.copySortSource||!h||e!==f)?_(t,e):(y.removeOnSpill?X:L)())}function _(e,t){var n=Q(e);h&&y.copySortSource&&t===f&&n.removeChild(s),A(t)?w.emit("cancel",e,f,f):w.emit("drop",e,t,f,p),Y()}function X(){var e,t;w.dragging&&((t=Q(e=h||s))&&t.removeChild(e),w.emit(h?"cancel":"remove",e,t,f),Y())}function L(e){var t,n,o;w.dragging&&(t=0<arguments.length?e:y.revertOnSpill,!1===(e=A(o=Q(n=h||s)))&&t&&(h?o&&o.removeChild(h):f.insertBefore(n,v)),e||t?w.emit("cancel",n,f,f):w.emit("drop",n,o,f,p),Y())}function Y(){var e=h||s;I(),l&&(j.rm(y.mirrorContainer,"gu-unselectable"),U(q,"remove","mousemove",P),Q(l).removeChild(l),l=null),e&&j.rm(e,"gu-transit"),n&&clearTimeout(n),w.dragging=!1,g&&w.emit("out",e,g,f),w.emit("dragend",e),f=s=h=v=p=n=g=null}function A(e,t){t=void 0!==t?t:l?p:Z(h||s);return e===f&&t===v}function B(t,n,o){for(var r=t;r&&!function(){if(!1===u(r))return!1;var e=D(r,t),e=F(r,e,n,o);if(A(r,e))return!0;return y.accepts(s,r,f,e)}();)r=Q(r);return r}function P(e){if(l){e.preventDefault();var t=ee("clientX",e)||0,n=ee("clientY",e)||0,o=t-d,r=n-m;l.style.left=o+"px",l.style.top=r+"px";var i=h||s,e=H(l,t,n),o=B(e,t,n),u=null!==o&&o!==g;!u&&null!==o||(g&&a("out"),g=o,u&&a("over"));r=Q(i);if(o!==f||!h||y.copySortSource){var c,e=D(o,e);if(null!==e)c=F(o,e,t,n);else{if(!0!==y.revertOnSpill||h)return void(h&&r&&r.removeChild(i));c=v,o=f}(null===c&&u||c!==i&&c!==Z(i))&&(p=c,o.insertBefore(i,c),w.emit("shadow",i,o,f))}else r&&r.removeChild(i)}function a(e){w.emit(e,i,g,f)}}function D(e,t){for(var n=t;n!==e&&Q(n)!==e;)n=Q(n);return n===q?null:n}function F(r,t,i,u){var c="horizontal"===y.direction;return(t!==r?function(){var e=t.getBoundingClientRect();if(c)return n(i>e.left+G(e)/2);return n(u>e.top+J(e)/2)}:function(){var e,t,n,o=r.children.length;for(e=0;e<o;e++){if(t=r.children[e],n=t.getBoundingClientRect(),c&&n.left+n.width/2>i)return t;if(!c&&n.top+n.height/2>u)return t}return null})();function n(e){return e?Z(t):t}}}}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./classes":1,"contra/emitter":5,crossvent:6}],3:[function(e,t,n){t.exports=function(e,t){return Array.prototype.slice.call(e,t)}},{}],4:[function(e,t,n){"use strict";var o=e("ticky");t.exports=function(e,t,n){e&&o(function(){e.apply(n||null,t||[])})}},{ticky:10}],5:[function(e,t,n){"use strict";var c=e("atoa"),a=e("./debounce");t.exports=function(r,e){var i=e||{},u={};return void 0===r&&(r={}),r.on=function(e,t){return u[e]?u[e].push(t):u[e]=[t],r},r.once=function(e,t){return t._once=!0,r.on(e,t),r},r.off=function(e,t){var n=arguments.length;if(1===n)delete u[e];else if(0===n)u={};else{e=u[e];if(!e)return r;e.splice(e.indexOf(t),1)}return r},r.emit=function(){var e=c(arguments);return r.emitterSnapshot(e.shift()).apply(this,e)},r.emitterSnapshot=function(o){var e=(u[o]||[]).slice(0);return function(){var t=c(arguments),n=this||r;if("error"===o&&!1!==i.throws&&!e.length)throw 1===t.length?t[0]:t;return e.forEach(function(e){i.async?a(e,t,n):e.apply(n,t),e._once&&r.off(o,e)}),r}},r}},{"./debounce":4,atoa:3}],6:[function(n,o,e){(function(r){"use strict";var i=n("custom-event"),u=n("./eventmap"),c=r.document,e=function(e,t,n,o){return e.addEventListener(t,n,o)},t=function(e,t,n,o){return e.removeEventListener(t,n,o)},a=[];function l(e,t,n){t=function(e,t,n){var o,r;for(o=0;o<a.length;o++)if((r=a[o]).element===e&&r.type===t&&r.fn===n)return o}(e,t,n);if(t){n=a[t].wrapper;return a.splice(t,1),n}}r.addEventListener||(e=function(e,t,n){return e.attachEvent("on"+t,function(e,t,n){var o=l(e,t,n)||function(n,o){return function(e){var t=e||r.event;t.target=t.target||t.srcElement,t.preventDefault=t.preventDefault||function(){t.returnValue=!1},t.stopPropagation=t.stopPropagation||function(){t.cancelBubble=!0},t.which=t.which||t.keyCode,o.call(n,t)}}(e,n);return a.push({wrapper:o,element:e,type:t,fn:n}),o}(e,t,n))},t=function(e,t,n){n=l(e,t,n);if(n)return e.detachEvent("on"+t,n)}),o.exports={add:e,remove:t,fabricate:function(e,t,n){var o=-1===u.indexOf(t)?new i(t,{detail:n}):function(){var e;c.createEvent?(e=c.createEvent("Event")).initEvent(t,!0,!0):c.createEventObject&&(e=c.createEventObject());return e}();e.dispatchEvent?e.dispatchEvent(o):e.fireEvent("on"+t,o)}}}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./eventmap":7,"custom-event":8}],7:[function(e,r,t){(function(e){"use strict";var t=[],n="",o=/^on/;for(n in e)o.test(n)&&t.push(n.slice(2));r.exports=t}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],8:[function(e,n,t){(function(e){var t=e.CustomEvent;n.exports=function(){try{var e=new t("cat",{detail:{foo:"bar"}});return"cat"===e.type&&"bar"===e.detail.foo}catch(e){}}()?t:"undefined"!=typeof document&&"function"==typeof document.createEvent?function(e,t){var n=document.createEvent("CustomEvent");return t?n.initCustomEvent(e,t.bubbles,t.cancelable,t.detail):n.initCustomEvent(e,!1,!1,void 0),n}:function(e,t){var n=document.createEventObject();return n.type=e,t?(n.bubbles=Boolean(t.bubbles),n.cancelable=Boolean(t.cancelable),n.detail=t.detail):(n.bubbles=!1,n.cancelable=!1,n.detail=void 0),n}}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],9:[function(e,t,n){var o,r,t=t.exports={};function i(){throw new Error("setTimeout has not been defined")}function u(){throw new Error("clearTimeout has not been defined")}function c(t){if(o===setTimeout)return setTimeout(t,0);if((o===i||!o)&&setTimeout)return o=setTimeout,setTimeout(t,0);try{return o(t,0)}catch(e){try{return o.call(null,t,0)}catch(e){return o.call(this,t,0)}}}!function(){try{o="function"==typeof setTimeout?setTimeout:i}catch(e){o=i}try{r="function"==typeof clearTimeout?clearTimeout:u}catch(e){r=u}}();var a,l=[],f=!1,s=-1;function d(){f&&a&&(f=!1,a.length?l=a.concat(l):s=-1,l.length&&m())}function m(){if(!f){var e=c(d);f=!0;for(var t=l.length;t;){for(a=l,l=[];++s<t;)a&&a[s].run();s=-1,t=l.length}a=null,f=!1,function(t){if(r===clearTimeout)return clearTimeout(t);if((r===u||!r)&&clearTimeout)return r=clearTimeout,clearTimeout(t);try{r(t)}catch(e){try{return r.call(null,t)}catch(e){return r.call(this,t)}}}(e)}}function v(e,t){this.fun=e,this.array=t}function p(){}t.nextTick=function(e){var t=new Array(arguments.length-1);if(1<arguments.length)for(var n=1;n<arguments.length;n++)t[n-1]=arguments[n];l.push(new v(e,t)),1!==l.length||f||c(m)},v.prototype.run=function(){this.fun.apply(null,this.array)},t.title="browser",t.browser=!0,t.env={},t.argv=[],t.version="",t.versions={},t.on=p,t.addListener=p,t.once=p,t.off=p,t.removeListener=p,t.removeAllListeners=p,t.emit=p,t.prependListener=p,t.prependOnceListener=p,t.listeners=function(e){return[]},t.binding=function(e){throw new Error("process.binding is not supported")},t.cwd=function(){return"/"},t.chdir=function(e){throw new Error("process.chdir is not supported")},t.umask=function(){return 0}},{}],10:[function(e,n,t){(function(t){var e="function"==typeof t?function(e){t(e)}:function(e){setTimeout(e,0)};n.exports=e}).call(this,e("timers").setImmediate)},{timers:11}],11:[function(a,e,l){(function(e,t){var o=a("process/browser.js").nextTick,n=Function.prototype.apply,r=Array.prototype.slice,i={},u=0;function c(e,t){this._id=e,this._clearFn=t}l.setTimeout=function(){return new c(n.call(setTimeout,window,arguments),clearTimeout)},l.setInterval=function(){return new c(n.call(setInterval,window,arguments),clearInterval)},l.clearTimeout=l.clearInterval=function(e){e.close()},c.prototype.unref=c.prototype.ref=function(){},c.prototype.close=function(){this._clearFn.call(window,this._id)},l.enroll=function(e,t){clearTimeout(e._idleTimeoutId),e._idleTimeout=t},l.unenroll=function(e){clearTimeout(e._idleTimeoutId),e._idleTimeout=-1},l._unrefActive=l.active=function(e){clearTimeout(e._idleTimeoutId);var t=e._idleTimeout;0<=t&&(e._idleTimeoutId=setTimeout(function(){e._onTimeout&&e._onTimeout()},t))},l.setImmediate="function"==typeof e?e:function(e){var t=u++,n=!(arguments.length<2)&&r.call(arguments,1);return i[t]=!0,o(function(){i[t]&&(n?e.apply(null,n):e.call(null),l.clearImmediate(t))}),t},l.clearImmediate="function"==typeof t?t:function(e){delete i[e]}}).call(this,a("timers").setImmediate,a("timers").clearImmediate)},{"process/browser.js":9,timers:11}]},{},[2])(2)});
\ No newline at end of file
......@@ -23,26 +23,31 @@
const callback_success = function(response) {
let table_html = '';
let unresolved_constraint_violations = 0;
if(response.length > 0) {
// Update violation count badge
$('#violationCountBadge').html(response.length).removeClass('bg-success').addClass('bg-warning');
// Update violations table
for(let i=0;i<response.length;i++) {
if(response[i].manually_resolved)
if(response[i].manually_resolved) {
table_html += '<tr class="text-muted"><td class="nowrap">{% fa6_icon "check" "fas" %}</td>';
else
}
else {
table_html += '<tr><td></td>';
unresolved_constraint_violations++;
}
table_html += "<td>" + response[i].level_display + "</td><td>" + response[i].type_display + "</td><td>" + response[i].details + "</td><td class='nowrap'>" + response[i].timestamp_display + "</td><td><a href='" + response[i].edit_url + "'><i class='btn btn-primary fa fa-pen'></i></a></td></tr>";
}
}
else {
// Update violation count badge
$('#violationCountBadge').html(0).removeClass('bg-warning').addClass('bg-success');
// Update violations table
table_html ='<tr class="text-muted"><td colspan="5" class="text-center">{% trans "No violations" %}</td></tr>'
}
// Update violation count badge
if(unresolved_constraint_violations > 0)
$('#violationCountBadge').html(unresolved_constraint_violations).removeClass('bg-success').addClass('bg-warning');
else
$('#violationCountBadge').html(0).removeClass('bg-warning').addClass('bg-success');
// Show violation list (potentially empty) in violations table
$('#violationsTableBody').html(table_html);
......
......@@ -15,6 +15,7 @@
<script src="{% static "common/vendor/sortable/Sortable.min.js" %}"></script>
<script src="{% static "common/vendor/sortable/jquery-sortable.js" %}"></script>
<script src="{% static "AKScheduling/vendor/dragula/dragula.js" %}"></script>
<style>
.ak-list {
......@@ -30,8 +31,18 @@
.track-delete {
cursor: pointer;
}
.card-header {
cursor: move;
}
.card {
padding:0!important;
}
</style>
<link rel="stylesheet" href="{% static "AKScheduling/vendor/dragula/dragula.css" %}">
<script>
document.addEventListener('DOMContentLoaded', function () {
// CSRF Protection/Authentication
......@@ -188,6 +199,13 @@
});
}
});
// Make track containers sortable (when dragging the headers)
dragula([$('#workspace')[0]], {
moves: function (el, container, handle) {
return handle.classList.contains('card-header');
}
});
});
</script>
{% endblock extrahead %}
......@@ -206,7 +224,7 @@
<ul data-id="None" data-sync="false" class="ak-list">
{% for ak in aks_without_track %}
<li data-ak-id="{{ ak.pk }}" data-bs-toggle="tooltip" data-placement="top" title="">
{{ ak.name }} ({{ ak.category }})
{{ ak.name }} <span style="color:{{ ak.category.color }}">({{ ak.category }})</span>
</li>
{% endfor %}
</ul>
......@@ -225,7 +243,7 @@
<ul data-track-id="{{ track.pk }}" data-name="{{ track }}" data-sync="true" class="ak-list">
{% for ak in track.aks_with_category %}
<li data-ak-id="{{ ak.pk }}" data-bs-toggle="tooltip" data-placement="top" title="">
{{ ak.name }} ({{ ak.category }})
{{ ak.name }} <span style="color:{{ ak.category.color }}">({{ ak.category }})</span>
</li>
{% endfor %}
</ul>
......
......@@ -157,6 +157,7 @@
$('#id_start').val(info.startStr);
$('#id_end').val(info.endStr);
$('#id_room').val(info.resource._resource.id);
$('#id_room_name').val(info.resource._resource.title);
$('#id_duration').val(Math.abs(info.end-info.start)/1000/60/60);
$('#id_ak').val("");
$('#newAKSlotModal').modal('show');
......@@ -215,20 +216,24 @@
if(response.length > 0) {
// Update violations table
for(let i=0;i<response.length;i++) {
if(response[i].manually_resolved)
table_html += '<tr class="text-muted"><td class="nowrap">{% fa6_icon "check" "fas" %} ';
let icon_html = '';
let muted_html = '';
if(response[i].manually_resolved) {
icon_html = '{% fa6_icon "check" "fas" %} ';
muted_html = 'text-muted';
}
else {
table_html += '<tr><td>';
unresolved_violations_count++;
}
if(response[i].level_display==='{% trans "Violation" %}')
table_html += '{% fa6_icon "exclamation-triangle" "fas" %}';
icon_html += '{% fa6_icon "exclamation-triangle" "fas" %}';
else
table_html += '{% fa6_icon "info-circle" "fas" %}';
icon_html += '{% fa6_icon "info-circle" "fas" %}';
table_html += '<tr class="'+ muted_html+ '"><td class="nowrap">' + icon_html;
table_html += "</td><td class='small'>" + response[i].type_display + "</td></tr>";
table_html += "<tr><td colspan='2' class='small'>" + response[i].details + "</td></tr>"
table_html += "<tr class='" + muted_html + "'><td colspan='2' class='small'>" + response[i].details + "</td></tr>"
}
}
else {
......@@ -238,7 +243,7 @@
// Update violation count badge
if(unresolved_violations_count > 0)
$('#violationCountBadge').html(response.length).removeClass('bg-success').addClass('bg-warning');
$('#violationCountBadge').html(unresolved_violations_count).removeClass('bg-success').addClass('bg-warning');
else
$('#violationCountBadge').html(0).removeClass('bg-warning').addClass('bg-success');
......@@ -350,15 +355,17 @@
<h5 class="mt-2">{{ track_slots.grouper }}</h5>
{% endif %}
{% for slot in track_slots.list %}
<div class="unscheduled-slot badge bg-primary" style='background-color: {{ slot.ak.category.color }}'
data-event='{ "title": "{{ slot.ak.short_name }}", "duration": {"hours": "{{ slot.duration|unlocalize }}"}, "constraint": "roomAvailable", "description": "{{ slot.ak.details | escapejs }}", "slotID": "{{ slot.pk }}", "backgroundColor": "{{ slot.ak.category.color }}", "url": "{% url "admin:AKModel_akslot_change" slot.pk %}"}' data-details="{{ slot.ak.details }}">{{ slot.ak.short_name }}
<div class="unscheduled-slot badge" style='background-color: {% with slot.ak.category.color as color %} {% if color %}{{ color }}{% else %}#000000;{% endif %}{% endwith %}'
{% with slot.ak.details as details %}
data-event='{ "title": "{{ slot.ak.short_name }}", "duration": {"hours": "{{ slot.duration|unlocalize }}"}, "constraint": "roomAvailable", "description": "{{ details | escapejs }}", "slotID": "{{ slot.pk }}", "backgroundColor": "{{ slot.ak.category.color }}", "url": "{% url "admin:AKModel_akslot_change" slot.pk %}"}' data-details="{{ details }}">{{ slot.ak.short_name }}
({{ slot.duration }} h)<br>{{ slot.ak.owners_list }}
{% endwith %}
</div>
{% endfor %}
{% endfor %}
</div>
<div class="tab-pane fade" id="violations">
<table class="table table-striped mt-4 mb-4">
<table class="table mt-4 mb-4">
<thead>
<tr>
<th>{% trans "Level" %}</th>
......
......@@ -13,14 +13,19 @@
{% block content %}
<h4 class="mt-4 mb-4">{% trans "AKs with public notes" %}</h4>
{% for ak in aks_with_comment %}
<a href="{{ ak.edit_url }}">{{ ak }}</a><br>{{ ak.notes }}<br><br>
<a href="{{ ak.detail_url }}">{{ ak }}</a>
<a href="{{ ak.edit_url }}">{% fa6_icon "pen-to-square" %}</a>
<a class="link-warning" href="{% url "admin:AKModel_ak_change" object_id=ak.pk %}">{% fa6_icon "pen-to-square" %}</a><br>
{{ ak.notes }}<br><br>
{% empty %}
-
{% endfor %}
<h4 class="mt-4 mb-4">{% trans "AKs without availabilities" %}</h4>
{% for ak in aks_without_availabilities %}
<a href="{{ ak.edit_url }}">{{ ak }}</a><br>
<a href="{{ ak.detail_url }}">{{ ak }}</a>
<a href="{{ ak.edit_url }}">{% fa6_icon "pen-to-square" %}</a>
<a class="link-warning" href="{% url "admin:AKModel_ak_change" object_id=ak.pk %}">{% fa6_icon "pen-to-square" %}</a><br>
{% empty %}
-<br>
{% endfor %}
......@@ -30,7 +35,10 @@
<h4 class="mt-4 mb-4">{% trans "AK wishes with slots" %}</h4>
{% for ak in ak_wishes_with_slots %}
<a href="{{ ak.detail_url }}">{{ ak }}</a> <a href="{% url "admin:AKModel_akslot_changelist" %}?ak={{ ak.pk }}">({{ ak.akslot__count }})</a><br>
<a href="{% url "admin:AKModel_akslot_changelist" %}?ak={{ ak.pk }}">({{ ak.akslot__count }})</a>
<a href="{{ ak.detail_url }}">{{ ak }}</a>
<a href="{{ ak.edit_url }}">{% fa6_icon "pen-to-square" %}</a>
<a class="link-warning" href="{% url "admin:AKModel_ak_change" object_id=ak.pk %}">{% fa6_icon "pen-to-square" %}</a><br>
{% empty %}
-<br>
{% endfor %}
......@@ -39,7 +47,9 @@
<h4 class="mt-4 mb-4">{% trans "AKs without slots" %}</h4>
{% for ak in aks_without_slots %}
<a href="{{ ak.detail_url }}">{{ ak }}</a><br>
<a href="{{ ak.detail_url }}">{{ ak }}</a>
<a href="{{ ak.edit_url }}">{% fa6_icon "pen-to-square" %}</a>
<a class="link-warning" href="{% url "admin:AKModel_ak_change" object_id=ak.pk %}">{% fa6_icon "pen-to-square" %}</a><br>
{% empty %}
-<br>
{% endfor %}
......
{% load i18n %}
<div class="text-center">
<a href="{% url 'admin:constraint-violations' slug=event.slug %}">
<h1>{{ constraint_violations_count }}</h1>
{% blocktrans count constraint_violations_count=constraint_violations_count %}
<h3>Constraint Violation</h3>
{% plural %}
<h3>Constraint Violations</h3>
{% endblocktrans %}
</a>
</div>
......@@ -4,7 +4,7 @@ from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from AKModel.tests import BasicViewTests
from AKModel.tests.test_views import BasicViewTests
from AKModel.models import AKSlot, Event, Room
class ModelViewTests(BasicViewTests, TestCase):
......
......@@ -5,6 +5,8 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView, DetailView, UpdateView
from AKModel.metaviews import status_manager
from AKModel.metaviews.status import TemplateStatusWidget
from AKModel.models import AKSlot, AKTrack, Event, AK, AKCategory
from AKModel.metaviews.admin import EventSlugMixin, FilterByEventSlugMixin, AdminViewMixin, IntermediateAdminView
from AKScheduling.forms import AKInterestForm, AKAddSlotForm
......@@ -39,7 +41,9 @@ class SchedulingAdminView(AdminViewMixin, FilterByEventSlugMixin, ListView):
context_object_name = "slots_unscheduled"
def get_queryset(self):
return super().get_queryset().filter(start__isnull=True).select_related('event', 'ak').order_by('ak__track')
return super().get_queryset().filter(start__isnull=True).select_related('event', 'ak', 'ak__track',
'ak__category').prefetch_related('ak__types', 'ak__owners', 'ak__conflicts', 'ak__prerequisites',
'ak__requirements', 'ak__conflict').order_by('ak__track', 'ak')
def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs)
......@@ -150,6 +154,13 @@ class InterestEnteringAdminView(SuccessMessageMixin, AdminViewMixin, EventSlugMi
def get_success_url(self):
return self.request.path
def form_valid(self, form):
# Don't create a history entry for this change
form.instance.skip_history_when_saving = True
r = super().form_valid(form)
del form.instance.skip_history_when_saving
return r
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = f"{_('Enter interest')}"
......@@ -277,3 +288,22 @@ class AvailabilityAutocreateView(EventSlugMixin, IntermediateAdminView):
_("Created default availabilities for {count} AKs").format(count=success_count)
)
return super().form_valid(form)
@status_manager.register(name="scheduling_constraint_violations")
class CVWidget(TemplateStatusWidget):
"""
Status page widget: Constraint violations
"""
required_context_type = "event"
title = _("Constraint Violations")
template_name = "admin/AKScheduling/status/cvs.html"
def render_status(self, context: {}) -> str:
return "success" if context["constraint_violations_count"] == 0 else "warning"
def get_context_data(self, context) -> dict:
context = super().get_context_data(context)
context["constraint_violations_count"] = (context["event"].constraintviolation_set
.filter(manually_resolved=False).count())
return context
from rest_framework import permissions, viewsets
from rest_framework.response import Response
from AKModel.models import Event
from AKSolverInterface.serializers import ExportEventSerializer
class ExportEventForSolverViewSet(viewsets.GenericViewSet):
"""
API View: Current event, formatted to be consumed by a solver.
Read-only
Follows the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
permission_classes = (permissions.DjangoModelPermissions,)
serializer_class = ExportEventSerializer
lookup_url_kwarg = "event_slug"
lookup_field = "slug"
# only allow exporting of active events
queryset = Event.objects.filter(active=True)
# rename view name to avoid 'List' suffix
def get_view_name(self):
return "JSON-Export for scheduling solver"
# somewhat hacky solution: TODO: FIXME
def list(self, request, *args, **kwargs):
"""Construct HTTP response showing serialized event export."""
# Below is the code of mixins.RetrieveModelMixin::retrieve
# which would usually be used to serve this detail API page.
# However, we do not specify the event to serve at the end of the URL
# but instead prepend the URL with it, i.e.
# `<slug>/api/<export_endpoint>` instead of the usual `api/<export_endpoint>/<slug>`.
# To make this work with the DefaultRouter, we serve this page as a list instead
# of a detail page. Hence, we use the method `list` instead of `retrieve`.
instance = self.get_object()
serializer = self.get_serializer(instance)
return Response(serializer.data)
from django.apps import AppConfig
class AksolverinterfaceConfig(AppConfig):
"""
App configuration for the solver interface (default)
"""
name = "AKSolverInterface"
import json
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import best_match
from AKModel.forms import AdminIntermediateForm
from AKModel.models import AKCategory, AKTrack, AKType, Event
from AKSolverInterface.utils import construct_schema_validator
class JSONExportControlForm(forms.Form):
"""Form to control what objects are exported to the solver."""
# TODO: Filter rooms out by property?
# TODO: Add room availability filter?
export_scheduled_aks_as_fixed = forms.BooleanField(
label=_("Fixate all scheduled slots for the solver"),
help_text=_(
"In the solver export, all assigned times and room to slots are handled "
"as if the slot were fixed."
),
required=False,
)
export_categories = forms.ModelMultipleChoiceField(
queryset=AKCategory.objects.all(),
widget=forms.CheckboxSelectMultiple,
label=_("AK Categories to include in the export"),
required=False,
)
export_tracks = forms.ModelMultipleChoiceField(
queryset=AKTrack.objects.all(),
widget=forms.CheckboxSelectMultiple,
label=_("AK tracks to include in the export"),
required=False,
)
export_types = forms.ModelMultipleChoiceField(
queryset=AKType.objects.all(),
widget=forms.CheckboxSelectMultiple,
label=_("AK types to include in the export"),
required=False,
)
field_order = [
"export_scheduled_aks_as_fixed",
"export_categories",
"export_tracks",
"export_types",
]
def __init__(self, *args, event: Event, **kwargs):
self.event = event
initial = kwargs.get("initial", {})
initial["event"] = event
kwargs["initial"] = initial
super().__init__(*args, **kwargs)
def _set_queryset(field_name: str):
# restrict queryset to all objects of this event
self.fields[field_name].queryset = self.fields[field_name].queryset.filter(
event=self.event,
)
# default init: all objects are checked
self.fields[field_name].initial = self.fields[field_name].queryset
# if queryset is empty, simply remove the field
if not self.fields[field_name].queryset.exists():
self.fields.pop(field_name)
for field_name in ["export_categories", "export_tracks", "export_types"]:
_set_queryset(field_name)
class JSONScheduleImportForm(AdminIntermediateForm):
"""Form to import an AK schedule from a json file."""
json_data = forms.CharField(
required=False,
widget=forms.Textarea,
label=_("JSON data"),
help_text=_("JSON data from the scheduling solver"),
)
json_file = forms.FileField(
required=False,
label=_("File with JSON data"),
help_text=_("File with JSON data from the scheduling solver"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_schema_validator = construct_schema_validator(
schema="solver-output.schema.json"
)
def _check_json_data(self, data: str):
"""Validate `data` against our JSON schema.
:param data: The JSON string to validate using `self.json_schema_validator`.
:type data: str
:raises ValidationError: if the validation fails, with a description of the cause.
:return: The parsed JSON dict, if validation is successful.
"""
try:
schedule = json.loads(data)
except json.JSONDecodeError as ex:
raise ValidationError(_("Cannot decode as JSON"), "invalid") from ex
error = best_match(self.json_schema_validator.iter_errors(schedule))
if error:
raise ValidationError(
_("Invalid JSON format: %(msg)s at %(error_path)s"),
"invalid",
params={"msg": error.message, "error_path": error.json_path},
) from error
return schedule
def clean(self):
"""Extract and validate entered JSON data.
We allow entering of the schedule from two sources:
1. from an uploaded file
2. from a text field.
This function checks that data is entered from exactly one source.
If so, the entered JSON string is validated against our schema.
Any errors are reported at the corresponding form field.
"""
cleaned_data = super().clean()
if cleaned_data.get("json_file") and cleaned_data.get("json_data"):
err = ValidationError(
_("Please enter data as a file OR via text, not both."), "invalid"
)
self.add_error("json_data", err)
self.add_error("json_file", err)
elif not (cleaned_data.get("json_file") or cleaned_data.get("json_data")):
err = ValidationError(
_("No data entered. Please enter data as a file or via text."),
"invalid",
)
self.add_error("json_data", err)
self.add_error("json_file", err)
else:
source_field = "json_data"
data = cleaned_data.get(source_field)
if not data:
source_field = "json_file"
with cleaned_data.get(source_field).open() as ff:
data = ff.read()
try:
cleaned_data["data"] = self._check_json_data(data)
except ValidationError as ex:
self.add_error(source_field, ex)
return cleaned_data
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-21 18:56+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: AKSolverInterface/forms.py:20
msgid "Fixate all scheduled slots for the solver"
msgstr "Fixiere alle schon terminierten Slots für den Solver."
#: AKSolverInterface/forms.py:22
msgid ""
"In the solver export, all assigned times and room to slots are handled as if "
"the slot were fixed."
msgstr "Im Solver Export werden all zugewiesenen Zeiten und Räume der Slots "
"gehandhabt als ob sie fixiert wären."
#: AKSolverInterface/forms.py:30
msgid "AK Categories to include in the export"
msgstr "AK Kategorien, die im Export enthalten sein sollen"
#: AKSolverInterface/forms.py:36
msgid "AK tracks to include in the export"
msgstr "AK Tracks, die im Export enthalten sein sollen"
#: AKSolverInterface/forms.py:42
msgid "AK types to include in the export"
msgstr "AK Typen, die im Export enthalten sein sollen"
#: AKSolverInterface/forms.py:83
msgid "JSON data"
msgstr "JSON-Daten"
#: AKSolverInterface/forms.py:84
msgid "JSON data from the scheduling solver"
msgstr "JSON-Daten, die der scheduling-solver produziert hat"
#: AKSolverInterface/forms.py:89
msgid "File with JSON data"
msgstr "Datei mit JSON-Daten"
#: AKSolverInterface/forms.py:90
msgid "File with JSON data from the scheduling solver"
msgstr "Datei mit JSON-Daten, die der scheduling-solver produziert hat"
#: AKSolverInterface/forms.py:110
msgid "Cannot decode as JSON"
msgstr "Dekodierung als JSON fehlgeschlagen"
#: AKSolverInterface/forms.py:115
#, python-format
msgid "Invalid JSON format: %(msg)s at %(error_path)s"
msgstr "Ungültige JSON-Eingabe: %(msg)s bei %(error_path)s"
#: AKSolverInterface/forms.py:136
msgid "Please enter data as a file OR via text, not both."
msgstr "Gib die Daten bitte als Datei oder als Text ein, nicht beides."
#: AKSolverInterface/forms.py:142
msgid "No data entered. Please enter data as a file or via text."
msgstr ""
"Keine Daten eingegeben. Gib die Daten bitte als Datei oder als Text ein."
#: AKSolverInterface/templates/admin/AKSolverInterface/ak_json_export_control.html:27
msgid "Continue"
msgstr "Fortfahren"
#: AKSolverInterface/templates/admin/AKSolverInterface/ak_json_export_control.html:31
msgid "Reset Form"
msgstr "Formular zurücksetzen"
#: AKSolverInterface/templates/admin/AKSolverInterface/ak_json_export_control.html:35
#: AKSolverInterface/templates/admin/AKSolverInterface/import_json.html:27
msgid "Cancel"
msgstr "Abbrechen"
#: AKSolverInterface/templates/admin/AKSolverInterface/import_json.html:23
msgid "Confirm"
msgstr "Bestätigen"
#: AKSolverInterface/views.py:28
msgid "AK JSON Export"
msgstr "AK-JSON-Export"
#: AKSolverInterface/views.py:48
#, python-brace-format
msgid ""
"The following AKs have no slot assigned to them and are therefore not "
"exported: {aks_list}"
msgstr ""
"Die folgenden AKs haben keine zugehörigen Slots und werden deshalb nicht "
"exportiert: {aks_list}"
#: AKSolverInterface/views.py:77
#, fuzzy
#| msgid "No AK Slots are exported"
msgid "No AKSlots are exported"
msgstr "Keine AK Slots werden exportiert"
#: AKSolverInterface/views.py:85
msgid "No Rooms are exported"
msgstr "Keine Räume werden exportiert"
#: AKSolverInterface/views.py:91
msgid "No real participants are exported"
msgstr "Keine echten Teilnehmenden werden exportiert"
#: AKSolverInterface/views.py:104
msgid "No timeslots are exported"
msgstr "Keine Zeitslots werden exportiert"
#: AKSolverInterface/views.py:112
msgid "Exporting AKs for the solver failed! Reason: "
msgstr "Daten für den Solver exportieren fehlgeschlagen! Grund: "
#: AKSolverInterface/views.py:129
msgid "AK Schedule JSON Import"
msgstr "AK-Plan JSON-Import"
#: AKSolverInterface/views.py:140
#, python-brace-format
msgid "Successfully imported {n} slot(s)"
msgstr "Erfolgreich {n} Slot(s) importiert"
#: AKSolverInterface/views.py:148
msgid "Importing an AK schedule failed! Reason: "
msgstr "AK-Plan importieren fehlgeschlagen! Grund: "
from collections.abc import Callable
from django.apps import apps
from django.db.models.query import QuerySet
from rest_framework import serializers
from AKModel.models import AK, AKSlot, Event, Room
from AKModel.serializers import IntListField, StringListField
def _apply_filter_cb_to_queryset(
cb: Callable[[QuerySet], QuerySet], queryset: QuerySet,
) -> QuerySet:
"""Applies filter callback if queryset is a QuerySet object."""
# check if queryset is actually a queryset (and not e.g. an empty list)
return cb(queryset) if isinstance(queryset, QuerySet) else queryset
class ExportRoomInfoSerializer(serializers.ModelSerializer):
"""Serializer of Room objects for the 'info' field.
Used in `ExportRoomSerializer` to serialize Room objects
for the export to a solver. Part of the implementation of the
format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
class Meta:
model = Room
fields = ["name"]
read_only_fields = ["name"]
class ExportRoomSerializer(serializers.ModelSerializer):
"""Export serializer for Room objects.
Used to serialize Room objects for the export to a solver.
Part of the implementation of the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
time_constraints = StringListField(source="get_time_constraints", read_only=True)
fulfilled_room_constraints = StringListField(
source="get_fulfilled_room_constraints", read_only=True
)
info = ExportRoomInfoSerializer(source="*")
class Meta:
model = Room
fields = [
"id",
"capacity",
"time_constraints",
"fulfilled_room_constraints",
"info",
]
read_only_fields = [
"id",
"capacity",
"time_constraints",
"fulfilled_room_constraints",
"info",
]
class ExportAKSlotInfoSerializer(serializers.ModelSerializer):
"""Serializer of AKSlot objects for the 'info' field.
Used in `ExportAKSlotSerializer` to serialize AKSlot objects
for the export to a solver. Part of the implementation of the
format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
name = serializers.CharField(source="ak.name")
head = serializers.SerializerMethodField()
reso = serializers.BooleanField(source="ak.reso")
description = serializers.CharField(source="ak.description")
duration_in_hours = serializers.FloatField(source="duration")
django_ak_id = serializers.IntegerField(source="ak.pk")
types = StringListField(source="type_names")
def get_head(self, slot: AKSlot) -> str:
"""Get string representation for 'head' field."""
return ", ".join([str(owner) for owner in slot.ak.owners.all()])
class Meta:
model = AKSlot
fields = [
"name",
"head",
"description",
"reso",
"duration_in_hours",
"django_ak_id",
"types",
]
read_only_fields = [
"name",
"head",
"description",
"reso",
"duration_in_hours",
"django_ak_id",
"types",
]
class ExportAKSlotPropertiesSerializer(serializers.ModelSerializer):
"""Serializer of AKSlot objects for the 'properties' field.
Used in `ExportAKSlotSerializer` to serialize AKSlot objects
for the export to a solver. Part of the implementation of the
format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
conflicts = IntListField(source="conflict_pks")
dependencies = IntListField(source="depencency_pks")
class Meta:
model = AKSlot
fields = ["conflicts", "dependencies"]
read_only_fields = ["conflicts", "dependencies"]
class ExportAKSlotSerializer(serializers.ModelSerializer):
"""Export serializer for AKSlot objects.
Used to serialize AKSlot objects for the export to a solver.
Part of the implementation of the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
duration = serializers.IntegerField(source="export_duration")
room_constraints = serializers.SerializerMethodField()
time_constraints = serializers.SerializerMethodField()
info = ExportAKSlotInfoSerializer(source="*")
properties = ExportAKSlotPropertiesSerializer(source="*")
def __init__(self, *args, export_scheduled_aks_as_fixed: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.export_scheduled_aks_as_fixed = export_scheduled_aks_as_fixed
def get_room_constraints(self, slot: AKSlot):
"""Get serialized representation for room_constraints.
Used to pass `export_scheduled_aks_as_fixed` to AKSlot's function.
"""
return slot.get_room_constraints(
export_scheduled_aks_as_fixed=self.export_scheduled_aks_as_fixed,
)
def get_time_constraints(self, slot: AKSlot):
"""Get serialized representation for time_constraints.
Used to pass `export_scheduled_aks_as_fixed` to AKSlot's function.
"""
return slot.get_time_constraints(
export_scheduled_aks_as_fixed=self.export_scheduled_aks_as_fixed,
)
class Meta:
model = AKSlot
fields = [
"id",
"duration",
"properties",
"room_constraints",
"time_constraints",
"info",
]
read_only_fields = [
"id",
"duration",
"properties",
"room_constraints",
"time_constraints",
"info",
]
class ExportFilteredAKSlotSerializer(serializers.BaseSerializer):
def __init__(self, *args, export_scheduled_aks_as_fixed: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.export_scheduled_aks_as_fixed = export_scheduled_aks_as_fixed
def create(self, validated_data):
raise ValueError("`ExportFilteredAKSlotSerializer` is read-only.")
def to_internal_value(self, data):
raise ValueError("`ExportFilteredAKSlotSerializer` is read-only.")
def update(self, instance, validated_data):
raise ValueError("`ExportFilteredAKSlotSerializer` is read-only.")
def to_representation(self, instance):
slot_queryset = instance
slot_pks = set(slot_queryset.values_list("id", flat=True))
def _restrict_to_slots(pk_list: list[int]):
return sorted(set(pk_list) & slot_pks)
serialized_slots = ExportAKSlotSerializer(
slot_queryset,
export_scheduled_aks_as_fixed=self.export_scheduled_aks_as_fixed,
many=True,
).data
for slot_dict in serialized_slots:
slot_dict["properties"]["conflicts"] = _restrict_to_slots(
slot_dict["properties"]["conflicts"],
)
slot_dict["properties"]["dependencies"] = _restrict_to_slots(
slot_dict["properties"]["dependencies"],
)
return serialized_slots
class ExportParticipantAndDummiesSerializer(serializers.BaseSerializer):
"""Export serializer for EventParticipant objects that includes 'dummy' participants.
This serializer is a work-around to make the solver compatible with the AKOwner model.
Internally, `ExportParticipantSerializer` is used to serialize all EventParticipants of
the event to serialize. To avoid scheduling conflicts, a 'dummy' participant is then added
to the list for each AKOwner of the event. These dummy participants only have 'required'
preference for all AKs of the owner, so the target of the optimization is not impacted.
Part of the implementation of the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
def __init__(
self,
*args,
slots_qs: QuerySet = None,
filter_participants_cb: Callable[[QuerySet], QuerySet],
**kwargs,
):
super().__init__(*args, **kwargs)
self.filter_participants_cb = filter_participants_cb
self.slots_qs = slots_qs
def create(self, validated_data):
raise ValueError("`ExportParticipantAndDummiesSerializer` is read-only.")
def to_internal_value(self, data):
raise ValueError("`ExportParticipantAndDummiesSerializer` is read-only.")
def update(self, instance, validated_data):
raise ValueError("`ExportParticipantAndDummiesSerializer` is read-only.")
def to_representation(self, instance: Event):
event = instance
# default case
real_participants = []
next_participant_pk = 1
# set variable values if AKPreference app is installed
if apps.is_installed("AKPreference"):
# local import to decouple
# pylint: disable=import-outside-toplevel
from AKPreference.models import EventParticipant
from AKPreference.serializers import ExportParticipantSerializer
participants = _apply_filter_cb_to_queryset(self.filter_participants_cb, event.participants)
real_participants = ExportParticipantSerializer(participants, many=True).data
if EventParticipant.objects.exists():
next_participant_pk = EventParticipant.objects.latest("pk").pk + 1
dummies = []
# add one dummy participant per owner
# this ensures that the hard constraints from each owner are considered
for new_pk, owner in enumerate(event.owners, next_participant_pk):
owned_slots = event.slots.filter(ak__owners=owner).order_by().all()
if not owned_slots:
continue
new_participant_data = {
"id": new_pk,
"info": {"name": f"{owner} [AKOwner]"},
"room_constraints": [],
"time_constraints": [],
"preferences": [
{"ak_id": slot.pk, "required": True, "preference_score": -1}
for slot in owned_slots
]
}
dummies.append(new_participant_data)
all_participants = real_participants + dummies
if self.slots_qs is not None:
slot_pks = set(self.slots_qs.values_list("id", flat=True))
def _filter_to_slots_qs(preference: dict):
return preference["ak_id"] in slot_pks
for participant in all_participants:
participant["preferences"] = list(
filter(_filter_to_slots_qs, participant["preferences"])
)
return all_participants
class ExportEventInfoSerializer(serializers.ModelSerializer):
"""Serializer of an Event object for the 'info' field.
Used in `ExportEventSerializer` to serialize an Event object
for the export to a solver. Part of the implementation of the
format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
title = serializers.CharField(source="name")
contact_email = serializers.EmailField(required=False)
place = serializers.CharField(required=False)
class Meta:
model = Event
fields = ["title", "slug", "contact_email", "place"]
class ExportTimeslotBlockSerializer(serializers.BaseSerializer):
"""Read-only serializer for timeslots.
Used to serialize timeslots for the export to a solver.
Part of the implementation of the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
def __init__(self, *args, export_scheduled_aks_as_fixed: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.export_scheduled_aks_as_fixed = export_scheduled_aks_as_fixed
def create(self, validated_data):
raise ValueError("`ExportTimeslotBlockSerializer` is read-only.")
def to_internal_value(self, data):
raise ValueError("`ExportTimeslotBlockSerializer` is read-only.")
def update(self, instance, validated_data):
raise ValueError("`ExportTimeslotBlockSerializer` is read-only.")
def to_representation(self, instance: Event):
"""Construct serialized representation of the timeslots of an event."""
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
event = instance
blocks = list(event.discretize_timeslots())
def _check_event_not_covered(availabilities: list[Availability]) -> bool:
"""Test if event is not covered by availabilities."""
return not Availability.is_event_covered(event, availabilities)
def _check_akslot_fixed_in_timeslot(
ak_slot: AKSlot, timeslot: Availability
) -> bool:
"""Test if an AKSlot is fixed to overlap a timeslot slot."""
if ak_slot.start is None:
return False
if not (ak_slot.fixed or self.export_scheduled_aks_as_fixed):
return False
fixed_avail = Availability(
event=event, start=ak_slot.start, end=ak_slot.end
)
return fixed_avail.overlaps(timeslot, strict=True)
def _check_add_constraint(
slot: Availability, availabilities: list[Availability]
) -> bool:
"""Test if object is not available for whole event and may happen during slot."""
return _check_event_not_covered(availabilities) and slot.is_covered(
availabilities
)
def _generate_time_constraints(
avail_label: str,
avail_dict: dict,
timeslot_avail: Availability,
prefix: str = "availability",
) -> list[str]:
return [
f"{prefix}-{avail_label}-{pk}"
for pk, availabilities in avail_dict.items()
if _check_add_constraint(timeslot_avail, availabilities)
]
timeslots = {
"info": {"duration": float(event.export_slot)},
"blocks": [],
}
ak_availabilities = {
ak.pk: Availability.union(ak.availabilities.all())
for ak in AK.objects.filter(event=event).all()
}
room_availabilities = {
room.pk: Availability.union(room.availabilities.all()) for room in event.rooms
}
person_availabilities = {
person.pk: Availability.union(person.availabilities.all())
for person in event.owners
}
participant_availabilities = {
participant.pk: Availability.union(participant.availabilities.all())
for participant in event.participants
}
block_names = []
for block_idx, block in enumerate(blocks):
current_block = []
if not block:
continue
block_start = block[0].avail.start.astimezone(event.timezone)
block_end = block[-1].avail.end.astimezone(event.timezone)
start_day = block_start.strftime("%A, %d. %b")
if block_start.date() == block_end.date():
# same day
time_str = (
block_start.strftime("%H:%M") + " - " + block_end.strftime("%H:%M")
)
else:
# different days
time_str = (
block_start.strftime("%a %H:%M")
+ " - "
+ block_end.strftime("%a %H:%M")
)
block_names.append([start_day, time_str])
block_timeconstraints = [
f"notblock{idx}" for idx in range(len(blocks)) if idx != block_idx
]
for timeslot in block:
time_constraints = []
# if reso_deadline is set and timeslot ends before it,
# add fulfilled time constraint 'resolution'
if (
event.reso_deadline is None
or timeslot.avail.end < event.reso_deadline
):
time_constraints.append("resolution")
# add fulfilled time constraints for all AKs that cannot happen during full event
time_constraints.extend(
_generate_time_constraints("ak", ak_availabilities, timeslot.avail)
)
# add fulfilled time constraints for all persons that are not available for full event
time_constraints.extend(
_generate_time_constraints(
"person", person_availabilities, timeslot.avail
)
)
# add fulfilled time constraints for all rooms that are not available for full event
time_constraints.extend(
_generate_time_constraints(
"room", room_availabilities, timeslot.avail
)
)
# add fulfilled time constraints for all participants that are not available for full event
time_constraints.extend(
_generate_time_constraints(
"participant", participant_availabilities, timeslot.avail
)
)
# add fulfilled time constraints for all AKSlots fixed to happen during timeslot
time_constraints.extend(
[
f"fixed-akslot-{slot.id}"
for slot in AKSlot.objects.filter(
event=event, fixed=True
).exclude(start__isnull=True)
if _check_akslot_fixed_in_timeslot(slot, timeslot.avail)
]
)
time_constraints.extend(timeslot.constraints)
time_constraints.extend(block_timeconstraints)
time_constraints.sort()
current_block.append(
{
"id": timeslot.idx,
"info": {
"start": timeslot.avail.start.astimezone(
event.timezone
).strftime("%Y-%m-%d %H:%M"),
"end": timeslot.avail.end.astimezone(
event.timezone
).strftime("%Y-%m-%d %H:%M"),
},
"fulfilled_time_constraints": time_constraints,
}
)
timeslots["blocks"].append(current_block)
timeslots["info"]["blocknames"] = block_names
return timeslots
class ExportEventSerializer(serializers.BaseSerializer):
"""Export serializer for an Event object.
Allows filtering of the exported AKSlots and Rooms by
passing a filter callback function as a kwarg to __init__
Used to serialize an Event for the export to a solver.
Part of the implementation of the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
def __init__(
self,
*args,
filter_participants_cb: Callable[[QuerySet], QuerySet] | None = None,
filter_slots_cb: Callable[[QuerySet], QuerySet] | None = None,
filter_rooms_cb: Callable[[QuerySet], QuerySet] | None = None,
export_scheduled_aks_as_fixed: bool = False,
**kwargs,
):
def _identity(queryset: QuerySet) -> QuerySet:
return queryset
# use identity function if not specified
self.filter_participants_cb = filter_participants_cb or _identity
self.filter_rooms_cb = filter_rooms_cb or _identity
self.filter_slots_cb = filter_slots_cb or _identity
self.export_scheduled_aks_as_fixed = export_scheduled_aks_as_fixed
super().__init__(*args, **kwargs)
def create(self, validated_data):
raise ValueError("`ExportEventSerializer` is read-only.")
def to_internal_value(self, data):
raise ValueError("`ExportEventSerializer` is read-only.")
def update(self, instance, validated_data):
raise ValueError("`ExportEventSerializer` is read-only.")
def to_representation(self, instance: Event):
"""
Object instance -> Dict of primitive datatypes.
"""
event = instance
info = ExportEventInfoSerializer(event)
timeslots = ExportTimeslotBlockSerializer(event)
# we support filtering of Rooms and AKSlots
rooms = ExportRoomSerializer(
_apply_filter_cb_to_queryset(self.filter_rooms_cb, event.rooms),
many=True,
)
slots_qs = _apply_filter_cb_to_queryset(self.filter_slots_cb, event.slots)
slots = ExportFilteredAKSlotSerializer(
slots_qs,
export_scheduled_aks_as_fixed=self.export_scheduled_aks_as_fixed,
)
participants = ExportParticipantAndDummiesSerializer(
event,
slots_qs=slots_qs,
filter_participants_cb=self.filter_participants_cb,
)
return {
"participants": participants.data,
"rooms": rooms.data,
"timeslots": timeslots.data,
"info": info.data,
"aks": slots.data,
}
{% extends "admin/base_site.html" %}
{% load tz %}
{% block content %}
<div class="mb-3">
<label for="export_single_line" class="form-label">Exported JSON:</label>
<input readonly type="text" name="export_single_line" class="form-control form-control-sm" value="{{ json_data_oneline }}" style="font-family:var(--font-family-monospace);">
</div>
<div class="mb-3">
<label class="form-label">Exported JSON (indented for better readability):</label>
<pre class="prettyprint border rounded p-2">{{ json_data }}</pre>
</div>
<script>
$(document).ready(function() {
// JSON highlighting.
prettyPrint();
}
)
</script>
{% endblock %}
{% extends "admin/base_site.html" %}
{% load tz %}
{% load i18n %}
{% load django_bootstrap5 %}
{% load fontawesome_6 %}
{% block content %}
{% include "AKModel/load_fullcalendar_availabilities.html" %}
<script>
{% get_current_language as LANGUAGE_CODE %}
document.addEventListener('DOMContentLoaded', function () {
createAvailabilityEditors(
'{{ event.timezone }}',
'{{ LANGUAGE_CODE }}',
'{{ event.start | timezone:event.timezone | date:"Y-m-d H:i:s" }}',
'{{ event.end | timezone:event.timezone | date:"Y-m-d H:i:s" }}'
);
});
</script>
<form method="POST" class="post-form">{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="save btn btn-primary float-end">
{% fa6_icon "check" 'fas' %} {% trans "Continue" %}
</button>
<button type="reset" class="btn btn-danger">
{% fa6_icon "undo-alt" 'fas' %} {% trans "Reset Form" %}
</button>
<a href="{{ ak.detail_url }}" class="btn btn-secondary">
{% fa6_icon "times" 'fas' %} {% trans "Cancel" %}
</a>
</form>
{% endblock %}
{% extends "admin/base_site.html" %}
{% load tags_AKModel %}
{% load i18n %}
{% load django_bootstrap5 %}
{% load fontawesome_6 %}
{% block title %}{{event}}: {{ title }}{% endblock %}
{% block content %}
{% block action_preview %}
<p>
{{ preview|linebreaksbr }}
</p>
{% endblock %}
<form enctype="multipart/form-data" method="post">{% csrf_token %}
{% bootstrap_form form %}
<div class="float-end">
<button type="submit" class="save btn btn-success" value="Submit">
{% fa6_icon "check" 'fas' %} {% trans "Confirm" %}
</button>
</div>
<a href="javascript:history.back()" class="btn btn-info">
{% fa6_icon "times" 'fas' %} {% trans "Cancel" %}
</a>
</form>
{% endblock %}