Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ba5a40b
Add empty file for common routes
BrianRamsay Jul 2, 2026
0d64daf
Add allocation warning to pending LSF approval modal (#622)
DanielRukwasha Jul 8, 2026
ac0ace0
Highlight positions and break hours independently in allocation warning
DanielRukwasha Jul 8, 2026
3cd1205
Added new model requiremetns
BrianRamsay Jul 8, 2026
a57e055
Add new DDL to prod backup
BrianRamsay Jul 8, 2026
a6e09db
Fix failing admin management tests
BrianRamsay Jul 8, 2026
2de8e2b
Fix failing tests - missing Term, and new tracy setup reqs
BrianRamsay Jul 8, 2026
4203dc5
Fixed remaining failing tests
BrianRamsay Jul 8, 2026
cfd9ee9
Merge pull request #640 from BCStudentSoftwareDevTeam/add-models
BrianRamsay Jul 8, 2026
97f05f5
Add option to run just one file
BrianRamsay Jul 8, 2026
10ae9a2
Merge branch 'department-portal-base' of github.com:BCStudentSoftware…
BrianRamsay Jul 8, 2026
78df419
merging changes from brian branch
conwelld Jul 9, 2026
a08e85e
fixed semantic bug in demo data, changed current_year to hardcoded data
conwelld Jul 9, 2026
9522be3
fixed semantic bug in demo data, changed current_year to hardcoded da…
conwelld Jul 9, 2026
ca34008
Flash a warning after approving forms if the department is now over-a…
DanielRukwasha Jul 9, 2026
653ae75
fixed merge issues
conwelld Jul 9, 2026
8ad22e0
fixed merge issues
conwelld Jul 10, 2026
035cda0
Fix over-allocation check to look at each hour-band, not just the total
DanielRukwasha Jul 10, 2026
64e49b8
fixed test error
conwelld Jul 10, 2026
0369e4a
Merge pull request #643 from BCStudentSoftwareDevTeam/demoDataFix_dpb
MImran2002 Jul 10, 2026
f71b525
Merge remote-tracking branch 'origin/department-portal-base' into 622…
DanielRukwasha Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/main_routes/departmentPortal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from flask import render_template
65 changes: 8 additions & 57 deletions app/controllers/main_routes/main_routes.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
from flask import render_template, request, json, redirect, url_for, send_file, g, flash, jsonify
from peewee import JOIN, DoesNotExist, fn
from peewee import JOIN, DoesNotExist
from functools import reduce
import operator
from app.models.allocation import Allocation
from app.models.department import Department
from app.models.supervisor import Supervisor
from app.models.supervisorDepartment import SupervisorDepartment
from app.models.student import Student
from app.models.laborStatusForm import LaborStatusForm
from app.models.formHistory import FormHistory
from app.models.term import Term
from app.models.allocation import Allocation
from app.controllers.admin_routes.allPendingForms import checkAdjustment
from app.controllers.main_routes import main_bp
from app.logic.download import CSVMaker, saveFormSearchResult, retrieveFormSearchResult
Expand All @@ -19,6 +15,7 @@
from app.logic.getTableData import getDatatableData
from app.logic.banner import Banner
from app.logic.tracy import Tracy
from app.logic.allocation import getAllocationSummary

@main_bp.route('/logout', methods=['GET'])
def triggerLogout():
Expand Down Expand Up @@ -82,64 +79,18 @@ def departmentPortal(org=None,account=None):
if i.ORG == org:
supervisors.append(i.FIRST_NAME + " " + i.LAST_NAME + " (" + i.EMAIL + ")")

allocation = None
allocationBands = None
totalPositionsAllocated = None
totalPositionsUsed = None
breakHoursUsed = None
if dept and g.openTerm:
allocation = Allocation.get_or_none(Allocation.department == dept, Allocation.termCode == g.openTerm)
if allocation:
bandFields = [
('primary_10', 'Primary', 10),
('primary_12', 'Primary', 12),
('primary_15', 'Primary', 15),
('primary_20', 'Primary', 20),
('secondary_5', 'Secondary', 5),
('secondary_10', 'Secondary', 10),
]
allocationBands = {}
for fieldName, jobType, hours in bandFields:
used = (LaborStatusForm
.select()
.join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID))
.where(LaborStatusForm.department == dept,
LaborStatusForm.termCode == g.openTerm,
LaborStatusForm.jobType == jobType,
LaborStatusForm.weeklyHours == hours,
FormHistory.historyType == "Labor Status Form",
~(FormHistory.status % "Denied%"))
.distinct()
.count())
allocationBands[fieldName] = {'used': used, 'allocated': getattr(allocation, fieldName)}

