Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
661ff17
added my name to the contributor list
brightfietsop-ux Jul 30, 2026
bc46dae
Merge pull request #661 from BCStudentSoftwareDevTeam/Bright-contribu…
brightfietsop-ux Jul 30, 2026
643d58e
created a branch off of dep_portal_ad_ManageDepartments and then add…
johnlolonga19 Aug 3, 2026
8bbf144
added Annual positionn review email template
johnlolonga19 Aug 3, 2026
5b76836
Add sendAnnualPositionReviewRequests logic for Annual Position Review
johnlolonga19 Aug 3, 2026
3361ecb
Add POST route to trigger Annual Position Review requests
johnlolonga19 Aug 3, 2026
f9091b0
add js to anchor the request button and emailTemplates.js link to the…
johnlolonga19 Aug 3, 2026
338844b
manageDepartments.html: handle functionality of the Annual Position r…
johnlolonga19 Aug 3, 2026
9a31d84
added the test suite for the logic
johnlolonga19 Aug 4, 2026
6fb8246
fixed some requested changes
johnlolonga19 Aug 4, 2026
667112d
Merge branch 'dep_portal_ad_ManageDepartments' into annual-position-r…
johnlolonga19 Aug 4, 2026
73eaeb0
fixed requested change to validate rsp and academicYear
johnlolonga19 Aug 4, 2026
60a30f8
removed the duplicate fuction
johnlolonga19 Aug 4, 2026
d088351
ressolve changes
johnlolonga19 Aug 5, 2026
fd6e4c5
changes to route
johnlolonga19 Aug 5, 2026
48d3a54
ressolve changes
johnlolonga19 Aug 5, 2026
f6366b3
Merge branch 'dep_portal_ad_ManageDepartments' of https://github.com/…
johnlolonga19 Aug 7, 2026
80aa951
fix damages caused by merge conflicts
johnlolonga19 Aug 7, 2026
b92ba12
consolidated the try and accept
johnlolonga19 Aug 7, 2026
f0f122e
Fold Annual Position Review into emailHandler instead of a separate m…
johnlolonga19 Aug 7, 2026
829421e
remove dropdown for MD page
johnlolonga19 Aug 7, 2026
9279efc
used g.currentyear and added demo data
johnlolonga19 Aug 7, 2026
9ee697d
Merge branch 'development' of github.com:BCStudentSoftwareDevTeam/lsf…
Aug 31, 2026
e9dd270
Replaced positionReview with positionHistory.
Sep 8, 2026
e9c04bd
Replaced existing msg Flash
Sep 8, 2026
e21407d
Error message is more specific to issue.
Sep 8, 2026
7bcc558
Fixed merge conflicts when merging dep_portal_ad_ManageDepartments in…
Sep 9, 2026
0e5dc82
Fixed more merge conflicts
Sep 14, 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
4 changes: 3 additions & 1 deletion app/config/contributors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,6 @@ contributors:
- name: "Bhushan Sah "
username: "sahb"
year: 2029

- name: "Bright Feitsop"
username: "feitsopb"
year: 2029
63 changes: 52 additions & 11 deletions app/controllers/admin_routes/manageDepartments.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@
from app.models.allocation import *
from app.models.laborStatusForm import *

from app.logic.manageDepartments import *
from app.logic.manageDepartments import *
from app.logic.emailHandler import emailHandler
from app.logic.allocationManager import allocationExists
from app.logic.academicYearManager import getCurrentAndNextAY



