diff --git a/atst/domain/csp/files.py b/atst/domain/csp/files.py index dfb48a3c..74384500 100644 --- a/atst/domain/csp/files.py +++ b/atst/domain/csp/files.py @@ -14,7 +14,7 @@ class CSPFileError(Exception): class FileProviderInterface: - _PERMITTED_MIMETYPES = ["application/pdf", "image/png"] + _PERMITTED_MIMETYPES = ["application/pdf"] def _enforce_mimetype(self, fyle): # TODO: for hardening, we should probably use a better library for diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index ba7378c5..d5938bd1 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -1,8 +1,10 @@ -from wtforms.fields import BooleanField, DecimalField, StringField +from wtforms.fields import BooleanField, DecimalField, FileField, StringField from wtforms.fields.html5 import DateField from wtforms.validators import Required, Optional +from flask_wtf.file import FileAllowed from .forms import BaseForm +from atst.forms.validators import FileLength from atst.utils.localization import translate @@ -12,6 +14,14 @@ class TaskOrderForm(BaseForm): description=translate("forms.task_order.number_description"), validators=[Required()], ) + pdf = FileField( + None, + validators=[ + FileAllowed(["pdf"], translate("forms.task_order.file_format_not_allowed")), + FileLength(message=translate("forms.validators.file_length")), + ], + render_kw={"accept": ".pdf,application/pdf"}, + ) class FundingForm(BaseForm): diff --git a/atst/forms/validators.py b/atst/forms/validators.py index ff4b2366..99265769 100644 --- a/atst/forms/validators.py +++ b/atst/forms/validators.py @@ -99,3 +99,17 @@ def RequiredIf(criteria_function, message=translate("forms.validators.is_require raise StopValidation() return _required_if + + +def FileLength(max_length=50000000, message=None): + def _file_length(_form, field): + if field.data is None: + return True + + content = field.data.read() + if len(content) > max_length: + raise ValidationError(message) + else: + field.data.seek(0) + + return _file_length diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 1f1277fc..9778320f 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -8,25 +8,32 @@ from atst.models.permissions import Permissions from atst.utils.flash import formatted_flash as flash -@task_orders_bp.route("/portfolios//task_orders/new") -@task_orders_bp.route("/portfolios//task_orders//edit") -@user_can(Permissions.CREATE_TASK_ORDER, message="view new task order form") -def edit(portfolio_id, task_order_id=None): - form = None +def render_task_orders_edit(portfolio_id, task_order_id=None, form=None): + render_args = {} if task_order_id: task_order = TaskOrders.get(task_order_id) - form = TaskOrderForm(number=task_order.number) + render_args["form"] = form or TaskOrderForm( + number=task_order.number, pdf=task_order.pdf + ) + render_args["task_order_id"] = task_order_id else: - form = TaskOrderForm() + render_args["form"] = form or TaskOrderForm() - cancel_url = ( + render_args["cancel_url"] = ( http_request.referrer if http_request.referrer else url_for("task_orders.portfolio_funding", portfolio_id=portfolio_id) ) - return render_template("task_orders/edit.html", form=form, cancel_url=cancel_url) + return render_template("task_orders/edit.html", **render_args) + + +@task_orders_bp.route("/portfolios//task_orders/new") +@task_orders_bp.route("/portfolios//task_orders//edit") +@user_can(Permissions.CREATE_TASK_ORDER, message="view new task order form") +def edit(portfolio_id, task_order_id=None): + return render_task_orders_edit(portfolio_id, task_order_id) @task_orders_bp.route("/portfolios//task_orders/new", methods=["POST"]) @@ -35,7 +42,8 @@ def edit(portfolio_id, task_order_id=None): ) @user_can(Permissions.CREATE_TASK_ORDER, message="create new task order") def update(portfolio_id, task_order_id=None): - form_data = http_request.form + form_data = {**http_request.form, **http_request.files} + form = TaskOrderForm(form_data) if form.validate(): @@ -56,4 +64,4 @@ def update(portfolio_id, task_order_id=None): ) else: flash("form_errors") - return render_template("task_orders/edit.html", form=form) + return render_task_orders_edit(portfolio_id, task_order_id, form), 400 diff --git a/js/components/forms/base_form.js b/js/components/forms/base_form.js index 86612659..ecb47bc4 100644 --- a/js/components/forms/base_form.js +++ b/js/components/forms/base_form.js @@ -9,6 +9,7 @@ import levelofwarrant from '../levelofwarrant' import multicheckboxinput from '../multi_checkbox_input' import optionsinput from '../options_input' import textinput from '../text_input' +import uploadinput from '../upload_input' import toggler from '../toggler' export default { @@ -23,6 +24,7 @@ export default { optionsinput, textinput, toggler, + uploadinput, }, mixins: [FormMixin], } diff --git a/js/components/upload_input.js b/js/components/upload_input.js index a9b31460..843bdf62 100644 --- a/js/components/upload_input.js +++ b/js/components/upload_input.js @@ -19,17 +19,15 @@ export default { initialData: { type: String, }, - uploadErrors: { - type: Array, - default: () => [], + initialErrors: { + type: Boolean, }, }, data: function() { - const pdf = this.initialData - return { - showUpload: !pdf || this.uploadErrors.length > 0, + attachment: this.initialData || null, + showErrors: this.initialErrors, } }, @@ -37,5 +35,26 @@ export default { showUploadInput: function() { this.showUpload = true }, + addAttachment: function(e) { + this.attachment = e.target.value + this.showErrors = false + }, + removeAttachment: function(e) { + e.preventDefault() + this.attachment = null + this.$refs.attachmentInput.value = null + this.showErrors = false + }, + }, + + computed: { + baseName: function() { + if (this.attachment) { + return this.attachment.split(/[\\/]/).pop() + } + }, + hasAttachment: function() { + return !!this.attachment + }, }, } diff --git a/static/icons/check-circle-solid.svg b/static/icons/check-circle-solid.svg new file mode 100644 index 00000000..6aaa9742 --- /dev/null +++ b/static/icons/check-circle-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/styles/atat.scss b/styles/atat.scss index 5d41ed96..65c4c6a8 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -23,6 +23,7 @@ @import "elements/graphs"; @import "elements/menu"; @import "elements/card"; +@import "elements/uploader"; @import "components/accordion_table"; @import "components/topbar"; diff --git a/styles/elements/_uploader.scss b/styles/elements/_uploader.scss new file mode 100644 index 00000000..34f115fe --- /dev/null +++ b/styles/elements/_uploader.scss @@ -0,0 +1,71 @@ +.upload-widget { + position: relative; + + label.upload-label { + text-align: right; + border: 1px solid black; + padding: 0; + display: block; + + .upload-button { + padding: 1rem 1.5rem; + display: inline-block; + background-color: $color-blue; + border: 1px solid $color-blue; + margin: -1px; + color: white; + + &:hover { + background-color: $color-blue-darker; + } + } + } + + input { + opacity: 0; + position: absolute; + top: 0; + } +} + +.uploaded-file { + .icon { + vertical-align: middle; + + svg * { + fill: $color-green; + } + } + + .uploaded-file__name { + vertical-align: middle; + margin-left: 0.5rem; + font-weight: $font-bold; + text-decoration: underline; + } + + .uploaded-file__remove { + vertical-align: middle; + margin-left: 2rem; + font-size: $small-font-size; + } +} + +.usa-input--error { + .upload-widget { + label.upload-label { + border: 1px solid $color-red; + box-shadow: inset 0 0 0 2px $color-red; + position: relative; + + .upload-button { + margin-left: -3px; + border-left: 3px solid $color-red; + } + + .icon { + top: 0; + } + } + } +} diff --git a/templates/components/upload_input.html b/templates/components/upload_input.html index b6835484..f2835baf 100644 --- a/templates/components/upload_input.html +++ b/templates/components/upload_input.html @@ -1,24 +1,47 @@ +{% from "components/icon.html" import Icon %} + {% macro UploadInput(field, show_label=False) -%} - +
- - + {% for error in field.errors %} + {{error}} + {% endfor %} +
{%- endmacro %} diff --git a/templates/task_orders/edit.html b/templates/task_orders/edit.html index f67c42ea..e8a5b6a6 100644 --- a/templates/task_orders/edit.html +++ b/templates/task_orders/edit.html @@ -1,36 +1,36 @@ -{% extends "base.html" %} +{% extends "portfolios/base.html" %} {% from 'components/save_button.html' import SaveButton %} {% from 'components/text_input.html' import TextInput %} +{% from 'components/upload_input.html' import UploadInput %} -{% block content %} +{% block portfolio_content %}
{% include "fragments/flash.html" %} -
- {% block portfolio_header %} - {% include "portfolios/header.html" %} - {% endblock %} - -
- {{ form.csrf_token }} -
- - Add Funding - - - {{ "common.cancel" | translate }} - - {{ SaveButton(text=('common.save' | translate), element='input', form='new-task-order') }} -
-
- {{ "task_orders.new.form_help_text" | translate }} -
- {{ TextInput(form.number, validation='taskOrderNumber') }} -
-
-
-
+ + {% if task_order_id %} + {% set action = url_for("task_orders.update", portfolio_id=portfolio.id, task_order_id=task_order_id) %} + {% else %} + {% set action = url_for("task_orders.update", portfolio_id=portfolio.id) %} + {% endif %} +
+ {{ form.csrf_token }} + + Add Funding + + + {{ "common.cancel" | translate }} + + {{ SaveButton(text=('common.save' | translate), element='input', form='new-task-order') }} +

