Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 32 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,35 @@ def complianceStatusCheck():



@admin.route('/admin/manageDepartments/annualPositionReview', methods=['POST'])
def annualPositionReviewRequest():
"""
Sends an Annual Position Review request email to every active department's
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:
return jsonify({"Success": False}), 403

rsp = request.get_json(silent=True)
if not rsp or "academicYear" not in rsp:
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 as e:
print(e)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do we need this print statement (line 114)?

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']:

# 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 *
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) {
$("#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