totalPositionsAllocated = sum(band['allocated'] for band in allocationBands.values())
totalPositionsUsed = sum(band['used'] for band in allocationBands.values())

# Break hours are tracked on separate break-term rows (e.g. Thanksgiving Break)
# that share the same academic year prefix as the open AY term.
yearPrefix = str(g.openTerm.termCode)[:-2]
breakTermCodes = [t.termCode for t in Term.select().where(Term.isBreak == True)
if str(t.termCode).startswith(yearPrefix)]
breakHoursUsed = (LaborStatusForm
.select(fn.SUM(LaborStatusForm.contractHours))
.join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID))
.where(LaborStatusForm.department == dept,
LaborStatusForm.termCode.in_(breakTermCodes),
FormHistory.historyType == "Labor Status Form",
~(FormHistory.status % "Denied%"))
.scalar()) or 0
allocationSummary = getAllocationSummary(dept, g.openTerm)

return render_template('main/departmentPortal.html',
departments = departments,
department = dept,
positions = positions,
supervisors = supervisors,
allocation = allocation,
allocationBands = allocationBands,
totalPositionsAllocated = totalPositionsAllocated,
totalPositionsUsed = totalPositionsUsed,
breakHoursUsed = breakHoursUsed,
allocation = allocationSummary['allocation'],
allocationBands = allocationSummary['allocationBands'],
totalPositionsAllocated = allocationSummary['totalPositionsAllocated'],
totalPositionsUsed = allocationSummary['totalPositionsUsed'],
breakHoursUsed = allocationSummary['breakHoursUsed'],
currentTerm = g.openTerm)

@main_bp.route('/department/<org>/<account>/managepositions', methods=['GET'])
Expand Down
32 changes: 26 additions & 6 deletions app/logic/allPendingForms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
from datetime import date
from flask import jsonify
from flask import jsonify, g, flash
from app.models.formHistory import FormHistory
from app.models.status import Status
from app.logic.banner import Banner
Expand All @@ -14,9 +14,11 @@
from app.models.overloadForm import OverloadForm
from app.models.notes import Notes
from app.login_manager import DoesNotExist, render_template
from app.logic.allocation import getAllocationWarning


def saveStatus(new_status, formHistoryIds, currentUser):
approvedDepartments = {}
try:
if new_status == 'Denied by Admin':
# Index 1 will always hold the reject reason in the list, so we can
Expand Down Expand Up @@ -66,6 +68,8 @@ def saveStatus(new_status, formHistoryIds, currentUser):
email.laborStatusFormRejected()
if new_status == "Approved" and formType == "Labor Status Form":
email.laborStatusFormApproved()
dept = formHistory.formID.department
approvedDepartments[dept.departmentID] = dept
if new_status == "Approved" and formType == "Labor Adjustment Form":
# This function is triggered whenever an adjustment form is approved.
# The following function overrides the original data in lsf with the new data from adjustment form.
Expand All @@ -80,6 +84,16 @@ def saveStatus(new_status, formHistoryIds, currentUser):
print("Error preparing form for status update:", e)
return jsonify({"success": False}), 500

# After approving, let the admin know right away if any affected department
# is now over its allocated positions or break hours (informational only).
for dept in approvedDepartments.values():
warning = getAllocationWarning(dept, g.openTerm)
if warning and warning['isOverAllocated']:
messageParts = [f"{b['label']} ({b['used']}/{b['allocated']})" for b in warning['overAllocatedBands']]
if warning['isBreakHoursOverAllocated']:
messageParts.append(f"break hours ({warning['breakHoursUsed']}/{warning['breakHoursAllocated']})")
flash(f"{dept.DEPT_NAME} is now over its allocation for: {', '.join(messageParts)}.", "warning")

return jsonify({"success": True})

