Merge pull request #874 from dod-ccpo/to-uploader

Task order uploader
This commit is contained in:
dandds 2019-06-07 10:32:30 -04:00 committed by GitHub
commit d0c8d0e519
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 272 additions and 81 deletions

View File

@ -14,7 +14,7 @@ class CSPFileError(Exception):
class FileProviderInterface: class FileProviderInterface:
_PERMITTED_MIMETYPES = ["application/pdf", "image/png"] _PERMITTED_MIMETYPES = ["application/pdf"]
def _enforce_mimetype(self, fyle): def _enforce_mimetype(self, fyle):
# TODO: for hardening, we should probably use a better library for # TODO: for hardening, we should probably use a better library for

View File

@ -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.fields.html5 import DateField
from wtforms.validators import Required, Optional from wtforms.validators import Required, Optional
from flask_wtf.file import FileAllowed
from .forms import BaseForm from .forms import BaseForm
from atst.forms.validators import FileLength
from atst.utils.localization import translate from atst.utils.localization import translate
@ -12,6 +14,14 @@ class TaskOrderForm(BaseForm):
description=translate("forms.task_order.number_description"), description=translate("forms.task_order.number_description"),
validators=[Required()], 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): class FundingForm(BaseForm):

View File

@ -99,3 +99,17 @@ def RequiredIf(criteria_function, message=translate("forms.validators.is_require
raise StopValidation() raise StopValidation()
return _required_if 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

View File

@ -8,25 +8,32 @@ from atst.models.permissions import Permissions
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
@task_orders_bp.route("/portfolios/<portfolio_id>/task_orders/new") def render_task_orders_edit(portfolio_id, task_order_id=None, form=None):
@task_orders_bp.route("/portfolios/<portfolio_id>/task_orders/<task_order_id>/edit") render_args = {}
@user_can(Permissions.CREATE_TASK_ORDER, message="view new task order form")
def edit(portfolio_id, task_order_id=None):
form = None
if task_order_id: if task_order_id:
task_order = TaskOrders.get(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: else:
form = TaskOrderForm() render_args["form"] = form or TaskOrderForm()
cancel_url = ( render_args["cancel_url"] = (
http_request.referrer http_request.referrer
if http_request.referrer if http_request.referrer
else url_for("task_orders.portfolio_funding", portfolio_id=portfolio_id) 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/<portfolio_id>/task_orders/new")
@task_orders_bp.route("/portfolios/<portfolio_id>/task_orders/<task_order_id>/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/<portfolio_id>/task_orders/new", methods=["POST"]) @task_orders_bp.route("/portfolios/<portfolio_id>/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") @user_can(Permissions.CREATE_TASK_ORDER, message="create new task order")
def update(portfolio_id, task_order_id=None): 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) form = TaskOrderForm(form_data)
if form.validate(): if form.validate():
@ -56,4 +64,4 @@ def update(portfolio_id, task_order_id=None):
) )
else: else:
flash("form_errors") flash("form_errors")
return render_template("task_orders/edit.html", form=form) return render_task_orders_edit(portfolio_id, task_order_id, form), 400

View File

@ -9,6 +9,7 @@ import levelofwarrant from '../levelofwarrant'
import multicheckboxinput from '../multi_checkbox_input' import multicheckboxinput from '../multi_checkbox_input'
import optionsinput from '../options_input' import optionsinput from '../options_input'
import textinput from '../text_input' import textinput from '../text_input'
import uploadinput from '../upload_input'
import toggler from '../toggler' import toggler from '../toggler'
export default { export default {
@ -23,6 +24,7 @@ export default {
optionsinput, optionsinput,
textinput, textinput,
toggler, toggler,
uploadinput,
}, },
mixins: [FormMixin], mixins: [FormMixin],
} }

View File

@ -19,17 +19,15 @@ export default {
initialData: { initialData: {
type: String, type: String,
}, },
uploadErrors: { initialErrors: {
type: Array, type: Boolean,
default: () => [],
}, },
}, },
data: function() { data: function() {
const pdf = this.initialData
return { return {
showUpload: !pdf || this.uploadErrors.length > 0, attachment: this.initialData || null,
showErrors: this.initialErrors,
} }
}, },
@ -37,5 +35,26 @@ export default {
showUploadInput: function() { showUploadInput: function() {
this.showUpload = true 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
},
}, },
} }

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="check-circle" class="svg-inline--fa fa-check-circle fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M504 256c0 136.967-111.033 248-248 248S8 392.967 8 256 119.033 8 256 8s248 111.033 248 248zM227.314 387.314l184-184c6.248-6.248 6.248-16.379 0-22.627l-22.627-22.627c-6.248-6.249-16.379-6.249-22.628 0L216 308.118l-70.059-70.059c-6.248-6.248-16.379-6.248-22.628 0l-22.627 22.627c-6.248 6.248-6.248 16.379 0 22.627l104 104c6.249 6.249 16.379 6.249 22.628.001z"></path></svg>

