-
Notifications
You must be signed in to change notification settings - Fork 0
Edit position page #664
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: department-portal-base
Are you sure you want to change the base?
Edit position page #664
Changes from all commits
7c7fc83
1f7f422
48e83ec
496a93f
557e211
8cddd37
1d902d3
a52dab1
9799cde
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<org>/<account>/positions/<positionCode>/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) | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this route should reuse the same department access logic as Something like this would keep the behavior consistent: if not g.currentUser.isLaborAdmin:
if not SupervisorDepartment.select().where(
(SupervisorDepartment.supervisor == g.currentUser.supervisor) &
(SupervisorDepartment.department == dept.departmentID)
).exists():
return render_template('errors/403.html'), 403 |
||
| 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') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After saving a revision, should this redirect instead of rendering the page from the POST request? Right now, refreshing the browser after a successful save could submit the POST again and create another requested revision. A redirect after save would avoid duplicate revisions from refresh.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. submitted for submission and saved for saving a draft |
||
|
|
||
| sections = getPositionDescriptionSections(position) | ||
|
|
||
| return render_template( | ||
| 'main/revisepositionpage.html', | ||
| department=dept, | ||
| position=position, | ||
| sections=sections | ||
| ) | ||
|
|
||
|
|
||
| @main_bp.route('/department/<org>/<account>/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 | ||
| ) | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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*/?>') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This sanitizer is a good start, but parsing HTML with regex can be fragile. Could we either add stronger tests around tricky HTML cases, or use a dedicated HTML sanitizer if the project already has one available? The main thing I want to avoid is saving content with unsafe attributes or malformed tags that later get rendered with |
||
| hrefPattern = re.compile(r'href\s*=\s*(["\'])(https?:.*?|mailto:.*?|/.*?)\1', re.IGNORECASE) | ||
|
|
||
| def sanitizeDescriptionHTML(value): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we add a small test for this sanitizer? Since section content is edited with CKEditor and later rendered as HTML, it would be good to verify that allowed tags like |
||
| """ | ||
| Strips any HTML tag not in allowedDescriptionTags, and drops all attributes | ||
| except a safe href on <a> 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'<a href="{hrefMatch.group(2)}">' if hrefMatch else '<a>' | ||
| 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)): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we validate that If one list is longer than the other, |
||
| PositionDescriptionSection.create( | ||
| position=newPosition, | ||
| sectionTitle=sanitizeDescriptionHTML(sectionTitle), | ||
| sectionContent=sanitizeDescriptionHTML(sectionContent), | ||
| order=order | ||
| ) | ||
|
|
||
| return newPosition | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| $(document).ready(function () { | ||
| var sectionsContainer = document.getElementById('sectionsContainer'); | ||
| var sectionRowTemplate = document.getElementById('sectionRowTemplate'); | ||
|
|
||
| function initEditor(row) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should also remove the insetplaceholder as we don't need it. |
||
| var textarea = row.querySelector('textarea[name="sectionContent[]"]'); | ||
| if (textarea) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the CKeditor that we add need other features for the use to add like list, bold, italic, font size and so on |
||
| CKEDITOR.replace(textarea); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if you use this here: 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]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make sure this reliably destroys the CKEditor instance before removing a section? The textareas in the template do not have explicit IDs, but this lookup depends on |
||
| if (editor) { | ||
| editor.destroy(true); | ||
| } | ||
| row.remove(); | ||
| } | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| {% extends "base.html" %} | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when I look into it your page has html tags being display as well that means we need a more sophisticated text editor. I did a bit of digging and there is a editor in emailtemplate.html. And from your python you also need to have a restriction to only allow certain tags to prevent the text-editor from being hackable. @nahom70 this will be useful for you too
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks like the right direction. CKEditor is now loaded on the revise position page, and the JS initializes it for both existing sections and newly added sections. I also saw the backend sanitizer in Can we add a small test for |
||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script type="text/javascript" src="{{ url_for('static', filename='js/ckeditor/ckeditor.js') }}"></script> | ||
| <script type="text/javascript" src="{{ url_for('static', filename='js/revisepositionpage.js') }}?u={{ lastStaticUpdate }}"></script> | ||
| <link rel="stylesheet" type="text/css" href="/static/css/individualPositions.css?u={{ lastStaticUpdate }}" /> | ||
| <link rel="stylesheet" type="text/css" href="/static/css/revisepositionpage.css?u={{ lastStaticUpdate }}" /> | ||
| {% endblock %} | ||
|
|
||
| {% block app_content %} | ||
| <div class="department-header-container"> | ||
| <h1 class="department-header">Revise {{ position.positionTitle }}</h1> | ||
| </div> | ||
|
|
||
| <div class="container-fluid position-container"> | ||
| <div class="row"> | ||
| <div class="col-12"> | ||
|
|
||
| <form method="POST" action="{{ url_for('main.revisePosition', org=department.ORG, account=department.ACCOUNT, positionCode=position.positionCode) }}"> | ||
|
|
||
| <div class="position-information"> | ||
| <div class="form-group row"> | ||
| <label class="col-sm-3 col-form-label" for="positionTitle">Position Title:</label> | ||
| <div class="col-sm-9"> | ||
| <input type="text" class="form-control" id="positionTitle" name="positionTitle" value="{{ position.positionTitle }}" required> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="form-group row"> | ||
| <label class="col-sm-3 col-form-label">Position Code:</label> | ||
| <div class="col-sm-9"> | ||
| <p class="form-control-static">{{ position.positionCode }}</p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="form-group row"> | ||
| <label class="col-sm-3 col-form-label" for="wls">WLS Level:</label> | ||
| <div class="col-sm-9"> | ||
| <input type="number" class="form-control" id="wls" name="wls" min="0" max="6" value="{{ position.wls }}" required> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="form-group row"> | ||
| <label class="col-sm-3 col-form-label">Last Revision Date:</label> | ||
| <div class="col-sm-9"> | ||
| <p class="form-control-static">{{ position.revisionDate }}</p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="form-group row"> | ||
| <label class="col-sm-3 col-form-label">Revised By:</label> | ||
| <div class="col-sm-9"> | ||
| <p class="form-control-static">{{ position.revisedBy }}</p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <h3 class="description-header">Description</h3> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| <section id="sectionsContainer"> | ||
| {%- for section in sections %} | ||
| <div class="description-section-row"> | ||
| <div class="form-group"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| <label>Section Title</label> | ||
| <input type="text" class="form-control" name="sectionTitle[]" value="{{ section.sectionTitle }}"> | ||
| </div> | ||
| <div class="form-group"> | ||
| <label>Section Content</label> | ||
| <textarea class="form-control" name="sectionContent[]" rows="4">{{ section.sectionContent }}</textarea> | ||
| </div> | ||
| <button type="button" class="btn btn-danger btn-sm remove-section-btn">Remove Section</button> | ||
| </div> | ||
| {%- endfor %} | ||
| </section> | ||
|
|
||
| <button type="button" id="addSectionBtn" class="btn btn-default">Add Section</button> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this button should be green button |
||
|
|
||
| <div class="row revise-actions"> | ||
| <div class="col-xs-12 text-left"> | ||
| <a href="{{ url_for('main.managePositions', org=department.ORG, account=department.ACCOUNT) }}" class="btn btn-default"> | ||
| Cancel | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cancel should be red and there should be a new button for submit which should be green too. |
||
| </a> | ||
| <button type="submit" class="btn btn-primary">Save Revision</button> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Save Revision doesn't portray the full picture save revision mean they can come back and edit. it should be submit revision. |
||
| </div> | ||
| </div> | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We didn't considered for the draft stage where our user instead of submit can save and leave only to come back. This means we need to add Draft as a status. This also mean we can use this draft and not change its composite key but its other fields. Moreover, in case the revisiondate move another day we can create a new one with different revision date base on the filter that the revision date is recent and draft exist if not we create a new one. |
||
| </form> | ||
|
|
||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <template id="sectionRowTemplate"> | ||
| <div class="description-section-row"> | ||
| <div class="form-group"> | ||
| <label>Section Title</label> | ||
| <input type="text" class="form-control" name="sectionTitle[]" value=""> | ||
| </div> | ||
| <div class="form-group"> | ||
| <label>Section Content</label> | ||
| <textarea class="form-control" name="sectionContent[]" rows="4"></textarea> | ||
| </div> | ||
| <button type="button" class="btn btn-danger btn-sm remove-section-btn">Remove Section</button> | ||
| </div> | ||
| </template> | ||
| {% endblock %} | ||



There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add an authorization check here before allowing the user to view or save a revision?
managePositionschecks whether the user is a labor admin or a supervisor connected to this department, but this revise route does not appear to have the same check. Since this route can create a new position revision on POST, a user who knows the URL may be able to revise a position without going through Manage Positions.