def overrideOriginalStatusFormOnAdjustmentFormApproval(form, LSF):
Expand Down Expand Up @@ -196,9 +210,8 @@ def laborAdminOverloadApproval(rsp, historyForm, status, currentUser, currentDat

# extract data from the database to populate pending form approval modal
def modal_approval_and_denial_data(formHistoryIdList):
''' This method grabs the data that populated the on approve modal for lsf'''

details_list = []
allocationWarningsByDept = {}
for fhID in formHistoryIdList:
formHistory = FormHistory.get(FormHistory.formHistoryID == fhID)
lsf = formHistory.formID
Expand All @@ -208,7 +221,8 @@ def modal_approval_and_denial_data(formHistoryIdList):
supervisorName = f"{lsf.supervisor.FIRST_NAME} {lsf.supervisor.LAST_NAME}"
weeklyHours = lsf.weeklyHours
contractHours = lsf.contractHours
deptName = lsf.department.DEPT_NAME
dept = lsf.department
deptName = dept.DEPT_NAME

if formHistory.adjustedForm:
match formHistory.adjustedForm.fieldAdjusted:
Expand All @@ -223,11 +237,17 @@ def modal_approval_and_denial_data(formHistoryIdList):
case "contractHours":
contractHours = formHistory.adjustedForm.newValue
case "department":
deptName = Department.get(Department.ORG==formHistory.adjustedForm.newValue).DEPT_NAME
dept = Department.get(Department.ORG==formHistory.adjustedForm.newValue)
deptName = dept.DEPT_NAME

details_list.append([studentName, deptName, position, str(weeklyHours),str(contractHours), supervisorName])

return details_list
if dept.departmentID not in allocationWarningsByDept:
warning = getAllocationWarning(dept, g.openTerm)
if warning:
allocationWarningsByDept[dept.departmentID] = warning

return {"details": details_list, "allocationWarnings": list(allocationWarningsByDept.values())}


def financialAidSAASOverloadApproval(historyForm, rsp, status, currentUser, currentDate):
Expand Down
104 changes: 104 additions & 0 deletions app/logic/allocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from peewee import fn
from app.models.allocation import Allocation
from app.models.laborStatusForm import LaborStatusForm
from app.models.formHistory import FormHistory
from app.models.term import Term

# Each entry is (Allocation field name, LaborStatusForm.jobType, LaborStatusForm.weeklyHours)
ALLOCATION_BAND_FIELDS = [
('primary_10', 'Primary', 10),
('primary_12', 'Primary', 12),
('primary_15', 'Primary', 15),
('primary_20', 'Primary', 20),
('secondary_5', 'Secondary', 5),
('secondary_10', 'Secondary', 10),
]

BAND_LABELS = {fieldName: f"{hours} Hour {jobType}" for fieldName, jobType, hours in ALLOCATION_BAND_FIELDS}


def getAllocationSummary(dept, term):
summary = {
'allocation': None,
'allocationBands': None,
'totalPositionsAllocated': None,
'totalPositionsUsed': None,
'breakHoursUsed': None,
}

if not (dept and term):
return summary

allocation = Allocation.get_or_none(Allocation.department == dept, Allocation.termCode == term)
summary['allocation'] = allocation
if not allocation:
return summary

allocationBands = {}
for fieldName, jobType, hours in ALLOCATION_BAND_FIELDS:
used = (LaborStatusForm
.select()
.join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID))
.where(LaborStatusForm.department == dept,
LaborStatusForm.termCode == term,
LaborStatusForm.jobType == jobType,
LaborStatusForm.weeklyHours == hours,
FormHistory.historyType == "Labor Status Form",
~(FormHistory.status % "Denied%"))
.distinct()
.count())
allocationBands[fieldName] = {'used': used, 'allocated': getattr(allocation, fieldName)}

summary['allocationBands'] = allocationBands
summary['totalPositionsAllocated'] = sum(band['allocated'] for band in allocationBands.values())
summary['totalPositionsUsed'] = sum(band['used'] for band in allocationBands.values())

