Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
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
32 changes: 31 additions & 1 deletion app/controllers/admin_routes/manageDepartments.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
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 sendAnnualPositionReviewRequests



Expand Down Expand Up @@ -67,6 +68,7 @@ def manageDepartments(academicYear = None):
currentAY = currentAY,
previousAY = previousAY,
nextAY = nextAY,
chosenAY = chosenAY,
academicYear = chosenAY.termName,
breakHoursByDepartment = breakHoursByDepartment,
allocationStatus = allocationStatus
Expand All @@ -93,6 +95,34 @@ def complianceStatusCheck():



@admin.route('/admin/manageDepartments/annualPositionReview', methods=['POST'])
def annualPositionReviewRequest():
"""
Sends an Annual Position Review request email to every active department's

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.

using g.openterm will create a lot of bugs in case user have two open term, if the position review is for next ay while the openterm is for this ay

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:

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.

we also need to give access to isLaborDepartmentStudent as well I have checked the controllers and we do give access.

return jsonify({"Success": False}), 403

rsp = request.get_json(silent=True)
if not rsp or "academicYear" not in rsp:

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.

from line 110 to 122 can be consolidate into two try and except with the first try doing academic year and second try doing result and returning success jsonify.

return jsonify({"Success": False, "message": "Request must include academicYear."}), 400

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

try:
result = sendAnnualPositionReviewRequests(academicYear, currentUser)
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
78 changes: 78 additions & 0 deletions app/logic/annualPositionReview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from datetime import datetime

from flask_mail import Mail, Message

from app import app
from app.models.department import Department
from app.models.emailTemplate import EmailTemplate
from app.models.positionReview import PositionReview
from app.models.term import Term
from app.logic.getSupervisors import getSupervisors


def sendMail(mail, message: Message):

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 sendMail function looks very similar to the existing emailHandler.send logic. Can we reuse the existing email sending helper or extract the shared behavior so we do not have two versions of the same mail override / reply_to / testing behavior?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i just got rid of annualPositionReview.py and move both the sendAnnualPositionReviewRequests() and sendMail() into emailHandler.py so that i can just use the function as send() to remove the duplicate

if app.config['ENV'] == 'production' or app.config['ALWAYS_SEND_MAIL']:

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.

emailHandler page has a emailhandler class but it runs on id, that means you will have to expand the emailhandler to be flexible. This means emailhandler can have a second optional parameter and turn the id parameter into a default too. For the second parameter turn it to default and have a condition in the init so that when one of the parameter is added the class knows what to create.

This means you won't need this file while you incorporate sendannualpositionreviewrequests into the emailhandler function which is good as we would be doing similar with allocation request


# If we have set an override address
if app.config['MAIL_OVERRIDE_ALL']:
message.html = "<b>Original message intended for {}.</b><br>".format(", ".join(message.recipients)) + message.html
message.recipients = [app.config['MAIL_OVERRIDE_ALL']]

message.reply_to = app.config["REPLY_TO_ADDRESS"]
mail.send(message)

elif app.config['ENV'] == 'testing':
pass
else:
print("ENV: {}. Email not sent to {}, subject '{}'.".format(app.config['ENV'], message.recipients, message.subject))


def sendAnnualPositionReviewRequests(academicYearTermCode, 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 the given academic year.
"""
mail = Mail(app)
term = Term.get(Term.termCode == academicYearTermCode)
template = EmailTemplate.get(EmailTemplate.purpose == "Annual Position Review Request")
departments = Department.select().where(Department.isActive == True)

