diff --git a/.gitignore b/.gitignore index 9af55ef..b891720 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,7 @@ WAM/.ropeproject/history WAM/.ropeproject/objectdb WAM/local_settings.py .DS_Store -django.log +*.log /WAM/local *THINKINGS.txt* *.key diff --git a/WAM/settings.py b/WAM/settings.py index 6746d05..ba7e341 100644 --- a/WAM/settings.py +++ b/WAM/settings.py @@ -130,6 +130,8 @@ # https://docs.djangoproject.com/en/1.8/howto/static-files/ STATIC_URL = '/static/' +# This ensures local files are searched first +STATICFILES_DIRS = [os.path.join(BASE_DIR, 'loads/static')] # Where do we store media MEDIA_ROOT = os.path.join(BASE_DIR, 'media') diff --git a/loads/forms.py b/loads/forms.py index 520a2a1..9f95235 100644 --- a/loads/forms.py +++ b/loads/forms.py @@ -129,6 +129,13 @@ class LoadsByModulesForm(forms.Form): ) +class LoadChartsForm(forms.Form): + """ This exposes some options for load charts views """ + + show_90_110 = forms.BooleanField(required=False, initial=False, help_text='Show 90% and 110% boundaries') + sort_by_load = forms.BooleanField(required=False, initial=True) + + class ModulesIndexForm(forms.Form): """This prompts for comma separated semesters used for some restrictions""" semesters = forms.CharField( @@ -464,11 +471,14 @@ def save(self): class BaseModuleStaffByStaffFormSet(FancyModelFormSet): + """ Enables altering teaching allocation for a member of staff from staff views """ + def clean(self): """ - Adds validation to check that no two links have the same anchor or URL - and that all links have both an anchor and URL. + Adds validation to check that no module is in the list twice, or that combined percentages exceed 100 """ + + # Don't validate the whole formset (yet) if individual forms have issues if any(self.errors): return @@ -508,19 +518,23 @@ def clean(self): code='invalid_assessment_proportion' ) - if duplicates: - raise forms.ValidationError( - 'Modules should not appear more than once.', - code='duplicate_modules' - ) + if duplicates: + raise forms.ValidationError( + 'Modules should not appear more than once.', + code='duplicate_modules' + ) class BaseModuleStaffByModuleFormSet(FancyModelFormSet): + """ Enables altering teaching allocation for a module from module views """ + def clean(self): """ - Adds validation to check that no two links have the same anchor or URL - and that all links have both an anchor and URL. + Adds overall validation to check that no staff member is in the list twice, + or that combined percentages exceed 100 """ + + # Don't validate the whole formset (yet) if individual forms have issues if any(self.errors): return @@ -567,23 +581,62 @@ def clean(self): code='invalid_assessment_proportion' ) - if duplicates: - raise forms.ValidationError( - 'Staff members should not appear more than once.', - code='duplicate_staff' - ) - if contact_total > 100: - raise forms.ValidationError( - 'Contact proportions are over 100%', - code='invalid_contact_total' - ) - if admin_total > 100: - raise forms.ValidationError( - 'Admin proportions are over 100%', - code='invalid_contact_total' - ) - if assessment_total > 100: - raise forms.ValidationError( - 'Assessment proportions are over 100%', - code='invalid_contact_total' - ) + if duplicates: + raise forms.ValidationError( + 'Staff members should not appear more than once.', + code='duplicate_staff' + ) + + if contact_total > 100: + raise forms.ValidationError( + 'Contact proportions are over 100%', + code='invalid_contact_total' + ) + + if admin_total > 100: + raise forms.ValidationError( + 'Admin proportions are over 100%', + code='invalid_contact_total' + ) + + if assessment_total > 100: + raise forms.ValidationError( + 'Assessment proportions are over 100%', + code='invalid_contact_total' + ) + + +class BaseProjectStaffFormSet(FancyModelFormSet): + """ Enables altering project allocations """ + + def clean(self): + """ + Adds overall validation to check that no staff member is in the list twice + """ + + # Don't validate the whole formset (yet) if individual forms have issues + if any(self.errors): + return + + staff_members = [] + + duplicates = False + + for form in self.forms: + # If the form is deleted, don't validate, its data is about to be nuked + if form in self.deleted_forms: + continue + + if form.cleaned_data: + staff = form.cleaned_data['staff'] + + if staff in staff_members: + duplicates = True + staff_members.append(staff) + + if duplicates: + raise forms.ValidationError( + 'Staff members should not appear more than once.', + code='duplicate_staff' + ) + diff --git a/loads/static/fancy_formset/README.TXT b/loads/static/fancy_formset/README.TXT new file mode 100644 index 0000000..0b74b9c --- /dev/null +++ b/loads/static/fancy_formset/README.TXT @@ -0,0 +1,11 @@ +WARNING! + +This file is here because there is an apparent bug in fancy_formset 1.0.0, which seems to decrement total forms on +a form deletion. This does not appear to be correct behaviour, and can be diagnosed from trying to delete a single +remaining formset that was already in the model layer. + +This file includes a patched version of the library that will be loaded in preference if STATICFILES_DIR is set +up correctly. + +This file / directory needs to be removed if and when the library is patched, or this local copy will continue to +override the main library. \ No newline at end of file diff --git a/loads/static/fancy_formset/formset.js b/loads/static/fancy_formset/formset.js new file mode 100644 index 0000000..75d4c81 --- /dev/null +++ b/loads/static/fancy_formset/formset.js @@ -0,0 +1 @@ +(function(r,o){typeof exports=="object"&&typeof module<"u"?o(exports):typeof define=="function"&&define.amd?define(["exports"],o):(r=typeof globalThis<"u"?globalThis:r||self,o(r.formset={}))})(this,function(r){"use strict";var u=Object.defineProperty;var c=(r,o,n)=>o in r?u(r,o,{enumerable:!0,configurable:!0,writable:!0,value:n}):r[o]=n;var a=(r,o,n)=>(c(r,typeof o!="symbol"?o+"":o,n),n);class o{constructor(t,s,e){a(this,"templatePrefix","__prefix__");a(this,"_namePattern");this.formset=t,this.rootEl=s,this.options=e,this._namePattern=new RegExp(`^${e.prefix}-([^-]+)-(.+)$`),this.isTemplate=this.getFields()[0].name.match(this._namePattern)[1]===this.templatePrefix,this.deleteEl=this.getDeleteEl(),this.render()}getFields(){return Array.from(this.rootEl.querySelectorAll("input,select,textarea"))}getValues(){return this.getFields().reduce((s,e,i)=>(s[e.name.match(this._namePattern)[2]]=e.value,s),{})}hasContent(t){return this.getFields().some((i,m)=>{const h=i.name.match(this._namePattern)[2];if(h===this.options.pkFieldName){if(i.value)return!0}else return i.value!==t[h]})}getDeleteEl(){var s;let t=this.rootEl.querySelector('[name$="-DELETE"]');if(t)return t.addEventListener("click",e=>{(this.isDeleted&&this.formset.atMin&&!this.options.allowDeleteAtMin||!this.isDeleted&&this.formset.atMax&&!this.options.allowAddAtMax)&&(e.preventDefault(),e.stopPropagation())}),t.addEventListener("change",()=>{t.checked?this.deleted():this.undeleted()}),(s=t.closest(this.options.deleteConClosest))==null||s.classList.add(this.options.deleteConCss),t}get isDeleted(){var t;return((t=this.deleteEl)==null?void 0:t.checked)===!0}deleted(){this.formset.deactivateForm(this)}undeleted(){this.formset.activateForm(this)}render(){this.rootEl.classList.toggle(this.options.formDeletedCss,this.isDeleted)}destroy(){this.rootEl.remove(),this.formset.destroyedForm(this),this.formset=null}}class n{constructor(t,s){a(this,"formClass",o);this.rootEl=t;let e=s.prefix||t.getAttribute(s.prefixAttr);if(!e)throw this._e("Formset prefix not found");this.options=s={...s,prefix:e},this.totalFormsEl=document.getElementById(`id_${e}-TOTAL_FORMS`),this.initialFormsEl=document.getElementById(`id_${e}-INITIAL_FORMS`),this.numFormsMin=parseInt(document.getElementById(`id_${e}-MIN_NUM_FORMS`).value,10),this.numFormsMax=parseInt(document.getElementById(`id_${e}-MAX_NUM_FORMS`).value,10),this.addEl=this.getAddEl(),this.collectForms(),this.rootEl.classList.add(this.options.formsetActiveCss),this.render(),this.event("formset:init")}_e(t){return new Error(t,{cause:this.rootEl})}collectForms(){this.forms=Array.from(this.rootEl.querySelectorAll(this.options.formSelector),e=>new this.formClass(this,e,this.options));let t=this.forms.findIndex(e=>e.isTemplate);if(t>-1)this.template=this.forms.splice(t,1)[0];else throw this._e(`Formset ${this.options.prefix} template form not found`);this.numForms=this.forms.length;const s=parseInt(this.initialFormsEl.value,10);if(this.numForms>s){const e=this.template.getValues();this.forms.slice().reverse().forEach(i=>{this.atMin||i.hasContent(e)||i.destroy()})}this._nextId=this.numForms}getAddEl(){let t=document.createElement("button");return t.innerHTML=this.options.addButtonLabel,t.type="button",t.className=this.options.addButtonCss,this.rootEl.appendChild(t),t.onclick=()=>{this.addForm()},t}addForm(){if(this.atMax&&!this.options.allowAddAtMax)return;let t=this._nextId++,s=this.createForm(t),e=this.insertForm(t,s);this.activateForm(e)}render(){this.forms.forEach(t=>{t.render()}),this.rootEl.classList.toggle(this.options.formsetAtMinCss,this.atMin),this.rootEl.classList.toggle(this.options.formsetAtMaxCss,this.atMax)}get numForms(){return parseInt(this.totalFormsEl.value,10)}set numForms(t){this.totalFormsEl.value=t}createForm(t){let e=this.template.rootEl.innerHTML.replace(/__prefix__/g,t),i=this.template.rootEl.cloneNode();return i.removeAttribute("style"),i.classList.add(this.options.formAddedCss),i.innerHTML=e,i}insertForm(t,s){let e=this.template;this.forms.length>0&&(e=this.forms[this.forms.length-1]);let i=e.rootEl;i.parentNode.insertBefore(s,i.nextSibling);let m=new o(this,s,this.options);return this.forms.push(m),this.event("formset:form-add",m),m}event(t,s){const e={formset:this,form:s};this.rootEl.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:e}))}activateForm(t){this.numForms+=1,this.render(),this.event("formset:form-activate",t)}deactivateForm(t){this.render(),this.event("formset:form-deactivate",t)}destroyedForm(t){this.forms.splice(this.forms.indexOf(t),1),this.numForms-=1,this.render(),this.event("formset:form-destroy",t)}get atMax(){return this.numForms>=this.numFormsMax}get atMin(){return this.numForms<=this.numFormsMin}}const d={formsetSelector:"[data-formset]",formsetClass:n,prefix:null,prefixAttr:"data-formset",formSelector:":scope > fieldset",formClass:o,pkFieldName:"id",formsetActiveCss:"formset-active",formAddedCss:"added",formDeletedCss:"deleted",addButtonLabel:"Add",addButtonCss:"formset-add",deleteConClosest:"p,div,tr,li",deleteConCss:"formset-delete",formsetAtMinCss:"formset-at-min",allowDeleteAtMin:!1,formsetAtMaxCss:"formset-at-max",allowAddAtMax:!1};function f(l={},t=null){l={...d,...l},t=t||l.formsetSelector;let s;return t instanceof Array?s=t:t instanceof HTMLElement?s=[t]:(t&&!(t instanceof NodeList)&&(t=document.querySelectorAll(t)),s=Array.from(t)),s.map(i=>new l.formsetClass(i,l))}r.Form=o,r.Formset=n,r.defaultOptions=d,r.init=f,Object.defineProperty(r,Symbol.toStringTag,{value:"Module"})}); diff --git a/loads/templates/loads/loads_charts.html b/loads/templates/loads/loads_charts.html index b03c7bc..cf6e6c3 100644 --- a/loads/templates/loads/loads_charts.html +++ b/loads/templates/loads/loads_charts.html @@ -40,6 +40,77 @@
!! Please note these exceptions + Filter + + +
+
Use this form to select graph formatting options.
+
+ {% csrf_token %} + {# Process hidden fields, we don't need to style them #} + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} + + {% for field in form.visible_fields %} +
+ + {% if field.field.widget.input_type == "text" %} +
+ + {# Show field errors as a list, one per line #} + {% if field.errors %} +
+ {% for error in field.errors %} +

{{ error|escape }}

+ {% endfor %} +
+ {% endif %} +
+ {% elif field.field.widget.input_type == "select" %} +
+ + {# Show field errors as a list, one per line #} + {% if field.errors %} +
+ {% for error in field.errors %} +

{{ error|escape }}

+ {% endfor %} +
+ {% endif %} +
+ {% elif field.field.widget.input_type == "checkbox" %} +
+ +
+ {% else %} +
Unknown field type: {{ field.field.widget.input_type }}
+ {% endif %} +
+ {% endfor %} + + {# {{ form }} #} + +
+
+ {% if group_data %} {% for group, group_list, group_total, group_average, group_allocated_staff, group_allocated_average in group_data %}
@@ -51,7 +122,9 @@

{% for staff, loads_by_category, hours, bar_width, scaled_hours in group_list %}
- +
{% if show_90_110 %} @@ -72,7 +145,7 @@

{% endfor %}
-
+
Legend: The red dashed line represents 100% capacity, taking into account staff FTE. {% if show_90_110 %} Grey dashed lines represent 90% and 100% capacity. @@ -98,6 +171,8 @@

No allocations for this group at this time

+

+
{% endif %} {% endfor %}

diff --git a/loads/templates/loads/modules/allocations.html b/loads/templates/loads/modules/allocations.html index 50484e5..bb6aa65 100644 --- a/loads/templates/loads/modules/allocations.html +++ b/loads/templates/loads/modules/allocations.html @@ -45,6 +45,13 @@

{% endfor %} + {% if formset.non_form_errors %} +
+ {% for error in formset.non_form_errors %} + {{ error|escape }} + {% endfor %} +
+ {% endif %}

diff --git a/loads/templates/loads/projects/allocations.html b/loads/templates/loads/projects/allocations.html index 0440a32..332ad80 100644 --- a/loads/templates/loads/projects/allocations.html +++ b/loads/templates/loads/projects/allocations.html @@ -106,6 +106,13 @@
Project details
{% endfor %} + {% if formset.non_form_errors %} +
+ {% for error in formset.non_form_errors %} + {{ error|escape }} + {% endfor %} +
+ {% endif %} diff --git a/loads/templates/loads/staff/allocations.html b/loads/templates/loads/staff/allocations.html index 6e5be40..f0c80b1 100644 --- a/loads/templates/loads/staff/allocations.html +++ b/loads/templates/loads/staff/allocations.html @@ -35,6 +35,13 @@
{{package}}
{% endfor %} + {% if formset.non_form_errors %} +
+ {% for error in formset.non_form_errors %} + {{ error|escape }} + {% endfor %} +
+ {% endif %} diff --git a/loads/views.py b/loads/views.py index d3fecd6..6273b34 100644 --- a/loads/views.py +++ b/loads/views.py @@ -44,7 +44,9 @@ from .forms import AssessmentResourceForm from .forms import AssessmentStaffForm from .forms import AssessmentStateSignOffForm +from .forms import BaseProjectStaffFormSet from .forms import LoadsByModulesForm +from .forms import LoadChartsForm from .forms import TaskForm from .forms import TaskCompletionForm from .forms import StaffWorkPackageForm @@ -407,11 +409,27 @@ def loads_by_staff_chart(request): return HttpResponseRedirect(url) logger.info("[%s] loads by staff chart viewed" % request.user, extra={'package': package}) - # We will likely want these to be configurable + # By default, we will sort lists by highest to lowest workload (if False, alphabetically) - sort_lists = True - # By default, we will add lines at 90% and 100% + sort_by_load = True + # By default, we will not add lines at 90% and 100% show_90_110 = False + + # if this is a POST request we need to process the form data + if request.method == 'POST': + # create a form instance and populate it with data from the request + form = LoadChartsForm(request.POST) + + # check whether it's valid: + if form.is_valid(): + sort_by_load = form.cleaned_data['sort_by_load'] + show_90_110 = form.cleaned_data['show_90_110'] + + # if a GET (or any other method) we'll create a form from the current logged in user + else: + form = LoadChartsForm() + + #TODO: This is still pretty much built in as True by assumption in some of the code below scale_fte = True @@ -500,7 +518,7 @@ def loads_by_staff_chart(request): group_allocated_average = group_total / group_allocated_staff # We want to sort with the most loaded staff at the top, using scaled hours to compensate for FTE - if sort_lists: + if sort_by_load: group_list = sorted(group_list, key=lambda item : item[4], reverse=True) group_data.append( @@ -513,7 +531,8 @@ def loads_by_staff_chart(request): template = loader.get_template('loads/loads_charts.html') context = { - 'sort_lists': sort_lists, + 'form': form, + 'sort_by_load': sort_by_load, 'show_90_110': show_90_110, 'group_data': group_data, 'total': total, @@ -1136,21 +1155,40 @@ def module_staff_allocation(request, module_id, package_id): logger.info("[%s] user has no permissions to alter allocations" % request.user) return HttpResponseRedirect(reverse('forbidden')) - # Get a formset with only the choosable fields - allocation_form_set = modelformset_factory(ModuleStaff, formset=BaseModuleStaffByModuleFormSet, - fields=('staff', 'contact_proportion', 'admin_proportion', - 'assessment_proportion'), - can_delete=True) + # We usually want to restrict the staff to select to the package, but best honour the possibility + # that there are staff not in, or no longer in the package, so add them too. + package_staff_qs = package.get_all_staff() + module_staff_ids = ModuleStaff.objects.filter(module=module).values_list('staff_id', flat=True) + module_staff_qs = Staff.objects.filter(pk__in=module_staff_ids) + + # There can be challenges directly adding to package_staff, especially with some DB layers so + package_pks = [s.pk for s in package_staff_qs] + combined_pks = set(package_pks) | set(module_staff_ids) + combined_staff_qs = Staff.objects.filter(pk__in=combined_pks).distinct().order_by('user__last_name') + + # Create a formset with only the choosable fields, and the information to populate the others + allocation_formset_factory = modelformset_factory(ModuleStaff, + formset=BaseModuleStaffByModuleFormSet, + fields=('staff', + 'contact_proportion', + 'admin_proportion', + 'assessment_proportion'), + can_delete=True) if request.method == "POST": - formset = allocation_form_set( - request.POST, request.FILES, - queryset=ModuleStaff.objects.filter(package=package).filter(module=module).order_by( - 'staff__user__last_name') - ) + # Processing the form post submit, get the formset first + formset = allocation_formset_factory(request.POST, request.FILES, + queryset=ModuleStaff.objects.filter(module=module).order_by( + 'staff__user__last_name')) + + logger.debug("[%s] inbound POST %s" % (request.user, request.POST)) + logger.debug("[%s] %u forms before validation" % (request.user, len(formset.forms))) + logger.debug("[%s] %u deleted forms before validation" % (request.user, len(formset.deleted_forms))) + # We need to tweak the queryset to only allow staff in the package for form in formset: - form.fields['staff'].queryset = package.get_all_staff() + form.fields['staff'].queryset = combined_staff_qs + if formset.is_valid(): formset.save(commit=False) for form in formset: @@ -1159,9 +1197,11 @@ def module_staff_allocation(request, module_id, package_id): # Fix the fields allocation.module = module allocation.package = package + logger.info("[%s] allocation for %s processed", request.user, allocation.staff) # Now do a real save formset.save(commit=True) - logger.info("[%s] adjusted the module allocation for module %s" % (request.user, module), extra={'formset': formset}) + logger.info("[%s] adjusted the module allocation for module %s" % (request.user, module), + extra={'formset': formset}) # redirect to the activites page # TODO this might just be a different package from this one, note. @@ -1169,11 +1209,11 @@ def module_staff_allocation(request, module_id, package_id): url = reverse('modules_details', args=[module_id]) return HttpResponseRedirect(url) else: - formset = allocation_form_set(queryset=ModuleStaff.objects.filter(package=package).filter(module=module).order_by( + formset = allocation_formset_factory(queryset=ModuleStaff.objects.filter(module=module).order_by( 'staff__user__last_name')) # Again, only allow staff members in the package for form in formset: - form.fields['staff'].queryset = package.get_all_staff() + form.fields['staff'].queryset = combined_staff_qs logger.info("[%s] opened the form for the module allocation for module %s" % (request.user, module), extra={'formset': formset}) return render(request, 'loads/modules/allocations.html', {'module': module, 'package': package, 'formset': formset}) @@ -1664,6 +1704,11 @@ def staff_module_allocation(request, staff_id, package_id): request.POST, request.FILES, queryset=ModuleStaff.objects.filter(package=package).filter(staff=staff) ) + + logger.debug("[%s] inbound POST %s" % (request.user, request.POST)) + logger.debug("[%s] %u forms before validation" % (request.user, len(formset.forms))) + logger.debug("[%s] %u deleted forms before validation" % (request.user, len(formset.deleted_forms))) + # We need to tweak the queryset to only allow modules in the package for form in formset: form.fields['module'].queryset = Module.objects.filter(package=package) @@ -1776,7 +1821,7 @@ def projects_details(request, project_id): package = user_staff.package # Get a formset with only the choosable fields - ProjectStaffFormSet = modelformset_factory(ProjectStaff, formset=FancyModelFormSet, + ProjectStaffFormSet = modelformset_factory(ProjectStaff, formset=BaseProjectStaffFormSet, fields=('staff', 'start', 'end', 'hours_per_week'), widgets={'start' : DateInput(), 'end' : DateInput(),}, can_delete=True) @@ -1787,16 +1832,39 @@ def projects_details(request, project_id): request.POST, request.FILES, queryset=ProjectStaff.objects.filter(project=project), ) + + logger.debug("[%s] inbound POST %s" % (request.user, request.POST)) + logger.debug("[%s] %u forms before validation" % (request.user, len(formset.forms))) + logger.debug("[%s] %u deleted forms before validation" % (request.user, len(formset.deleted_forms))) + + # Save the Project Form itself if valid if project_form.is_valid(): project_form.save() + # A loop for debugging before validity checks + for form in formset: + logger.debug("[%s] (admin) formset: project %s, form %s" % (request.user, project, form)) + if formset.is_valid(): formset.save(commit=False) + + logger.debug("[%s] (admin) formset processing" % (request.user,)) for form in formset: # Some fields are missing, so don't do a full save yet allocation = form.save(commit=False) # Fix the fields allocation.project = project + logger.debug("[%s] (admin) processing project allocation %s, staff %s" % (request.user, + project, + form.cleaned_data.get("staff"))) + + for allocation in formset.deleted_objects: + logger.debug("[%s] (admin) deleting project allocation %s, staff %s" % (request.user, + allocation.project, + allocation.staff)) + allocation.delete() + + # Now do a real save formset.save(commit=True) logger.info("[%s] (admin) edited the details for project %s" % (request.user, project), @@ -1807,6 +1875,9 @@ def projects_details(request, project_id): url = reverse('projects_index') return HttpResponseRedirect(url) + else: + logger.debug("[%s] (admin) formset errors %s" % (request.user, formset.errors), + extra={'formset': formset}) else: project_form = ProjectForm(instance=project) formset = ProjectStaffFormSet(queryset=ProjectStaff.objects.filter(project=project))