From 9bd90f637cb2e72d2ebd41550ee604a467e3b468 Mon Sep 17 00:00:00 2001 From: conwelld Date: Tue, 21 Jul 2026 10:54:46 -0400 Subject: [PATCH 01/20] added changes from previous branch to avoid all files being commited --- app/controllers/main_routes/__init__.py | 1 + app/controllers/main_routes/main_routes.py | 113 ++++- app/models/positionHistory.py | 1 + app/static/css/base.css | 1 + app/static/css/departmentPortal.css | 75 +++ app/static/js/departmentPortal.js | 4 + app/templates/main/departmentPortal.html | 47 +- database/demo_data.py | 515 ++++++++++++++++++++- 8 files changed, 747 insertions(+), 10 deletions(-) create mode 100644 app/static/css/departmentPortal.css diff --git a/app/controllers/main_routes/__init__.py b/app/controllers/main_routes/__init__.py index 54b32d3c5..e8c4c6bb0 100755 --- a/app/controllers/main_routes/__init__.py +++ b/app/controllers/main_routes/__init__.py @@ -25,3 +25,4 @@ def injectGlobalData(): from app.controllers.main_routes import studentLaborEvaluation from app.controllers.main_routes import search from app.controllers.main_routes import studentResponse +from app.controllers.main_routes import departmentPortal diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 0a2f21e4b..de9a189c3 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -1,5 +1,6 @@ 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 flask_bootstrap import forms from functools import reduce import operator from app.models.department import Department @@ -16,6 +17,9 @@ from app.login_manager import require_login, logout from app.logic.getTableData import getDatatableData from app.logic.banner import Banner +from app.models.allocation import Allocation +from app.logic.tracy import Tracy +from app.models.positionHistory import PositionHistory @main_bp.route('/logout', methods=['GET']) def triggerLogout(): @@ -51,9 +55,12 @@ def supervisorPortal(): @main_bp.route('/department/', methods=['GET']) @main_bp.route('/department//', methods=['GET']) def departmentPortal(org=None,account=None): - try: - dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) - except (NameError, DoesNotExist): + if org and account: + try: + dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) + except (NameError, DoesNotExist): + dept = None + else: dept = None @@ -62,11 +69,105 @@ def departmentPortal(org=None,account=None): departments = list(Department.select().order_by(Department.isActive.desc(), Department.DEPT_NAME.asc())) else: departments = list(getDepartmentsForSupervisor(g.currentUser).order_by(Department.isActive.desc(), Department.DEPT_NAME.asc())) + try: + allocation = Allocation.select(Allocation, Term).join(Term).where(Allocation.department == dept, Allocation.termCode == 202500).get() + except DoesNotExist: + allocation = None + + supervisorDepartments = (SupervisorDepartment.select().join(Supervisor).where(SupervisorDepartment.department == dept) + .order_by(fn.COALESCE(Supervisor.preferred_name, Supervisor.legal_name, Supervisor.LAST_NAME).asc())) + + laborCoordinators = [] + supervisors = [] + + for supervisorDepartment in supervisorDepartments: + supervisor = supervisorDepartment.supervisor + + if supervisor is None: + continue + + firstName = supervisor.preferred_name or supervisor.legal_name or "" + lastName = supervisor.LAST_NAME or "" + + supervisorName = f"{firstName} {lastName}".strip() + + supervisorDisplay = { + "name": supervisorName, + "email": supervisor.EMAIL + } + + if supervisorDepartment.isCoordinator: + laborCoordinators.append(supervisorDisplay) + else: + supervisors.append(supervisorDisplay) + + totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == 202500).scalar() + usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(True))]) + studentHours = {} + for form in LaborStatusForm.select().where(LaborStatusForm.department == dept,LaborStatusForm.termCode_id == 202500,LaborStatusForm.contractHours.is_null(True) + ): + studentSuperviseeId = form.studentSupervisee_id + if studentSuperviseeId not in studentHours: + studentHours[studentSuperviseeId] = [] + studentHours[studentSuperviseeId].append({ + "jobType": form.jobType, + "weeklyHours": form.weeklyHours + }) + + + def count_workers(job_type, hours_bucket): + return LaborStatusForm.select().where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.jobType == job_type, LaborStatusForm.weeklyHours == hours_bucket, LaborStatusForm.contractHours.is_null(True)).count() + + usedPositions = { + "used_10": count_workers("Primary", "10"), + "used_12": count_workers("Primary", "12"), + "used_15": count_workers("Primary", "15"), + "used_20": count_workers("Primary", "20"), + "used_5_sec": count_workers("Secondary", "5"), + "used_10_sec": count_workers("Secondary", "10"), +} + break_allocation = LaborStatusForm.select(LaborStatusForm.contractHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(False)) + sumBreak = sum(form.contractHours or 0 for form in break_allocation) + positions = list(PositionHistory.select().where(PositionHistory.department == dept, PositionHistory.status == "Active").order_by(PositionHistory.positionTitle.asc())) if dept else [] + positionsList = [] + posUrl = [] + if not positions: + positionsList = ["No active positions in this department"] + else: + for i in positions: + positionsList.append(i.positionTitle + ": " + "(WLS " + str(i.wls) + ")") + posUrl.append(str(i.positionCode)) return render_template('main/departmentPortal.html', departments = departments, - department = dept) - + department = dept, + allocation = allocation, + total_allocation = totalPositions, + used_allocation = usedAllocation, + term = g.openTerm.termName, + studentHours = studentHours, + usedPositions = usedPositions, + break_hours = sumBreak, + positions = positionsList, + posUrl = posUrl, + supervisors = supervisors, + laborCoordinators=laborCoordinators, + currentUser=g.currentUser + ) +@main_bp.route('/department///managepositions', methods=['GET']) +def managePositions(org, account): + try: + dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) + except DoesNotExist: + return render_template('errors/404.html'), 404 + + positions = Tracy().getPositionsFromDepartment(org, account) + print(positions) + return render_template('main/managepositions.html', + department = dept, + department_name = dept.DEPT_NAME, + positions = positions + ) @main_bp.route('/supervisorPortal/addUserToDept', methods=['GET', 'POST']) def addUserToDept(): userDeptData = request.form diff --git a/app/models/positionHistory.py b/app/models/positionHistory.py index 514670759..9a248aacb 100644 --- a/app/models/positionHistory.py +++ b/app/models/positionHistory.py @@ -2,6 +2,7 @@ from app.models.department import Department class PositionHistory(baseModel): + positionTitle = CharField() positionCode = CharField() department = ForeignKeyField(Department) status = CharField() diff --git a/app/static/css/base.css b/app/static/css/base.css index 940f8d6e5..6b87b2286 100755 --- a/app/static/css/base.css +++ b/app/static/css/base.css @@ -207,6 +207,7 @@ a { background-clip: border-box; border: 1px solid rgba(0, 0, 0, 0.125); border-radius: 0.25rem; + overflow: hidden; } .card-body { diff --git a/app/static/css/departmentPortal.css b/app/static/css/departmentPortal.css new file mode 100644 index 000000000..6cccf556a --- /dev/null +++ b/app/static/css/departmentPortal.css @@ -0,0 +1,75 @@ +.card { + border-radius: 1rem; + overflow: hidden; + width: 100%; + height: 100%; + min-width: 100%; + box-shadow: 2px 2px 4px rgba(100, 100, 100, 0.26); +} +.bi-suitcase-lg-fill { /* Bootstrap Icon */ + border: 1px solid #c0c0c0; + border-radius: 8px; + padding: 3px 3.5px 1.5px 3.5px; + font-size: 3rem; + color:#6e6e6e; +} +.bi-clock { + border: 1px solid #c0c0c0; + border-radius: 8px; + padding: 3px 3.5px 1.5px 3.5px; + font-size: 3rem; + color:#6e6e6e; +} +.bi-info-circle { + padding: 3px 3.5px 1.5px 3.5px; + font-size: 1.5rem; + vertical-align: middle; + color:#6e6e6e; +} +.header-container { + display: flex; + justify-content: space-between; + align-items: center; + padding-left: 2%; + padding-right: 10px; +} +.allocation-list { + display: flex; + justify-content: space-between; + align-items: center; + padding-left: 10px; + padding-right: 10px; +} +.primary-chart { + font-size: 1.2em; + padding-left: 15%; +} +.secondary-chart { + font-size: 1.2em; + padding-right: 15%; +} + +.bi-people-fill { /* Bootstrap Icon for Members Card */ + border: 1px solid #c0c0c0; + border-radius: 8px; + padding: 3px 3.5px 1.5px 3.5px; + font-size: 3rem; + color:#6e6e6e; +} + +.card-group { + gap: 1rem; +} +.form-group { + width: 50%; + margin-left: auto ; + margin-right:auto; +} +.card-body { + padding: 1rem 1rem; + min-width: 100% +} +.row { + display: flex; + flex-wrap: wrap; +} \ No newline at end of file diff --git a/app/static/js/departmentPortal.js b/app/static/js/departmentPortal.js index cb2023a3a..06ae72e76 100644 --- a/app/static/js/departmentPortal.js +++ b/app/static/js/departmentPortal.js @@ -4,3 +4,7 @@ $(document).ready(function() { window.location = `/department/${deptData.org}/${deptData.account}`; }); }); + +$(function () { + $('[data-toggle="tooltip"]').tooltip() +}) diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index 8ee01838b..b51354f0e 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -3,11 +3,12 @@ {% block scripts %} {{super()}} + {% endblock %} - {% block app_content %}

{% if department %} {{department.DEPT_NAME}} Portal {% else %} Choose a Department: {% endif %}

+
+
+{% if department %} +
+
+
+
+
+
+ +
+
+

Allocations

+
+
+

AY 2025-2026

{{used_allocation}}/{{total_allocation or 0}} Positions

+
+
+
    +
  • 10 Hour - {{usedPositions.used_10}}/{{allocation.primary_10}}
  • +
  • 12 Hour - {{usedPositions.used_12}}/{{allocation.primary_12}}
  • +
  • 15 Hour - {{usedPositions.used_15}}/{{allocation.primary_15}}
  • +
  • 20 Hour - {{usedPositions.used_20}}/{{allocation.primary_20}}
  • +
+
    +
  • 5 Hour - {{usedPositions.used_5_sec}}/{{allocation.secondary_5}}
  • +
  • 10 Hour - {{usedPositions.used_10_sec}}/{{allocation.secondary_10}}
  • +
+
+
+

Break Hours

{{break_hours}}/{{allocation.breakHours or 0}} Hours

+
+
+
+ +
+
-{% endblock %} +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/database/demo_data.py b/database/demo_data.py index de6047624..3bce122a8 100644 --- a/database/demo_data.py +++ b/database/demo_data.py @@ -19,6 +19,10 @@ from app.models.supervisorDepartment import SupervisorDepartment from app.models.allocation import Allocation from app.models.positionHistory import PositionHistory +from app.models.laborReleaseForm import LaborReleaseForm +from app.models.supervisorDepartment import SupervisorDepartment +from app.models.allocation import Allocation +from app.models.positionHistory import PositionHistory print("Inserting data for demo and testing purposes") @@ -41,7 +45,36 @@ "LAST_POSN":"Media Technician", "LAST_SUP_PIDM":"7" }, - + { + "ID":"B00741361", + "PIDM":"99", + "FIRST_NAME":"Antonia", + "LAST_NAME":"Schmith", + "CLASS_LEVEL":"Freshman", + "ACADEMIC_FOCUS":"Computer Science", + "MAJOR":"Computer Science", + "PROBATION":"0", + "ADVISOR":"Scott Heggen", + "STU_EMAIL":"schmitha@berea.edu", + "STU_CPO":"777", + "LAST_POSN":"TA", + "LAST_SUP_PIDM":"7" + }, + { + "ID":"B00732363", + "PIDM":"58", + "FIRST_NAME":"Barbara", + "LAST_NAME":"Williams", + "CLASS_LEVEL":"Junior", + "ACADEMIC_FOCUS":"Computer Science", + "MAJOR":"Computer Science", + "PROBATION":"0", + "ADVISOR":"Jasmine Jones", + "STU_EMAIL":"williamsb@berea.edu", + "STU_CPO":"118", + "LAST_POSN":"TA", + "LAST_SUP_PIDM":"7" + }, { "ID":"B00730361", "PIDM":"1", @@ -104,6 +137,12 @@ "LAST_POSN":"Student Manager", "LAST_SUP_PIDM":"7" }, + {"ID": "B00811617", "legal_name": "Chris Georgiev", "isActive": True, "PIDM": "8", "FIRST_NAME": "Chris", "LAST_NAME": "Georgiev"}, + {"ID": "B00815474", "legal_name": "Julius Fritz", "isActive": True, "PIDM": "9", "FIRST_NAME": "Julius", "LAST_NAME": "Fritz"}, + {"ID": "B12345223", "legal_name": "Subaru Natsuki", "isActive": True, "PIDM": "10", "FIRST_NAME": "Subaru", "LAST_NAME": "Natsuki"}, + {"ID": "B12345003", "legal_name": "Hatsune Miku", "isActive": True, "PIDM": "11", "FIRST_NAME": "Hatsune", "LAST_NAME": "Miku"}, + {"ID": "B12345772", "legal_name": "Michael Jackson", "isActive": True, "PIDM": "12", "FIRST_NAME": "Michael", "LAST_NAME": "Jackson"}, + {"ID": "B12345756", "legal_name": "Genji Overwatch", "isActive": True, "PIDM": "13", "FIRST_NAME": "Genji", "LAST_NAME": "Overwatch"} ] tracyStudents = [ { @@ -411,6 +450,22 @@ "isSaasAdmin": None }, { + "student": "B00741361", + "supervisor": None, + "username": "schmitha", + "isLaborAdmin": None, + "isFinancialAidAdmin": None, + "isSaasAdmin": None + }, + { + "student": "B00732363", + "supervisor": None, + "username": "williamsb", + "isLaborAdmin": None, + "isFinancialAidAdmin": None, + "isSaasAdmin": None + }, + { "student": "B00730361", "supervisor": None, "username": "jamalie", @@ -567,6 +622,124 @@ "createdDate": f"2025-04-14", "status_id": "Pending" }]).on_conflict_replace().execute() +LaborStatusForm.insert([{ + "laborStatusFormID": 11, + "termCode_id": f"202500", + "studentName": "Antonia Schmith", + "studentSupervisee_id": "B00741361", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Primary", + "WLS": 1, + "POSN_TITLE": "Student Programmer", + "POSN_CODE": "S61407", + "weeklyHours": 10, + "startDate": f"2026-04-01", + "endDate": f"2026-09-01", + "studentConfirmation": True + }]).on_conflict_replace().execute() + +FormHistory.insert([{ + "formHistoryID": 11, + "formID_id": "11", + "historyType_id": "Labor Status Form", + "createdBy_id": 1, + "createdDate": f"2025-04-14", + "status": "Approved" + }]).on_conflict_replace().execute() + +LaborStatusForm.insert([{ + "laborStatusFormID": 12, + "termCode_id": f"202500", + "studentName": "Barbara Williams", + "studentSupervisee_id": "B00732363", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Primary", + "WLS": 1, + "POSN_TITLE": "Student Programmer", + "POSN_CODE": "S61407", + "weeklyHours": 10, + "startDate": f"2027-04-01", + "endDate": f"2029-09-01", + "studentConfirmation": True + }]).on_conflict_replace().execute() + +FormHistory.insert([{ + "formHistoryID": 12, + "formID_id": "12", + "historyType_id": "Labor Status Form", + "createdBy_id": 1, + "createdDate": f"2025-04-14", + "status": "Approved" + }]).on_conflict_replace().execute() + +LaborReleaseForm.insert([{ + "laborReleaseFormID": 10, + "conditionAtRelease": "unsatisfactory", + "releaseDate": f"2025-04-14", + "reasonForRelease": "Smoking Cigarettes in the Programmers' space." + }]).on_conflict_replace().execute() + +FormHistory.insert([{ + "formHistoryID": 13, + "formID_id": "12", + "historyType_id": "Labor Release Form", + "releaseForm": 10, + "createdBy_id": 1, + "createdDate": f"2025-04-14", + "status": "Approved" + }]).on_conflict_replace().execute() + +LaborStatusForm.insert([{ + "laborStatusFormID": 4, + "termCode_id": f"202500", + "studentName": "Elaleh Jamali", + "studentSupervisee_id": "B00730361", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Secondary", + "WLS": 1, + "POSN_TITLE": "Labor Workers", + "POSN_CODE": "S61419", + "weeklyHours": 10, + "startDate": f"2027-04-01", + "endDate": "2027-09-01" + }]).on_conflict_replace().execute() + +FormHistory.insert([{ + "formHistoryID": 4, + "formID_id": "4", + "historyType_id": "Labor Status Form", + "createdBy_id": 1, + "createdDate": f"2025-04-14", + "status": "Approved" + }]).on_conflict_replace().execute() + +LaborStatusForm.insert([{ + "laborStatusFormID": 5, + "termCode_id": f"202500", + "studentName": "Oluwagbayi Makinde", + "studentSupervisee_id": "B00791326", + "supervisor_id": "B12365892", + "department_id": 1, + "jobType": "Primary", + "WLS": 1, + "POSN_TITLE": "Labor Workers", + "POSN_CODE": "S61429", + "weeklyHours": 10, + "startDate": f"2025-04-01", + "endDate": "2029-09-01" + }]).on_conflict_replace().execute() + +FormHistory.insert([{ + "formHistoryID": 5, + "formID_id": "5", + "historyType_id": "Labor Status Form", + "createdBy_id": 1, + "createdDate": f"2025-04-14", + "status": "Approved" + }]).on_conflict_replace().execute() LaborStatusForm.insert([{ "laborStatusFormID": 3, @@ -592,6 +765,23 @@ "createdDate": f"2025-04-14", "status_id": "Approved" }]).on_conflict_replace().execute() +LaborStatusForm.insert([{ + + "laborStatusFormID": 9, + "termCode_id": f"202500", + "studentName": "Genji Overwatch", + "studentSupervisee_id": "B12345756", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Primary", + "WLS": 1, + "POSN_TITLE": "overwtahc guy", + "POSN_CODE": "S61410", + "contractHours": 15, + "startDate": f"2025-04-01", + "endDate": "2025-09-01" + + }]).on_conflict_replace().execute() @@ -796,4 +986,325 @@ ] PositionHistory.insert_many(positionHistory).on_conflict_replace().execute() -print(" * position history added") \ No newline at end of file +print(" * position history added") + +############################ +# Allocation Dummy Data: +########################### +allocations = [ + { + "termCode": 202500, + "department": 3, + "isFinal": False, + "approvedOn": None, + "approvedBy": None, + "justification": "Downscaling due to decrease in student enrollment caused by current economic conditions", + "primary_10": 2, + "primary_12": 2, + "primary_15": 1, + "primary_20": 0, + "secondary_5": 1, + "secondary_10": 0, + "breakHours": 260, + }, + { + "termCode": 202500, + "department": 2, + "isFinal": False, + "approvedOn": None, + "approvedBy": None, + "justification": "Increase in student enrollment due to exodous from CS department", + "primary_10": 4, + "primary_12": 2, + "primary_15": 7, + "primary_20": 4, + "secondary_5": 2, + "secondary_10": 0, + "breakHours": 750, + }, + { + "termCode": 202500, + "department": 1, + "isFinal": False, + "approvedOn": None, + "approvedBy": None, + "justification": "We are hiring more students to help with the increased workload in the department", + "primary_10": 5, + "primary_12": 6, + "primary_15": 4, + "primary_20": 1, + "secondary_5": 7, + "secondary_10": 0, + "breakHours": 550, + }, + { + "termCode": 202500, + "department": 4, + "isFinal": False, + "approvedOn": None, + "approvedBy": None, + "justification": "Downscaling the number of students in the department due to budget cuts", + "primary_10": 4, + "primary_12": 5, + "primary_15": 0, + "primary_20": 0, + "secondary_5": 1, + "secondary_10": 0, + "breakHours": 300, + }, + { + "termCode": 202500, + "department": 5, + "isFinal": False, + "approvedOn": None, + "approvedBy": None, + "justification": "Due to rapid department growth, we need to hire more students to help with the increased workload", + "primary_10": 8, + "primary_12": 10, + "primary_15": 7, + "primary_20": 4, + "secondary_5": 5, + "secondary_10": 1, + "breakHours": 900, + }, + + ] +Allocation.insert_many(allocations).on_conflict_replace().execute() + +print("Data insertion complete :)") +allocation =[ + { + "termCode":f"{2025}00", + "department": 3, + "isFinal": True, + "approvedOn": f"{2025}-06-30", + "approvedBy": "B12365892", + "justification": "We just want it for fun", + "primary_10": 2, + "primary_12": 3, + "primary_15": 1, + "primary_20": 6, + "secondary_5": 2, + "secondary_10": 0, + "breakHours": 500 + }, + { + "termCode":f"{2025}00", + "department": 2, + "isFinal": False, + "approvedOn": f"{2025}-06-20", + "approvedBy": "B00763721", + "justification": "We need it to lower the amount of allocations we have", + "primary_10": 1, + "primary_12": 2, + "primary_15": 5, + "primary_20": 2, + "secondary_5": 10, + "secondary_10": 0, + "breakHours": 1500 + } + ] +Allocation.insert_many(allocation).on_conflict_replace().execute() +print(" * allocation added") + + +############################# +# Position History +############################# + +positionHistory = [ + { + "positionTitle": "Student Programmer", + "positionCode": "S61407", + "status": "Active", + "wls": 1, + "revisionDate": f"2026-07-01", + "description": "", + "department": 1 + }, + { + "positionTitle": "Research Associate", + "positionCode": "S61408", + "status": "Active", + "wls": 2, + "revisionDate": f"2026-09-01", + "description": "", + "department": 1 + }, + { + "positionTitle": "Labor Workers", + "positionCode": "S61409", + "status": "Active", + "wls": 3, + "revisionDate": f"2026-07-01", + "description": "", + "department": 1 + }, + { + "positionTitle": "Teaching Associate", + "positionCode": "S61411", + "status": "Active", + "wls":3, + "revisionDate" : f"2026-01-01", + "description": "", + "department" : 1 + + }, + { + "positionTitle": "Teaching Associate", + "positionCode": "S61410", + "status": "Inactive", + "wls":2, + "revisionDate" : f"2026-01-01", + "description": "", + "department" : 3 + }, + { + "positionTitle": "Teaching Associate", + "positionCode": "S61410", + "status": "Active", + "wls":2, + "revisionDate" : f"2026-03-29", + "description": "", + "department" : 3 + }, + { + "positionTitle": "DUMMY POSITION", + "positionCode": "S12345", + "status": "Active", + "wls":3, + "revisionDate" : f"2026-01-23", + "description": "", + "department" : 1 + }, + { + "positionTitle": "Junior Data Analyst", + "positionCode": "S39568", + "status": "Active", + "wls":4, + "revisionDate" : f"2026-01-31", + "description": "", + "department" : 1 + }, + { + "positionTitle": "Student Manager", + "positionCode": "S74933", + "status": "Active", + "wls":5, + "revisionDate" : f"2026-04-01", + "description": "", + "department" : 1 + }, + { + "positionTitle": "IT Technician", + "positionCode": "S94932", + "status": "Active", + "wls":6, + "revisionDate" : f"2026-05-03", + "description": "", + "department" : 1 + }, + { + "positionTitle": "Human code generator", + "positionCode": "S22222", + "status": "Active", + "wls":1, + "revisionDate" : f"2026-05-03", + "description": "", + "department" : 1 + }, + { + "positionTitle": "Senior Software Engineer", + "positionCode": "S00000", + "status": "Active", + "wls":6, + "revisionDate" : f"2026-05-03", + "description": "", + "department" : 1 + } + + + +] +PositionHistory.insert_many(positionHistory).on_conflict_replace().execute() + +dummy_lsf = [ + { + "laborStatusFormID": 4, + "termCode_id": f"202500", + "studentName": "Chris Georgiev", + "studentSupervisee_id": "B00811617", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Primary", + "WLS": 4, + "POSN_TITLE": "guy who does stuff", + "POSN_CODE": "S61415", + "weeklyHours": 12, + "startDate": f"2025-04-01", + "endDate": "2025-09-01" + }, + { + + "laborStatusFormID": 5, + "termCode_id": f"202500", + "studentName": "Julius Fritz", + "studentSupervisee_id": "B00815474", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Primary", + "WLS": 2, + "POSN_TITLE": "guy who sits in chair", + "POSN_CODE": "S61416", + "weeklyHours": 15, + "startDate": f"2025-04-01", + "endDate": "2025-09-01" + }, + { + "laborStatusFormID": 6, + "termCode_id": f"202500", + "studentName": "Subaru Natsuki", + "studentSupervisee_id": "B12345223", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Primary", + "WLS": 1, + "POSN_TITLE": "Aura Monster", + "POSN_CODE": "S61417", + "weeklyHours": 20, + "startDate": f"2025-04-01", + "endDate": "2025-09-01" + }, + { + "laborStatusFormID": 7, + "termCode_id": f"202500", + "studentName": "Hatsune Miku", + "studentSupervisee_id": "B12345003", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Primary", + "WLS": 6, + "POSN_TITLE": "Singer", + "POSN_CODE": "S61409", + "weeklyHours": 20, + "startDate": f"2025-04-01", + "endDate": "2025-09-01" + + }, + { + "laborStatusFormID": 8, + "termCode_id": f"202500", + "studentName": "Michael Jackson", + "studentSupervisee_id": "B12345772", + "supervisor_id": "B12361006", + "department_id": 1, + "jobType": "Secondary", + "WLS": 6, + "POSN_TITLE": "Famous singer", + "POSN_CODE": "S61410", + "weeklyHours": 5, + "startDate": f"2025-04-01", + "endDate": "2025-09-01" + } +] +LaborStatusForm.insert_many(dummy_lsf).on_conflict_replace().execute() \ No newline at end of file From 56d5a2971ea6a3d93be4341c5e1096ecfac94091 Mon Sep 17 00:00:00 2001 From: conwelld Date: Tue, 21 Jul 2026 11:28:11 -0400 Subject: [PATCH 02/20] fixed some pr comments --- app/controllers/main_routes/main_routes.py | 30 +++------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index de9a189c3..45a791318 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -101,9 +101,9 @@ def departmentPortal(org=None,account=None): else: supervisors.append(supervisorDisplay) - totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == 202500).scalar() - usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(True))]) - studentHours = {} + totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == 202500).scalar() #grabs the total positions that can be fufilled by contracts + usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(True))]) #grabs the total amount of contracts fufilled from totalPositions + studentHours = {} #Group each active LSF (job type + weekly hours) by student, so we can show all of a student's jobs together for form in LaborStatusForm.select().where(LaborStatusForm.department == dept,LaborStatusForm.termCode_id == 202500,LaborStatusForm.contractHours.is_null(True) ): studentSuperviseeId = form.studentSupervisee_id @@ -128,16 +128,7 @@ def count_workers(job_type, hours_bucket): } break_allocation = LaborStatusForm.select(LaborStatusForm.contractHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(False)) sumBreak = sum(form.contractHours or 0 for form in break_allocation) - positions = list(PositionHistory.select().where(PositionHistory.department == dept, PositionHistory.status == "Active").order_by(PositionHistory.positionTitle.asc())) if dept else [] - positionsList = [] - posUrl = [] - if not positions: - positionsList = ["No active positions in this department"] - else: - for i in positions: - positionsList.append(i.positionTitle + ": " + "(WLS " + str(i.wls) + ")") - posUrl.append(str(i.positionCode)) - + return render_template('main/departmentPortal.html', departments = departments, department = dept, @@ -154,20 +145,7 @@ def count_workers(job_type, hours_bucket): laborCoordinators=laborCoordinators, currentUser=g.currentUser ) -@main_bp.route('/department///managepositions', methods=['GET']) -def managePositions(org, account): - try: - dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) - except DoesNotExist: - return render_template('errors/404.html'), 404 - positions = Tracy().getPositionsFromDepartment(org, account) - print(positions) - return render_template('main/managepositions.html', - department = dept, - department_name = dept.DEPT_NAME, - positions = positions - ) @main_bp.route('/supervisorPortal/addUserToDept', methods=['GET', 'POST']) def addUserToDept(): userDeptData = request.form From faf19578132b3b969d583813ac8929a1ced636cf Mon Sep 17 00:00:00 2001 From: conwelld Date: Tue, 21 Jul 2026 11:34:27 -0400 Subject: [PATCH 03/20] fixed some pr comments for css styling --- app/controllers/main_routes/main_routes.py | 24 ++++++++++++++-------- app/static/css/departmentPortal.css | 4 ++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 45a791318..15ef9faf7 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -101,9 +101,9 @@ def departmentPortal(org=None,account=None): else: supervisors.append(supervisorDisplay) - totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == 202500).scalar() #grabs the total positions that can be fufilled by contracts - usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(True))]) #grabs the total amount of contracts fufilled from totalPositions - studentHours = {} #Group each active LSF (job type + weekly hours) by student, so we can show all of a student's jobs together + totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == 202500).scalar() # Total allocated positions for this department/term, summed across all hour buckets + usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(True))]) # Count how many of those positions are currently filled (excludes contract/break-hour forms) + studentHours = {} # Group each active LSF (job type + weekly hours) by student, so we can show all of a student's jobs together for form in LaborStatusForm.select().where(LaborStatusForm.department == dept,LaborStatusForm.termCode_id == 202500,LaborStatusForm.contractHours.is_null(True) ): studentSuperviseeId = form.studentSupervisee_id @@ -139,13 +139,21 @@ def count_workers(job_type, hours_bucket): studentHours = studentHours, usedPositions = usedPositions, break_hours = sumBreak, - positions = positionsList, - posUrl = posUrl, - supervisors = supervisors, - laborCoordinators=laborCoordinators, - currentUser=g.currentUser ) +@main_bp.route('/department///managepositions', methods=['GET']) +def managePositions(org, account): + try: + dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) + except DoesNotExist: + return render_template('errors/404.html'), 404 + positions = Tracy().getPositionsFromDepartment(org, account) + print(positions) + return render_template('main/managepositions.html', + department = dept, + department_name = dept.DEPT_NAME, + positions = positions + ) @main_bp.route('/supervisorPortal/addUserToDept', methods=['GET', 'POST']) def addUserToDept(): userDeptData = request.form diff --git a/app/static/css/departmentPortal.css b/app/static/css/departmentPortal.css index 6cccf556a..9937e0a99 100644 --- a/app/static/css/departmentPortal.css +++ b/app/static/css/departmentPortal.css @@ -42,11 +42,11 @@ } .primary-chart { font-size: 1.2em; - padding-left: 15%; + padding-left: 7%; } .secondary-chart { font-size: 1.2em; - padding-right: 15%; + padding-right: 7%; } .bi-people-fill { /* Bootstrap Icon for Members Card */ From 120426913fcf4e951b3b545c3fcd05c86da32522 Mon Sep 17 00:00:00 2001 From: conwelld Date: Tue, 21 Jul 2026 11:43:35 -0400 Subject: [PATCH 04/20] fixed useless code --- app/controllers/main_routes/main_routes.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 15ef9faf7..5d8d60b52 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -103,18 +103,7 @@ def departmentPortal(org=None,account=None): totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == 202500).scalar() # Total allocated positions for this department/term, summed across all hour buckets usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(True))]) # Count how many of those positions are currently filled (excludes contract/break-hour forms) - studentHours = {} # Group each active LSF (job type + weekly hours) by student, so we can show all of a student's jobs together - for form in LaborStatusForm.select().where(LaborStatusForm.department == dept,LaborStatusForm.termCode_id == 202500,LaborStatusForm.contractHours.is_null(True) - ): - studentSuperviseeId = form.studentSupervisee_id - if studentSuperviseeId not in studentHours: - studentHours[studentSuperviseeId] = [] - studentHours[studentSuperviseeId].append({ - "jobType": form.jobType, - "weeklyHours": form.weeklyHours - }) - - + def count_workers(job_type, hours_bucket): return LaborStatusForm.select().where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.jobType == job_type, LaborStatusForm.weeklyHours == hours_bucket, LaborStatusForm.contractHours.is_null(True)).count() @@ -136,7 +125,6 @@ def count_workers(job_type, hours_bucket): total_allocation = totalPositions, used_allocation = usedAllocation, term = g.openTerm.termName, - studentHours = studentHours, usedPositions = usedPositions, break_hours = sumBreak, ) From 0f166c268ab2d36f1bf369a5b11ecda37458d8e1 Mon Sep 17 00:00:00 2001 From: conwelld Date: Tue, 21 Jul 2026 12:01:04 -0400 Subject: [PATCH 05/20] fixed department code --- app/controllers/main_routes/main_routes.py | 27 ---------------------- 1 file changed, 27 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 5d8d60b52..340be409f 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -74,33 +74,6 @@ def departmentPortal(org=None,account=None): except DoesNotExist: allocation = None - supervisorDepartments = (SupervisorDepartment.select().join(Supervisor).where(SupervisorDepartment.department == dept) - .order_by(fn.COALESCE(Supervisor.preferred_name, Supervisor.legal_name, Supervisor.LAST_NAME).asc())) - - laborCoordinators = [] - supervisors = [] - - for supervisorDepartment in supervisorDepartments: - supervisor = supervisorDepartment.supervisor - - if supervisor is None: - continue - - firstName = supervisor.preferred_name or supervisor.legal_name or "" - lastName = supervisor.LAST_NAME or "" - - supervisorName = f"{firstName} {lastName}".strip() - - supervisorDisplay = { - "name": supervisorName, - "email": supervisor.EMAIL - } - - if supervisorDepartment.isCoordinator: - laborCoordinators.append(supervisorDisplay) - else: - supervisors.append(supervisorDisplay) - totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == 202500).scalar() # Total allocated positions for this department/term, summed across all hour buckets usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(True))]) # Count how many of those positions are currently filled (excludes contract/break-hour forms) From 6f5850a121c0c2bbea42505c12abd5e8630aadbb Mon Sep 17 00:00:00 2001 From: rukwashai <{rukwashai}@berea.edu> Date: Wed, 22 Jul 2026 10:14:03 -0400 Subject: [PATCH 06/20] We have change the hard coded part with variable able to be flexible everything touching the 202500 --- app/controllers/main_routes/main_routes.py | 15 +++++++++------ app/templates/main/departmentPortal.html | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 340be409f..e457970e2 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -55,6 +55,9 @@ def supervisorPortal(): @main_bp.route('/department/', methods=['GET']) @main_bp.route('/department//', methods=['GET']) def departmentPortal(org=None,account=None): + open_term = g.openTerm + term_code = open_term.termCode + if org and account: try: dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) @@ -70,15 +73,15 @@ def departmentPortal(org=None,account=None): else: departments = list(getDepartmentsForSupervisor(g.currentUser).order_by(Department.isActive.desc(), Department.DEPT_NAME.asc())) try: - allocation = Allocation.select(Allocation, Term).join(Term).where(Allocation.department == dept, Allocation.termCode == 202500).get() + allocation = Allocation.select(Allocation, Term).join(Term).where(Allocation.department == dept, Allocation.termCode == term_code).get() except DoesNotExist: allocation = None - totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == 202500).scalar() # Total allocated positions for this department/term, summed across all hour buckets - usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(True))]) # Count how many of those positions are currently filled (excludes contract/break-hour forms) + totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == term_code).scalar() # Total allocated positions for this department/term, summed across all hour buckets + usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == term_code, LaborStatusForm.contractHours.is_null(True))]) # Count how many of those positions are currently filled (excludes contract/break-hour forms) def count_workers(job_type, hours_bucket): - return LaborStatusForm.select().where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.jobType == job_type, LaborStatusForm.weeklyHours == hours_bucket, LaborStatusForm.contractHours.is_null(True)).count() + return LaborStatusForm.select().where(LaborStatusForm.department == dept, LaborStatusForm.termCode == term_code, LaborStatusForm.jobType == job_type, LaborStatusForm.weeklyHours == hours_bucket, LaborStatusForm.contractHours.is_null(True)).count() usedPositions = { "used_10": count_workers("Primary", "10"), @@ -88,7 +91,7 @@ def count_workers(job_type, hours_bucket): "used_5_sec": count_workers("Secondary", "5"), "used_10_sec": count_workers("Secondary", "10"), } - break_allocation = LaborStatusForm.select(LaborStatusForm.contractHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == 202500, LaborStatusForm.contractHours.is_null(False)) + break_allocation = LaborStatusForm.select(LaborStatusForm.contractHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == term_code, LaborStatusForm.contractHours.is_null(False)) sumBreak = sum(form.contractHours or 0 for form in break_allocation) return render_template('main/departmentPortal.html', @@ -97,7 +100,7 @@ def count_workers(job_type, hours_bucket): allocation = allocation, total_allocation = totalPositions, used_allocation = usedAllocation, - term = g.openTerm.termName, + term = open_term, usedPositions = usedPositions, break_hours = sumBreak, ) diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index b51354f0e..6d17feae5 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -40,7 +40,7 @@