After

Width:  |  Height:  |  Size: 600 B

View File

@ -23,6 +23,7 @@
@import "elements/graphs"; @import "elements/graphs";
@import "elements/menu"; @import "elements/menu";
@import "elements/card"; @import "elements/card";
@import "elements/uploader";
@import "components/accordion_table"; @import "components/accordion_table";
@import "components/topbar"; @import "components/topbar";

View File

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

View File

@ -1,24 +1,47 @@
{% from "components/icon.html" import Icon %}
{% macro UploadInput(field, show_label=False) -%} {% macro UploadInput(field, show_label=False) -%}
<uploadinput inline-template v-bind:initial-data='{{ field.data | tojson }}' v-bind:upload-errors='{{ field.errors | list }}'> <uploadinput
inline-template
{% if not field.errors %}
v-bind:initial-data='{{ field.data | tojson }}'
{% else %}
v-bind:initial-errors='true'
{% endif %}
>
<div> <div>
<template v-if="showUpload"> <div v-show="hasAttachment" class="uploaded-file">
<div class="usa-input {% if field.errors %} usa-input--error {% endif %}"> {{ Icon("check-circle-solid") }}
{% if show_label %} <span class="uploaded-file__name" v-html="baseName"></span>
{{ field.label }} <a href="#" class="uploaded-file__remove" v-on:click="removeAttachment">Remove</a>
{% endif %} </div>
{{ field.description }} <div v-show="hasAttachment === false" v-bind:class='{ "usa-input": true, "usa-input--error": showErrors }'>
{{ field }} {% if show_label %}
{% for error in field.errors %} {{ field.label }}
<span class="usa-input__message">{{error}}</span> {% endif %}
{% endfor %} {{ field.description }}
<div class="upload-widget">
<label class="upload-label" for="{{ field.name }}">
<span class="upload-button">
Browse
</span>
{% if field.errors %}
<span v-show="showErrors">{{ Icon('alert',classes="icon-validation") }}</span>
{% endif %}
</label>
<input
v-on:change="addAttachment"
ref="attachmentInput"
accept="{{ field.accept }}"
id="{{ field.name }}"
name="{{ field.name }}"
aria-label="Task Order Upload"
type="file">
</div> </div>
</template> {% for error in field.errors %}
<template v-else> <span v-show="showErrors" class="usa-input__message">{{error}}</span>
<p>Uploaded {{ field.data.filename }}</p> {% endfor %}
<div> </div>
<button type="button" v-on:click="showUploadInput">Change</button>
</div>
</template>
</div> </div>
</uploadinput> </uploadinput>
{%- endmacro %} {%- endmacro %}

View File