@admin.route('/admin/manageDepartments/', methods=['GET'])
def manageDepartments():
@admin.route('/admin/manageDepartments/<academicYear>', methods=['GET'])
def manageDepartments(academicYear = None):
"""
Returns the Manage Departments page, which allows the admin to view all the departments
and their allocations.
Expand All @@ -38,8 +40,16 @@ def manageDepartments():
elif currentUser.supervisor:
return render_template('errors/403.html'), 403

currentAY, nextAY = getCurrentAndNextAY()
chosenAY = Term.get(Term.termCode == currentAY.termCode)

# The condition below may be deleted if the routing to the Manage Departments page is changed.
if academicYear == None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

academicYear = g.currentAY[0] * 100 if academicYear is None else int(academicYear)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This if else can be consolidated in a conditional expression

academicYear = g.currentAY[0] * 100
else:
academicYear = int(academicYear)


currentAY, nextAY = generateAdjacentYears(academicYear)
chosenAY = Term.get(Term.termCode == academicYear)

breakHoursByDepartment = {row["department"]: str(row["totalHours"] or 0) for row in getUsedBreakHours(chosenAY)}

Expand All @@ -59,6 +69,7 @@ def manageDepartments():
allSupervisors = allSupervisors,
currentAY = currentAY,
nextAY = nextAY,
chosenAY = chosenAY,
academicYear = chosenAY.termName,
breakHoursByDepartment = breakHoursByDepartment,
allocationStatus = allocationStatus
Expand All @@ -85,6 +96,36 @@ def complianceStatusCheck():



@admin.route('/admin/manageDepartments/annualPositionReview', methods=['POST'])
def annualPositionReviewRequest():
"""
Sends an Annual Position Review request email to every active department's
Comment thread
GalinaP7 marked this conversation as resolved.
Labor Coordinators and supervisors for the selected academic year, and
records the request. Triggered from the Manage Departments page.
"""
currentUser = require_login()
if not currentUser or not (currentUser.isLaborAdmin or currentUser.isLaborDepartmentStudent):
return jsonify({"Success": False}), 403

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of jsonify({"success":false}) we usually use render_template('errors/403.html')


rsp = request.get_json(silent=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this rsp is receiving the chosen academic year from javascript from the front-end we no longer allow the users to chose a academic year.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to select a academic year we are doing position review for the positions in each department means this likely is a interval of either 3 years. we are not allowing the labor department to select which year its more of in intervals the positions are being reviewed.


try:
academicYear = int(rsp["academicYear"])
except (TypeError, ValueError, KeyError):
return jsonify({"Success": False, "message": "Request must include a valid academicYear."}), 400

try:
handler = emailHandler(academicYearTermCode=academicYear)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not handler the variable would most likely be email something like positionReviewEmail.

result = handler.sendAnnualPositionReviewRequests(currentUser)
if result["failedDepartments"]:
result["message"] = "Requests sent to {} of {} departments. Failed departments: {}.".format(
result["sentCount"], result["departmentCount"], ", ".join(result["failedDepartments"])
)
return jsonify({"Success": False, **result})
return jsonify({"Success": True, **result})
except Exception:
return jsonify({"Success": False})

@admin.route('/admin/manageDepartments/<org>/<account>/allocationReview', methods=['GET'])
def allocationReview(org=None, account=None):
"""
Expand Down Expand Up @@ -116,12 +157,12 @@ def allocationReview(org=None, account=None):


# checking if the allocation has already been approved
if allocationExists(nextAY.termCode, dept, isFinal=True):
if allocationExists(nextAY.termCode, dept, isFinal=True):
flash("You cannot reapprove an allocation request.", "info")
return redirect('/admin/manageDepartments/')

# checking if the department has requested any allocation review

# checking if the department has requested any allocation review
if not allocationExists(nextAY.termCode, dept, isFinal=False):
flash(f"The {dept.DEPT_NAME} department has not requested an allocation review yet.", "info")
return redirect('/admin/manageDepartments/')
Expand All @@ -133,7 +174,7 @@ def allocationReview(org=None, account=None):