{% if department %} {{department.DEPT_NAME}} Portal {% e

Allocations

-

AY 2025-2026

{{used_allocation}}/{{total_allocation or 0}} Positions

+

{{ term.termName }}

{{used_allocation}}/{{total_allocation or 0}} Positions

    @@ -67,4 +67,4 @@

    Break Hours {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} From 813f398c440e34acf15c96f79cc3918cc2e671f2 Mon Sep 17 00:00:00 2001 From: rukwashai <{rukwashai}@berea.edu> Date: Wed, 22 Jul 2026 13:52:37 -0400 Subject: [PATCH 07/20] Temporary allocation logic will be replaced by the official shared service. See #657 but beyond that we created allo files --- app/controllers/main_routes/main_routes.py | 28 +++------ app/logic/allocation_utilization.py | 71 ++++++++++++++++++++++ database/reset_database.sh | 2 - 3 files changed, 78 insertions(+), 23 deletions(-) create mode 100644 app/logic/allocation_utilization.py diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index e457970e2..ca45fa7c7 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, fn +from peewee import JOIN, DoesNotExist from flask_bootstrap import forms from functools import reduce import operator @@ -17,6 +17,7 @@ from app.login_manager import require_login, logout from app.logic.getTableData import getDatatableData from app.logic.banner import Banner +from app.logic.allocation_utilization import get_department_allocation_summary from app.models.allocation import Allocation from app.logic.tracy import Tracy from app.models.positionHistory import PositionHistory @@ -77,32 +78,17 @@ def departmentPortal(org=None,account=None): except DoesNotExist: allocation = None - totalPositions = Allocation.select(fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) + fn.SUM(Allocation.primary_15) + fn.SUM(Allocation.primary_20) + fn.sum(Allocation.secondary_5) + fn.SUM(Allocation.secondary_10)).where(Allocation.department == dept, Allocation.termCode == term_code).scalar() # Total allocated positions for this department/term, summed across all hour buckets - usedAllocation = len([hours for hours in LaborStatusForm.select(LaborStatusForm.weeklyHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == term_code, LaborStatusForm.contractHours.is_null(True))]) # Count how many of those positions are currently filled (excludes contract/break-hour forms) - - def count_workers(job_type, hours_bucket): - return LaborStatusForm.select().where(LaborStatusForm.department == dept, LaborStatusForm.termCode == term_code, LaborStatusForm.jobType == job_type, LaborStatusForm.weeklyHours == hours_bucket, LaborStatusForm.contractHours.is_null(True)).count() - - usedPositions = { - "used_10": count_workers("Primary", "10"), - "used_12": count_workers("Primary", "12"), - "used_15": count_workers("Primary", "15"), - "used_20": count_workers("Primary", "20"), - "used_5_sec": count_workers("Secondary", "5"), - "used_10_sec": count_workers("Secondary", "10"), -} - break_allocation = LaborStatusForm.select(LaborStatusForm.contractHours).where(LaborStatusForm.department == dept, LaborStatusForm.termCode == term_code, LaborStatusForm.contractHours.is_null(False)) - sumBreak = sum(form.contractHours or 0 for form in break_allocation) + allocation_summary = get_department_allocation_summary(dept, term_code) return render_template('main/departmentPortal.html', departments = departments, department = dept, allocation = allocation, - total_allocation = totalPositions, - used_allocation = usedAllocation, + total_allocation = allocation_summary["total_positions"], + used_allocation = allocation_summary["used_allocation"], term = open_term, - usedPositions = usedPositions, - break_hours = sumBreak, + usedPositions = allocation_summary["used_positions"], + break_hours = allocation_summary["break_hours"], ) @main_bp.route('/department///managepositions', methods=['GET']) def managePositions(org, account): diff --git a/app/logic/allocation_utilization.py b/app/logic/allocation_utilization.py new file mode 100644 index 000000000..77df38e73 --- /dev/null +++ b/app/logic/allocation_utilization.py @@ -0,0 +1,71 @@ +from peewee import fn + +from app.models.allocation import Allocation +from app.models.laborStatusForm import LaborStatusForm + + +def get_department_allocation_summary(department, term_code): + """Return allocation-utilization values for one department and term.""" + total_positions = ( + Allocation.select( + fn.SUM(Allocation.primary_10) + + fn.SUM(Allocation.primary_12) + + fn.SUM(Allocation.primary_15) + + fn.SUM(Allocation.primary_20) + + fn.SUM(Allocation.secondary_5) + + fn.SUM(Allocation.secondary_10) + ) + .where( + Allocation.department == department, + Allocation.termCode == term_code, + ) + .scalar() + ) + + used_allocation = ( + LaborStatusForm.select() + .where( + LaborStatusForm.department == department, + LaborStatusForm.termCode == term_code, + LaborStatusForm.contractHours.is_null(True), + ) + .count() + ) + + def count_workers(job_type, hours_bucket): + return ( + LaborStatusForm.select() + .where( + LaborStatusForm.department == department, + LaborStatusForm.termCode == term_code, + LaborStatusForm.jobType == job_type, + LaborStatusForm.weeklyHours == hours_bucket, + LaborStatusForm.contractHours.is_null(True), + ) + .count() + ) + + used_positions = { + "used_10": count_workers("Primary", 10), + "used_12": count_workers("Primary", 12), + "used_15": count_workers("Primary", 15), + "used_20": count_workers("Primary", 20), + "used_5_sec": count_workers("Secondary", 5), + "used_10_sec": count_workers("Secondary", 10), + } + + break_hours = sum( + form.contractHours or 0 + for form in LaborStatusForm.select(LaborStatusForm.contractHours).where( + LaborStatusForm.department == department, + LaborStatusForm.termCode == term_code, + LaborStatusForm.contractHours.is_null(False), + ) + ) + + return { + "total_positions": total_positions or 0, + "used_allocation": used_allocation, + "used_positions": used_positions, + "break_hours": break_hours, + } diff --git a/database/reset_database.sh b/database/reset_database.sh index 82f6cff53..ba87204db 100755 --- a/database/reset_database.sh +++ b/database/reset_database.sh @@ -29,8 +29,6 @@ echo "Recreating databases and users" mysql -u root -proot --execute="CREATE DATABASE IF NOT EXISTS \`lsf\`; CREATE USER IF NOT EXISTS 'lsf_user'@'%' IDENTIFIED BY 'password'; GRANT ALL PRIVILEGES ON *.* TO 'lsf_user'@'%';" mysql -u root -proot --execute="CREATE DATABASE IF NOT EXISTS \`UTE\`; CREATE USER IF NOT EXISTS 'tracy_user'@'%' IDENTIFIED BY 'password'; GRANT ALL PRIVILEGES ON *.* TO 'tracy_user'@'%';" -cd database - rm -rf lsf_migrations rm -rf tracy_migrations rm -rf migrations.json From 7584ffa12f6bfccb3a4d2fb762c0d6c527ed453e Mon Sep 17 00:00:00 2001 From: rukwashai Date: Mon, 27 Jul 2026 11:59:42 -0400 Subject: [PATCH 08/20] fix the review comment from Minran, all of them --- app/controllers/main_routes/main_routes.py | 44 ++++++++----------- ...tilization.py => allocationUtilization.py} | 32 ++++++++++++-- app/templates/main/departmentPortal.html | 4 +- database/base_data.py | 5 +++ database/demo_data.py | 5 +++ database/migrate_db.sh | 2 + database/migrate_db_tracy.sh | 2 + 7 files changed, 63 insertions(+), 31 deletions(-) rename app/logic/{allocation_utilization.py => allocationUtilization.py} (68%) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index ca45fa7c7..60ce9b65e 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -1,6 +1,5 @@ from flask import render_template, request, json, redirect, url_for, send_file, g, flash, jsonify from peewee import JOIN, DoesNotExist -from flask_bootstrap import forms from functools import reduce import operator from app.models.department import Department @@ -17,9 +16,8 @@ from app.login_manager import require_login, logout from app.logic.getTableData import getDatatableData from app.logic.banner import Banner -from app.logic.allocation_utilization import get_department_allocation_summary +from app.logic.allocationUtilization import getDepartmentAllocationSummary from app.models.allocation import Allocation -from app.logic.tracy import Tracy from app.models.positionHistory import PositionHistory @main_bp.route('/logout', methods=['GET']) @@ -56,37 +54,33 @@ def supervisorPortal(): @main_bp.route('/department/', methods=['GET']) @main_bp.route('/department//', methods=['GET']) def departmentPortal(org=None,account=None): - open_term = g.openTerm - term_code = open_term.termCode - - if org and account: - try: - dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) - except (NameError, DoesNotExist): - dept = None - else: + try: + dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) + except (NameError, DoesNotExist): dept = None - - - if g.currentUser.isLaborAdmin: departments = list(Department.select().order_by(Department.isActive.desc(), Department.DEPT_NAME.asc())) else: departments = list(getDepartmentsForSupervisor(g.currentUser).order_by(Department.isActive.desc(), Department.DEPT_NAME.asc())) - try: - allocation = Allocation.select(Allocation, Term).join(Term).where(Allocation.department == dept, Allocation.termCode == term_code).get() - except DoesNotExist: + + allocation_summary = getDepartmentAllocationSummary(dept) + recentTerm = allocation_summary["term"] + + if recentTerm: + try: + allocation = Allocation.select(Allocation, Term).join(Term).where(Allocation.department == dept, Allocation.termCode == recentTerm.termCode).get() + except DoesNotExist: + allocation = None + else: allocation = None - - allocation_summary = get_department_allocation_summary(dept, term_code) - - return render_template('main/departmentPortal.html', + + return render_template('main/departmentPortal.html', departments = departments, department = dept, allocation = allocation, - total_allocation = allocation_summary["total_positions"], - used_allocation = allocation_summary["used_allocation"], - term = open_term, + allocated = allocation_summary["allocated"], + used = allocation_summary["used"], + term = recentTerm, usedPositions = allocation_summary["used_positions"], break_hours = allocation_summary["break_hours"], ) diff --git a/app/logic/allocation_utilization.py b/app/logic/allocationUtilization.py similarity index 68% rename from app/logic/allocation_utilization.py rename to app/logic/allocationUtilization.py index 77df38e73..5fc14abfc 100644 --- a/app/logic/allocation_utilization.py +++ b/app/logic/allocationUtilization.py @@ -2,10 +2,33 @@ from app.models.allocation import Allocation from app.models.laborStatusForm import LaborStatusForm +from app.models.term import Term -def get_department_allocation_summary(department, term_code): - """Return allocation-utilization values for one department and term.""" +def getDepartmentAllocationSummary(department): + """Return allocation-utilization values for a department's most recent term.""" + departmentAllocations = list( + Allocation.select(Allocation, Term).join(Term).where(Allocation.department == department) + ) + if not departmentAllocations: + return { + "term": None, + "allocated": 0, + "used": 0, + "used_positions": { + "used_10": 0, + "used_12": 0, + "used_15": 0, + "used_20": 0, + "used_5_sec": 0, + "used_10_sec": 0, + }, + "break_hours": 0, + } + + recentTerm = Term.order_by_term([a.termCode for a in departmentAllocations], reverse=True)[0] + term_code = recentTerm.termCode + total_positions = ( Allocation.select( fn.SUM(Allocation.primary_10) @@ -64,8 +87,9 @@ def count_workers(job_type, hours_bucket): ) return { - "total_positions": total_positions or 0, - "used_allocation": used_allocation, + "term": recentTerm, + "allocated": total_positions or 0, + "used": used_allocation, "used_positions": used_positions, "break_hours": break_hours, } diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index 6d17feae5..0fa624887 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -37,10 +37,10 @@

    {% if department %} {{department.DEPT_NAME}} Portal {% e

-

Allocations

+

Allocations

-

{{ term.termName }}

{{used_allocation}}/{{total_allocation or 0}} Positions

+

AY 2025-2026

{{used}}/{{allocated or 0}} Positions

    diff --git a/database/base_data.py b/database/base_data.py index c081fb752..f42d57ce9 100644 --- a/database/base_data.py +++ b/database/base_data.py @@ -1,3 +1,8 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + from app.models.status import Status from app.models.historyType import HistoryType from app.models.emailTemplate import EmailTemplate diff --git a/database/demo_data.py b/database/demo_data.py index 3bce122a8..d231963ab 100644 --- a/database/demo_data.py +++ b/database/demo_data.py @@ -2,6 +2,11 @@ Chech phpmyadmin to see if your changes are reflected This file will need to be changed if the format of models changes (new fields, dropping fields, renaming...)''' +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + from app import app from app.models.Tracy import db diff --git a/database/migrate_db.sh b/database/migrate_db.sh index dea226d8e..c693c7086 100755 --- a/database/migrate_db.sh +++ b/database/migrate_db.sh @@ -1,4 +1,6 @@ +export PYTHONPATH="$(cd "$(dirname "$0")/.." && pwd):$PYTHONPATH" + pem init # See: https://stackoverflow.com/questions/394230/how-to-detect-the-os-from-a-bash-script/18434831 diff --git a/database/migrate_db_tracy.sh b/database/migrate_db_tracy.sh index 0b5466dc8..fabca9d30 100755 --- a/database/migrate_db_tracy.sh +++ b/database/migrate_db_tracy.sh @@ -1,4 +1,6 @@ +export FLASK_APP="$(cd "$(dirname "$0")/.." && pwd)/app.py" + DB_DIR=tracy_migrations flask db init -d $DB_DIR From 20b424cc04434031b48b3a03ea4b9c72e83a6fc9 Mon Sep 17 00:00:00 2001 From: rukwashai Date: Wed, 29 Jul 2026 14:42:20 -0400 Subject: [PATCH 09/20] Fix import after allocationUtilization rename to getAllocation, add integration tests --- app/controllers/main_routes/main_routes.py | 7 +- ...ocationUtilization.py => getAllocation.py} | 0 tests/code/test_getAllocation.py | 208 ++++++++++++++++++ 3 files changed, 212 insertions(+), 3 deletions(-) rename app/logic/{allocationUtilization.py => getAllocation.py} (100%) create mode 100644 tests/code/test_getAllocation.py diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 40f53aa14..ae9a4d49d 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -9,6 +9,8 @@ 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.models.positionHistory import PositionHistory from app.controllers.admin_routes.allPendingForms import checkAdjustment from app.controllers.main_routes import main_bp from app.logic.download import CSVMaker, saveFormSearchResult, retrieveFormSearchResult @@ -16,9 +18,8 @@ from app.login_manager import require_login, logout from app.logic.getTableData import getDatatableData from app.logic.banner import Banner -from app.logic.allocationUtilization import getDepartmentAllocationSummary -from app.models.allocation import Allocation -from app.models.positionHistory import PositionHistory +from app.logic.getAllocation import getDepartmentAllocationSummary + from app.logic.getPositions import getActivePositions @main_bp.route('/logout', methods=['GET']) diff --git a/app/logic/allocationUtilization.py b/app/logic/getAllocation.py similarity index 100% rename from app/logic/allocationUtilization.py rename to app/logic/getAllocation.py diff --git a/tests/code/test_getAllocation.py b/tests/code/test_getAllocation.py new file mode 100644 index 000000000..fb8c6d51a --- /dev/null +++ b/tests/code/test_getAllocation.py @@ -0,0 +1,208 @@ +import pytest +from app.models import mainDB +from app.models.department import Department +from app.models.term import Term +from app.models.allocation import Allocation +from app.models.laborStatusForm import LaborStatusForm +from app.models.student import Student +from app.models.supervisor import Supervisor +from app.logic.getAllocation import getDepartmentAllocationSummary + + +@pytest.mark.integration +def test_getDepartmentAllocationSummary_no_allocation(): + """ + Test that a department with no Allocation rows gets a zeroed-out summary + with term=None, instead of an error. + """ + with mainDB.atomic() as transaction: + dept = Department.create(departmentID=200, DEPT_NAME="Physics", ACCOUNT="6750", ORG="2120", isActive=True) + + summary = getDepartmentAllocationSummary(dept) + + assert summary["term"] is None + assert summary["allocated"] == 0 + assert summary["used"] == 0 + assert summary["break_hours"] == 0 + assert summary["used_positions"] == { + "used_10": 0, + "used_12": 0, + "used_15": 0, + "used_20": 0, + "used_5_sec": 0, + "used_10_sec": 0, + } + + transaction.rollback() + + +@pytest.mark.integration +def test_getDepartmentAllocationSummary_uses_most_recent_term(): + """ + Test that when a department has allocations across multiple terms, the + summary reflects only the most recent term's data. + """ + with mainDB.atomic() as transaction: + dept = Department.create(departmentID=201, DEPT_NAME="Chemistry", ACCOUNT="6751", ORG="2121", isActive=True) + + oldTerm = Term.create(termCode=900000, termName="AY Test Old") + newTerm = Term.create(termCode=900100, termName="AY Test New") + + Allocation.create( + termCode=oldTerm, department=dept, isFinal=True, justification="old", + primary_10=1, primary_12=0, primary_15=0, primary_20=0, + secondary_5=0, secondary_10=0, breakHours=50, + ) + Allocation.create( + termCode=newTerm, department=dept, isFinal=True, justification="new", + primary_10=2, primary_12=3, primary_15=0, primary_20=0, + secondary_5=1, secondary_10=0, breakHours=100, + ) + + supervisor = Supervisor.create(ID="SUP001", isActive=True) + student = Student.create(ID="STU001", isActive=True) + + # Under the OLD term - should be excluded from the summary + LaborStatusForm.create( + termCode=oldTerm, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Old Job", POSN_CODE="S001", + weeklyHours=10, contractHours=None, + ) + # Under the NEW (most recent) term - should be counted + LaborStatusForm.create( + termCode=newTerm, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="New Job", POSN_CODE="S002", + weeklyHours=10, contractHours=None, + ) + + summary = getDepartmentAllocationSummary(dept) + + assert summary["term"].termCode == 900100 + assert summary["allocated"] == 6 # 2 + 3 + 0 + 0 + 1 + 0, from the new term only + assert summary["used"] == 1 # only the new term's LaborStatusForm counts + assert summary["used_positions"]["used_10"] == 1 + assert summary["break_hours"] == 0 + + transaction.rollback() + + +@pytest.mark.integration +def test_getDepartmentAllocationSummary_break_hours(): + """ + Test that break_hours only sums forms with contractHours set (break-term + contracts), and that those forms are excluded from the weekly "used" count. + """ + with mainDB.atomic() as transaction: + dept = Department.create(departmentID=202, DEPT_NAME="Biology", ACCOUNT="6752", ORG="2122", isActive=True) + term = Term.create(termCode=900200, termName="AY Test Break") + + Allocation.create( + termCode=term, department=dept, isFinal=True, justification="test", + primary_10=1, primary_12=0, primary_15=0, primary_20=0, + secondary_5=0, secondary_10=0, breakHours=200, + ) + + supervisor = Supervisor.create(ID="SUP002", isActive=True) + student = Student.create(ID="STU002", isActive=True) + + LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Break Worker", POSN_CODE="S003", + weeklyHours=None, contractHours=40, + ) + + summary = getDepartmentAllocationSummary(dept) + + assert summary["break_hours"] == 40 + assert summary["used"] == 0 + + transaction.rollback() + + +@pytest.mark.integration +def test_getDepartmentAllocationSummary_department_none(): + """ + Test that passing department=None (e.g. when Department.get() fails in + the departmentPortal route) returns the zeroed-out fallback instead of + raising an error. + """ + summary = getDepartmentAllocationSummary(None) + + assert summary["term"] is None + assert summary["allocated"] == 0 + assert summary["used"] == 0 + assert summary["break_hours"] == 0 + assert summary["used_positions"] == { + "used_10": 0, + "used_12": 0, + "used_15": 0, + "used_20": 0, + "used_5_sec": 0, + "used_10_sec": 0, + } + + +@pytest.mark.integration +def test_getDepartmentAllocationSummary_multiple_allocations_same_term(): + """ + Test that if a department has more than one Allocation row for the same + most-recent term (e.g. a draft and a final revision, which the model's + (termCode, department, isFinal) index allows), the totals sum across + both rows rather than picking just one. + """ + with mainDB.atomic() as transaction: + dept = Department.create(departmentID=203, DEPT_NAME="Mathematics", ACCOUNT="6753", ORG="2123", isActive=True) + term = Term.create(termCode=900300, termName="AY Test Multi") + + Allocation.create( + termCode=term, department=dept, isFinal=False, justification="draft", + primary_10=1, primary_12=0, primary_15=0, primary_20=0, + secondary_5=0, secondary_10=0, breakHours=10, + ) + Allocation.create( + termCode=term, department=dept, isFinal=True, justification="final", + primary_10=2, primary_12=0, primary_15=0, primary_20=0, + secondary_5=0, secondary_10=0, breakHours=20, + ) + + summary = getDepartmentAllocationSummary(dept) + + assert summary["term"].termCode == 900300 + assert summary["allocated"] == 3 # 1 + 2, summed across both rows + + transaction.rollback() + + +@pytest.mark.integration +def test_getDepartmentAllocationSummary_allocation_no_labor_status_forms(): + """ + Test that a department with an allocation for the most recent term but no + LaborStatusForm records at all shows allocated > 0 with used/break_hours + at 0, rather than erroring on an empty result set. + """ + with mainDB.atomic() as transaction: + dept = Department.create(departmentID=204, DEPT_NAME="History", ACCOUNT="6754", ORG="2124", isActive=True) + term = Term.create(termCode=900400, termName="AY Test Empty") + + Allocation.create( + termCode=term, department=dept, isFinal=True, justification="test", + primary_10=3, primary_12=2, primary_15=0, primary_20=0, + secondary_5=1, secondary_10=0, breakHours=150, + ) + + summary = getDepartmentAllocationSummary(dept) + + assert summary["term"].termCode == 900400 + assert summary["allocated"] == 6 + assert summary["used"] == 0 + assert summary["break_hours"] == 0 + assert summary["used_positions"] == { + "used_10": 0, + "used_12": 0, + "used_15": 0, + "used_20": 0, + "used_5_sec": 0, + "used_10_sec": 0, + } + + transaction.rollback() From ee0e066beba6f1508c9dbb5d5444f7df224324dd Mon Sep 17 00:00:00 2001 From: rukwashai Date: Thu, 30 Jul 2026 09:34:58 -0400 Subject: [PATCH 10/20] Address PR review comments: consolidate getAllocation return dict, extract countWorkers/getBreakHours with FormHistory approval filter, dynamic term in departmentPortal.html, and revert environment-specific path edits in base_data.py/demo_data.py. Add integration test coverage for the new logic functions. --- app/logic/getAllocation.py | 108 +++++++++-------- app/templates/main/departmentPortal.html | 2 +- database/base_data.py | 5 - database/demo_data.py | 10 -- tests/code/test_getAllocation.py | 141 ++++++++++++++++++++++- 5 files changed, 197 insertions(+), 69 deletions(-) diff --git a/app/logic/getAllocation.py b/app/logic/getAllocation.py index 5fc14abfc..84e6e7389 100644 --- a/app/logic/getAllocation.py +++ b/app/logic/getAllocation.py @@ -3,31 +3,65 @@ from app.models.allocation import Allocation from app.models.laborStatusForm import LaborStatusForm from app.models.term import Term +from app.models.formHistory import FormHistory + + +def countWorkers(department, term_code, job_type, hours_bucket): + workerCount = ( + LaborStatusForm.select() + .where( + LaborStatusForm.department == department, + LaborStatusForm.termCode == term_code, + LaborStatusForm.jobType == job_type, + LaborStatusForm.weeklyHours == hours_bucket, + LaborStatusForm.contractHours.is_null(True), + ) + .count() + ) + return workerCount + + +def getBreakHours(department, term_code): + breakHoursTotal = ( + LaborStatusForm.select(fn.SUM(LaborStatusForm.contractHours)) + .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) + .where( + LaborStatusForm.department == department, + LaborStatusForm.termCode == term_code, + FormHistory.historyType == "Labor Status Form", + FormHistory.status == "Approved", + ) + .scalar() + ) or 0 + return breakHoursTotal def getDepartmentAllocationSummary(department): """Return allocation-utilization values for a department's most recent term.""" + result = { + "term": None, + "allocated": 0, + "used": 0, + "used_positions": { + "used_10": 0, + "used_12": 0, + "used_15": 0, + "used_20": 0, + "used_5_sec": 0, + "used_10_sec": 0, + }, + "break_hours": 0, + } + departmentAllocations = list( Allocation.select(Allocation, Term).join(Term).where(Allocation.department == department) ) if not departmentAllocations: - return { - "term": None, - "allocated": 0, - "used": 0, - "used_positions": { - "used_10": 0, - "used_12": 0, - "used_15": 0, - "used_20": 0, - "used_5_sec": 0, - "used_10_sec": 0, - }, - "break_hours": 0, - } + return result recentTerm = Term.order_by_term([a.termCode for a in departmentAllocations], reverse=True)[0] term_code = recentTerm.termCode + result["term"] = recentTerm total_positions = ( Allocation.select( @@ -44,6 +78,7 @@ def getDepartmentAllocationSummary(department): ) .scalar() ) + result["allocated"] = total_positions or 0 used_allocation = ( LaborStatusForm.select() @@ -54,42 +89,17 @@ def getDepartmentAllocationSummary(department): ) .count() ) + result["used"] = used_allocation - def count_workers(job_type, hours_bucket): - return ( - LaborStatusForm.select() - .where( - LaborStatusForm.department == department, - LaborStatusForm.termCode == term_code, - LaborStatusForm.jobType == job_type, - LaborStatusForm.weeklyHours == hours_bucket, - LaborStatusForm.contractHours.is_null(True), - ) - .count() - ) - - used_positions = { - "used_10": count_workers("Primary", 10), - "used_12": count_workers("Primary", 12), - "used_15": count_workers("Primary", 15), - "used_20": count_workers("Primary", 20), - "used_5_sec": count_workers("Secondary", 5), - "used_10_sec": count_workers("Secondary", 10), + result["used_positions"] = { + "used_10": countWorkers(department, term_code, "Primary", 10), + "used_12": countWorkers(department, term_code, "Primary", 12), + "used_15": countWorkers(department, term_code, "Primary", 15), + "used_20": countWorkers(department, term_code, "Primary", 20), + "used_5_sec": countWorkers(department, term_code, "Secondary", 5), + "used_10_sec": countWorkers(department, term_code, "Secondary", 10), } - break_hours = sum( - form.contractHours or 0 - for form in LaborStatusForm.select(LaborStatusForm.contractHours).where( - LaborStatusForm.department == department, - LaborStatusForm.termCode == term_code, - LaborStatusForm.contractHours.is_null(False), - ) - ) + result["break_hours"] = getBreakHours(department, term_code) - return { - "term": recentTerm, - "allocated": total_positions or 0, - "used": used_allocation, - "used_positions": used_positions, - "break_hours": break_hours, - } + return result diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index 4cb45ee2a..c3d6eae2c 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -40,7 +40,7 @@

    {% if department %} {{department.DEPT_NAME}} Portal {% e

    Allocations

-

AY 2025-2026

{{used}}/{{allocated or 0}} Positions

+

{{ term.termName if term else "No term data" }}

{{used}}/{{allocated or 0}} Positions

    diff --git a/database/base_data.py b/database/base_data.py index f42d57ce9..c081fb752 100644 --- a/database/base_data.py +++ b/database/base_data.py @@ -1,8 +1,3 @@ -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) - from app.models.status import Status from app.models.historyType import HistoryType from app.models.emailTemplate import EmailTemplate diff --git a/database/demo_data.py b/database/demo_data.py index ab2d0db27..08784e51c 100644 --- a/database/demo_data.py +++ b/database/demo_data.py @@ -1,14 +1,4 @@ -'''Add new fields to this file and run it to add new enteries into your local database. -Chech phpmyadmin to see if your changes are reflected -This file will need to be changed if the format of models changes (new fields, dropping fields, renaming...)''' - -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) - from app import app - from app.models.Tracy import db from app.models.Tracy.studata import STUDATA from app.models.Tracy.stuposn import STUPOSN diff --git a/tests/code/test_getAllocation.py b/tests/code/test_getAllocation.py index fb8c6d51a..6ac0fc246 100644 --- a/tests/code/test_getAllocation.py +++ b/tests/code/test_getAllocation.py @@ -1,3 +1,5 @@ +from datetime import date + import pytest from app.models import mainDB from app.models.department import Department @@ -6,7 +8,26 @@ from app.models.laborStatusForm import LaborStatusForm from app.models.student import Student from app.models.supervisor import Supervisor -from app.logic.getAllocation import getDepartmentAllocationSummary +from app.models.formHistory import FormHistory +from app.models.historyType import HistoryType +from app.models.status import Status +from app.models.user import User +from app.logic.getAllocation import getDepartmentAllocationSummary, countWorkers, getBreakHours + + +def _createFormHistory(form, statusName): + """Attach a FormHistory row to a LaborStatusForm, since getBreakHours now + only counts forms with an approved "Labor Status Form" history entry.""" + user = User.create(username=f"testuser_{form.laborStatusFormID}") + historyType = HistoryType.get(HistoryType.historyTypeName == "Labor Status Form") + status = Status.get(Status.statusName == statusName) + return FormHistory.create( + formID=form, + historyType=historyType, + createdBy=user, + createdDate=date.today(), + status=status, + ) @pytest.mark.integration @@ -89,8 +110,9 @@ def test_getDepartmentAllocationSummary_uses_most_recent_term(): @pytest.mark.integration def test_getDepartmentAllocationSummary_break_hours(): """ - Test that break_hours only sums forms with contractHours set (break-term - contracts), and that those forms are excluded from the weekly "used" count. + Test that break_hours only sums approved forms with contractHours set + (break-term contracts), and that those forms are excluded from the + weekly "used" count. """ with mainDB.atomic() as transaction: dept = Department.create(departmentID=202, DEPT_NAME="Biology", ACCOUNT="6752", ORG="2122", isActive=True) @@ -105,11 +127,12 @@ def test_getDepartmentAllocationSummary_break_hours(): supervisor = Supervisor.create(ID="SUP002", isActive=True) student = Student.create(ID="STU002", isActive=True) - LaborStatusForm.create( + breakForm = LaborStatusForm.create( termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, jobType="Primary", WLS="10", POSN_TITLE="Break Worker", POSN_CODE="S003", weeklyHours=None, contractHours=40, ) + _createFormHistory(breakForm, "Approved") summary = getDepartmentAllocationSummary(dept) @@ -206,3 +229,113 @@ def test_getDepartmentAllocationSummary_allocation_no_labor_status_forms(): } transaction.rollback() + + +@pytest.mark.integration +def test_countWorkers(): + """ + Test that countWorkers only counts LaborStatusForm rows matching the + given department, term, job type, and weekly-hours bucket, and excludes + forms with a different job type/hours bucket or a break-term contract + (contractHours set instead of weeklyHours). + """ + with mainDB.atomic() as transaction: + dept = Department.create(departmentID=205, DEPT_NAME="English", ACCOUNT="6755", ORG="2125", isActive=True) + term = Term.create(termCode=900500, termName="AY Test Workers") + + supervisor = Supervisor.create(ID="SUP003", isActive=True) + student = Student.create(ID="STU003", isActive=True) + + # Matches department, term, job type, and hours bucket - should count + LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Match", POSN_CODE="S010", + weeklyHours=10, contractHours=None, + ) + # Different job type - should not count toward ("Primary", 10) + LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Secondary", WLS="10", POSN_TITLE="Wrong Job Type", POSN_CODE="S011", + weeklyHours=10, contractHours=None, + ) + # Different hours bucket - should not count toward ("Primary", 10) + LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="12", POSN_TITLE="Wrong Hours", POSN_CODE="S012", + weeklyHours=12, contractHours=None, + ) + # Break-term contract (contractHours set) - should not count even though + # job type and weeklyHours otherwise match + LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Break Contract", POSN_CODE="S013", + weeklyHours=10, contractHours=40, + ) + + assert countWorkers(dept, term.termCode, "Primary", 10) == 1 + assert countWorkers(dept, term.termCode, "Secondary", 10) == 1 + assert countWorkers(dept, term.termCode, "Primary", 12) == 1 + assert countWorkers(dept, term.termCode, "Primary", 15) == 0 + + transaction.rollback() + + +@pytest.mark.integration +def test_getBreakHours(): + """ + Test that getBreakHours sums only APPROVED forms with contractHours set + (break-term contracts) for the given department and term, excludes + weekly-hours forms, excludes forms under a different term, and excludes + forms that are not approved (e.g. still pending). + """ + with mainDB.atomic() as transaction: + dept = Department.create(departmentID=206, DEPT_NAME="Philosophy", ACCOUNT="6756", ORG="2126", isActive=True) + term = Term.create(termCode=900600, termName="AY Test Break Hours") + otherTerm = Term.create(termCode=900601, termName="AY Test Other Term") + + supervisor = Supervisor.create(ID="SUP004", isActive=True) + student = Student.create(ID="STU004", isActive=True) + + # Approved break-term contracts under the target term - should be summed + formA = LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Break A", POSN_CODE="S020", + weeklyHours=None, contractHours=40, + ) + _createFormHistory(formA, "Approved") + + formB = LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Secondary", WLS="5", POSN_TITLE="Break B", POSN_CODE="S021", + weeklyHours=None, contractHours=60, + ) + _createFormHistory(formB, "Approved") + + # Weekly-hours form (contractHours=None) - should be excluded regardless + formC = LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Weekly Job", POSN_CODE="S022", + weeklyHours=10, contractHours=None, + ) + _createFormHistory(formC, "Approved") + + # Break-term contract under a DIFFERENT term - should be excluded + formD = LaborStatusForm.create( + termCode=otherTerm, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Break Other Term", POSN_CODE="S023", + weeklyHours=None, contractHours=100, + ) + _createFormHistory(formD, "Approved") + + # Break-term contract that is still PENDING - should be excluded + formE = LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Break Pending", POSN_CODE="S024", + weeklyHours=None, contractHours=999, + ) + _createFormHistory(formE, "Pending") + + assert getBreakHours(dept, term.termCode) == 100 # 40 + 60, excludes the pending form + assert getBreakHours(dept, otherTerm.termCode) == 100 + + transaction.rollback() From 8618b68d1a74bc53fe883111c8dd4d3c6306eb19 Mon Sep 17 00:00:00 2001 From: rukwashai Date: Thu, 30 Jul 2026 11:12:53 -0400 Subject: [PATCH 11/20] Adopt approval-status filtering from UsedAllocFunction branch in getAllocation.py, excluding denied forms from countWorkers and the used count. Update tests to cover the new behavior. --- app/logic/getAllocation.py | 6 ++++++ tests/code/test_getAllocation.py | 30 +++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/logic/getAllocation.py b/app/logic/getAllocation.py index 84e6e7389..893da61bc 100644 --- a/app/logic/getAllocation.py +++ b/app/logic/getAllocation.py @@ -9,12 +9,15 @@ def countWorkers(department, term_code, job_type, hours_bucket): workerCount = ( LaborStatusForm.select() + .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) .where( LaborStatusForm.department == department, LaborStatusForm.termCode == term_code, LaborStatusForm.jobType == job_type, LaborStatusForm.weeklyHours == hours_bucket, LaborStatusForm.contractHours.is_null(True), + FormHistory.historyType == "Labor Status Form", + ~(FormHistory.status % "Denied%"), ) .count() ) @@ -82,10 +85,13 @@ def getDepartmentAllocationSummary(department): used_allocation = ( LaborStatusForm.select() + .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) .where( LaborStatusForm.department == department, LaborStatusForm.termCode == term_code, LaborStatusForm.contractHours.is_null(True), + FormHistory.historyType == "Labor Status Form", + ~(FormHistory.status % "Denied%"), ) .count() ) diff --git a/tests/code/test_getAllocation.py b/tests/code/test_getAllocation.py index 6ac0fc246..3d9259066 100644 --- a/tests/code/test_getAllocation.py +++ b/tests/code/test_getAllocation.py @@ -90,11 +90,12 @@ def test_getDepartmentAllocationSummary_uses_most_recent_term(): weeklyHours=10, contractHours=None, ) # Under the NEW (most recent) term - should be counted - LaborStatusForm.create( + newForm = LaborStatusForm.create( termCode=newTerm, studentSupervisee=student, supervisor=supervisor, department=dept, jobType="Primary", WLS="10", POSN_TITLE="New Job", POSN_CODE="S002", weeklyHours=10, contractHours=None, ) + _createFormHistory(newForm, "Approved") summary = getDepartmentAllocationSummary(dept) @@ -236,8 +237,8 @@ def test_countWorkers(): """ Test that countWorkers only counts LaborStatusForm rows matching the given department, term, job type, and weekly-hours bucket, and excludes - forms with a different job type/hours bucket or a break-term contract - (contractHours set instead of weeklyHours). + forms with a different job type/hours bucket, a break-term contract + (contractHours set instead of weeklyHours), or a denied history status. """ with mainDB.atomic() as transaction: dept = Department.create(departmentID=205, DEPT_NAME="English", ACCOUNT="6755", ORG="2125", isActive=True) @@ -247,30 +248,45 @@ def test_countWorkers(): student = Student.create(ID="STU003", isActive=True) # Matches department, term, job type, and hours bucket - should count - LaborStatusForm.create( + matchForm = LaborStatusForm.create( termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, jobType="Primary", WLS="10", POSN_TITLE="Match", POSN_CODE="S010", weeklyHours=10, contractHours=None, ) + _createFormHistory(matchForm, "Approved") + # Different job type - should not count toward ("Primary", 10) - LaborStatusForm.create( + wrongJobTypeForm = LaborStatusForm.create( termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, jobType="Secondary", WLS="10", POSN_TITLE="Wrong Job Type", POSN_CODE="S011", weeklyHours=10, contractHours=None, ) + _createFormHistory(wrongJobTypeForm, "Approved") + # Different hours bucket - should not count toward ("Primary", 10) - LaborStatusForm.create( + wrongHoursForm = LaborStatusForm.create( termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, jobType="Primary", WLS="12", POSN_TITLE="Wrong Hours", POSN_CODE="S012", weeklyHours=12, contractHours=None, ) + _createFormHistory(wrongHoursForm, "Approved") + # Break-term contract (contractHours set) - should not count even though # job type and weeklyHours otherwise match - LaborStatusForm.create( + breakContractForm = LaborStatusForm.create( termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, jobType="Primary", WLS="10", POSN_TITLE="Break Contract", POSN_CODE="S013", weeklyHours=10, contractHours=40, ) + _createFormHistory(breakContractForm, "Approved") + + # Matches everything but was DENIED - should not count + deniedForm = LaborStatusForm.create( + termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + jobType="Primary", WLS="10", POSN_TITLE="Denied Match", POSN_CODE="S014", + weeklyHours=10, contractHours=None, + ) + _createFormHistory(deniedForm, "Denied by Admin") assert countWorkers(dept, term.termCode, "Primary", 10) == 1 assert countWorkers(dept, term.termCode, "Secondary", 10) == 1 From a0ba238cfdde9199ab6f075e7f7d672c26d0f1aa Mon Sep 17 00:00:00 2001 From: munsakad Date: Mon, 3 Aug 2026 11:19:36 -0400 Subject: [PATCH 12/20] Polish the allocation card: dynamic Fall/Spring term label, "X of Y" wording, and aligned Primary/Secondary table Replace the raw "AY 2025-2026" term name with a computed current-semester label (e.g. "Fall 2025") derived from the term's own year and today's month, matching the Fall/Spring termCode convention used elsewhere. Reword ratios as "X of Y" instead of "X/Y", rename the card title to "Current Allocation", and rebuild the Primary/Secondary breakdown as a single table so every row (term info, headers, hour buckets, break hours) shares the same column alignment and stays legible down to mobile widths. --- app/controllers/main_routes/main_routes.py | 1 + app/logic/getAllocation.py | 19 ++++++++ app/static/css/departmentPortal.css | 53 ++++++++++++++-------- app/templates/main/departmentPortal.html | 52 +++++++++++++-------- tests/code/test_getAllocation.py | 25 +++++++++- 5 files changed, 113 insertions(+), 37 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 442d39722..5d9d160dd 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -91,6 +91,7 @@ def departmentPortal(org=None,account=None): allocated = allocation_summary["allocated"], used = allocation_summary["used"], term = recentTerm, + currentSemester = allocation_summary["current_semester"], usedPositions = allocation_summary["used_positions"], break_hours = allocation_summary["break_hours"], supervisors = supervisors, diff --git a/app/logic/getAllocation.py b/app/logic/getAllocation.py index 893da61bc..f5d5d281f 100644 --- a/app/logic/getAllocation.py +++ b/app/logic/getAllocation.py @@ -1,3 +1,5 @@ +from datetime import date + from peewee import fn from app.models.allocation import Allocation @@ -6,6 +8,21 @@ from app.models.formHistory import FormHistory +def getCurrentSemesterLabel(term): + """Return the current Fall/Spring semester label (e.g. "Fall 2025") for + the academic year that the given term belongs to. The season is picked + from today's month and the year comes from the term's own termCode, + following the AY/Fall/Spring termCode convention in termManagement.py + (AY code, code+11 = Fall of that year, code+12 = Spring of the next). + """ + if not term: + return None + academicYear = int(str(term.termCode)[:4]) + if date.today().month >= 8: + return f"Fall {academicYear}" + return f"Spring {academicYear + 1}" + + def countWorkers(department, term_code, job_type, hours_bucket): workerCount = ( LaborStatusForm.select() @@ -43,6 +60,7 @@ def getDepartmentAllocationSummary(department): """Return allocation-utilization values for a department's most recent term.""" result = { "term": None, + "current_semester": None, "allocated": 0, "used": 0, "used_positions": { @@ -65,6 +83,7 @@ def getDepartmentAllocationSummary(department): recentTerm = Term.order_by_term([a.termCode for a in departmentAllocations], reverse=True)[0] term_code = recentTerm.termCode result["term"] = recentTerm + result["current_semester"] = getCurrentSemesterLabel(recentTerm) total_positions = ( Allocation.select( diff --git a/app/static/css/departmentPortal.css b/app/static/css/departmentPortal.css index 9904ae8bc..e0b15ed9d 100644 --- a/app/static/css/departmentPortal.css +++ b/app/static/css/departmentPortal.css @@ -43,27 +43,31 @@ vertical-align: middle; color:#6e6e6e; } -.header-container { - display: flex; - justify-content: space-between; - align-items: center; - padding-left: 2%; +.allocation-table-wrapper { + overflow-x: auto; + margin: 10px 0; +} +.allocation-table { + width: 100%; + border-collapse: collapse; + font-size: 1.2em; +} +.allocation-table th, +.allocation-table td { + text-align: left; + white-space: nowrap; +} +.allocation-table th:first-child, +.allocation-table td:first-child { padding-right: 10px; } -.allocation-list { - display: flex; - justify-content: space-between; - align-items: center; - padding-left: 10px; - padding-right: 10px; -} -.primary-chart { - font-size: 1.2em; - padding-left: 7%; +.allocation-table th { + font-weight: 700; + padding-top: 10px; } -.secondary-chart { - font-size: 1.2em; - padding-right: 7%; +.allocation-table .term-row h4, +.allocation-table .break-row h4 { + margin: 10px 0; } .bi-people-fill { /* Bootstrap Icon for Members Card */ @@ -96,3 +100,16 @@ flex-direction: column; } } + +@media (min-width: 1200px) and (max-width: 1450px), (max-width: 480px) { + .allocation-table { + font-size: 1em; + } + .allocation-table td { + padding: 2px 5px; + } + .allocation-table .term-row h4, + .allocation-table .break-row h4 { + font-size: 0.9rem; + } +} diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index 3e37d3b25..c800784c3 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -42,25 +42,41 @@

    {% if department %} {{department.DEPT_NAME}} Portal {% e

-

Allocations

+

Current Allocation

-
-

{{ term.termName if term else "No term data" }}

{{used}}/{{allocated or 0}} Positions

-
-
-
    -
  • 10 Hour - {{usedPositions.used_10}}/{{allocation.primary_10}}
  • -
  • 12 Hour - {{usedPositions.used_12}}/{{allocation.primary_12}}
  • -
  • 15 Hour - {{usedPositions.used_15}}/{{allocation.primary_15}}
  • -
  • 20 Hour - {{usedPositions.used_20}}/{{allocation.primary_20}}
  • -
-
    -
  • 5 Hour - {{usedPositions.used_5_sec}}/{{allocation.secondary_5}}
  • -
  • 10 Hour - {{usedPositions.used_10_sec}}/{{allocation.secondary_10}}
  • -
-
-
-

Break Hours

{{break_hours}}/{{allocation.breakHours or 0}} Hours

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ currentSemester if currentSemester else "No term data" }}

{{used}} of {{allocated or 0}} Positions

PrimarySecondary
10 Hour - {{usedPositions.used_10}} of {{allocation.primary_10}}5 Hour - {{usedPositions.used_5_sec}} of {{allocation.secondary_5}}
12 Hour - {{usedPositions.used_12}} of {{allocation.primary_12}}10 Hour - {{usedPositions.used_10_sec}} of {{allocation.secondary_10}}
15 Hour - {{usedPositions.used_15}} of {{allocation.primary_15}}
20 Hour - {{usedPositions.used_20}} of {{allocation.primary_20}}

Break Hours

{{break_hours}} of {{allocation.breakHours or 0}} Hours

diff --git a/tests/code/test_getAllocation.py b/tests/code/test_getAllocation.py index 3d9259066..82d137106 100644 --- a/tests/code/test_getAllocation.py +++ b/tests/code/test_getAllocation.py @@ -1,4 +1,5 @@ from datetime import date +from unittest.mock import patch import pytest from app.models import mainDB @@ -12,7 +13,7 @@ from app.models.historyType import HistoryType from app.models.status import Status from app.models.user import User -from app.logic.getAllocation import getDepartmentAllocationSummary, countWorkers, getBreakHours +from app.logic.getAllocation import getDepartmentAllocationSummary, countWorkers, getBreakHours, getCurrentSemesterLabel def _createFormHistory(form, statusName): @@ -30,6 +31,28 @@ def _createFormHistory(form, statusName): ) +def test_getCurrentSemesterLabel_none_term(): + assert getCurrentSemesterLabel(None) is None + + +def test_getCurrentSemesterLabel_fall(): + """A term whose termCode's academic year is 2025 should read as Fall 2025 + when today falls in the Aug-Dec half of the academic year.""" + term = Term(termCode=202500) + with patch("app.logic.getAllocation.date") as mockDate: + mockDate.today.return_value = date(2025, 9, 15) + assert getCurrentSemesterLabel(term) == "Fall 2025" + + +def test_getCurrentSemesterLabel_spring(): + """The same academic-year term should read as Spring 2026 when today + falls in the Jan-Jul half of the academic year.""" + term = Term(termCode=202500) + with patch("app.logic.getAllocation.date") as mockDate: + mockDate.today.return_value = date(2026, 2, 10) + assert getCurrentSemesterLabel(term) == "Spring 2026" + + @pytest.mark.integration def test_getDepartmentAllocationSummary_no_allocation(): """ From 45b13e0b291f554f1d137a257c6118dbe6b7d7c4 Mon Sep 17 00:00:00 2001 From: munsakad Date: Mon, 3 Aug 2026 14:35:07 -0400 Subject: [PATCH 13/20] Fix duplicate main.managePositions endpoint left over from the department-portal-base merge The base branch (department-portal-base) moved managePositions out of main_routes.py into its own departmentPortal.py with proper permission checks (commit ee19d87e), but merging that branch in and accepting both sides left the old, now-dead copy in main_routes.py alongside the new file, so Flask registered two view functions under the same endpoint name and crashed on startup with "View function mapping is overwriting an existing endpoint function: main.managePositions". Remove the stale duplicate (it also referenced an unimported Tracy class) and the duplicate departmentPortal import in __init__.py from the same merge. --- app/controllers/main_routes/__init__.py | 1 - app/controllers/main_routes/main_routes.py | 14 -------------- 2 files changed, 15 deletions(-) diff --git a/app/controllers/main_routes/__init__.py b/app/controllers/main_routes/__init__.py index 5ab0bf423..e8c4c6bb0 100755 --- a/app/controllers/main_routes/__init__.py +++ b/app/controllers/main_routes/__init__.py @@ -26,4 +26,3 @@ def injectGlobalData(): from app.controllers.main_routes import search from app.controllers.main_routes import studentResponse from app.controllers.main_routes import departmentPortal -from app.controllers.main_routes import departmentPortal diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 5d9d160dd..e9ec85e8f 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -100,20 +100,6 @@ def departmentPortal(org=None,account=None): positions = positionsList, posURL = posURL, ) -@main_bp.route('/department///managepositions', methods=['GET']) -def managePositions(org, account): - try: - dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) - except DoesNotExist: - return render_template('errors/404.html'), 404 - - positions = Tracy().getPositionsFromDepartment(org, account) - print(positions) - return render_template('main/managepositions.html', - department = dept, - department_name = dept.DEPT_NAME, - positions = positions - ) @main_bp.route('/supervisorPortal/addUserToDept', methods=['GET', 'POST']) def addUserToDept(): userDeptData = request.form From 74bdd3ae687c6e45ae9c35a12cd38587ef35618d Mon Sep 17 00:00:00 2001 From: munsakad Date: Mon, 3 Aug 2026 15:03:24 -0400 Subject: [PATCH 14/20] Rework allocation rows to the "N hr: X contracts (out of Y allocations)" format and drop Break Hours Match the supervisor's whiteboard mockup: each Primary/Secondary hour bucket now reads as two lines ("10 hr: 2 contracts" / "(out of 5 allocations)") with correct singular/plural wording, via a small Jinja macro to avoid repeating the format six times. Break Hours is removed from the card entirely. Column alignment (left edges shared across the term row, headers, and hour rows) is unchanged, and the pinch-zone media query is retuned for the new, longer per-row text so nothing clips or overflows at narrow widths. --- app/static/css/departmentPortal.css | 16 ++++++---------- app/templates/main/departmentPortal.html | 19 +++++++++---------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/app/static/css/departmentPortal.css b/app/static/css/departmentPortal.css index e0b15ed9d..b04f98ad1 100644 --- a/app/static/css/departmentPortal.css +++ b/app/static/css/departmentPortal.css @@ -56,17 +56,14 @@ .allocation-table td { text-align: left; white-space: nowrap; -} -.allocation-table th:first-child, -.allocation-table td:first-child { - padding-right: 10px; + padding: 4px 10px 4px 0; + line-height: 1.3; } .allocation-table th { font-weight: 700; padding-top: 10px; } -.allocation-table .term-row h4, -.allocation-table .break-row h4 { +.allocation-table .term-row h4 { margin: 10px 0; } @@ -103,13 +100,12 @@ @media (min-width: 1200px) and (max-width: 1450px), (max-width: 480px) { .allocation-table { - font-size: 1em; + font-size: 0.85em; } .allocation-table td { - padding: 2px 5px; + padding: 2px 5px 2px 0; } - .allocation-table .term-row h4, - .allocation-table .break-row h4 { + .allocation-table .term-row h4 { font-size: 0.9rem; } } diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index 41eab4f91..b6178bf99 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -44,6 +44,9 @@

{% if department %} {{department.DEPT_NAME}} Portal {% e

Current Allocation

+ {% macro allocationCell(hours, used, allocated) -%} + {{ hours }} hr: {{ used }} contract{{ 's' if used != 1 else '' }}
(out of {{ allocated }} allocation{{ 's' if allocated != 1 else '' }}) + {%- endmacro %}
@@ -56,25 +59,21 @@

Current Allocation

- - + {{ allocationCell(10, usedPositions.used_10, allocation.primary_10) }} + {{ allocationCell(5, usedPositions.used_5_sec, allocation.secondary_5) }} - - + {{ allocationCell(12, usedPositions.used_12, allocation.primary_12) }} + {{ allocationCell(10, usedPositions.used_10_sec, allocation.secondary_10) }} - + {{ allocationCell(15, usedPositions.used_15, allocation.primary_15) }} - + {{ allocationCell(20, usedPositions.used_20, allocation.primary_20) }} - - - -
Secondary
10 Hour - {{usedPositions.used_10}} of {{allocation.primary_10}}5 Hour - {{usedPositions.used_5_sec}} of {{allocation.secondary_5}}
12 Hour - {{usedPositions.used_12}} of {{allocation.primary_12}}10 Hour - {{usedPositions.used_10_sec}} of {{allocation.secondary_10}}
15 Hour - {{usedPositions.used_15}} of {{allocation.primary_15}}
20 Hour - {{usedPositions.used_20}} of {{allocation.primary_20}}

Break Hours

{{break_hours}} of {{allocation.breakHours or 0}} Hours

From a7ca47461753507b9b94f8b84bfd834e03d92a3c Mon Sep 17 00:00:00 2001 From: munsakad Date: Tue, 4 Aug 2026 10:19:49 -0400 Subject: [PATCH 15/20] Address allocation card review: camelCase naming, stacked layout, consolidated tests - Rename allocation_summary and its snake_case keys/locals to camelCase (allocationSummary, currentSemester, usedPositions, breakHours, used10, usedSecondary5, ...) across the logic, route, template, and tests - Shorten the getCurrentSemesterLabel docstring - Reword the card to Contracted/Allocated in both the tooltip and the position count - Stack Secondary below Primary (and the count below the term) on narrow cards by splitting the paired table into two, instead of shrinking the font - Point the not-yet-built View Allocations page at "#" like the Members card, so the button no longer 404s - Collapse the per-scenario tests into one test per function, drop the leading underscore from createFormHistory, and mark the unit test so run_tests.sh stops deselecting it - Cover the used-count denial filter and make the term filter and the getBreakHours other-term assertion actually meaningful - Revert unrelated whitespace/formatting churn in main_routes.py --- app/controllers/main_routes/main_routes.py | 24 +- app/logic/getAllocation.py | 72 +++--- app/static/css/departmentPortal.css | 35 ++- app/templates/main/departmentPortal.html | 66 +++--- tests/code/test_getAllocation.py | 255 +++++++++------------ 5 files changed, 212 insertions(+), 240 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index e9ec85e8f..de37e4b4e 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -12,6 +12,7 @@ from app.models.term import Term from app.models.allocation import Allocation from app.models.positionHistory import PositionHistory + from app.controllers.admin_routes.allPendingForms import checkAdjustment from app.controllers.main_routes import main_bp @@ -24,6 +25,7 @@ from app.logic.getSupervisors import getSupervisors from app.logic.getPositions import getActivePositions + @main_bp.route('/logout', methods=['GET']) def triggerLogout(): return redirect(logout()) @@ -71,8 +73,8 @@ def departmentPortal(org=None,account=None): supervisors, laborCoordinators = getSupervisors(dept) - allocation_summary = getDepartmentAllocationSummary(dept) - recentTerm = allocation_summary["term"] + allocationSummary = getDepartmentAllocationSummary(dept) + recentTerm = allocationSummary["term"] if recentTerm: try: @@ -82,24 +84,24 @@ def departmentPortal(org=None,account=None): else: allocation = None - positionsList, posURL = getActivePositions(dept) + positionsList, posURL = getActivePositions(dept) - return render_template('main/departmentPortal.html', + return render_template('main/departmentPortal.html', departments = departments, department = dept, allocation = allocation, - allocated = allocation_summary["allocated"], - used = allocation_summary["used"], + allocated = allocationSummary["allocated"], + used = allocationSummary["used"], term = recentTerm, - currentSemester = allocation_summary["current_semester"], - usedPositions = allocation_summary["used_positions"], - break_hours = allocation_summary["break_hours"], + currentSemester = allocationSummary["currentSemester"], + usedPositions = allocationSummary["usedPositions"], + breakHours = allocationSummary["breakHours"], supervisors = supervisors, laborCoordinators=laborCoordinators, currentUser=currentUser, positions = positionsList, - posURL = posURL, - ) + posURL = posURL) + @main_bp.route('/supervisorPortal/addUserToDept', methods=['GET', 'POST']) def addUserToDept(): userDeptData = request.form diff --git a/app/logic/getAllocation.py b/app/logic/getAllocation.py index f5d5d281f..1f7c879d3 100644 --- a/app/logic/getAllocation.py +++ b/app/logic/getAllocation.py @@ -9,12 +9,8 @@ def getCurrentSemesterLabel(term): - """Return the current Fall/Spring semester label (e.g. "Fall 2025") for - the academic year that the given term belongs to. The season is picked - from today's month and the year comes from the term's own termCode, - following the AY/Fall/Spring termCode convention in termManagement.py - (AY code, code+11 = Fall of that year, code+12 = Spring of the next). - """ + """Return the Fall/Spring label (e.g. "Fall 2025") for the AY term's + current semester, picking the season from today's month.""" if not term: return None academicYear = int(str(term.termCode)[:4]) @@ -23,15 +19,15 @@ def getCurrentSemesterLabel(term): return f"Spring {academicYear + 1}" -def countWorkers(department, term_code, job_type, hours_bucket): +def countWorkers(department, termCode, jobType, hoursBucket): workerCount = ( LaborStatusForm.select() .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) .where( LaborStatusForm.department == department, - LaborStatusForm.termCode == term_code, - LaborStatusForm.jobType == job_type, - LaborStatusForm.weeklyHours == hours_bucket, + LaborStatusForm.termCode == termCode, + LaborStatusForm.jobType == jobType, + LaborStatusForm.weeklyHours == hoursBucket, LaborStatusForm.contractHours.is_null(True), FormHistory.historyType == "Labor Status Form", ~(FormHistory.status % "Denied%"), @@ -41,13 +37,13 @@ def countWorkers(department, term_code, job_type, hours_bucket): return workerCount -def getBreakHours(department, term_code): +def getBreakHours(department, termCode): breakHoursTotal = ( LaborStatusForm.select(fn.SUM(LaborStatusForm.contractHours)) .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) .where( LaborStatusForm.department == department, - LaborStatusForm.termCode == term_code, + LaborStatusForm.termCode == termCode, FormHistory.historyType == "Labor Status Form", FormHistory.status == "Approved", ) @@ -60,18 +56,18 @@ def getDepartmentAllocationSummary(department): """Return allocation-utilization values for a department's most recent term.""" result = { "term": None, - "current_semester": None, + "currentSemester": None, "allocated": 0, "used": 0, - "used_positions": { - "used_10": 0, - "used_12": 0, - "used_15": 0, - "used_20": 0, - "used_5_sec": 0, - "used_10_sec": 0, + "usedPositions": { + "used10": 0, + "used12": 0, + "used15": 0, + "used20": 0, + "usedSecondary5": 0, + "usedSecondary10": 0, }, - "break_hours": 0, + "breakHours": 0, } departmentAllocations = list( @@ -81,11 +77,11 @@ def getDepartmentAllocationSummary(department): return result recentTerm = Term.order_by_term([a.termCode for a in departmentAllocations], reverse=True)[0] - term_code = recentTerm.termCode + termCode = recentTerm.termCode result["term"] = recentTerm - result["current_semester"] = getCurrentSemesterLabel(recentTerm) + result["currentSemester"] = getCurrentSemesterLabel(recentTerm) - total_positions = ( + totalPositions = ( Allocation.select( fn.SUM(Allocation.primary_10) + fn.SUM(Allocation.primary_12) @@ -96,35 +92,35 @@ def getDepartmentAllocationSummary(department): ) .where( Allocation.department == department, - Allocation.termCode == term_code, + Allocation.termCode == termCode, ) .scalar() ) - result["allocated"] = total_positions or 0 + result["allocated"] = totalPositions or 0 - used_allocation = ( + usedAllocation = ( LaborStatusForm.select() .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) .where( LaborStatusForm.department == department, - LaborStatusForm.termCode == term_code, + LaborStatusForm.termCode == termCode, LaborStatusForm.contractHours.is_null(True), FormHistory.historyType == "Labor Status Form", ~(FormHistory.status % "Denied%"), ) .count() ) - result["used"] = used_allocation - - result["used_positions"] = { - "used_10": countWorkers(department, term_code, "Primary", 10), - "used_12": countWorkers(department, term_code, "Primary", 12), - "used_15": countWorkers(department, term_code, "Primary", 15), - "used_20": countWorkers(department, term_code, "Primary", 20), - "used_5_sec": countWorkers(department, term_code, "Secondary", 5), - "used_10_sec": countWorkers(department, term_code, "Secondary", 10), + result["used"] = usedAllocation + + result["usedPositions"] = { + "used10": countWorkers(department, termCode, "Primary", 10), + "used12": countWorkers(department, termCode, "Primary", 12), + "used15": countWorkers(department, termCode, "Primary", 15), + "used20": countWorkers(department, termCode, "Primary", 20), + "usedSecondary5": countWorkers(department, termCode, "Secondary", 5), + "usedSecondary10": countWorkers(department, termCode, "Secondary", 10), } - result["break_hours"] = getBreakHours(department, term_code) + result["breakHours"] = getBreakHours(department, termCode) return result diff --git a/app/static/css/departmentPortal.css b/app/static/css/departmentPortal.css index b04f98ad1..88ae06e29 100644 --- a/app/static/css/departmentPortal.css +++ b/app/static/css/departmentPortal.css @@ -47,8 +47,24 @@ overflow-x: auto; margin: 10px 0; } +.allocation-summary { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 0 1rem; +} +.allocation-summary h4 { + margin: 10px 0; +} +.allocation-columns { + display: flex; + flex-wrap: wrap; + gap: 0 2rem; +} .allocation-table { - width: 100%; + /* wraps onto its own line when the card is too narrow, instead of shrinking */ + flex: 1 1 180px; border-collapse: collapse; font-size: 1.2em; } @@ -63,9 +79,6 @@ font-weight: 700; padding-top: 10px; } -.allocation-table .term-row h4 { - margin: 10px 0; -} .bi-people-fill { /* Bootstrap Icon for Members Card */ border: 1px solid #c0c0c0; @@ -98,14 +111,14 @@ } } +/* Narrow card: stack Secondary below Primary, and the position count below the + term, rather than shrinking the text to keep them side by side. */ @media (min-width: 1200px) and (max-width: 1450px), (max-width: 480px) { - .allocation-table { - font-size: 0.85em; - } - .allocation-table td { - padding: 2px 5px 2px 0; + .allocation-summary { + flex-direction: column; + gap: 0; } - .allocation-table .term-row h4 { - font-size: 0.9rem; + .allocation-columns .allocation-table { + flex-basis: 100%; } } diff --git a/app/templates/main/departmentPortal.html b/app/templates/main/departmentPortal.html index b6178bf99..ce0b051fe 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -9,11 +9,11 @@ {% endblock %} + {% block app_content %}

{% if department %} {{department.DEPT_NAME}} Portal {% else %} Choose a Department: {% endif %}

-
-{% if department %} + {% if department %}
@@ -42,45 +42,43 @@

{% if department %} {{department.DEPT_NAME}} Portal {% e

-

Current Allocation

+

Current Allocations

- {% macro allocationCell(hours, used, allocated) -%} - {{ hours }} hr: {{ used }} contract{{ 's' if used != 1 else '' }}
(out of {{ allocated }} allocation{{ 's' if allocated != 1 else '' }}) + {% macro allocationRow(hours, used, allocated) -%} + {{ hours }} hr: {{ used }} contract{{ 's' if used != 1 else '' }}
(out of {{ allocated }} allocation{{ 's' if allocated != 1 else '' }}) {%- endmacro %}
- - - - - - - - - - - - {{ allocationCell(10, usedPositions.used_10, allocation.primary_10) }} - {{ allocationCell(5, usedPositions.used_5_sec, allocation.secondary_5) }} - - - {{ allocationCell(12, usedPositions.used_12, allocation.primary_12) }} - {{ allocationCell(10, usedPositions.used_10_sec, allocation.secondary_10) }} - - - {{ allocationCell(15, usedPositions.used_15, allocation.primary_15) }} - - - - {{ allocationCell(20, usedPositions.used_20, allocation.primary_20) }} - - - -

{{ currentSemester if currentSemester else "No term data" }}

{{used}} of {{allocated or 0}} Positions

PrimarySecondary
+
+

{{ currentSemester if currentSemester else "No term data" }}

+

{{used}} contracted of {{allocated or 0}} allocated Positions

+
+
+ + + + + + {{ allocationRow(10, usedPositions.used10, allocation.primary_10) }} + {{ allocationRow(12, usedPositions.used12, allocation.primary_12) }} + {{ allocationRow(15, usedPositions.used15, allocation.primary_15) }} + {{ allocationRow(20, usedPositions.used20, allocation.primary_20) }} + +
Primary
+ + + + + + {{ allocationRow(5, usedPositions.usedSecondary5, allocation.secondary_5) }} + {{ allocationRow(10, usedPositions.usedSecondary10, allocation.secondary_10) }} + +
Secondary
+
diff --git a/tests/code/test_getAllocation.py b/tests/code/test_getAllocation.py index 82d137106..72e115db3 100644 --- a/tests/code/test_getAllocation.py +++ b/tests/code/test_getAllocation.py @@ -16,9 +16,9 @@ from app.logic.getAllocation import getDepartmentAllocationSummary, countWorkers, getBreakHours, getCurrentSemesterLabel -def _createFormHistory(form, statusName): - """Attach a FormHistory row to a LaborStatusForm, since getBreakHours now - only counts forms with an approved "Labor Status Form" history entry.""" +def createFormHistory(form, statusName): + """Attach a "Labor Status Form" history entry with the given status, since + the allocation queries only count forms that have one.""" user = User.create(username=f"testuser_{form.laborStatusFormID}") historyType = HistoryType.get(HistoryType.historyTypeName == "Labor Status Form") status = Status.get(Status.statusName == statusName) @@ -31,74 +31,82 @@ def _createFormHistory(form, statusName): ) -def test_getCurrentSemesterLabel_none_term(): +@pytest.mark.unit +def test_getCurrentSemesterLabel(): + """ + Test that a term maps to the Fall/Spring label for whichever half of the + academic year today falls in, and that a missing term has no label. + """ + # No term (e.g. a department with no allocations) - nothing to label assert getCurrentSemesterLabel(None) is None - -def test_getCurrentSemesterLabel_fall(): - """A term whose termCode's academic year is 2025 should read as Fall 2025 - when today falls in the Aug-Dec half of the academic year.""" term = Term(termCode=202500) + + # Aug-Dec half of the academic year - reads as Fall of the term's own year with patch("app.logic.getAllocation.date") as mockDate: mockDate.today.return_value = date(2025, 9, 15) assert getCurrentSemesterLabel(term) == "Fall 2025" - -def test_getCurrentSemesterLabel_spring(): - """The same academic-year term should read as Spring 2026 when today - falls in the Jan-Jul half of the academic year.""" - term = Term(termCode=202500) + # Jan-Jul half of the same academic-year term - reads as Spring of the next year with patch("app.logic.getAllocation.date") as mockDate: mockDate.today.return_value = date(2026, 2, 10) assert getCurrentSemesterLabel(term) == "Spring 2026" @pytest.mark.integration -def test_getDepartmentAllocationSummary_no_allocation(): +def test_getDepartmentAllocationSummary(): """ - Test that a department with no Allocation rows gets a zeroed-out summary - with term=None, instead of an error. + Test that the summary reports allocated/used/breakHours for a department's + most recent term, covering a missing department, a department with no + Allocation rows, allocations spread across terms, several Allocation rows + in one term, break-term contracts, and an allocation with no forms. """ + zeroedUsedPositions = { + "used10": 0, + "used12": 0, + "used15": 0, + "used20": 0, + "usedSecondary5": 0, + "usedSecondary10": 0, + } + + # department=None (e.g. when Department.get() fails in the departmentPortal + # route) returns the zeroed-out fallback instead of raising an error + summary = getDepartmentAllocationSummary(None) + + assert summary["term"] is None + assert summary["allocated"] == 0 + assert summary["used"] == 0 + assert summary["breakHours"] == 0 + assert summary["usedPositions"] == zeroedUsedPositions + with mainDB.atomic() as transaction: - dept = Department.create(departmentID=200, DEPT_NAME="Physics", ACCOUNT="6750", ORG="2120", isActive=True) + # A department with no Allocation rows gets the same zeroed-out summary + # with term=None + emptyDept = Department.create(departmentID=200, DEPT_NAME="Physics", ACCOUNT="6750", ORG="2120", isActive=True) - summary = getDepartmentAllocationSummary(dept) + summary = getDepartmentAllocationSummary(emptyDept) assert summary["term"] is None assert summary["allocated"] == 0 assert summary["used"] == 0 - assert summary["break_hours"] == 0 - assert summary["used_positions"] == { - "used_10": 0, - "used_12": 0, - "used_15": 0, - "used_20": 0, - "used_5_sec": 0, - "used_10_sec": 0, - } - - transaction.rollback() - + assert summary["breakHours"] == 0 + assert summary["usedPositions"] == zeroedUsedPositions -@pytest.mark.integration -def test_getDepartmentAllocationSummary_uses_most_recent_term(): - """ - Test that when a department has allocations across multiple terms, the - summary reflects only the most recent term's data. - """ - with mainDB.atomic() as transaction: - dept = Department.create(departmentID=201, DEPT_NAME="Chemistry", ACCOUNT="6751", ORG="2121", isActive=True) + # With allocations across multiple terms, the summary reflects only the + # most recent term's data + multiTermDept = Department.create(departmentID=201, DEPT_NAME="Chemistry", ACCOUNT="6751", ORG="2121", isActive=True) oldTerm = Term.create(termCode=900000, termName="AY Test Old") newTerm = Term.create(termCode=900100, termName="AY Test New") Allocation.create( - termCode=oldTerm, department=dept, isFinal=True, justification="old", + termCode=oldTerm, department=multiTermDept, isFinal=True, justification="old", primary_10=1, primary_12=0, primary_15=0, primary_20=0, secondary_5=0, secondary_10=0, breakHours=50, ) Allocation.create( - termCode=newTerm, department=dept, isFinal=True, justification="new", + termCode=newTerm, department=multiTermDept, isFinal=True, justification="new", primary_10=2, primary_12=3, primary_15=0, primary_20=0, secondary_5=1, secondary_10=0, breakHours=100, ) @@ -106,151 +114,106 @@ def test_getDepartmentAllocationSummary_uses_most_recent_term(): supervisor = Supervisor.create(ID="SUP001", isActive=True) student = Student.create(ID="STU001", isActive=True) - # Under the OLD term - should be excluded from the summary - LaborStatusForm.create( - termCode=oldTerm, studentSupervisee=student, supervisor=supervisor, department=dept, + # Approved under the OLD term - excluded by the term filter alone + oldForm = LaborStatusForm.create( + termCode=oldTerm, studentSupervisee=student, supervisor=supervisor, department=multiTermDept, jobType="Primary", WLS="10", POSN_TITLE="Old Job", POSN_CODE="S001", weeklyHours=10, contractHours=None, ) + createFormHistory(oldForm, "Approved") + # Under the NEW (most recent) term - should be counted newForm = LaborStatusForm.create( - termCode=newTerm, studentSupervisee=student, supervisor=supervisor, department=dept, + termCode=newTerm, studentSupervisee=student, supervisor=supervisor, department=multiTermDept, jobType="Primary", WLS="10", POSN_TITLE="New Job", POSN_CODE="S002", weeklyHours=10, contractHours=None, ) - _createFormHistory(newForm, "Approved") + createFormHistory(newForm, "Approved") - summary = getDepartmentAllocationSummary(dept) + # Denied under the NEW term - should not count toward used + deniedForm = LaborStatusForm.create( + termCode=newTerm, studentSupervisee=student, supervisor=supervisor, department=multiTermDept, + jobType="Primary", WLS="12", POSN_TITLE="Denied Job", POSN_CODE="S004", + weeklyHours=12, contractHours=None, + ) + createFormHistory(deniedForm, "Denied by Admin") + + summary = getDepartmentAllocationSummary(multiTermDept) assert summary["term"].termCode == 900100 assert summary["allocated"] == 6 # 2 + 3 + 0 + 0 + 1 + 0, from the new term only - assert summary["used"] == 1 # only the new term's LaborStatusForm counts - assert summary["used_positions"]["used_10"] == 1 - assert summary["break_hours"] == 0 + assert summary["used"] == 1 # only the new term's approved LaborStatusForm counts + assert summary["usedPositions"]["used10"] == 1 + assert summary["usedPositions"]["used12"] == 0 # the denied form is not counted + assert summary["breakHours"] == 0 - transaction.rollback() - - -@pytest.mark.integration -def test_getDepartmentAllocationSummary_break_hours(): - """ - Test that break_hours only sums approved forms with contractHours set - (break-term contracts), and that those forms are excluded from the - weekly "used" count. - """ - with mainDB.atomic() as transaction: - dept = Department.create(departmentID=202, DEPT_NAME="Biology", ACCOUNT="6752", ORG="2122", isActive=True) - term = Term.create(termCode=900200, termName="AY Test Break") + # breakHours only sums approved forms with contractHours set (break-term + # contracts), and those forms are excluded from the weekly "used" count + breakDept = Department.create(departmentID=202, DEPT_NAME="Biology", ACCOUNT="6752", ORG="2122", isActive=True) + breakTerm = Term.create(termCode=900200, termName="AY Test Break") Allocation.create( - termCode=term, department=dept, isFinal=True, justification="test", + termCode=breakTerm, department=breakDept, isFinal=True, justification="test", primary_10=1, primary_12=0, primary_15=0, primary_20=0, secondary_5=0, secondary_10=0, breakHours=200, ) - supervisor = Supervisor.create(ID="SUP002", isActive=True) - student = Student.create(ID="STU002", isActive=True) + breakSupervisor = Supervisor.create(ID="SUP002", isActive=True) + breakStudent = Student.create(ID="STU002", isActive=True) breakForm = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, + termCode=breakTerm, studentSupervisee=breakStudent, supervisor=breakSupervisor, department=breakDept, jobType="Primary", WLS="10", POSN_TITLE="Break Worker", POSN_CODE="S003", weeklyHours=None, contractHours=40, ) - _createFormHistory(breakForm, "Approved") + createFormHistory(breakForm, "Approved") - summary = getDepartmentAllocationSummary(dept) + summary = getDepartmentAllocationSummary(breakDept) - assert summary["break_hours"] == 40 + assert summary["breakHours"] == 40 assert summary["used"] == 0 - transaction.rollback() - - -@pytest.mark.integration -def test_getDepartmentAllocationSummary_department_none(): - """ - Test that passing department=None (e.g. when Department.get() fails in - the departmentPortal route) returns the zeroed-out fallback instead of - raising an error. - """ - summary = getDepartmentAllocationSummary(None) - - assert summary["term"] is None - assert summary["allocated"] == 0 - assert summary["used"] == 0 - assert summary["break_hours"] == 0 - assert summary["used_positions"] == { - "used_10": 0, - "used_12": 0, - "used_15": 0, - "used_20": 0, - "used_5_sec": 0, - "used_10_sec": 0, - } - - -@pytest.mark.integration -def test_getDepartmentAllocationSummary_multiple_allocations_same_term(): - """ - Test that if a department has more than one Allocation row for the same - most-recent term (e.g. a draft and a final revision, which the model's - (termCode, department, isFinal) index allows), the totals sum across - both rows rather than picking just one. - """ - with mainDB.atomic() as transaction: - dept = Department.create(departmentID=203, DEPT_NAME="Mathematics", ACCOUNT="6753", ORG="2123", isActive=True) - term = Term.create(termCode=900300, termName="AY Test Multi") + # More than one Allocation row for the same most-recent term (e.g. a + # draft and a final revision, which the model's (termCode, department, + # isFinal) index allows) sums across both rows rather than picking one + multiRowDept = Department.create(departmentID=203, DEPT_NAME="Mathematics", ACCOUNT="6753", ORG="2123", isActive=True) + multiRowTerm = Term.create(termCode=900300, termName="AY Test Multi") Allocation.create( - termCode=term, department=dept, isFinal=False, justification="draft", + termCode=multiRowTerm, department=multiRowDept, isFinal=False, justification="draft", primary_10=1, primary_12=0, primary_15=0, primary_20=0, secondary_5=0, secondary_10=0, breakHours=10, ) Allocation.create( - termCode=term, department=dept, isFinal=True, justification="final", + termCode=multiRowTerm, department=multiRowDept, isFinal=True, justification="final", primary_10=2, primary_12=0, primary_15=0, primary_20=0, secondary_5=0, secondary_10=0, breakHours=20, ) - summary = getDepartmentAllocationSummary(dept) + summary = getDepartmentAllocationSummary(multiRowDept) assert summary["term"].termCode == 900300 assert summary["allocated"] == 3 # 1 + 2, summed across both rows - transaction.rollback() - - -@pytest.mark.integration -def test_getDepartmentAllocationSummary_allocation_no_labor_status_forms(): - """ - Test that a department with an allocation for the most recent term but no - LaborStatusForm records at all shows allocated > 0 with used/break_hours - at 0, rather than erroring on an empty result set. - """ - with mainDB.atomic() as transaction: - dept = Department.create(departmentID=204, DEPT_NAME="History", ACCOUNT="6754", ORG="2124", isActive=True) - term = Term.create(termCode=900400, termName="AY Test Empty") + # An allocation for the most recent term with no LaborStatusForm records + # at all shows allocated > 0 with used/breakHours at 0, rather than + # erroring on an empty result set + noFormsDept = Department.create(departmentID=204, DEPT_NAME="History", ACCOUNT="6754", ORG="2124", isActive=True) + noFormsTerm = Term.create(termCode=900400, termName="AY Test Empty") Allocation.create( - termCode=term, department=dept, isFinal=True, justification="test", + termCode=noFormsTerm, department=noFormsDept, isFinal=True, justification="test", primary_10=3, primary_12=2, primary_15=0, primary_20=0, secondary_5=1, secondary_10=0, breakHours=150, ) - summary = getDepartmentAllocationSummary(dept) + summary = getDepartmentAllocationSummary(noFormsDept) assert summary["term"].termCode == 900400 assert summary["allocated"] == 6 assert summary["used"] == 0 - assert summary["break_hours"] == 0 - assert summary["used_positions"] == { - "used_10": 0, - "used_12": 0, - "used_15": 0, - "used_20": 0, - "used_5_sec": 0, - "used_10_sec": 0, - } + assert summary["breakHours"] == 0 + assert summary["usedPositions"] == zeroedUsedPositions transaction.rollback() @@ -276,7 +239,7 @@ def test_countWorkers(): jobType="Primary", WLS="10", POSN_TITLE="Match", POSN_CODE="S010", weeklyHours=10, contractHours=None, ) - _createFormHistory(matchForm, "Approved") + createFormHistory(matchForm, "Approved") # Different job type - should not count toward ("Primary", 10) wrongJobTypeForm = LaborStatusForm.create( @@ -284,7 +247,7 @@ def test_countWorkers(): jobType="Secondary", WLS="10", POSN_TITLE="Wrong Job Type", POSN_CODE="S011", weeklyHours=10, contractHours=None, ) - _createFormHistory(wrongJobTypeForm, "Approved") + createFormHistory(wrongJobTypeForm, "Approved") # Different hours bucket - should not count toward ("Primary", 10) wrongHoursForm = LaborStatusForm.create( @@ -292,7 +255,7 @@ def test_countWorkers(): jobType="Primary", WLS="12", POSN_TITLE="Wrong Hours", POSN_CODE="S012", weeklyHours=12, contractHours=None, ) - _createFormHistory(wrongHoursForm, "Approved") + createFormHistory(wrongHoursForm, "Approved") # Break-term contract (contractHours set) - should not count even though # job type and weeklyHours otherwise match @@ -301,7 +264,7 @@ def test_countWorkers(): jobType="Primary", WLS="10", POSN_TITLE="Break Contract", POSN_CODE="S013", weeklyHours=10, contractHours=40, ) - _createFormHistory(breakContractForm, "Approved") + createFormHistory(breakContractForm, "Approved") # Matches everything but was DENIED - should not count deniedForm = LaborStatusForm.create( @@ -309,7 +272,7 @@ def test_countWorkers(): jobType="Primary", WLS="10", POSN_TITLE="Denied Match", POSN_CODE="S014", weeklyHours=10, contractHours=None, ) - _createFormHistory(deniedForm, "Denied by Admin") + createFormHistory(deniedForm, "Denied by Admin") assert countWorkers(dept, term.termCode, "Primary", 10) == 1 assert countWorkers(dept, term.termCode, "Secondary", 10) == 1 @@ -341,14 +304,14 @@ def test_getBreakHours(): jobType="Primary", WLS="10", POSN_TITLE="Break A", POSN_CODE="S020", weeklyHours=None, contractHours=40, ) - _createFormHistory(formA, "Approved") + createFormHistory(formA, "Approved") formB = LaborStatusForm.create( termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, jobType="Secondary", WLS="5", POSN_TITLE="Break B", POSN_CODE="S021", weeklyHours=None, contractHours=60, ) - _createFormHistory(formB, "Approved") + createFormHistory(formB, "Approved") # Weekly-hours form (contractHours=None) - should be excluded regardless formC = LaborStatusForm.create( @@ -356,15 +319,15 @@ def test_getBreakHours(): jobType="Primary", WLS="10", POSN_TITLE="Weekly Job", POSN_CODE="S022", weeklyHours=10, contractHours=None, ) - _createFormHistory(formC, "Approved") + createFormHistory(formC, "Approved") # Break-term contract under a DIFFERENT term - should be excluded formD = LaborStatusForm.create( termCode=otherTerm, studentSupervisee=student, supervisor=supervisor, department=dept, jobType="Primary", WLS="10", POSN_TITLE="Break Other Term", POSN_CODE="S023", - weeklyHours=None, contractHours=100, + weeklyHours=None, contractHours=25, ) - _createFormHistory(formD, "Approved") + createFormHistory(formD, "Approved") # Break-term contract that is still PENDING - should be excluded formE = LaborStatusForm.create( @@ -372,9 +335,9 @@ def test_getBreakHours(): jobType="Primary", WLS="10", POSN_TITLE="Break Pending", POSN_CODE="S024", weeklyHours=None, contractHours=999, ) - _createFormHistory(formE, "Pending") + createFormHistory(formE, "Pending") - assert getBreakHours(dept, term.termCode) == 100 # 40 + 60, excludes the pending form - assert getBreakHours(dept, otherTerm.termCode) == 100 + assert getBreakHours(dept, term.termCode) == 100 # 40 + 60, excludes the pending form + assert getBreakHours(dept, otherTerm.termCode) == 25 # only the other term's contract transaction.rollback() From 25d9a9be2eafbe8845b70052b066a5d6ee1ca7e0 Mon Sep 17 00:00:00 2001 From: munsakad Date: Wed, 5 Aug 2026 11:24:16 -0400 Subject: [PATCH 16/20] Reuse allocationManager.py in getAllocation.py getDepartmentAllocationSummary now calls allocationManager's getTotalAllocations/getContractedAllocations instead of reimplementing the same counts, removing countWorkers/getBreakHours and the manual Allocation SUM query. Falls back to zeroed defaults for a most-recent term that only has a draft allocation (no final row yet), and coalesces getContractedAllocations' break_hours since its SQL SUM() can return None. Updated test_getAllocation.py to match: removed the now-redundant countWorkers/ getBreakHours unit tests, updated the multi-row-allocation expectation to reflect final-only summing, and added coverage for the draft-only fallback. --- app/logic/getAllocation.py | 103 +++++--------------- tests/code/test_getAllocation.py | 158 +++++++------------------------ 2 files changed, 61 insertions(+), 200 deletions(-) diff --git a/app/logic/getAllocation.py b/app/logic/getAllocation.py index 1f7c879d3..2d45cc9f2 100644 --- a/app/logic/getAllocation.py +++ b/app/logic/getAllocation.py @@ -1,11 +1,8 @@ from datetime import date -from peewee import fn - +from app.logic.allocationManager import getContractedAllocations, getTotalAllocations from app.models.allocation import Allocation -from app.models.laborStatusForm import LaborStatusForm from app.models.term import Term -from app.models.formHistory import FormHistory def getCurrentSemesterLabel(term): @@ -19,39 +16,6 @@ def getCurrentSemesterLabel(term): return f"Spring {academicYear + 1}" -def countWorkers(department, termCode, jobType, hoursBucket): - workerCount = ( - LaborStatusForm.select() - .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) - .where( - LaborStatusForm.department == department, - LaborStatusForm.termCode == termCode, - LaborStatusForm.jobType == jobType, - LaborStatusForm.weeklyHours == hoursBucket, - LaborStatusForm.contractHours.is_null(True), - FormHistory.historyType == "Labor Status Form", - ~(FormHistory.status % "Denied%"), - ) - .count() - ) - return workerCount - - -def getBreakHours(department, termCode): - breakHoursTotal = ( - LaborStatusForm.select(fn.SUM(LaborStatusForm.contractHours)) - .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) - .where( - LaborStatusForm.department == department, - LaborStatusForm.termCode == termCode, - FormHistory.historyType == "Labor Status Form", - FormHistory.status == "Approved", - ) - .scalar() - ) or 0 - return breakHoursTotal - - def getDepartmentAllocationSummary(department): """Return allocation-utilization values for a department's most recent term.""" result = { @@ -81,46 +45,29 @@ def getDepartmentAllocationSummary(department): result["term"] = recentTerm result["currentSemester"] = getCurrentSemesterLabel(recentTerm) - totalPositions = ( - Allocation.select( - fn.SUM(Allocation.primary_10) - + fn.SUM(Allocation.primary_12) - + fn.SUM(Allocation.primary_15) - + fn.SUM(Allocation.primary_20) - + fn.SUM(Allocation.secondary_5) - + fn.SUM(Allocation.secondary_10) - ) - .where( - Allocation.department == department, - Allocation.termCode == termCode, - ) - .scalar() - ) - result["allocated"] = totalPositions or 0 - - usedAllocation = ( - LaborStatusForm.select() - .join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) - .where( - LaborStatusForm.department == department, - LaborStatusForm.termCode == termCode, - LaborStatusForm.contractHours.is_null(True), - FormHistory.historyType == "Labor Status Form", - ~(FormHistory.status % "Denied%"), - ) - .count() - ) - result["used"] = usedAllocation - - result["usedPositions"] = { - "used10": countWorkers(department, termCode, "Primary", 10), - "used12": countWorkers(department, termCode, "Primary", 12), - "used15": countWorkers(department, termCode, "Primary", 15), - "used20": countWorkers(department, termCode, "Primary", 20), - "usedSecondary5": countWorkers(department, termCode, "Secondary", 5), - "usedSecondary10": countWorkers(department, termCode, "Secondary", 10), - } - - result["breakHours"] = getBreakHours(department, termCode) + # allocationManager's helpers only look at the *final* Allocation row for the + # term (they call getAllocation(..., isFinal=True) under the hood) and raise + # if one doesn't exist yet. A department whose most recent term is still a + # draft has no final row - fall back to the zeroed defaults above rather + # than letting that propagate into a 500 on the department portal. + try: + result["allocated"] = getTotalAllocations(termCode, department.departmentID)["totalAllocations"] + + contractedAllocations = getContractedAllocations(termCode, department.departmentID) + result["used"] = contractedAllocations["used_total"] + result["usedPositions"] = { + "used10": contractedAllocations["used_10"], + "used12": contractedAllocations["used_12"], + "used15": contractedAllocations["used_15"], + "used20": contractedAllocations["used_20"], + "usedSecondary5": contractedAllocations["used_5_sec"], + "usedSecondary10": contractedAllocations["used_10_sec"], + } + # getContractedAllocations' underlying SQL SUM() returns None (not 0) + # for a department/term whose only approved forms are weekly-hours + # ones (no break contract) - coalesce so the card doesn't render "None" + result["breakHours"] = contractedAllocations["break_hours"] or 0 + except Allocation.DoesNotExist: + pass return result diff --git a/tests/code/test_getAllocation.py b/tests/code/test_getAllocation.py index 72e115db3..6592fcffe 100644 --- a/tests/code/test_getAllocation.py +++ b/tests/code/test_getAllocation.py @@ -13,7 +13,7 @@ from app.models.historyType import HistoryType from app.models.status import Status from app.models.user import User -from app.logic.getAllocation import getDepartmentAllocationSummary, countWorkers, getBreakHours, getCurrentSemesterLabel +from app.logic.getAllocation import getDepartmentAllocationSummary, getCurrentSemesterLabel def createFormHistory(form, statusName): @@ -59,7 +59,13 @@ def test_getDepartmentAllocationSummary(): Test that the summary reports allocated/used/breakHours for a department's most recent term, covering a missing department, a department with no Allocation rows, allocations spread across terms, several Allocation rows - in one term, break-term contracts, and an allocation with no forms. + in one term, break-term contracts, an allocation with no forms, and a + most-recent term that only has a draft (not yet final) allocation. + + The allocated/used/breakHours values are sourced from allocationManager's + getTotalAllocations/getContractedAllocations (see test_allocationManger.py + for those functions' own unit coverage) - only the term-selection and + fallback behavior is re-verified here. """ zeroedUsedPositions = { "used10": 0, @@ -175,7 +181,9 @@ def test_getDepartmentAllocationSummary(): # More than one Allocation row for the same most-recent term (e.g. a # draft and a final revision, which the model's (termCode, department, - # isFinal) index allows) sums across both rows rather than picking one + # isFinal) index allows) reports only the final row - the draft is not + # counted, since allocationManager's getTotalAllocations only looks at + # the isFinal=True row for a term multiRowDept = Department.create(departmentID=203, DEPT_NAME="Mathematics", ACCOUNT="6753", ORG="2123", isActive=True) multiRowTerm = Term.create(termCode=900300, termName="AY Test Multi") @@ -193,7 +201,7 @@ def test_getDepartmentAllocationSummary(): summary = getDepartmentAllocationSummary(multiRowDept) assert summary["term"].termCode == 900300 - assert summary["allocated"] == 3 # 1 + 2, summed across both rows + assert summary["allocated"] == 2 # only the final row counts, the draft's 1 is not added in # An allocation for the most recent term with no LaborStatusForm records # at all shows allocated > 0 with used/breakHours at 0, rather than @@ -215,129 +223,35 @@ def test_getDepartmentAllocationSummary(): assert summary["breakHours"] == 0 assert summary["usedPositions"] == zeroedUsedPositions - transaction.rollback() - - -@pytest.mark.integration -def test_countWorkers(): - """ - Test that countWorkers only counts LaborStatusForm rows matching the - given department, term, job type, and weekly-hours bucket, and excludes - forms with a different job type/hours bucket, a break-term contract - (contractHours set instead of weeklyHours), or a denied history status. - """ - with mainDB.atomic() as transaction: - dept = Department.create(departmentID=205, DEPT_NAME="English", ACCOUNT="6755", ORG="2125", isActive=True) - term = Term.create(termCode=900500, termName="AY Test Workers") - - supervisor = Supervisor.create(ID="SUP003", isActive=True) - student = Student.create(ID="STU003", isActive=True) - - # Matches department, term, job type, and hours bucket - should count - matchForm = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Primary", WLS="10", POSN_TITLE="Match", POSN_CODE="S010", - weeklyHours=10, contractHours=None, - ) - createFormHistory(matchForm, "Approved") + # A most-recent term with only a draft (isFinal=False) allocation - no + # final row exists yet, so allocationManager's getTotalAllocations has + # nothing to select and would raise; the summary should fall back to + # the zeroed defaults (still reporting the term) instead of erroring + draftOnlyDept = Department.create(departmentID=205, DEPT_NAME="Art", ACCOUNT="6755", ORG="2125", isActive=True) + draftOnlyTerm = Term.create(termCode=900500, termName="AY Test Draft Only") - # Different job type - should not count toward ("Primary", 10) - wrongJobTypeForm = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Secondary", WLS="10", POSN_TITLE="Wrong Job Type", POSN_CODE="S011", - weeklyHours=10, contractHours=None, + Allocation.create( + termCode=draftOnlyTerm, department=draftOnlyDept, isFinal=False, justification="draft", + primary_10=5, primary_12=0, primary_15=0, primary_20=0, + secondary_5=0, secondary_10=0, breakHours=30, ) - createFormHistory(wrongJobTypeForm, "Approved") - # Different hours bucket - should not count toward ("Primary", 10) - wrongHoursForm = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Primary", WLS="12", POSN_TITLE="Wrong Hours", POSN_CODE="S012", - weeklyHours=12, contractHours=None, - ) - createFormHistory(wrongHoursForm, "Approved") - - # Break-term contract (contractHours set) - should not count even though - # job type and weeklyHours otherwise match - breakContractForm = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Primary", WLS="10", POSN_TITLE="Break Contract", POSN_CODE="S013", - weeklyHours=10, contractHours=40, - ) - createFormHistory(breakContractForm, "Approved") + summary = getDepartmentAllocationSummary(draftOnlyDept) - # Matches everything but was DENIED - should not count - deniedForm = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Primary", WLS="10", POSN_TITLE="Denied Match", POSN_CODE="S014", - weeklyHours=10, contractHours=None, - ) - createFormHistory(deniedForm, "Denied by Admin") - - assert countWorkers(dept, term.termCode, "Primary", 10) == 1 - assert countWorkers(dept, term.termCode, "Secondary", 10) == 1 - assert countWorkers(dept, term.termCode, "Primary", 12) == 1 - assert countWorkers(dept, term.termCode, "Primary", 15) == 0 + assert summary["term"].termCode == 900500 + assert summary["allocated"] == 0 + assert summary["used"] == 0 + assert summary["breakHours"] == 0 + assert summary["usedPositions"] == zeroedUsedPositions transaction.rollback() -@pytest.mark.integration -def test_getBreakHours(): - """ - Test that getBreakHours sums only APPROVED forms with contractHours set - (break-term contracts) for the given department and term, excludes - weekly-hours forms, excludes forms under a different term, and excludes - forms that are not approved (e.g. still pending). - """ - with mainDB.atomic() as transaction: - dept = Department.create(departmentID=206, DEPT_NAME="Philosophy", ACCOUNT="6756", ORG="2126", isActive=True) - term = Term.create(termCode=900600, termName="AY Test Break Hours") - otherTerm = Term.create(termCode=900601, termName="AY Test Other Term") - - supervisor = Supervisor.create(ID="SUP004", isActive=True) - student = Student.create(ID="STU004", isActive=True) - - # Approved break-term contracts under the target term - should be summed - formA = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Primary", WLS="10", POSN_TITLE="Break A", POSN_CODE="S020", - weeklyHours=None, contractHours=40, - ) - createFormHistory(formA, "Approved") - - formB = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Secondary", WLS="5", POSN_TITLE="Break B", POSN_CODE="S021", - weeklyHours=None, contractHours=60, - ) - createFormHistory(formB, "Approved") - - # Weekly-hours form (contractHours=None) - should be excluded regardless - formC = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Primary", WLS="10", POSN_TITLE="Weekly Job", POSN_CODE="S022", - weeklyHours=10, contractHours=None, - ) - createFormHistory(formC, "Approved") - - # Break-term contract under a DIFFERENT term - should be excluded - formD = LaborStatusForm.create( - termCode=otherTerm, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Primary", WLS="10", POSN_TITLE="Break Other Term", POSN_CODE="S023", - weeklyHours=None, contractHours=25, - ) - createFormHistory(formD, "Approved") - - # Break-term contract that is still PENDING - should be excluded - formE = LaborStatusForm.create( - termCode=term, studentSupervisee=student, supervisor=supervisor, department=dept, - jobType="Primary", WLS="10", POSN_TITLE="Break Pending", POSN_CODE="S024", - weeklyHours=None, contractHours=999, - ) - createFormHistory(formE, "Pending") - - assert getBreakHours(dept, term.termCode) == 100 # 40 + 60, excludes the pending form - assert getBreakHours(dept, otherTerm.termCode) == 25 # only the other term's contract - - transaction.rollback() +# countWorkers and getBreakHours were removed in favor of calling +# allocationManager's getContractedAllocations directly from +# getDepartmentAllocationSummary (see test_allocationManger.py's +# test_countContracts/test_getContractedAllocations for that function's own +# coverage). Note the counting rules aren't identical to the old +# countWorkers/getBreakHours: getContractedAllocations doesn't exclude +# break-term contracts (contractHours set) from the weekly-hours buckets, and +# uses a narrower status whitelist instead of "anything not Denied". From f5640b3aa829b0cec41590576083a9fa636b1b8b Mon Sep 17 00:00:00 2001 From: munsakad Date: Wed, 5 Aug 2026 11:31:55 -0400 Subject: [PATCH 17/20] Fix missing LaborReleaseForm import in demo_data.py database/demo_data.py called LaborReleaseForm.insert(...) but never imported the model, so any fresh database/reset_database.sh run failed with a NameError partway through seeding demo data. Pre-existing bug from the department-portal-base merge, unrelated to the allocation card changes. --- database/demo_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/database/demo_data.py b/database/demo_data.py index 2e4543d34..eb02f8e17 100644 --- a/database/demo_data.py +++ b/database/demo_data.py @@ -14,6 +14,7 @@ from app.models.user import User from app.models.term import Term from app.models.laborStatusForm import LaborStatusForm +from app.models.laborReleaseForm import LaborReleaseForm from app.models.formHistory import FormHistory from app.models.notes import Notes from app.models.supervisorDepartment import SupervisorDepartment From 4c17019ff02c46dccbd07315161a50c426dd87fa Mon Sep 17 00:00:00 2001 From: munsakad Date: Wed, 5 Aug 2026 11:39:35 -0400 Subject: [PATCH 18/20] Fix allocation card showing zeros for departments with only a draft allocation Root cause: allocationManager's getContractedAllocations called getAllocation (isFinal=True only) for a value it never used, so any department whose most recent term only has a draft (not yet final) Allocation row raised DoesNotExist. getAllocation.py's earlier try/except caught that and fell back to zeroed defaults, which is why real demo data (departments with only draft allocations) started rendering 0s instead of their actual numbers. - allocationManager.py: removed the dead getAllocation() call from getContractedAllocations. Also folded its break-hours query to filter by department directly in SQL instead of fetching every department's totals and picking the first match after grouping by (department, termCode) - that grouping meant a department with approved break hours under both the specific term and the academic-year "00" bucket term would silently keep only whichever row came back first instead of summing them. Coalesces the SQL SUM() to 0 instead of leaving it None. - getAllocation.py: "allocated" is now summed directly from the Allocation rows already fetched for the most recent term (draft + final, matching the original pre-refactor behavior) instead of going through getTotalAllocations, which only looks at the final row. Dropped the DoesNotExist fallback - no longer needed now that the crash source is fixed at the root. - Updated test_getAllocation.py's multi-row and draft-only cases to expect the correct summed/draft values instead of the zeroed fallback. - Added two regression tests to test_allocationManger.py: getContractedAllocations without any Allocation row present, and break hours summed across a specific term and its academic-year bucket term. --- app/logic/allocationManager.py | 42 ++++++++--------------- app/logic/getAllocation.py | 46 ++++++++++++------------- tests/code/test_allocationManger.py | 53 ++++++++++++++++++++++++++++- tests/code/test_getAllocation.py | 32 +++++++++-------- 4 files changed, 105 insertions(+), 68 deletions(-) diff --git a/app/logic/allocationManager.py b/app/logic/allocationManager.py index f1c58cbc6..537744ffc 100644 --- a/app/logic/allocationManager.py +++ b/app/logic/allocationManager.py @@ -59,36 +59,22 @@ def countContracts(jobType: str, weeklyContractHours: int, termCode: int, dept: def getContractedAllocations(termCode: int, dept: int): ''' - This function returns a dictionary with a breakdown of all types of contracts + This function returns a dictionary with a breakdown of all types of contracts for the given department and term in the form of a dictionary. ''' academicYearCode = int(str(termCode)[:4] + "00") - allocationObject = getAllocation(termCode, dept) - breakAllocation = FormHistory.select( - LaborStatusForm.department, - LaborStatusForm.termCode, - fn.SUM(LaborStatusForm.contractHours).alias('total_hours') - ).join( - LaborStatusForm, - on=(FormHistory.formID == LaborStatusForm.laborStatusFormID), - ).join( - Term, - on = (LaborStatusForm.termCode == Term.termCode ) - ).where( - (FormHistory.historyType == "Labor Status Form") & - (FormHistory.status == "Approved") & - (LaborStatusForm.termCode.in_([termCode,academicYearCode])) - ).group_by( - LaborStatusForm.department, - LaborStatusForm.termCode).dicts() - - breakSum = {"total_hours": 0} - if dept: - for row in breakAllocation: - if row["department"] == dept: - breakSum = row - break - + breakHoursTotal = ( + FormHistory.select(fn.SUM(LaborStatusForm.contractHours)) + .join(LaborStatusForm, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID)) + .where( + FormHistory.historyType == "Labor Status Form", + FormHistory.status == "Approved", + LaborStatusForm.termCode.in_([termCode, academicYearCode]), + LaborStatusForm.department == dept, + ) + .scalar() + ) or 0 + # dictionary definition: usedPositions = { "used_10": countContracts("Primary", "10", termCode, dept), @@ -100,7 +86,7 @@ def getContractedAllocations(termCode: int, dept: int): "used_primaries": 0, "used_secondaries": 0, "used_total": 0, # all contracts with weekly hours, i.e. primaries + secondaries (not break contracts) - "break_hours": breakSum["total_hours"] # all break hours contracted (but not necessarily worked) + "break_hours": breakHoursTotal # all break hours contracted (but not necessarily worked) } usedPositions["used_primaries"] = sum(list(usedPositions.values())[:4]) usedPositions["used_secondaries"] = sum(list(usedPositions.values())[4:6]) diff --git a/app/logic/getAllocation.py b/app/logic/getAllocation.py index 2d45cc9f2..5c09cd497 100644 --- a/app/logic/getAllocation.py +++ b/app/logic/getAllocation.py @@ -1,6 +1,6 @@ from datetime import date -from app.logic.allocationManager import getContractedAllocations, getTotalAllocations +from app.logic.allocationManager import getContractedAllocations from app.models.allocation import Allocation from app.models.term import Term @@ -45,29 +45,27 @@ def getDepartmentAllocationSummary(department): result["term"] = recentTerm result["currentSemester"] = getCurrentSemesterLabel(recentTerm) - # allocationManager's helpers only look at the *final* Allocation row for the - # term (they call getAllocation(..., isFinal=True) under the hood) and raise - # if one doesn't exist yet. A department whose most recent term is still a - # draft has no final row - fall back to the zeroed defaults above rather - # than letting that propagate into a 500 on the department portal. - try: - result["allocated"] = getTotalAllocations(termCode, department.departmentID)["totalAllocations"] + # "allocated" is summed directly from the rows already fetched above rather + # than through allocationManager's getTotalAllocations, since that only + # looks at the *final* Allocation row for a term - a department whose most + # recent term is still a draft (isFinal=False, no final row yet) would + # otherwise show 0 allocated instead of its draft numbers. + recentTermAllocations = [a for a in departmentAllocations if a.termCode_id == termCode] + result["allocated"] = sum( + a.primary_10 + a.primary_12 + a.primary_15 + a.primary_20 + a.secondary_5 + a.secondary_10 + for a in recentTermAllocations + ) - contractedAllocations = getContractedAllocations(termCode, department.departmentID) - result["used"] = contractedAllocations["used_total"] - result["usedPositions"] = { - "used10": contractedAllocations["used_10"], - "used12": contractedAllocations["used_12"], - "used15": contractedAllocations["used_15"], - "used20": contractedAllocations["used_20"], - "usedSecondary5": contractedAllocations["used_5_sec"], - "usedSecondary10": contractedAllocations["used_10_sec"], - } - # getContractedAllocations' underlying SQL SUM() returns None (not 0) - # for a department/term whose only approved forms are weekly-hours - # ones (no break contract) - coalesce so the card doesn't render "None" - result["breakHours"] = contractedAllocations["break_hours"] or 0 - except Allocation.DoesNotExist: - pass + contractedAllocations = getContractedAllocations(termCode, department.departmentID) + result["used"] = contractedAllocations["used_total"] + result["usedPositions"] = { + "used10": contractedAllocations["used_10"], + "used12": contractedAllocations["used_12"], + "used15": contractedAllocations["used_15"], + "used20": contractedAllocations["used_20"], + "usedSecondary5": contractedAllocations["used_5_sec"], + "usedSecondary10": contractedAllocations["used_10_sec"], + } + result["breakHours"] = contractedAllocations["break_hours"] return result diff --git a/tests/code/test_allocationManger.py b/tests/code/test_allocationManger.py index ea7da6782..3b90c9260 100644 --- a/tests/code/test_allocationManger.py +++ b/tests/code/test_allocationManger.py @@ -204,4 +204,55 @@ def test_getContractedAllocations(testLaborStatusForm, testTerm, testDepartment, assert contractedAllocation['used_secondaries'] == 0 assert contractedAllocation['used_total'] == 1 - assert contractedAllocation['break_hours'] == 500 \ No newline at end of file + assert contractedAllocation['break_hours'] == 500 + +@pytest.mark.integration +def test_getContractedAllocations_withoutAnAllocationRow(testLaborStatusForm, testTerm, testDepartment, testFormHistory): + ''' + getContractedAllocations must not require an Allocation row to exist for + the department/term (e.g. before one has been created or finalized) - + it should still report the LaborStatusForm-derived counts. + ''' + contractedAllocation = getContractedAllocations(testTerm.termCode, testDepartment.departmentID) + assert contractedAllocation['used_15'] == 1 + assert contractedAllocation['break_hours'] == 500 + +@pytest.mark.integration +def test_getContractedAllocations_sumsBreakHoursAcrossAcademicYearCode(testDepartment, testStudent, testSupervisor, testUser): + ''' + A department can have approved break-term contracts under both a specific + term and that year's academic-year "00" bucket term - break_hours should + sum both, not silently keep only whichever one the query happens to see + first. + ''' + specificTerm = Term.create(termCode=200610) + academicYearTerm = Term.create(termCode=200600) # matches testTerm's code + + specificTermForm = LaborStatusForm.create( + laborStatusFormID=9001, termCode=specificTerm, studentSupervisee=testStudent, + supervisor_id=testSupervisor.ID, department=testDepartment, jobType="Primary", WLS=1, + POSN_TITLE="Specific Term Break", POSN_CODE="S9001", contractHours=100, weeklyHours=None, + ) + FormHistory.create( + formHistoryID=9001, formID=specificTermForm, historyType="Labor Status Form", + createdBy=testUser.userID, createdDate="2025-03-02", status="Approved", + ) + + academicYearForm = LaborStatusForm.create( + laborStatusFormID=9002, termCode=academicYearTerm, studentSupervisee=testStudent, + supervisor_id=testSupervisor.ID, department=testDepartment, jobType="Primary", WLS=1, + POSN_TITLE="Academic Year Break", POSN_CODE="S9002", contractHours=250, weeklyHours=None, + ) + FormHistory.create( + formHistoryID=9002, formID=academicYearForm, historyType="Labor Status Form", + createdBy=testUser.userID, createdDate="2025-03-02", status="Approved", + ) + + try: + contractedAllocation = getContractedAllocations(specificTerm.termCode, testDepartment.departmentID) + assert contractedAllocation['break_hours'] == 350 # 100 + 250, both terms summed + finally: + specificTermForm.delete_instance() + academicYearForm.delete_instance() + specificTerm.delete_instance() + academicYearTerm.delete_instance() \ No newline at end of file diff --git a/tests/code/test_getAllocation.py b/tests/code/test_getAllocation.py index 6592fcffe..8fe21fcca 100644 --- a/tests/code/test_getAllocation.py +++ b/tests/code/test_getAllocation.py @@ -59,13 +59,17 @@ def test_getDepartmentAllocationSummary(): Test that the summary reports allocated/used/breakHours for a department's most recent term, covering a missing department, a department with no Allocation rows, allocations spread across terms, several Allocation rows - in one term, break-term contracts, an allocation with no forms, and a - most-recent term that only has a draft (not yet final) allocation. - - The allocated/used/breakHours values are sourced from allocationManager's - getTotalAllocations/getContractedAllocations (see test_allocationManger.py - for those functions' own unit coverage) - only the term-selection and - fallback behavior is re-verified here. + in one term (draft and final both counted), break-term contracts, an + allocation with no forms, and a most-recent term that only has a draft + (not yet final) allocation. + + "used"/"usedPositions"/"breakHours" are sourced from allocationManager's + getContractedAllocations (see test_allocationManger.py for that + function's own unit coverage) - only the term-selection and allocated-sum + behavior is re-verified here. "allocated" is summed directly from the + Allocation rows for the most recent term (both draft and final), not + routed through allocationManager, since a department's allocation is + often still a draft when this is viewed. """ zeroedUsedPositions = { "used10": 0, @@ -181,9 +185,7 @@ def test_getDepartmentAllocationSummary(): # More than one Allocation row for the same most-recent term (e.g. a # draft and a final revision, which the model's (termCode, department, - # isFinal) index allows) reports only the final row - the draft is not - # counted, since allocationManager's getTotalAllocations only looks at - # the isFinal=True row for a term + # isFinal) index allows) sums across both rows rather than picking one multiRowDept = Department.create(departmentID=203, DEPT_NAME="Mathematics", ACCOUNT="6753", ORG="2123", isActive=True) multiRowTerm = Term.create(termCode=900300, termName="AY Test Multi") @@ -201,7 +203,7 @@ def test_getDepartmentAllocationSummary(): summary = getDepartmentAllocationSummary(multiRowDept) assert summary["term"].termCode == 900300 - assert summary["allocated"] == 2 # only the final row counts, the draft's 1 is not added in + assert summary["allocated"] == 3 # 1 + 2, summed across both rows # An allocation for the most recent term with no LaborStatusForm records # at all shows allocated > 0 with used/breakHours at 0, rather than @@ -224,9 +226,9 @@ def test_getDepartmentAllocationSummary(): assert summary["usedPositions"] == zeroedUsedPositions # A most-recent term with only a draft (isFinal=False) allocation - no - # final row exists yet, so allocationManager's getTotalAllocations has - # nothing to select and would raise; the summary should fall back to - # the zeroed defaults (still reporting the term) instead of erroring + # final row exists yet - still reports the draft's own numbers rather + # than zeroing out, since a department's allocation is often still a + # draft before it's finalized draftOnlyDept = Department.create(departmentID=205, DEPT_NAME="Art", ACCOUNT="6755", ORG="2125", isActive=True) draftOnlyTerm = Term.create(termCode=900500, termName="AY Test Draft Only") @@ -239,7 +241,7 @@ def test_getDepartmentAllocationSummary(): summary = getDepartmentAllocationSummary(draftOnlyDept) assert summary["term"].termCode == 900500 - assert summary["allocated"] == 0 + assert summary["allocated"] == 5 assert summary["used"] == 0 assert summary["breakHours"] == 0 assert summary["usedPositions"] == zeroedUsedPositions From f3b43c6ee70a4f12ae83640d0145e781d3dbf85c Mon Sep 17 00:00:00 2001 From: munsakad Date: Wed, 5 Aug 2026 15:05:48 -0400 Subject: [PATCH 19/20] removed unneccesary comments --- tests/code/test_getAllocation.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/code/test_getAllocation.py b/tests/code/test_getAllocation.py index 8fe21fcca..7db6e8980 100644 --- a/tests/code/test_getAllocation.py +++ b/tests/code/test_getAllocation.py @@ -249,11 +249,3 @@ def test_getDepartmentAllocationSummary(): transaction.rollback() -# countWorkers and getBreakHours were removed in favor of calling -# allocationManager's getContractedAllocations directly from -# getDepartmentAllocationSummary (see test_allocationManger.py's -# test_countContracts/test_getContractedAllocations for that function's own -# coverage). Note the counting rules aren't identical to the old -# countWorkers/getBreakHours: getContractedAllocations doesn't exclude -# break-term contracts (contractHours set) from the weekly-hours buckets, and -# uses a narrower status whitelist instead of "anything not Denied". From 6ae39318b9575284a521d013d036dd4c645cdaf9 Mon Sep 17 00:00:00 2001 From: munsakad Date: Wed, 5 Aug 2026 15:28:29 -0400 Subject: [PATCH 20/20] removed department information in main routes --- app/controllers/main_routes/main_routes.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index bf2d382e4..ce3ac4c3c 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -102,21 +102,6 @@ def departmentPortal(org=None,account=None): positions = positionsList, posURL = posURL) -@main_bp.route('/supervisorPortal/addUserToDept', methods=['GET', 'POST']) -def addUserToDept(): - userDeptData = request.form - supervisorDeptRecord = SupervisorDepartment.get_or_none(supervisor = userDeptData['supervisorID'], department = userDeptData['departmentID']) - try: - if supervisorDeptRecord: - return "False" - - else: - SupervisorDepartment.create(supervisor=userDeptData['supervisorID'], department=userDeptData['departmentID']) - return "True" - - except Exception as e: - print(f'Could not add user to department: {e}') - return "", 500 @main_bp.route('/supervisorPortal/download', methods=['POST']) def downloadSupervisorPortalResults():