From b4d408ffa6b34a0d50df12fc4648bf4069ad0efd Mon Sep 17 00:00:00 2001 From: rukwashai Date: Tue, 7 Jul 2026 14:24:52 -0400 Subject: [PATCH 01/15] Wire Current Allocation card to real Allocation/LaborStatusForm data (#602) Fix migrate_db.sh missing the Allocation model, seed demo Allocation rows per department, and query real allocated/used position counts and break hours in departmentPortal() instead of hardcoded placeholder numbers. --- app/controllers/main_routes/main_routes.py | 61 +++++++++++++++- app/templates/main/departmentPortal.html | 20 +++--- database/demo_data.py | 84 ++++++++++++++++++++++ database/migrate_db.sh | 1 + 4 files changed, 155 insertions(+), 11 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 7fc414b75..71e44bfdf 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -1,5 +1,5 @@ from flask import render_template, request, json, redirect, url_for, send_file, g, flash, jsonify -from peewee import JOIN, DoesNotExist +from peewee import JOIN, DoesNotExist, fn from functools import reduce import operator from app.models.department import Department @@ -9,6 +9,7 @@ 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 @@ -80,11 +81,65 @@ def departmentPortal(org=None,account=None): if i.ORG == org: supervisors.append(i.FIRST_NAME + " " + i.LAST_NAME + " (" + i.EMAIL + ")") - return render_template('main/departmentPortal.html', + 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 + + return render_template('main/departmentPortal.html', departments = departments, department = dept, positions = positions, - supervisors = supervisors) + supervisors = supervisors, + allocation = allocation, + allocationBands = allocationBands, + totalPositionsAllocated = totalPositionsAllocated, + totalPositionsUsed = totalPositionsUsed, + breakHoursUsed = breakHoursUsed, + currentTerm = g.openTerm) @main_bp.route('/department///managepositions', methods=['GET']) def managePositions(org, account): diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index b40f7a4aa..15eddc229 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -41,23 +41,27 @@

Allocations

-

AY 26/27

15/20 Positions

+

{{ currentTerm.termName if currentTerm else "No open term" }}

+

{{ totalPositionsUsed if allocation else "No allocation" }}{% if allocation %}/{{ totalPositionsAllocated }}{% endif %} Positions

+ {% if allocation %}
    -
  • 10 Hour - 2/2
  • -
  • 12 Hour - 3/3
  • -
  • 15 Hour - 5/5
  • -
  • 20 Hour - 5/5
  • +
  • 10 Hour - {{ allocationBands.primary_10.used }}/{{ allocationBands.primary_10.allocated }}
  • +
  • 12 Hour - {{ allocationBands.primary_12.used }}/{{ allocationBands.primary_12.allocated }}
  • +
  • 15 Hour - {{ allocationBands.primary_15.used }}/{{ allocationBands.primary_15.allocated }}
  • +
  • 20 Hour - {{ allocationBands.primary_20.used }}/{{ allocationBands.primary_20.allocated }}
    -
  • 5 Hour - 2/2
  • -
  • 10 Hour - 3/3
  • +
  • 5 Hour - {{ allocationBands.secondary_5.used }}/{{ allocationBands.secondary_5.allocated }}
  • +
  • 10 Hour - {{ allocationBands.secondary_10.used }}/{{ allocationBands.secondary_10.allocated }}
+ {% endif %}
-

Break Hours

0/200 Hours

+

Break Hours

+

{{ breakHoursUsed if allocation else "No allocation" }}{% if allocation %}/{{ allocation.breakHours }}{% endif %} Hours

-

{{ currentTerm.termName if currentTerm else "No open term" }}

+

{{ currentTerm.termName.replace("AY", "Term") if currentTerm else "No open term" }}

{{ totalPositionsUsed if allocation else "No allocation" }}{% if allocation %}/{{ totalPositionsAllocated }}{% endif %} Positions

{% if allocation %} @@ -68,9 +68,6 @@

{{ breakHoursUsed if allocation else "No allocation" }}{% if allocation %}/{ Manage Allocations - - Request Allocation -

From 747a75e68cc47de79763672d92ae0ff72e8f1a24 Mon Sep 17 00:00:00 2001 From: rukwashai Date: Tue, 7 Jul 2026 14:39:07 -0400 Subject: [PATCH 03/15] Add Primary/Secondary titles above the hour-band breakdown lists --- app/templates/main/departmentPortal.html | 28 ++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index 011c80152..0208be474 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -45,17 +45,23 @@

{{ currentTerm.termName.replace("AY", "Term") if currentTerm else "No open t

{{ totalPositionsUsed if allocation else "No allocation" }}{% if allocation %}/{{ totalPositionsAllocated }}{% endif %} Positions

{% if allocation %} -
-
    -
  • 10 Hour - {{ allocationBands.primary_10.used }}/{{ allocationBands.primary_10.allocated }}
  • -
  • 12 Hour - {{ allocationBands.primary_12.used }}/{{ allocationBands.primary_12.allocated }}
  • -
  • 15 Hour - {{ allocationBands.primary_15.used }}/{{ allocationBands.primary_15.allocated }}
  • -
  • 20 Hour - {{ allocationBands.primary_20.used }}/{{ allocationBands.primary_20.allocated }}
  • -
-
    -
  • 5 Hour - {{ allocationBands.secondary_5.used }}/{{ allocationBands.secondary_5.allocated }}
  • -
  • 10 Hour - {{ allocationBands.secondary_10.used }}/{{ allocationBands.secondary_10.allocated }}
  • -
+
+
+

Primary

+
    +
  • 10 Hour - {{ allocationBands.primary_10.used }}/{{ allocationBands.primary_10.allocated }}
  • +
  • 12 Hour - {{ allocationBands.primary_12.used }}/{{ allocationBands.primary_12.allocated }}
  • +
  • 15 Hour - {{ allocationBands.primary_15.used }}/{{ allocationBands.primary_15.allocated }}
  • +
  • 20 Hour - {{ allocationBands.primary_20.used }}/{{ allocationBands.primary_20.allocated }}
  • +
+
+
+

Secondary

+
    +
  • 5 Hour - {{ allocationBands.secondary_5.used }}/{{ allocationBands.secondary_5.allocated }}
  • +
  • 10 Hour - {{ allocationBands.secondary_10.used }}/{{ allocationBands.secondary_10.allocated }}
  • +
+
{% endif %}
From 0d64daf5252acab74dd7c997e9ca7328c007b1db Mon Sep 17 00:00:00 2001 From: rukwashai Date: Wed, 8 Jul 2026 11:14:25 -0400 Subject: [PATCH 04/15] 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 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..479eb9997 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 000000000..ce9525220 --- /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 ae79740e4..d0049423b 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 33ff1c992..66421353e 100644 --- a/app/templates/snips/pendingApprovalModal.html +++ b/app/templates/snips/pendingApprovalModal.html @@ -10,6 +10,7 @@