diff --git a/app/controllers/main_routes/main_routes.py b/app/controllers/main_routes/main_routes.py index b651fe49..cc6f3b86 100755 --- a/app/controllers/main_routes/main_routes.py +++ b/app/controllers/main_routes/main_routes.py @@ -10,8 +10,8 @@ from app.models.laborStatusForm import LaborStatusForm from app.models.formHistory import FormHistory from app.models.term import Term -from app.models.positionHistory import PositionHistory 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,7 +24,7 @@ from app.logic.getSupervisors import getSupervisors from app.logic.getPositions import getActivePositions from app.logic.allocationManager import getBreakContracts, getContractedAllocations, getTotalAllocations -from app.logic.getTerms import getTerms +from app.logic.getTerms import getTerms, getCurrentSemester @main_bp.route('/logout', methods=['GET']) @@ -74,11 +74,21 @@ def departmentPortal(org=None,account=None): supervisors, laborCoordinators = getSupervisors(dept) + + currentAY, fallTerm, springTerm = getTerms() + allocationDict = getTotalAllocations(currentAY, dept) + currentSemester = getCurrentSemester() + contracts = getContractedAllocations(currentSemester, dept) + + positionsList, posURL = getActivePositions(dept) return render_template('main/departmentPortal.html', departments = departments, department = dept, + contracts = contracts, + allocation = allocationDict, + currentSemester = currentSemester.termName, supervisors = supervisors, laborCoordinators=laborCoordinators, currentUser=currentUser, diff --git a/app/logic/allocationManager.py b/app/logic/allocationManager.py index dbd35c18..53e23862 100644 --- a/app/logic/allocationManager.py +++ b/app/logic/allocationManager.py @@ -98,36 +98,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), @@ -139,7 +125,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/getTerms.py b/app/logic/getTerms.py index ea3b60da..19df06ec 100644 --- a/app/logic/getTerms.py +++ b/app/logic/getTerms.py @@ -33,3 +33,15 @@ def getTerms(academicYear: str = None): springTerm = Term.select().where(Term.termCode == currentDate.year * 100 + 12).get() return currentAY, fallTerm, springTerm +def getCurrentSemester(): + ''' + The difference between this function and the one above is that it gets just the current term + It does not get both Fall and Spring, it just gets one depending on the month. + ''' + currentDate = date.today() + if currentDate.month <= 6: + springTerm = Term.select().where(Term.termCode == currentDate.year * 100 + 12 - 100).get() + return springTerm + else: + fallTerm = Term.select().where(Term.termCode == currentDate.year * 100 + 11).get() + return fallTerm diff --git a/app/static/css/departmentPortal.css b/app/static/css/departmentPortal.css index d9acbba7..0f64aa2e 100644 --- a/app/static/css/departmentPortal.css +++ b/app/static/css/departmentPortal.css @@ -11,25 +11,71 @@ padding: 1rem; min-width: 100%; } - -.members-icon { - display: inline-block; +.bi-suitcase-lg-fill { /* Bootstrap Icon */ border: 1px solid #c0c0c0; border-radius: 8px; - padding: 3px 3.5px 1.5px; - font-size: 36px; - color: #6e6e6e; + padding: 3px 3.5px 1.5px 3.5px; + font-size: 3rem; + color:#6e6e6e; } - margin-bottom: 20px; - min-height: 200px; +.bi-clock { + border: 1px solid #c0c0c0; + border-radius: 8px; + padding: 3px 3.5px 1.5px 3.5px; + font-size: 3rem; + color:#6e6e6e; } -.bi-suitcase-lg-fill { /* Bootstrap Icon */ +.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 +98,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 a9b56e29..d82a4f3f 100644 --- a/app/templates/main/departmentPortal.html +++ b/app/templates/main/departmentPortal.html @@ -33,8 +33,50 @@

{% 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 if allocated is integer else 0 }} allocation{{ 's' if allocated != 1 else '' }} + {%- endmacro %} +
+
+

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

+

{{contracts["used_total"]}} contracted of {{allocation["totalAllocations"] if allocation["totalAllocations"] is integer else 0}} allocations

+
+
+ + + + + + {{ allocationRow(10, contracts["used_10"], allocation["primary_10"]) }} + {{ allocationRow(12, contracts["used_12"], allocation["primary_12"]) }} + {{ allocationRow(15, contracts["used_15"], allocation["primary_15"]) }} + {{ allocationRow(20, contracts["used_20"], allocation["primary_20"]) }} + +
Primary
+ + + + + + {{ allocationRow(5,contracts["used_5_sec"], allocation["secondary_5"]) }} + {{ allocationRow(10, contracts["used_10_sec"], allocation["secondary_10"]) }} + +
Secondary
+
+
+
+
diff --git a/database/demo_data.py b/database/demo_data.py index d1e500bb..c002a2e0 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 @@ -510,6 +511,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", @@ -743,6 +760,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, @@ -1830,3 +1965,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/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_allocationManager.py b/tests/code/test_allocationManager.py index eb3a149f..7b4fe6e7 100644 --- a/tests/code/test_allocationManager.py +++ b/tests/code/test_allocationManager.py @@ -260,6 +260,55 @@ def test_getContractedAllocations(testLaborStatusForm, testTerm, testDepartment, 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() def test_getBreakContracts(testBreakLaborStatusForm, testBreakTerm, testDepartment,testTerm): # Test that the formHistory object exists @@ -295,4 +344,4 @@ def test_getBreakContracts(testBreakLaborStatusForm, testBreakTerm, testDepartme breakContractHours = getBreakContracts(testBreakTerm, testDepartment) assert breakContractHours == 0 - \ No newline at end of file +