diff --git a/app/controllers/main_routes/departmentPortal.py b/app/controllers/main_routes/departmentPortal.py new file mode 100644 index 000000000..351757701 --- /dev/null +++ b/app/controllers/main_routes/departmentPortal.py @@ -0,0 +1 @@ +from flask import render_template diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 3393758a2..ac48a1c4a 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -1,16 +1,12 @@ from flask import render_template, request, json, redirect, url_for, send_file, g, flash, jsonify -from peewee import JOIN, DoesNotExist, fn +from peewee import JOIN, DoesNotExist from functools import reduce import operator -from app.models.allocation import Allocation from app.models.department import Department from app.models.supervisor import Supervisor from app.models.supervisorDepartment import SupervisorDepartment from app.models.student import Student -from app.models.laborStatusForm import LaborStatusForm from app.models.formHistory import FormHistory -from app.models.term import Term -from app.models.allocation import Allocation from app.controllers.admin_routes.allPendingForms import checkAdjustment from app.controllers.main_routes import main_bp from app.logic.download import CSVMaker, saveFormSearchResult, retrieveFormSearchResult @@ -19,6 +15,7 @@ from app.logic.getTableData import getDatatableData from app.logic.banner import Banner from app.logic.tracy import Tracy +from app.logic.allocation import getAllocationSummary @main_bp.route('/logout', methods=['GET']) def triggerLogout(): @@ -82,64 +79,18 @@ def departmentPortal(org=None,account=None): if i.ORG == org: supervisors.append(i.FIRST_NAME + " " + i.LAST_NAME + " (" + i.EMAIL + ")") - allocation = None - allocationBands = None - totalPositionsAllocated = None - totalPositionsUsed = None - breakHoursUsed = None - if dept and g.openTerm: - allocation = Allocation.get_or_none(Allocation.department == dept, Allocation.termCode == g.openTerm) - if allocation: - bandFields = [ - ('primary_10', 'Primary', 10), - ('primary_12', 'Primary', 12), - ('primary_15', 'Primary', 15), - ('primary_20', 'Primary', 20), - ('secondary_5', 'Secondary', 5), - ('secondary_10', 'Secondary', 10), - ] - allocationBands = {} - for fieldName, jobType, hours in bandFields: - used = (LaborStatusForm - .select() - .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) - .where(LaborStatusForm.department == dept, - LaborStatusForm.termCode == g.openTerm, - LaborStatusForm.jobType == jobType, - LaborStatusForm.weeklyHours == hours, - FormHistory.historyType == "Labor Status Form", - ~(FormHistory.status % "Denied%")) - .distinct() - .count()) - allocationBands[fieldName] = {'used': used, 'allocated': getattr(allocation, fieldName)} - - totalPositionsAllocated = sum(band['allocated'] for band in allocationBands.values()) - totalPositionsUsed = sum(band['used'] for band in allocationBands.values()) - - # Break hours are tracked on separate break-term rows (e.g. Thanksgiving Break) - # that share the same academic year prefix as the open AY term. - yearPrefix = str(g.openTerm.termCode)[:-2] - breakTermCodes = [t.termCode for t in Term.select().where(Term.isBreak == True) - if str(t.termCode).startswith(yearPrefix)] - breakHoursUsed = (LaborStatusForm - .select(fn.SUM(LaborStatusForm.contractHours)) - .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) - .where(LaborStatusForm.department == dept, - LaborStatusForm.termCode.in_(breakTermCodes), - FormHistory.historyType == "Labor Status Form", - ~(FormHistory.status % "Denied%")) - .scalar()) or 0 + allocationSummary = getAllocationSummary(dept, g.openTerm) return render_template('main/departmentPortal.html', departments = departments, department = dept, positions = positions, supervisors = supervisors, - allocation = allocation, - allocationBands = allocationBands, - totalPositionsAllocated = totalPositionsAllocated, - totalPositionsUsed = totalPositionsUsed, - breakHoursUsed = breakHoursUsed, + allocation = allocationSummary['allocation'], + allocationBands = allocationSummary['allocationBands'], + totalPositionsAllocated = allocationSummary['totalPositionsAllocated'], + totalPositionsUsed = allocationSummary['totalPositionsUsed'], + breakHoursUsed = allocationSummary['breakHoursUsed'], currentTerm = g.openTerm) @main_bp.route('/department///managepositions', methods=['GET']) diff --git a/app/logic/allPendingForms.py b/app/logic/allPendingForms.py index a9d4a3bd2..f93af930d 100644 --- a/app/logic/allPendingForms.py +++ b/app/logic/allPendingForms.py @@ -1,6 +1,6 @@ import json from datetime import date -from flask import jsonify +from flask import jsonify, g, flash from app.models.formHistory import FormHistory from app.models.status import Status from app.logic.banner import Banner @@ -14,9 +14,11 @@ from app.models.overloadForm import OverloadForm from app.models.notes import Notes from app.login_manager import DoesNotExist, render_template +from app.logic.allocation import getAllocationWarning def saveStatus(new_status, formHistoryIds, currentUser): + approvedDepartments = {} try: if new_status == 'Denied by Admin': # Index 1 will always hold the reject reason in the list, so we can @@ -66,6 +68,8 @@ def saveStatus(new_status, formHistoryIds, currentUser): email.laborStatusFormRejected() if new_status == "Approved" and formType == "Labor Status Form": email.laborStatusFormApproved() + dept = formHistory.formID.department + approvedDepartments[dept.departmentID] = dept if new_status == "Approved" and formType == "Labor Adjustment Form": # This function is triggered whenever an adjustment form is approved. # The following function overrides the original data in lsf with the new data from adjustment form. @@ -80,6 +84,16 @@ def saveStatus(new_status, formHistoryIds, currentUser): print("Error preparing form for status update:", e) return jsonify({"success": False}), 500 + # After approving, let the admin know right away if any affected department + # is now over its allocated positions or break hours (informational only). + for dept in approvedDepartments.values(): + warning = getAllocationWarning(dept, g.openTerm) + if warning and warning['isOverAllocated']: + messageParts = [f"{b['label']} ({b['used']}/{b['allocated']})" for b in warning['overAllocatedBands']] + if warning['isBreakHoursOverAllocated']: + messageParts.append(f"break hours ({warning['breakHoursUsed']}/{warning['breakHoursAllocated']})") + flash(f"{dept.DEPT_NAME} is now over its allocation for: {', '.join(messageParts)}.", "warning") + return jsonify({"success": True}) def overrideOriginalStatusFormOnAdjustmentFormApproval(form, LSF): @@ -196,9 +210,8 @@ def laborAdminOverloadApproval(rsp, historyForm, status, currentUser, currentDat # extract data from the database to populate pending form approval modal def modal_approval_and_denial_data(formHistoryIdList): - ''' This method grabs the data that populated the on approve modal for lsf''' - details_list = [] + allocationWarningsByDept = {} for fhID in formHistoryIdList: formHistory = FormHistory.get(FormHistory.formHistoryID == fhID) lsf = formHistory.formID @@ -208,7 +221,8 @@ def modal_approval_and_denial_data(formHistoryIdList): supervisorName = f"{lsf.supervisor.FIRST_NAME} {lsf.supervisor.LAST_NAME}" weeklyHours = lsf.weeklyHours contractHours = lsf.contractHours - deptName = lsf.department.DEPT_NAME + dept = lsf.department + deptName = dept.DEPT_NAME if formHistory.adjustedForm: match formHistory.adjustedForm.fieldAdjusted: @@ -223,11 +237,17 @@ def modal_approval_and_denial_data(formHistoryIdList): case "contractHours": contractHours = formHistory.adjustedForm.newValue case "department": - deptName = Department.get(Department.ORG==formHistory.adjustedForm.newValue).DEPT_NAME + dept = Department.get(Department.ORG==formHistory.adjustedForm.newValue) + deptName = dept.DEPT_NAME details_list.append([studentName, deptName, position, str(weeklyHours),str(contractHours), supervisorName]) - return details_list + if dept.departmentID not in allocationWarningsByDept: + warning = getAllocationWarning(dept, g.openTerm) + if warning: + allocationWarningsByDept[dept.departmentID] = warning + + return {"details": details_list, "allocationWarnings": list(allocationWarningsByDept.values())} def financialAidSAASOverloadApproval(historyForm, rsp, status, currentUser, currentDate): diff --git a/app/logic/allocation.py b/app/logic/allocation.py new file mode 100644 index 000000000..9fce3a8b7 --- /dev/null +++ b/app/logic/allocation.py @@ -0,0 +1,104 @@ +from peewee import fn +from app.models.allocation import Allocation +from app.models.laborStatusForm import LaborStatusForm +from app.models.formHistory import FormHistory +from app.models.term import Term + +# Each entry is (Allocation field name, LaborStatusForm.jobType, LaborStatusForm.weeklyHours) +ALLOCATION_BAND_FIELDS = [ + ('primary_10', 'Primary', 10), + ('primary_12', 'Primary', 12), + ('primary_15', 'Primary', 15), + ('primary_20', 'Primary', 20), + ('secondary_5', 'Secondary', 5), + ('secondary_10', 'Secondary', 10), +] + +BAND_LABELS = {fieldName: f"{hours} Hour {jobType}" for fieldName, jobType, hours in ALLOCATION_BAND_FIELDS} + + +def getAllocationSummary(dept, term): + summary = { + 'allocation': None, + 'allocationBands': None, + 'totalPositionsAllocated': None, + 'totalPositionsUsed': None, + 'breakHoursUsed': None, + } + + if not (dept and term): + return summary + + allocation = Allocation.get_or_none(Allocation.department == dept, Allocation.termCode == term) + summary['allocation'] = allocation + if not allocation: + return summary + + allocationBands = {} + for fieldName, jobType, hours in ALLOCATION_BAND_FIELDS: + used = (LaborStatusForm + .select() + .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) + .where(LaborStatusForm.department == dept, + LaborStatusForm.termCode == term, + LaborStatusForm.jobType == jobType, + LaborStatusForm.weeklyHours == hours, + FormHistory.historyType == "Labor Status Form", + ~(FormHistory.status % "Denied%")) + .distinct() + .count()) + allocationBands[fieldName] = {'used': used, 'allocated': getattr(allocation, fieldName)} + + summary['allocationBands'] = allocationBands + summary['totalPositionsAllocated'] = sum(band['allocated'] for band in allocationBands.values()) + summary['totalPositionsUsed'] = sum(band['used'] for band in allocationBands.values()) + + # Break hours are tracked on separate break-term rows (e.g. Thanksgiving Break) + # that share the same academic year prefix as the given AY term. + yearPrefix = str(term.termCode)[:-2] + breakTermCodes = [t.termCode for t in Term.select().where(Term.isBreak == True) + if str(t.termCode).startswith(yearPrefix)] + summary['breakHoursUsed'] = (LaborStatusForm + .select(fn.SUM(LaborStatusForm.contractHours)) + .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) + .where(LaborStatusForm.department == dept, + LaborStatusForm.termCode.in_(breakTermCodes), + FormHistory.historyType == "Labor Status Form", + ~(FormHistory.status % "Denied%")) + .scalar()) or 0 + + return summary + + +def getAllocationWarning(dept, term): + summary = getAllocationSummary(dept, term) + if not summary['allocation']: + return None + + positionsRemaining = summary['totalPositionsAllocated'] - summary['totalPositionsUsed'] + breakHoursRemaining = summary['allocation'].breakHours - summary['breakHoursUsed'] + + # A department can be within its total position count while still exceeding + # one specific hour-band (e.g. over on 10-hour Primary but under on others), + # so each band needs to be checked individually, not just the aggregate total. + overAllocatedBands = [ + {'label': BAND_LABELS[fieldName], 'used': band['used'], 'allocated': band['allocated']} + for fieldName, band in summary['allocationBands'].items() + if band['used'] > band['allocated'] + ] + isPositionsOverAllocated = positionsRemaining < 0 or bool(overAllocatedBands) + isBreakHoursOverAllocated = breakHoursRemaining < 0 + + return { + 'departmentName': dept.DEPT_NAME, + 'totalPositionsAllocated': summary['totalPositionsAllocated'], + 'totalPositionsUsed': summary['totalPositionsUsed'], + 'positionsRemaining': positionsRemaining, + 'isPositionsOverAllocated': isPositionsOverAllocated, + 'overAllocatedBands': overAllocatedBands, + 'breakHoursAllocated': summary['allocation'].breakHours, + 'breakHoursUsed': summary['breakHoursUsed'], + 'breakHoursRemaining': breakHoursRemaining, + 'isBreakHoursOverAllocated': isBreakHoursOverAllocated, + 'isOverAllocated': isPositionsOverAllocated or isBreakHoursOverAllocated, + } diff --git a/app/models/allocation.py b/app/models/allocation.py index 9f333e0da..eb83877a0 100644 --- a/app/models/allocation.py +++ b/app/models/allocation.py @@ -6,9 +6,9 @@ class Allocation(baseModel): termCode = ForeignKeyField(Term) department = ForeignKeyField(Department) - isApproved = BooleanField(default=False) - approvedOn = DateField() - approvedBy = CharField() + isFinal = BooleanField(default=False) + approvedOn = DateField(null=True) + approvedBy = ForeignKeyField(Supervisor, null=True) justification = TextField() primary_10 = IntegerField() primary_12 = IntegerField() @@ -17,3 +17,7 @@ class Allocation(baseModel): secondary_5 = IntegerField() secondary_10 = IntegerField() breakHours = IntegerField() + + class Meta: + indexes = ( (('termCode', 'department', 'isFinal'), True), ) + diff --git a/app/models/positionHistory.py b/app/models/positionHistory.py index 95fb3d287..3679cf147 100644 --- a/app/models/positionHistory.py +++ b/app/models/positionHistory.py @@ -2,12 +2,12 @@ from app.models.department import Department class PositionHistory(baseModel): - positioncode = CharField() + positionCode = CharField() + department = ForeignKeyField(Department) status = CharField() - WLS = IntegerField() - revisiondate = DateField() - Description = TextField(default=None) - Department = ForeignKeyField(Department) + wls = IntegerField() + revisionDate = DateField() + description = TextField(default=None) class Meta: - primary_key = CompositeKey('positioncode', 'revisiondate', 'status') \ No newline at end of file + indexes = ( (('positionCode', 'revisionDate', 'status'), True), ) diff --git a/app/models/supervisor.py b/app/models/supervisor.py index 7c08354d3..e16d43282 100644 --- a/app/models/supervisor.py +++ b/app/models/supervisor.py @@ -16,6 +16,7 @@ class Supervisor(baseModel): legal_name = CharField(null=True) preferred_name = CharField(null=True) isActive = BooleanField(default=False) + isBanned = BooleanField(default=False) @property diff --git a/app/models/supervisorDepartment.py b/app/models/supervisorDepartment.py index 72a80ea6d..dd5b15385 100644 --- a/app/models/supervisorDepartment.py +++ b/app/models/supervisorDepartment.py @@ -3,14 +3,6 @@ from app.models.department import Department class SupervisorDepartment(baseModel): - supervisor = ForeignKeyField(Supervisor, null=True) + supervisor = ForeignKeyField(Supervisor) department = ForeignKeyField(Department) - banStatus = BooleanField(default=False) - isActive = BooleanField(default=False) isCoordinator = BooleanField(default=False) - - @property - def isBanned(self): - return self.banStatus - - diff --git a/app/static/js/allPendingForms.js b/app/static/js/allPendingForms.js index ae79740e4..308440487 100644 --- a/app/static/js/allPendingForms.js +++ b/app/static/js/allPendingForms.js @@ -87,8 +87,8 @@ function insertApprovals(laborHistoryId = null) { contentType: 'application/json', success: function(response) { if (response) { - var returned_details = response; - updateApproveTableData(returned_details); + updateApproveTableData(response.details); + updateAllocationWarnings(response.allocationWarnings); } } }); @@ -112,6 +112,40 @@ function updateApproveTableData(returned_details) { } } +// Shows a non-blocking allocation warning per department represented among the +// selected forms, so admins can see the impact of approval before confirming. +// Each category (positions / break hours) is highlighted independently, since +// a department can be over on one and fine on the other. +function updateAllocationWarnings(allocationWarnings) { + if (!allocationWarnings) { return; } + for (var i = 0; i < allocationWarnings.length; i++) { + var w = allocationWarnings[i]; + var boxClass = w.isOverAllocated ? 'alert-warning' : 'alert-info'; + var overStyle = 'color:#a94442; font-weight:bold;'; + var positionsStyle = w.isPositionsOverAllocated ? overStyle : ''; + var breakHoursStyle = w.isBreakHoursOverAllocated ? overStyle : ''; + var positionsFlag = w.isPositionsOverAllocated ? ' ⚠ Over allocation' : ''; + var breakHoursFlag = w.isBreakHoursOverAllocated ? ' ⚠ Over allocation' : ''; + // A department can look fine in total while one specific hour-band is over, + // so call those bands out by name instead of only showing the aggregate. + var bandDetail = ''; + if (w.overAllocatedBands && w.overAllocatedBands.length > 0) { + var bandStrings = w.overAllocatedBands.map(function(b) { + return b.label + ' (' + b.used + '/' + b.allocated + ')'; + }); + bandDetail = '
Over on: ' + bandStrings.join(', ') + ''; + } + var html = ''; + $('#allocationWarnings').append(html); + } +} + $('#approvalModal').on('hidden.bs.modal', function () {// Makes the close functionality work when clicking outside of the modal approvalModalClose(); @@ -120,6 +154,7 @@ $('#approvalModal').on('hidden.bs.modal', function () {// Makes the close functi function approvalModalClose(){// on close of approval modal we are clearing the table to prevent duplicate data. $('#classTableBody').empty(); + $('#allocationWarnings').empty(); labor_details_ids = [] // emptying the list, becuase otherwise will cause duplicate data. } diff --git a/app/templates/snips/pendingApprovalModal.html b/app/templates/snips/pendingApprovalModal.html index 33ff1c992..66421353e 100644 --- a/app/templates/snips/pendingApprovalModal.html +++ b/app/templates/snips/pendingApprovalModal.html @@ -10,6 +10,7 @@