diff --git a/app/__init__.py b/app/__init__.py index 11c90a0a9..ceb438d8d 100755 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,5 @@ import os +from datetime import date from flask import Flask from flask_restful import Api @@ -78,7 +79,21 @@ def load_openTerm(): if term: session['openTerm'] = model_to_dict(term) g.openTerm = term - + +def getCurrentYear(): + + today = date.today() + year = today.year + + if today.month < 7: + return year - 1, year + + return year, year + 1 + +@app.before_request +def load_currentYear(): + g.currentYear = getCurrentYear() + @app.context_processor def inject_environment(): return dict(env=app.config['ENV']) diff --git a/app/controllers/admin_routes/manageDepartments.py b/app/controllers/admin_routes/manageDepartments.py index c202513a9..982aceea8 100644 --- a/app/controllers/admin_routes/manageDepartments.py +++ b/app/controllers/admin_routes/manageDepartments.py @@ -15,11 +15,13 @@ 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 @admin.route('/admin/manageDepartments/', methods=['GET']) +@admin.route('/admin/manageDepartments/', methods=['GET']) def manageDepartments(academicYear = None): """ Returns the Manage Departments page, which allows the admin to view all the departments @@ -37,12 +39,14 @@ def manageDepartments(academicYear = None): return render_template('errors/403.html'), 403 - # The condition below may be deleted if the routing to the Manage Departments page is changed. - if academicYear == None: - academicYear = g.openTerm.termCode - else: + # The condition below may be deleted if the routing to the Manage Departments page is changed. + if academicYear == None: + academicYear = g.currentYear[0] * 100 + else: academicYear = int(academicYear) + print("Academic Year Term Code asasasas:", academicYear) + currentAY, nextAY = generateAdjacentYears(academicYear) chosenAY = Term.get(Term.termCode == academicYear) @@ -65,6 +69,7 @@ def manageDepartments(academicYear = None): allSupervisors = allSupervisors, currentAY = currentAY, nextAY = nextAY, + chosenAY = chosenAY, academicYear = chosenAY.termName, breakHoursByDepartment = breakHoursByDepartment, allocationStatus = allocationStatus @@ -91,6 +96,31 @@ 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 or currentUser.isLaborDepartmentStudent): + return jsonify({"Success": False}), 403 + + rsp = request.get_json(silent=True) + + 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) + result = handler.sendAnnualPositionReviewRequests(currentUser) + return jsonify({"Success": True, **result}) + except Exception: + return jsonify({"Success": False}) + @admin.route('/admin/manageDepartments///allocationReview', methods=['GET']) def allocationReview(org=None, account=None): """ diff --git a/app/logic/emailHandler.py b/app/logic/emailHandler.py index 1de8ef492..8258c9dc9 100644 --- a/app/logic/emailHandler.py +++ b/app/logic/emailHandler.py @@ -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.positionReview import PositionReview +from app.logic.getSupervisors import getSupervisors class emailHandler(): - def __init__(self, formHistoryKey): + def __init__(self, formHistoryKey=None, academicYearTermCode=None): 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']: @@ -110,7 +124,53 @@ 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(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 == self.term, + PositionReview.department == department + ) + if existingReview: + existingReview.requestedOn = datetime.now() + existingReview.requestedBy = requestingUser + existingReview.save() + else: + PositionReview.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) + + message = Message(subject, recipients=list(recipients)) + message.html = body + self.send(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, self.term.termName)) + return {"sentCount": sentCount, "departmentCount": departments.count()} # 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. diff --git a/app/logic/manageDepartments.py b/app/logic/manageDepartments.py index 4f3fbb9d8..fcd1a2499 100644 --- a/app/logic/manageDepartments.py +++ b/app/logic/manageDepartments.py @@ -14,19 +14,19 @@ -def generateAdjacentYears(academicYearTermCode=None): +def generateAdjacentYears(academicYearTermCode=None): """ - Generates the current, the previous, and the following academic years. + Generates the current and the following academic years. """ - currentYear = g.openTerm.termCode // 100 - nextYear = currentYear + 1 + currentYear = g.currentYear[0] + nextYear = g.currentYear[1] currentAYCode = currentYear * 100 nextAYCode = nextYear * 100 - # Admins cannot view allocations for the years that are beyond the current, the previous, or the following academic year + # Admins can only view the current positions and the requested positions for the incoming academic year if academicYearTermCode not in (None, currentAYCode, nextAYCode): abort(400) 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 88fcf297e..d3988de88 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 @@ -45,26 +47,23 @@

Manage Departments


-
+
{% include "snips/uploadAllocations.html" %} -
- -
+ +
{% include "snips/annualAllocationReview.html" %} - {% include "snips/annualPositionReview.html" %} -
@@ -86,15 +85,17 @@

Manage Departments


+ +
- - - + + + @@ -127,31 +128,20 @@

Manage Departments

Secondary: {{department.lsfCountSecondaries}} of {{department.totalSecondaries}} -
- - Break: {{ breakHoursByDepartment.get(department.departmentID, 0) }} of {{ - department.allocation.breakHours }} hours - - + +
DepartmentStatusCurrent Allocations
({{ academicYear }})
Requested Allocations
({{ nextAY.termName }})
PositionsAllocation Status
({{ academicYear }})
Break Hours
(Used/Given)
Actions
- - - Primary: - {{department.lsfCountPrimaries}} of {{department.totalPrimaries}} - -
- - Secondary: - {{department.lsfCountSecondaries}} of {{department.totalSecondaries}} - -
- - Break: {{ breakHoursByDepartment.get(department.departmentID, 0) }} of {{ department.allocation.breakHours }} hours - +
+ {{ breakHoursByDepartment.get(department.departmentID, 0) }} / {{ department.allocation.breakHours }} + @@ -162,12 +152,9 @@

Manage Departments

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 @@