diff --git a/app/controllers/main_routes/__init__.py b/app/controllers/main_routes/__init__.py index 52853db7..e8c4c6bb 100755 --- a/app/controllers/main_routes/__init__.py +++ b/app/controllers/main_routes/__init__.py @@ -25,4 +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 \ No newline at end of file +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 0875988c..de37e4b4 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,51 @@ 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/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(): ''' diff --git a/app/logic/getAllocation.py b/app/logic/getAllocation.py new file mode 100644 index 00000000..1f7c879d --- /dev/null +++ b/app/logic/getAllocation.py @@ -0,0 +1,126 @@ +from datetime import date + +from peewee import fn + +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): + """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 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 = { + "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) + + 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) + + 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 20c4517d..2e46ca61 100644 --- a/database/demo_data.py +++ b/database/demo_data.py @@ -1,9 +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...)''' - from app import app - from app.models.Tracy import db from app.models.Tracy.studata import STUDATA from app.models.Tracy.stuposn import STUPOSN @@ -19,6 +14,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 +40,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 +132,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 = [ { @@ -461,6 +495,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", @@ -617,6 +667,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, @@ -642,6 +810,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() @@ -939,4 +1124,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() diff --git a/database/migrate_db.sh b/database/migrate_db.sh index dea226d8..c693c708 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_getAllocation.py b/tests/code/test_getAllocation.py new file mode 100644 index 00000000..72e115db --- /dev/null +++ b/tests/code/test_getAllocation.py @@ -0,0 +1,343 @@ +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, countWorkers, getBreakHours, 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, 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: + # 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 + + 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") + + # 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, + ) + 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") + + # 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 + + 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()