# Break hours are tracked on separate break-term rows (e.g. Thanksgiving Break)
# that share the same academic year prefix as the given AY term.
yearPrefix = str(term.termCode)[:-2]
breakTermCodes = [t.termCode for t in Term.select().where(Term.isBreak == True)
if str(t.termCode).startswith(yearPrefix)]
summary['breakHoursUsed'] = (LaborStatusForm
.select(fn.SUM(LaborStatusForm.contractHours))
.join(FormHistory, on=(FormHistory.formID == LaborStatusForm.laborStatusFormID))
.where(LaborStatusForm.department == dept,
LaborStatusForm.termCode.in_(breakTermCodes),
FormHistory.historyType == "Labor Status Form",
~(FormHistory.status % "Denied%"))
.scalar()) or 0

return summary


def getAllocationWarning(dept, term):
summary = getAllocationSummary(dept, term)
if not summary['allocation']:
return None

positionsRemaining = summary['totalPositionsAllocated'] - summary['totalPositionsUsed']
breakHoursRemaining = summary['allocation'].breakHours - summary['breakHoursUsed']

# A department can be within its total position count while still exceeding
# one specific hour-band (e.g. over on 10-hour Primary but under on others),
# so each band needs to be checked individually, not just the aggregate total.
overAllocatedBands = [
{'label': BAND_LABELS[fieldName], 'used': band['used'], 'allocated': band['allocated']}
for fieldName, band in summary['allocationBands'].items()
if band['used'] > band['allocated']
]
isPositionsOverAllocated = positionsRemaining < 0 or bool(overAllocatedBands)
isBreakHoursOverAllocated = breakHoursRemaining < 0

return {
'departmentName': dept.DEPT_NAME,
'totalPositionsAllocated': summary['totalPositionsAllocated'],
'totalPositionsUsed': summary['totalPositionsUsed'],
'positionsRemaining': positionsRemaining,
'isPositionsOverAllocated': isPositionsOverAllocated,
'overAllocatedBands': overAllocatedBands,
'breakHoursAllocated': summary['allocation'].breakHours,
'breakHoursUsed': summary['breakHoursUsed'],
'breakHoursRemaining': breakHoursRemaining,
'isBreakHoursOverAllocated': isBreakHoursOverAllocated,
'isOverAllocated': isPositionsOverAllocated or isBreakHoursOverAllocated,
}
10 changes: 7 additions & 3 deletions app/models/allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
class Allocation(baseModel):
termCode = ForeignKeyField(Term)
department = ForeignKeyField(Department)
isApproved = BooleanField(default=False)
approvedOn = DateField()
approvedBy = CharField()
isFinal = BooleanField(default=False)
approvedOn = DateField(null=True)
approvedBy = ForeignKeyField(Supervisor, null=True)
justification = TextField()
primary_10 = IntegerField()
primary_12 = IntegerField()
Expand All @@ -17,3 +17,7 @@ class Allocation(baseModel):
secondary_5 = IntegerField()
secondary_10 = IntegerField()
breakHours = IntegerField()

class Meta:
indexes = ( (('termCode', 'department', 'isFinal'), True), )

12 changes: 6 additions & 6 deletions app/models/positionHistory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from app.models.department import Department

class PositionHistory(baseModel):
positioncode = CharField()
positionCode = CharField()
department = ForeignKeyField(Department)
status = CharField()
WLS = IntegerField()
revisiondate = DateField()
Description = TextField(default=None)
Department = ForeignKeyField(Department)
wls = IntegerField()
revisionDate = DateField()
description = TextField(default=None)

class Meta:
primary_key = CompositeKey('positioncode', 'revisiondate', 'status')
indexes = ( (('positionCode', 'revisionDate', 'status'), True), )
1 change: 1 addition & 0 deletions app/models/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Supervisor(baseModel):
legal_name = CharField(null=True)
preferred_name = CharField(null=True)
isActive = BooleanField(default=False)
isBanned = BooleanField(default=False)


@property
Expand Down
10 changes: 1 addition & 9 deletions app/models/supervisorDepartment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,6 @@
from app.models.department import Department

class SupervisorDepartment(baseModel):
supervisor = ForeignKeyField(Supervisor, null=True)
supervisor = ForeignKeyField(Supervisor)
department = ForeignKeyField(Department)
banStatus = BooleanField(default=False)
isActive = BooleanField(default=False)
isCoordinator = BooleanField(default=False)

@property
def isBanned(self):
return self.banStatus


Loading