@ -1,36 +1,36 @@
{% extends "base.html" %} {% extends "portfolios/base.html" %}
{% from 'components/save_button.html' import SaveButton %} {% from 'components/save_button.html' import SaveButton %}
{% from 'components/text_input.html' import TextInput %} {% from 'components/text_input.html' import TextInput %}
{% from 'components/upload_input.html' import UploadInput %}
{% block content %} {% block portfolio_content %}
<div class="col task-order-form"> <div class="col task-order-form">
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
<div class="panel"> <base-form inline-template>
{% block portfolio_header %} {% if task_order_id %}
{% include "portfolios/header.html" %} {% set action = url_for("task_orders.update", portfolio_id=portfolio.id, task_order_id=task_order_id) %}
{% endblock %} {% else %}
<base-form inline-template> {% set action = url_for("task_orders.update", portfolio_id=portfolio.id) %}
<form id="new-task-order" action='{{ url_for("task_orders.update", portfolio_id=portfolio.id) }}' method="POST" autocomplete="off"> {% endif %}
{{ form.csrf_token }} <form id="new-task-order" action='{{ action }}' method="POST" autocomplete="off" enctype="multipart/form-data">
<div class="panel__content"> {{ form.csrf_token }}
<!-- TODO: implement save bar with component --> <!-- TODO: implement save bar with component -->
<span class="h3">Add Funding</span> <span class="h3">Add Funding</span>
<a <a
href="{{ cancel_url }}" href="{{ cancel_url }}"
class="action-group__action icon-link"> class="action-group__action icon-link">
<span class="icon icon--x"></span> <span class="icon icon--x"></span>
{{ "common.cancel" | translate }} {{ "common.cancel" | translate }}
</a> </a>
{{ SaveButton(text=('common.save' | translate), element='input', form='new-task-order') }} {{ SaveButton(text=('common.save' | translate), element='input', form='new-task-order') }}
</div> <p>
<div class="panel__content"> {{ "task_orders.new.form_help_text" | translate }}
{{ "task_orders.new.form_help_text" | translate }} </p>
<hr> <hr>
{{ TextInput(form.number, validation='taskOrderNumber') }} {{ TextInput(form.number, validation='taskOrderNumber') }}
</div> {{ UploadInput(form.pdf) }}
</form> </form>
</base-form> </base-form>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,13 +1,7 @@
from wtforms.validators import ValidationError, StopValidation from wtforms.validators import ValidationError, StopValidation
import pytest import pytest
from atst.forms.validators import ( from atst.forms.validators import *
Name,
IsNumber,
PhoneNumber,
ListItemsUnique,
RequiredIf,
)
class TestIsNumber: class TestIsNumber:
@ -97,3 +91,12 @@ class TestRequiredIf:
with pytest.raises(StopValidation): with pytest.raises(StopValidation):
validator(dummy_form, dummy_field) 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)

View File

@ -3,7 +3,7 @@ from flask import url_for
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
from atst.domain.task_orders import TaskOrders 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 atst.utils.localization import translate
from tests.factories import ( from tests.factories import (
@ -39,13 +39,15 @@ def test_task_orders_new(client, user_session, portfolio):
assert response.status_code == 200 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) user_session(portfolio.owner)
data = {"number": "0123456789", "pdf": pdf_upload}
response = client.post( response = client.post(
url_for("task_orders.update", portfolio_id=portfolio.id), url_for("task_orders.update", portfolio_id=portfolio.id), data=data
data={"number": "0123456789"},
) )
assert response.status_code == 302 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): 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( response = client.post(
url_for("task_orders.update", portfolio_id=portfolio.id), data={"number": ""} 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 num_task_orders == len(portfolio.task_orders)
assert "There were some errors" in response.data.decode() assert "There were some errors" in response.data.decode()
def test_task_orders_edit(): def test_task_orders_update(client, user_session, portfolio, pdf_upload):
pass 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(): def test_task_orders_update_pdf(
pass 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") @pytest.mark.skip(reason="Update after implementing new TO form")

View File

@ -336,6 +336,7 @@ forms:
list_items_unique_message: Items must be unique list_items_unique_message: Items must be unique
name_message: 'This field accepts letters, numbers, commas, apostrophes, hyphens, and periods.' 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. phone_number_message: Please enter a valid 5 or 10 digit phone number.
file_length: Your file may not exceed 50 MB.
fragments: fragments:
edit_application_form: 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. explain: AT-AT allows you to create multiple applications within a portfolio. Each application can then be broken down into its own customizable environments.