sentCount = 0
for department in departments:
# 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 skipping the department.
existingReview = PositionReview.get_or_none(
PositionReview.academicYear == term,
PositionReview.department == department
)
if existingReview:
existingReview.requestedOn = datetime.now()
existingReview.requestedBy = requestingUser
existingReview.save()
else:
PositionReview.create(
academicYear=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@@", term.termName)
body = template.body.replace("@@Department@@", department.DEPT_NAME).replace("@@AcademicYear@@", term.termName)

message = Message(subject, recipients=list(recipients))
message.html = body
sendMail(mail, message)

sentCount += 1

return {"sentCount": sentCount, "departmentCount": departments.count()}

96 changes: 75 additions & 21 deletions app/logic/emailHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,79 @@
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.positionReview import PositionReview
from app.logic.getSupervisors import getSupervisors


def send(mail, message: Message):
if app.config['ENV'] == 'production' or app.config['ALWAYS_SEND_MAIL']:

# If we have set an override address
if app.config['MAIL_OVERRIDE_ALL']:
message.html = "<b>Original message intended for {}.</b><br>".format(", ".join(message.recipients)) + message.html
message.recipients = [app.config['MAIL_OVERRIDE_ALL']]

message.reply_to = app.config["REPLY_TO_ADDRESS"]
mail.send(message)

elif app.config['ENV'] == 'testing':
# TODO: we really should have a way to check that we're sending emails that doesn't spam the logs
pass
else:
print("ENV: {}. Email not sent to {}, subject '{}'.".format(app.config['ENV'], message.recipients, message.subject))


def sendAnnualPositionReviewRequests(academicYearTermCode, 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 the given academic year.
"""
mail = Mail(app)
term = Term.get(Term.termCode == academicYearTermCode)
template = EmailTemplate.get(EmailTemplate.purpose == "Annual Position Review Request")
departments = Department.select().where(Department.isActive == True)

sentCount = 0
for department in departments:
# 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 = PositionReview.get_or_none(
PositionReview.academicYear == term,
PositionReview.department == department
)
if existingReview:
existingReview.requestedOn = datetime.now()
existingReview.requestedBy = requestingUser
existingReview.save()
else:
PositionReview.create(
academicYear=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@@", term.termName)
body = template.body.replace("@@Department@@", department.DEPT_NAME).replace("@@AcademicYear@@", term.termName)

message = Message(subject, recipients=list(recipients))
message.html = body
send(mail, message)

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, term.termName))
return {"sentCount": sentCount, "departmentCount": departments.count()}


class emailHandler():
Expand Down Expand Up @@ -93,25 +166,6 @@ def __init__(self, formHistoryKey):
if e.__class__.__name__ != "AttributeError":
print (e)

def send(self, message: Message):
if app.config['ENV'] == 'production' or app.config['ALWAYS_SEND_MAIL']:

# If we have set an override address
if app.config['MAIL_OVERRIDE_ALL']:
message.html = "<b>Original message intended for {}.</b><br>".format(", ".join(message.recipients)) + message.html
message.recipients = [app.config['MAIL_OVERRIDE_ALL']]

message.reply_to = app.config["REPLY_TO_ADDRESS"]
self.mail.send(message)

elif app.config['ENV'] == 'testing':
# TODO: we really should have a way to check that we're sending emails that doesn't spam the logs
pass
else:
print("ENV: {}. Email not sent to {}, subject '{}'.".format(app.config['ENV'], message.recipients, message.subject))



# 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.
# Every method will use the replaceText and sendEmail methods to accomplish the email sending.
Expand Down Expand Up @@ -298,7 +352,7 @@ def overloadVerification(self, dept, link):
)
message.html = self.replaceText(emailTemplateID.body)

self.send(message)
send(self.mail, message)

# The function below was commented out becasuse there is no email template with the purpose "Labor Admin Notification"
# Since an admin can still see the decision from SAAS or Financial Aid in the pending Overload Form Modal,
Expand Down Expand Up @@ -387,7 +441,7 @@ def sendEmail(self, template, sendTo):
subject = template.subject
)

self.send(message)
send(self.mail, message)

# This method is responsible for replacing the keyword form the templates in the database with the data in the laborStatusForm
def replaceText(self, form):
Expand Down
14 changes: 14 additions & 0 deletions app/models/positionReview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from app.models import *

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.

we should try to work off from positionhistory model

from app.models.department import Department
from app.models.term import Term
from app.models.user import User


class PositionReview(baseModel):
academicYear = ForeignKeyField(Term)
department = ForeignKeyField(Department)
requestedOn = DateTimeField()
requestedBy = ForeignKeyField(User)

class Meta:
indexes = ( (('academicYear', 'department'), True), )
50 changes: 50 additions & 0 deletions app/static/js/annualPositionReview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
function submitAnnualPositionReview(button) {
/*
POSTs the Annual Position Review request for the currently selected academic year.
Sends a review request email to every active department's Labor Coordinators and
supervisors, then shows a success/failure flash message.

RETURNS: None
*/
var academicYear = $('[data-target="#annualPositionModal"]').data('academic-year');

// Disable immediately so a double-click can't fire this request twice -
// PositionReview dedupes the record, but the emails would still go out
// more than once. Re-enabled in complete regardless of outcome so a retry
// after a failure is possible without reloading the page.
$(button).prop("disabled", true);

$.ajax({

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.

Can we add an error callback here?

Right now, if the server returns 403, 500, or the request fails, the modal will likely stay open and the user will not get a clear message. The success handler handles {"Success": false}, but it does not handle actual AJAX errors.

method: "POST",
url: "/admin/manageDepartments/annualPositionReview",
dataType: "json",
contentType: "application/json",
data: JSON.stringify({"academicYear": academicYear}),
success: function(response) {
$("#annualPositionModal").modal("hide");

if (response["Success"]) {
flashMessage("success", "Position review requests sent to " + response["sentCount"] + " of " + response["departmentCount"] + " departments.");
} else {
flashMessage("danger", "Something went wrong sending the Annual Position Review requests.");
}
},
error: function(jqXHR) {
// Covers cases success: never sees - a 403 (not a labor admin), a 500,
// or the request failing outright. Leaves the modal open so the admin
// can retry instead of silently doing nothing.
var msg = jqXHR.status === 403
? "You don't have permission to send Annual Position Review requests."
: "Something went wrong sending the Annual Position Review requests.";
flashMessage("danger", msg);
},
complete: function() {
$(button).prop("disabled", false);
}
})
}

function flashMessage(category, msg) {

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.

there is a flash message function in base.js

$("#flash_container").html('<div class="alert alert-'+ category +'" role="alert" id="flasher">'+msg+'</div>');
$("#flasher").delay(3000).fadeOut();
}
51 changes: 51 additions & 0 deletions app/static/js/emailTemplates.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,61 @@ function getEmailArray() {
dataType: "json",
success: function(response) {
emailTemplateArray = response;
prefillFromQueryParams();
}
})
}

function prefillFromQueryParams() {
// Allows deep-linking into this page (e.g. from another admin page's
// "Edit Email Template" button) with the Recipient/Form Type/Action
// selectpickers pre-selected, so the template loads without the admin
// having to click through the cascading dropdowns manually.
var params = new URLSearchParams(window.location.search);
var recipient = params.get("audience");
var formType = params.get("formType");
var action = params.get("action");
if (!recipient) {
return;
}

// populatePurpose() ends by inserting the body into the CKEditor instance,
// which initializes asynchronously (CKEDITOR.replace() in emailTemplates.html).
// Running this cascade immediately on page load (unlike a human clicking
// through the dropdowns) can race ahead of that, so wait for the editor
// to be ready before touching it.
function runPrefill() {
$("#recipient").val(recipient).selectpicker("refresh");
populateFormType();

if (formType) {
$("#formType").val(formType).selectpicker("refresh");
populateAction();

if (action) {
$("#action").val(action).selectpicker("refresh");
populatePurpose();
}
}
}

// CKEDITOR.instances["editor1"] is registered as soon as CKEDITOR.replace()
// is called, well before the editor is actually ready to accept content
// (that's the "ready" status). Checking mere existence isn't enough here.
var editor = CKEDITOR.instances["editor1"];
if (editor && editor.status === "ready") {
runPrefill();
} else if (editor) {
editor.on("instanceReady", runPrefill);
} else {
CKEDITOR.on("instanceReady", function(evt) {
if (evt.editor.name === "editor1") {
runPrefill();
}
});
}
}

function populateFormType() {
// populates Form Type only when Recipient is selected
$("#formType").prop("disabled", false);
Expand Down
Loading