From ba5a40b6175ba98a71bf487ab54b06dd349d620f Mon Sep 17 00:00:00 2001 From: Brian Ramsay Date: Thu, 2 Jul 2026 16:01:35 -0400 Subject: [PATCH 01/16] Add empty file for common routes --- app/controllers/main_routes/departmentPortal.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/controllers/main_routes/departmentPortal.py diff --git a/app/controllers/main_routes/departmentPortal.py b/app/controllers/main_routes/departmentPortal.py new file mode 100644 index 00000000..35175770 --- /dev/null +++ b/app/controllers/main_routes/departmentPortal.py @@ -0,0 +1 @@ +from flask import render_template From 0d64daf5252acab74dd7c997e9ca7328c007b1db Mon Sep 17 00:00:00 2001 From: rukwashai Date: Wed, 8 Jul 2026 11:14:25 -0400 Subject: [PATCH 02/16] Add allocation warning to pending LSF approval modal (#622) Extract allocation logic into app/logic/allocation.py (getAllocationSummary, getAllocationWarning), wire it into modal_approval_and_denial_data() to show a per-department warning box in the approval confirmation modal, and fix demo data so students/supervisors are marked active (needed for the approval checkbox to render at all). --- app/controllers/main_routes/main_routes.py | 65 ++--------- app/logic/allPendingForms.py | 20 +++- app/logic/allocation.py | 106 ++++++++++++++++++ app/static/js/allPendingForms.js | 23 +++- app/templates/snips/pendingApprovalModal.html | 1 + database/demo_data.py | 2 + 6 files changed, 153 insertions(+), 64 deletions(-) create mode 100644 app/logic/allocation.py diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 3393758a..ac48a1c4 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 a9d4a3bd..479eb999 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 from app.models.formHistory import FormHistory from app.models.status import Status from app.logic.banner import Banner @@ -14,6 +14,7 @@ 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): @@ -196,9 +197,11 @@ 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''' + ''' This method grabs the data that populated the on approve modal for lsf, + plus an over-allocation warning per unique department among the selected forms. ''' details_list = [] + allocationWarningsByDept = {} for fhID in formHistoryIdList: formHistory = FormHistory.get(FormHistory.formHistoryID == fhID) lsf = formHistory.formID @@ -208,7 +211,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 +227,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 00000000..ce952522 --- /dev/null +++ b/app/logic/allocation.py @@ -0,0 +1,106 @@ +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), +] + + +def getAllocationSummary(dept, term): + """ + Returns a dict describing a department's allocation vs. actual usage for the given term: + - allocation: the Allocation row for this dept/term, or None if none exists + - allocationBands: {fieldName: {'used': int, 'allocated': int}} per hour-band, or None + - totalPositionsAllocated / totalPositionsUsed: ints, or None + - breakHoursUsed: int, or None + 'used' counts are non-denied LaborStatusForms, matching the same filter pattern + used elsewhere in the app (see app/logic/statusFormFunctions.py). + """ + 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): + """ + Returns a summary dict for displaying an over-allocation warning for the given + department/term (e.g. in the pending-LSF approval modal), or None if there's no + allocation on record for that department/term to compare against. + + Note: 'used' counts (from getAllocationSummary) include Pending as well as + Approved forms, so a form currently Pending already occupies a slot here - + these numbers already reflect what utilization would be once it's approved. + """ + summary = getAllocationSummary(dept, term) + if not summary['allocation']: + return None + + positionsRemaining = summary['totalPositionsAllocated'] - summary['totalPositionsUsed'] + breakHoursRemaining = summary['allocation'].breakHours - summary['breakHoursUsed'] + + return { + 'departmentName': dept.DEPT_NAME, + 'totalPositionsAllocated': summary['totalPositionsAllocated'], + 'totalPositionsUsed': summary['totalPositionsUsed'], + 'positionsRemaining': positionsRemaining, + 'breakHoursAllocated': summary['allocation'].breakHours, + 'breakHoursUsed': summary['breakHoursUsed'], + 'breakHoursRemaining': breakHoursRemaining, + 'isOverAllocated': positionsRemaining < 0 or breakHoursRemaining < 0, + } diff --git a/app/static/js/allPendingForms.js b/app/static/js/allPendingForms.js index ae79740e..d0049423 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,24 @@ 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. +function updateAllocationWarnings(allocationWarnings) { + if (!allocationWarnings) { return; } + for (var i = 0; i < allocationWarnings.length; i++) { + var w = allocationWarnings[i]; + var alertClass = w.isOverAllocated ? 'alert-danger' : 'alert-info'; + 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 +138,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 33ff1c99..66421353 100644 --- a/app/templates/snips/pendingApprovalModal.html +++ b/app/templates/snips/pendingApprovalModal.html @@ -10,6 +10,7 @@