diff --git a/app/controllers/admin_routes/manageDepartments.py b/app/controllers/admin_routes/manageDepartments.py index 308af4942..7b754d887 100644 --- a/app/controllers/admin_routes/manageDepartments.py +++ b/app/controllers/admin_routes/manageDepartments.py @@ -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 @@ -67,6 +68,7 @@ def manageDepartments(academicYear = None): currentAY = currentAY, previousAY = previousAY, nextAY = nextAY, + chosenAY = chosenAY, academicYear = chosenAY.termName, breakHoursByDepartment = breakHoursByDepartment, allocationStatus = allocationStatus @@ -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) + return jsonify({"Success": False}) + + + @admin.route('/admin/manageDepartments///allocationReview', methods=['GET']) def allocationReview(org=None, account=None): """ diff --git a/app/logic/annualPositionReview.py b/app/logic/annualPositionReview.py new file mode 100644 index 000000000..05566545a --- /dev/null +++ b/app/logic/annualPositionReview.py @@ -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): + 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 = "Original message intended for {}.
".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()} + diff --git a/app/logic/emailHandler.py b/app/logic/emailHandler.py index 1de8ef492..a326f2323 100644 --- a/app/logic/emailHandler.py +++ b/app/logic/emailHandler.py @@ -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 = "Original message intended for {}.
".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(): @@ -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 = "Original message intended for {}.
".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. @@ -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, @@ -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): diff --git a/app/models/positionReview.py b/app/models/positionReview.py new file mode 100644 index 000000000..fa11d3ce4 --- /dev/null +++ b/app/models/positionReview.py @@ -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), ) diff --git a/app/static/js/annualPositionReview.js b/app/static/js/annualPositionReview.js new file mode 100644 index 000000000..a37f426b5 --- /dev/null +++ b/app/static/js/annualPositionReview.js @@ -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({ + 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(''); + $("#flasher").delay(3000).fadeOut(); +} diff --git a/app/static/js/emailTemplates.js b/app/static/js/emailTemplates.js index 6ef0f715c..f7aba7f02 100644 --- a/app/static/js/emailTemplates.js +++ b/app/static/js/emailTemplates.js @@ -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); diff --git a/app/templates/admin/manageDepartments.html b/app/templates/admin/manageDepartments.html index d8cb2f5ca..2742508f4 100755 --- a/app/templates/admin/manageDepartments.html +++ b/app/templates/admin/manageDepartments.html @@ -7,6 +7,8 @@ src="{{url_for('static', filename='js/manageDepartments.js') }}?u={{lastStaticUpdate}}"> + {% endblock %} {% block app_content %}
Click to Skip @@ -87,7 +89,7 @@

Manage Departments

Annual Allocation Review {% include "snips/annualPositionReview.html" %} -
diff --git a/app/templates/snips/annualPositionReview.html b/app/templates/snips/annualPositionReview.html index dac86a31e..547f75953 100644 --- a/app/templates/snips/annualPositionReview.html +++ b/app/templates/snips/annualPositionReview.html @@ -5,7 +5,7 @@ @@ -13,9 +13,9 @@