return render_template('admin/allocationReview.html',
department = dept,
department = dept,
nextAY = nextAY,
currentAlloc = currentAlloc,
requestedAlloc = requestedAlloc
Expand All @@ -143,7 +184,7 @@ def allocationReview(org=None, account=None):

@admin.route('/admin/allocationReview/approve', methods=['POST'])
def approveAllocationReview():

# Retrieving the current and following academic years
currentAY, nextAY = getCurrentAndNextAY()

Expand All @@ -154,8 +195,8 @@ def approveAllocationReview():
requester = request.form.get("requester", type=int, default=None)

# saving the newly approved allocation
newApprovedAlloc = Allocation.create(termCode = nextAY.termCode,
department = requester,
newApprovedAlloc = Allocation.create(termCode = nextAY.termCode,
department = requester,
isFinal = True,
approvedBy = approverID,
approvedOn = date.today(),
Expand Down
214 changes: 144 additions & 70 deletions app/logic/emailHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,82 +16,96 @@
from app import app
import os
from datetime import datetime, date
from app.models.department import Department
from app.models.term import Term
from app.models.positionHistory import PositionHistory
from app.logic.getSupervisors import getSupervisors


class emailHandler():
def __init__(self, formHistoryKey):
def __init__(self, formHistoryKey=None, academicYearTermCode=None):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

academicYearTermCode is general like how formHistoryKey is not related to email but it is a distinction of what the emailhandler do we should turn the parameter naming of academicYearTermCode to something that resembles its purpose. This academicYearTermCode is going to use in two instance the allocation review email and position review email. So the parameter naming can be termCodeUnderReview, targetTermCode and more

self.mail = Mail(app)

self.formHistory = FormHistory.get(FormHistory.formHistoryID == formHistoryKey)
self.laborStatusForm = self.formHistory.formID
self.term = self.laborStatusForm.termCode
self.student = self.laborStatusForm.studentSupervisee
self.studentEmail = self.student.STU_EMAIL
self.creatorEmail = self.formHistory.createdBy.email
self.supervisorEmail = self.laborStatusForm.supervisor.EMAIL
self.date = self.laborStatusForm.startDate.strftime("%m/%d/%Y")
self.weeklyHours = str(self.laborStatusForm.weeklyHours)
self.contractHours = str(self.laborStatusForm.contractHours)
self.adminName = ""
self.positions = LaborStatusForm.select().where(LaborStatusForm.termCode == self.term, LaborStatusForm.studentSupervisee == self.student)
self.supervisors = []
for position in self.positions:
self.supervisors.append(position.supervisor)

if not self.term.isBreak:
try:
ayTermCode = str(self.laborStatusForm.termCode.termCode)[:-2] + '00'
self.primaryEmail = None
self.primaryForm = None
self.primaryForm = FormHistory.select().join_from(FormHistory, LaborStatusForm) \
.join_from(FormHistory, HistoryType).join_from(FormHistory, Status) \
.where((FormHistory.formID.jobType == "Primary") &
(FormHistory.formID.studentSupervisee == self.laborStatusForm.studentSupervisee) &
((FormHistory.formID.termCode == self.laborStatusForm.termCode) | (FormHistory.formID.termCode == ayTermCode)) &
(FormHistory.historyType.historyTypeName == "Labor Status Form") &
~(FormHistory.status.statusName % "Denied%")).get()
self.primaryEmail = self.primaryForm.formID.supervisor.EMAIL
except DoesNotExist:
# This case happens from some of the old data
pass

self.link = ""
self.releaseReason = ""
self.releaseDate = ""
self.newAdjustmentField = ""
self.oldAdjustmentField = ""

# generating a confirmation link for student approval
self.confirmationLink = ""
if self.laborStatusForm.confirmationToken:
self.confirmationLink = f"{request.host_url}studentResponse/confirm?token={self.laborStatusForm.confirmationToken}"


if self.formHistory.adjustedForm:
if self.formHistory.adjustedForm.fieldAdjusted == "supervisor":
from app.logic.userInsertFunctions import createSupervisorFromTracy
newSupervisor = createSupervisorFromTracy(bnumber=self.formHistory.adjustedForm.newValue)
self.newAdjustmentField = "Pending new Supervisor: {0} {1}".format(newSupervisor.FIRST_NAME, newSupervisor.LAST_NAME)
self.oldAdjustmentField = "Current Supervisor: {0} {1}".format(self.formHistory.formID.supervisor.FIRST_NAME, self.formHistory.formID.supervisor.LAST_NAME)
elif self.formHistory.adjustedForm.fieldAdjusted == "position":
currentPosition = Tracy().getPositionFromCode(self.formHistory.adjustedForm.oldValue)
newPosition = Tracy().getPositionFromCode(self.formHistory.adjustedForm.newValue)
self.oldAdjustmentField = "Current Position: {0} ({1})".format(currentPosition.POSN_TITLE, currentPosition.WLS)
self.newAdjustmentField = "Pending new Position: {0} ({1})".format(newPosition.POSN_TITLE, newPosition.WLS)
else:
self.oldAdjustmentField = "Current Hours: {0}".format(self.formHistory.adjustedForm.oldValue)
self.newAdjustmentField = "Pending new Hours: {0}".format(self.formHistory.adjustedForm.newValue)

try:
self.releaseDate = self.formHistory.releaseForm.releaseDate.strftime("%m/%d/%Y")
self.releaseReason = self.formHistory.releaseForm.reasonForRelease
# emailHandler was originally built entirely around a single
# LaborStatusForm (formHistoryKey). Annual Position Review isn't tied
# to a form at all - it's scoped to an academic year across every
# department - so construction branches on whichever was given.
if formHistoryKey is not None:
self.formHistory = FormHistory.get(FormHistory.formHistoryID == formHistoryKey)
self.laborStatusForm = self.formHistory.formID
self.term = self.laborStatusForm.termCode
self.student = self.laborStatusForm.studentSupervisee
self.studentEmail = self.student.STU_EMAIL
self.creatorEmail = self.formHistory.createdBy.email
self.supervisorEmail = self.laborStatusForm.supervisor.EMAIL
self.date = self.laborStatusForm.startDate.strftime("%m/%d/%Y")
self.weeklyHours = str(self.laborStatusForm.weeklyHours)
self.contractHours = str(self.laborStatusForm.contractHours)
self.adminName = ""
self.positions = LaborStatusForm.select().where(LaborStatusForm.termCode == self.term, LaborStatusForm.studentSupervisee == self.student)
self.supervisors = []
for position in self.positions:
self.supervisors.append(position.supervisor)

if not self.term.isBreak:
try:
ayTermCode = str(self.laborStatusForm.termCode.termCode)[:-2] + '00'
self.primaryEmail = None
self.primaryForm = None
self.primaryForm = FormHistory.select().join_from(FormHistory, LaborStatusForm) \
.join_from(FormHistory, HistoryType).join_from(FormHistory, Status) \
.where((FormHistory.formID.jobType == "Primary") &
(FormHistory.formID.studentSupervisee == self.laborStatusForm.studentSupervisee) &
((FormHistory.formID.termCode == self.laborStatusForm.termCode) | (FormHistory.formID.termCode == ayTermCode)) &
(FormHistory.historyType.historyTypeName == "Labor Status Form") &
~(FormHistory.status.statusName % "Denied%")).get()
self.primaryEmail = self.primaryForm.formID.supervisor.EMAIL
except DoesNotExist:
# This case happens from some of the old data
pass

self.link = ""
self.releaseReason = ""
self.releaseDate = ""
self.newAdjustmentField = ""
self.oldAdjustmentField = ""

# generating a confirmation link for student approval
self.confirmationLink = ""
if self.laborStatusForm.confirmationToken:
self.confirmationLink = f"{request.host_url}studentResponse/confirm?token={self.laborStatusForm.confirmationToken}"


if self.formHistory.adjustedForm:
if self.formHistory.adjustedForm.fieldAdjusted == "supervisor":
from app.logic.userInsertFunctions import createSupervisorFromTracy
newSupervisor = createSupervisorFromTracy(bnumber=self.formHistory.adjustedForm.newValue)
self.newAdjustmentField = "Pending new Supervisor: {0} {1}".format(newSupervisor.FIRST_NAME, newSupervisor.LAST_NAME)
self.oldAdjustmentField = "Current Supervisor: {0} {1}".format(self.formHistory.formID.supervisor.FIRST_NAME, self.formHistory.formID.supervisor.LAST_NAME)
elif self.formHistory.adjustedForm.fieldAdjusted == "position":
currentPosition = Tracy().getPositionFromCode(self.formHistory.adjustedForm.oldValue)
newPosition = Tracy().getPositionFromCode(self.formHistory.adjustedForm.newValue)
self.oldAdjustmentField = "Current Position: {0} ({1})".format(currentPosition.POSN_TITLE, currentPosition.WLS)
self.newAdjustmentField = "Pending new Position: {0} ({1})".format(newPosition.POSN_TITLE, newPosition.WLS)
else:
self.oldAdjustmentField = "Current Hours: {0}".format(self.formHistory.adjustedForm.oldValue)
self.newAdjustmentField = "Pending new Hours: {0}".format(self.formHistory.adjustedForm.newValue)

except Exception as e:
# The error you should get when the form is not a release form
# is the 'AttributeError' error. We expect to get the 'AttributeError',
# but if we get anything else then we want to print the error
if e.__class__.__name__ != "AttributeError":
print (e)
try:
self.releaseDate = self.formHistory.releaseForm.releaseDate.strftime("%m/%d/%Y")
self.releaseReason = self.formHistory.releaseForm.reasonForRelease

except Exception as e:
# The error you should get when the form is not a release form
# is the 'AttributeError' error. We expect to get the 'AttributeError',
# but if we get anything else then we want to print the error
if e.__class__.__name__ != "AttributeError":
print (e)

elif academicYearTermCode is not None:
self.term = Term.get(Term.termCode == academicYearTermCode)
else:
raise ValueError("emailHandler requires either formHistoryKey or academicYearTermCode")

def send(self, message: Message):
if app.config['ENV'] == 'production' or app.config['ALWAYS_SEND_MAIL']:
Expand All @@ -110,7 +124,67 @@ def send(self, message: Message):
else:
print("ENV: {}. Email not sent to {}, subject '{}'.".format(app.config['ENV'], message.recipients, message.subject))

def sendAnnualPositionReviewRequests(self, requestingUser):
"""
Sends an Annual Position Review request email to every active department's
Labor Coordinators and supervisors, and records that the request was made
for this handler's academic year (self.term).
"""
template = EmailTemplate.get_or_none(EmailTemplate.purpose == "Annual Position Review Request")
if template is None:
raise ValueError("The 'Annual Position Review Request' email template is missing.")

departments = Department.select().where(Department.isActive == True)

sentCount = 0
failedDepartments = []
for department in departments:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic is a function on its own emailHandler methods are only here to handle different email logic.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

existingReview = PositionHistory.get_or_none(
PositionHistory.academicYear == self.term,
PositionHistory.department == department,
PositionHistory.positionCode.is_null(True)
)
if existingReview:
existingReview.requestedOn = datetime.now()
existingReview.requestedBy = requestingUser
existingReview.save()
else:
PositionHistory.create(
academicYear=self.term,
department=department,
requestedOn=datetime.now(),
requestedBy=requestingUser
)

# A review is considered "requested" for every active department as soon
# as this runs, whether or not there's currently anyone to email - a
# department with no supervisors/coordinators assigned is itself worth
# surfacing, not silently skipping.
existingReview = PositionHistory.get_or_none(
PositionHistory.academicYear == self.term,
PositionHistory.department == department,
PositionHistory.positionCode.is_null(True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic feels iffy. the idea that we are considering existingreview to be based off from positioncode being null isn't a good logic here. I would encourage to see the composite key in positionhistory and see whether a position should be reviewed based on how long it has not been reviewed and what status it is. i believe status wise we should be reviewing the active status.

)
if existingReview:
existingReview.requestedOn = datetime.now()
existingReview.requestedBy = requestingUser
existingReview.save()
else:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think reviewing means creating a positionhistory

PositionHistory.create(
academicYear=self.term,
department=department,
requestedOn=datetime.now(),
requestedBy=requestingUser
)

supervisors, laborCoordinators = getSupervisors(department)
recipients = {person["email"] for person in supervisors + laborCoordinators if person["email"]}
if not recipients:
continue

subject = template.subject.replace("@@AcademicYear@@", self.term.termName)
body = template.body.replace("@@Department@@", department.DEPT_NAME).replace("@@AcademicYear@@", self.term.termName)

try:
message = Message(subject, recipients=list(recipients))
message.html = body
self.send(message)
except Exception as error:
failedDepartments.append(department.DEPT_NAME)
print("Failed to send Annual Position Review request for department {}: {}".format(department.DEPT_NAME, error))
continue

sentCount += 1
print("Sent Annual Position Review request to {} for department {}.".format(", ".join(recipients), department.DEPT_NAME))
print("{} Annual Position Review requests sent for academic year {}.".format(sentCount, self.term.termName))
return {
"sentCount": sentCount,
"departmentCount": departments.count(),
"failedDepartments": failedDepartments
}

# The methods of this class each handle a different email situation. Some of the methods need to handle
# "primary" and "secondary" forms differently, but a majority do not need to differentiate between the two.
Expand Down
Loading