diff --git a/app/controllers/main_routes/departmentPortal.py b/app/controllers/main_routes/departmentPortal.py index 5d69fdd7..a8df34b0 100644 --- a/app/controllers/main_routes/departmentPortal.py +++ b/app/controllers/main_routes/departmentPortal.py @@ -1,11 +1,11 @@ from datetime import datetime -from flask import g, render_template, request, send_file +from flask import flash, g, render_template, request, send_file from peewee import DoesNotExist from app.controllers.main_routes import main_bp from app.logic.download import makePositionDescriptionPDF -from app.logic.getPositions import getPosition, getPositions, getPositionDescriptionSections +from app.logic.getPositions import createPositionRevision, getPosition, getPositions, getPositionDescriptionSections from app.models.department import Department from app.models.positionHistory import PositionHistory from app.models.supervisorDepartment import SupervisorDepartment @@ -67,6 +67,43 @@ def downloadPositionDescription(org, account, positionCode): +@main_bp.route('/department///positions//revise', methods=['GET', 'POST']) +def revisePosition(org, account, positionCode): + try: + dept = Department.get(Department.ORG == org, Department.ACCOUNT == account) + except (NameError, DoesNotExist): + return render_template('errors/404.html'), 404 + + position = getPosition(dept, positionCode) + + if not position: + return render_template('errors/404.html'), 404 + + if request.method == 'POST': + wls = request.form.get('wls', type=int) + if wls is None or not (0 <= wls <= 6): + flash('WLS level must be between 0 and 6.') + else: + position = createPositionRevision( + position, + g.currentUser.fullName, + request.form.get('positionTitle'), + wls, + request.form.getlist('sectionTitle[]'), + request.form.getlist('sectionContent[]') + ) + flash('Position revision saved.', 'success') + + sections = getPositionDescriptionSections(position) + + return render_template( + 'main/revisepositionpage.html', + department=dept, + position=position, + sections=sections + ) + + @main_bp.route('/department///positions', methods=['GET']) def managePositions(org, account): try: @@ -87,4 +124,4 @@ def managePositions(org, account): department = dept, department_name = dept.DEPT_NAME, positions = positions - ) + ) \ No newline at end of file diff --git a/app/logic/getPositions.py b/app/logic/getPositions.py index 7410d7f7..df77e443 100644 --- a/app/logic/getPositions.py +++ b/app/logic/getPositions.py @@ -1,5 +1,7 @@ +import re from app.models.positionHistory import PositionHistory from app.models.positionDescriptionSection import PositionDescriptionSection +from datetime import date def getActivePositions(dept): """ @@ -52,7 +54,56 @@ def getPositionDescriptionSections(position): positionDescriptionSections = list(PositionDescriptionSection.select() .where(PositionDescriptionSection.position == position) .order_by(PositionDescriptionSection.order.asc())) - + return positionDescriptionSections +allowedDescriptionTags = {'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'} +tagPattern = re.compile(r'<(/?)\s*([a-zA-Z][a-zA-Z0-9]*)((?:\s+[^<>]*)?)\s*/?>') +hrefPattern = re.compile(r'href\s*=\s*(["\'])(https?:.*?|mailto:.*?|/.*?)\1', re.IGNORECASE) + +def sanitizeDescriptionHTML(value): + """ + Strips any HTML tag not in allowedDescriptionTags, and drops all attributes + except a safe href on tags, since section content is rendered with |safe. + """ + if not value: + return '' + + def replaceTag(match): + closingSlash, tag, attrs = match.groups() + tag = tag.lower() + if tag not in allowedDescriptionTags: + return '' + if tag == 'a' and not closingSlash: + hrefMatch = hrefPattern.search(attrs) + return f'' if hrefMatch else '' + return f'<{closingSlash}{tag}>' + + return tagPattern.sub(replaceTag, str(value)) + +def createPositionRevision(position, revisedBy, positionTitle, wls, sectionTitles, sectionContents): + """ + Creates a new pending (Requested) revision of a position, copying forward its + department and position code, and replaces its description sections with the + given titles/contents. Returns the newly created PositionHistory row. + """ + newPosition = PositionHistory.create( + positionTitle=positionTitle, + positionCode=position.positionCode, + department=position.department, + status="Requested", + wls=wls, + revisionDate=date.today(), + revisedBy=revisedBy + ) + + for order, (sectionTitle, sectionContent) in enumerate(zip(sectionTitles, sectionContents)): + PositionDescriptionSection.create( + position=newPosition, + sectionTitle=sanitizeDescriptionHTML(sectionTitle), + sectionContent=sanitizeDescriptionHTML(sectionContent), + order=order + ) + + return newPosition diff --git a/app/static/css/revisepositionpage.css b/app/static/css/revisepositionpage.css new file mode 100644 index 00000000..1d642d2d --- /dev/null +++ b/app/static/css/revisepositionpage.css @@ -0,0 +1,12 @@ +/* Revise position form - styles not covered by Bootstrap */ +.description-section-row { + background: #fff; + border: 1px solid #e6e6e6; + padding: 1rem; + border-radius: 6px; + margin-bottom: 1rem; +} + +.revise-actions { + margin-top: 1.5rem; +} diff --git a/app/static/js/revisepositionpage.js b/app/static/js/revisepositionpage.js new file mode 100644 index 00000000..fa741518 --- /dev/null +++ b/app/static/js/revisepositionpage.js @@ -0,0 +1,32 @@ +$(document).ready(function () { + var sectionsContainer = document.getElementById('sectionsContainer'); + var sectionRowTemplate = document.getElementById('sectionRowTemplate'); + + function initEditor(row) { + var textarea = row.querySelector('textarea[name="sectionContent[]"]'); + if (textarea) { + CKEDITOR.replace(textarea); + } + } + + sectionsContainer.querySelectorAll('.description-section-row').forEach(initEditor); + + document.getElementById('addSectionBtn').addEventListener('click', function () { + var fragment = sectionRowTemplate.content.cloneNode(true); + var row = fragment.querySelector('.description-section-row'); + sectionsContainer.appendChild(fragment); + initEditor(row); + }); + + sectionsContainer.addEventListener('click', function (event) { + if (event.target.classList.contains('remove-section-btn')) { + var row = event.target.closest('.description-section-row'); + var textarea = row.querySelector('textarea[name="sectionContent[]"]'); + var editor = textarea && CKEDITOR.instances[textarea.id]; + if (editor) { + editor.destroy(true); + } + row.remove(); + } + }); +}); diff --git a/app/templates/main/managePositions.html b/app/templates/main/managePositions.html index 81b139d4..72f1738c 100644 --- a/app/templates/main/managePositions.html +++ b/app/templates/main/managePositions.html @@ -33,9 +33,8 @@

{{ department_name }} Positions

{{ position.wls }} {{ position.revisionDate }} -
View - + View + Revise Position {% endfor %} diff --git a/app/templates/main/revisepositionpage.html b/app/templates/main/revisepositionpage.html new file mode 100644 index 00000000..eec5faa5 --- /dev/null +++ b/app/templates/main/revisepositionpage.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block scripts %} + {{ super() }} + + + + +{% endblock %} + +{% block app_content %} +
+

Revise {{ position.positionTitle }}

+
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+

{{ position.positionCode }}

+
+
+ +
+ +
+ +
+
+ +
+ +
+

{{ position.revisionDate }}

+
+
+ +
+ +
+

{{ position.revisedBy }}

+
+
+
+ +

Description

+
+ {%- for section in sections %} +
+
+ + +
+
+ + +
+ +
+ {%- endfor %} +
+ + + +
+
+ + Cancel + + +
+
+ +
+ +
+
+
+ + +{% endblock %}