+ {{ "task_orders.new.form_help_text" | translate }} +

+
+ {{ TextInput(form.number, validation='taskOrderNumber') }} + {{ UploadInput(form.pdf) }} +
+
{% endblock %} diff --git a/tests/forms/test_validators.py b/tests/forms/test_validators.py index 2dc80659..c57fe5e0 100644 --- a/tests/forms/test_validators.py +++ b/tests/forms/test_validators.py @@ -1,13 +1,7 @@ from wtforms.validators import ValidationError, StopValidation import pytest -from atst.forms.validators import ( - Name, - IsNumber, - PhoneNumber, - ListItemsUnique, - RequiredIf, -) +from atst.forms.validators import * class TestIsNumber: @@ -97,3 +91,12 @@ class TestRequiredIf: with pytest.raises(StopValidation): validator(dummy_form, dummy_field) + + +class TestFileLength: + def test_FileLength(self, dummy_form, dummy_field, pdf_upload): + validator = FileLength(max_length=1) + dummy_field.data = pdf_upload + + with pytest.raises(ValidationError): + validator(dummy_form, dummy_field) diff --git a/tests/routes/task_orders/test_new.py b/tests/routes/task_orders/test_new.py index 17904355..f4ee00d4 100644 --- a/tests/routes/task_orders/test_new.py +++ b/tests/routes/task_orders/test_new.py @@ -3,7 +3,7 @@ from flask import url_for from atst.domain.permission_sets import PermissionSets from atst.domain.task_orders import TaskOrders -from atst.models.attachment import Attachment +from atst.models import Attachment, TaskOrder from atst.utils.localization import translate from tests.factories import ( @@ -39,13 +39,15 @@ def test_task_orders_new(client, user_session, portfolio): assert response.status_code == 200 -def test_task_orders_create(client, user_session, portfolio): +def test_task_orders_create(client, user_session, portfolio, pdf_upload, session): user_session(portfolio.owner) + data = {"number": "0123456789", "pdf": pdf_upload} response = client.post( - url_for("task_orders.update", portfolio_id=portfolio.id), - data={"number": "0123456789"}, + url_for("task_orders.update", portfolio_id=portfolio.id), data=data ) assert response.status_code == 302 + task_order = session.query(TaskOrder).filter_by(number=data["number"]).one() + assert task_order.pdf.filename == pdf_upload.filename def test_task_orders_create_invalid_data(client, user_session, portfolio): @@ -54,17 +56,53 @@ def test_task_orders_create_invalid_data(client, user_session, portfolio): response = client.post( url_for("task_orders.update", portfolio_id=portfolio.id), data={"number": ""} ) - assert response.status_code == 200 + assert response.status_code == 400 assert num_task_orders == len(portfolio.task_orders) assert "There were some errors" in response.data.decode() -def test_task_orders_edit(): - pass +def test_task_orders_update(client, user_session, portfolio, pdf_upload): + user_session(portfolio.owner) + data = {"number": "0123456789", "pdf": pdf_upload} + task_order = TaskOrderFactory.create(number="0987654321") + response = client.post( + url_for( + "task_orders.update", portfolio_id=portfolio.id, task_order_id=task_order.id + ), + data=data, + ) + assert response.status_code == 302 + assert task_order.number == data["number"] -def test_task_orders_update(): - pass +def test_task_orders_update_pdf( + client, user_session, portfolio, pdf_upload, pdf_upload2 +): + user_session(portfolio.owner) + task_order = TaskOrderFactory.create(pdf=pdf_upload) + data = {"number": "0123456789", "pdf": pdf_upload2} + response = client.post( + url_for( + "task_orders.update", portfolio_id=portfolio.id, task_order_id=task_order.id + ), + data=data, + ) + assert response.status_code == 302 + assert task_order.pdf.filename == pdf_upload2.filename + + +def test_task_orders_update_delete_pdf(client, user_session, portfolio, pdf_upload): + user_session(portfolio.owner) + task_order = TaskOrderFactory.create(pdf=pdf_upload) + data = {"number": "0123456789", "pdf": None} + response = client.post( + url_for( + "task_orders.update", portfolio_id=portfolio.id, task_order_id=task_order.id + ), + data=data, + ) + assert response.status_code == 302 + assert task_order.pdf is None @pytest.mark.skip(reason="Update after implementing new TO form") diff --git a/translations.yaml b/translations.yaml index 1af070ff..27a7b7ac 100644 --- a/translations.yaml +++ b/translations.yaml @@ -336,6 +336,7 @@ forms: list_items_unique_message: Items must be unique name_message: 'This field accepts letters, numbers, commas, apostrophes, hyphens, and periods.' phone_number_message: Please enter a valid 5 or 10 digit phone number. + file_length: Your file may not exceed 50 MB. fragments: edit_application_form: explain: AT-AT allows you to create multiple applications within a portfolio. Each application can then be broken down into its own customizable environments.