diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index 0d24e5ff..ce3ac4c3 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -10,6 +10,7 @@ from app.models.laborStatusForm import LaborStatusForm from app.models.formHistory import FormHistory from app.models.term import Term +from app.models.allocation import Allocation from app.models.positionHistory import PositionHistory from app.controllers.admin_routes.allPendingForms import checkAdjustment @@ -20,6 +21,7 @@ from app.login_manager import require_login, logout from app.logic.getTableData import getDatatableData from app.logic.banner import Banner +from app.logic.getAllocation import getDepartmentAllocationSummary from app.logic.getSupervisors import getSupervisors from app.logic.getPositions import getActivePositions @@ -71,17 +73,36 @@ def departmentPortal(org=None,account=None): supervisors, laborCoordinators = getSupervisors(dept) + allocationSummary = getDepartmentAllocationSummary(dept) + recentTerm = allocationSummary["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 + positionsList, posURL = getActivePositions(dept) return render_template('main/departmentPortal.html', departments = departments, department = dept, + allocation = allocation, + allocated = allocationSummary["allocated"], + used = allocationSummary["used"], + term = recentTerm, + currentSemester = allocationSummary["currentSemester"], + usedPositions = allocationSummary["usedPositions"], + breakHours = allocationSummary["breakHours"], supervisors = supervisors, laborCoordinators=laborCoordinators, currentUser=currentUser, positions = positionsList, posURL = posURL) + @main_bp.route('/supervisorPortal/download', methods=['POST']) def downloadSupervisorPortalResults(): ''' diff --git a/app/logic/allocationManager.py b/app/logic/allocationManager.py index f1c58cbc..537744ff 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 new file mode 100644 index 00000000..5c09cd49 --- /dev/null +++ b/app/logic/getAllocation.py @@ -0,0 +1,71 @@ +from datetime import date + +from app.logic.allocationManager import getContractedAllocations +from app.models.allocation import Allocation +from app.models.term import Term + + +def getCurrentSemesterLabel(term): + """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]) + if date.today().month >= 8: + return f"Fall {academicYear}" + return f"Spring {academicYear + 1}" + + +def getDepartmentAllocationSummary(department): + """Return allocation-utilization values for a department's most recent term.""" + result = { + "term": None, + "currentSemester": None, + "allocated": 0, + "used": 0, + "usedPositions": { + "used10": 0, + "used12": 0, + "used15": 0, + "used20": 0, + "usedSecondary5": 0, + "usedSecondary10": 0, + }, + "breakHours": 0, + } + + departmentAllocations = list( + Allocation.select(Allocation, Term).join(Term).where(Allocation.department == department) + ) + if not departmentAllocations: + return result + + recentTerm = Term.order_by_term([a.termCode for a in departmentAllocations], reverse=True)[0] + termCode = recentTerm.termCode + result["term"] = recentTerm + result["currentSemester"] = getCurrentSemesterLabel(recentTerm) + + # "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"], + } + result["breakHours"] = contractedAllocations["break_hours"] + + return result diff --git a/app/static/css/departmentPortal.css b/app/static/css/departmentPortal.css index d9acbba7..88ae06e2 100644 --- a/app/static/css/departmentPortal.css +++ b/app/static/css/departmentPortal.css @@ -30,6 +30,64 @@ 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; +} +.allocation-table-wrapper { + 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 { + /* 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; +} +.allocation-table th, +.allocation-table td { + text-align: left; + white-space: nowrap; + padding: 4px 10px 4px 0; + line-height: 1.3; +} +.allocation-table th { + font-weight: 700; + padding-top: 10px; +} + +.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; } @@ -52,3 +110,15 @@ flex-direction: column; } } + +/* 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-summary { + flex-direction: column; + gap: 0; + } + .allocation-columns .allocation-table { + flex-basis: 100%; + } +} diff --git a/app/static/js/departmentPortal.js b/app/static/js/departmentPortal.js index cb2023a3..06ae72e7 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 e7786c85..ce0b051f 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -33,10 +33,52 @@

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

Insert Allocations Card Here

+
+
+
+
+ +
+
+

Current Allocations

+
+ {% macro allocationRow(hours, used, allocated) -%} + {{ hours }} hr: {{ used }} contract{{ 's' if used != 1 else '' }}
(out of {{ allocated }} allocation{{ 's' if allocated != 1 else '' }}) + {%- endmacro %} +
+
+

{{ 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/database/demo_data.py b/database/demo_data.py index 1b1e7978..eb02f8e1 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 @@ -43,6 +44,36 @@ "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", @@ -105,6 +136,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 = [ { @@ -462,6 +499,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", @@ -618,6 +671,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, @@ -643,6 +814,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() @@ -1230,3 +1418,121 @@ ).on_conflict_replace().execute() print(" * position description sections added") + + +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") + + +dummy_lsf = [ + { + "laborStatusFormID": 13, + "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": 14, + "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": 15, + "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": 16, + "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": 17, + "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() diff --git a/database/migrate_db.sh b/database/migrate_db.sh index e2ba150e..4153d172 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 0b5466dc..fabca9d3 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 diff --git a/database/reset_database.sh b/database/reset_database.sh index 82f6cff5..ba87204d 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 diff --git a/tests/code/test_allocationManger.py b/tests/code/test_allocationManger.py index ea7da678..3b90c926 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 new file mode 100644 index 00000000..7db6e898 --- /dev/null +++ b/tests/code/test_getAllocation.py @@ -0,0 +1,251 @@ +from datetime import date +from unittest.mock import patch + +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.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, getCurrentSemesterLabel + + +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) + return FormHistory.create( + formID=form, + historyType=historyType, + createdBy=user, + createdDate=date.today(), + status=status, + ) + + +@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 + + 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" + + # 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(): + """ + 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 (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, + "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: + # 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(emptyDept) + + assert summary["term"] is None + assert summary["allocated"] == 0 + assert summary["used"] == 0 + assert summary["breakHours"] == 0 + assert summary["usedPositions"] == zeroedUsedPositions + + # 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=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=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, + ) + + supervisor = Supervisor.create(ID="SUP001", isActive=True) + student = Student.create(ID="STU001", isActive=True) + + # 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=multiTermDept, + jobType="Primary", WLS="10", POSN_TITLE="New Job", POSN_CODE="S002", + weeklyHours=10, contractHours=None, + ) + createFormHistory(newForm, "Approved") + + # 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 approved LaborStatusForm counts + assert summary["usedPositions"]["used10"] == 1 + assert summary["usedPositions"]["used12"] == 0 # the denied form is not counted + assert summary["breakHours"] == 0 + + # 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=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, + ) + + breakSupervisor = Supervisor.create(ID="SUP002", isActive=True) + breakStudent = Student.create(ID="STU002", isActive=True) + + breakForm = LaborStatusForm.create( + 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") + + summary = getDepartmentAllocationSummary(breakDept) + + assert summary["breakHours"] == 40 + assert summary["used"] == 0 + + # 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=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=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(multiRowDept) + + assert summary["term"].termCode == 900300 + 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 + # 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=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(noFormsDept) + + assert summary["term"].termCode == 900400 + assert summary["allocated"] == 6 + assert summary["used"] == 0 + assert summary["breakHours"] == 0 + assert summary["usedPositions"] == zeroedUsedPositions + + # A most-recent term with only a draft (isFinal=False) allocation - no + # 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") + + 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, + ) + + summary = getDepartmentAllocationSummary(draftOnlyDept) + + assert summary["term"].termCode == 900500 + assert summary["allocated"] == 5 + assert summary["used"] == 0 + assert summary["breakHours"] == 0 + assert summary["usedPositions"] == zeroedUsedPositions + + transaction.rollback() + +