Merge pull request #566 from dod-ccpo/upload-csp-estimate

Upload proof of CSP estimate
This commit is contained in:
patricksmithdds 2019-01-23 14:56:28 -05:00 committed by GitHub
commit f553c9a9ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 223 additions and 31 deletions

View File

@ -8,7 +8,7 @@ from atst.domain.exceptions import UploadError
class FileProviderInterface: class FileProviderInterface:
_PERMITTED_MIMETYPES = ["application/pdf"] _PERMITTED_MIMETYPES = ["application/pdf", "image/png"]
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
@ -57,6 +57,7 @@ class RackspaceFileProvider(FileProviderInterface):
object_name = uuid4().hex object_name = uuid4().hex
with NamedTemporaryFile() as tempfile: with NamedTemporaryFile() as tempfile:
tempfile.write(fyle.stream.read()) tempfile.write(fyle.stream.read())
tempfile.seek(0)
self.container.upload_object( self.container.upload_object(
file_path=tempfile.name, file_path=tempfile.name,
object_name=object_name, object_name=object_name,

View File

@ -25,7 +25,7 @@ class TaskOrders(object):
], ],
"funding": [ "funding": [
"performance_length", "performance_length",
# "pdf", "csp_estimate",
"clin_01", "clin_01",
"clin_02", "clin_02",
"clin_03", "clin_03",

View File

@ -54,12 +54,11 @@ def mixedContentToJson(value):
This coerces the file upload in form data to its filename This coerces the file upload in form data to its filename
so that the data can be JSON serialized. so that the data can be JSON serialized.
""" """
if ( if isinstance(value, dict):
isinstance(value, dict) for k, v in value.items():
and "legacy_task_order" in value if hasattr(v, "filename"):
and hasattr(value["legacy_task_order"]["pdf"], "filename") value[k] = v.filename
):
value["legacy_task_order"]["pdf"] = value["legacy_task_order"]["pdf"].filename
return app.jinja_env.filters["tojson"](value) return app.jinja_env.filters["tojson"](value)

View File

@ -11,6 +11,7 @@ from wtforms.fields import (
from wtforms.fields.html5 import DateField, TelField from wtforms.fields.html5 import DateField, TelField
from wtforms.widgets import ListWidget, CheckboxInput from wtforms.widgets import ListWidget, CheckboxInput
from wtforms.validators import Length from wtforms.validators import Length
from flask_wtf.file import FileAllowed
from atst.forms.validators import IsNumber, PhoneNumber, RequiredIf from atst.forms.validators import IsNumber, PhoneNumber, RequiredIf
@ -86,9 +87,15 @@ class FundingForm(CacheableForm):
end_date = DateField( end_date = DateField(
translate("forms.task_order.end_date_label"), format="%m/%d/%Y" translate("forms.task_order.end_date_label"), format="%m/%d/%Y"
) )
pdf = FileField( csp_estimate = FileField(
translate("forms.task_order.pdf_label"), translate("forms.task_order.csp_estimate_label"),
description=translate("forms.task_order.pdf_description"), description=translate("forms.task_order.csp_estimate_description"),
validators=[
FileAllowed(
["pdf", "png"], translate("forms.task_order.file_format_not_allowed")
)
],
render_kw={"accept": ".pdf,.png,application/pdf,image/png"},
) )
clin_01 = IntegerField(translate("forms.task_order.clin_01_label")) clin_01 = IntegerField(translate("forms.task_order.clin_01_label"))
clin_02 = IntegerField(translate("forms.task_order.clin_02_label")) clin_02 = IntegerField(translate("forms.task_order.clin_02_label"))

View File

@ -2,10 +2,12 @@ from enum import Enum
import pendulum import pendulum
from sqlalchemy import Column, Numeric, String, ForeignKey, Date, Integer from sqlalchemy import Column, Numeric, String, ForeignKey, Date, Integer
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.types import ARRAY from sqlalchemy.types import ARRAY
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from werkzeug.datastructures import FileStorage
from atst.models import Base, types, mixins from atst.models import Attachment, Base, types, mixins
class Status(Enum): class Status(Enum):
@ -49,7 +51,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
end_date = Column(Date) end_date = Column(Date)
performance_length = Column(Integer) performance_length = Column(Integer)
attachment_id = Column(ForeignKey("attachments.id")) attachment_id = Column(ForeignKey("attachments.id"))
pdf = relationship("Attachment") _csp_estimate = relationship("Attachment")
clin_01 = Column(Numeric(scale=2)) clin_01 = Column(Numeric(scale=2))
clin_02 = Column(Numeric(scale=2)) clin_02 = Column(Numeric(scale=2))
clin_03 = Column(Numeric(scale=2)) clin_03 = Column(Numeric(scale=2))
@ -72,6 +74,23 @@ class TaskOrder(Base, mixins.TimestampsMixin):
number = Column(String, unique=True) # Task Order Number number = Column(String, unique=True) # Task Order Number
loa = Column(ARRAY(String)) # Line of Accounting (LOA) loa = Column(ARRAY(String)) # Line of Accounting (LOA)
@hybrid_property
def csp_estimate(self):
return self._csp_estimate
@csp_estimate.setter
def csp_estimate(self, new_csp_estimate):
if isinstance(new_csp_estimate, Attachment):
self._csp_estimate = new_csp_estimate
elif isinstance(new_csp_estimate, FileStorage):
self._csp_estimate = Attachment.attach(
new_csp_estimate, "task_order", self.id
)
elif not new_csp_estimate and self._csp_estimate:
self._csp_estimate = None
else:
raise TypeError("Could not set csp_estimate with invalid type")
@property @property
def is_submitted(self): def is_submitted(self):
return self.number is not None return self.number is not None

View File

@ -1,8 +1,9 @@
from io import BytesIO from io import BytesIO
from flask import g, Response from flask import g, Response, current_app as app
from . import task_orders_bp from . import task_orders_bp
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.domain.exceptions import NotFoundError
from atst.utils.docx import Docx from atst.utils.docx import Docx
@ -17,3 +18,22 @@ def download_summary(task_order_id):
headers={"Content-Disposition": "attachment; filename={}".format(filename)}, headers={"Content-Disposition": "attachment; filename={}".format(filename)},
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document", mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
) )
@task_orders_bp.route("/task_orders/csp_estimate/<task_order_id>")
def download_csp_estimate(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id)
if task_order.csp_estimate:
estimate = task_order.csp_estimate
generator = app.csp.files.download(estimate.object_name)
return Response(
generator,
headers={
"Content-Disposition": "attachment; filename={}".format(
estimate.filename
)
},
)
else:
raise NotFoundError("task_order CSP estimate")

View File

@ -272,8 +272,9 @@ def new(screen, task_order_id=None, portfolio_id=None):
"/portfolios/<portfolio_id>/task_orders/new/<int:screen>", methods=["POST"] "/portfolios/<portfolio_id>/task_orders/new/<int:screen>", methods=["POST"]
) )
def update(screen, task_order_id=None, portfolio_id=None): def update(screen, task_order_id=None, portfolio_id=None):
form_data = {**http_request.form, **http_request.files}
workflow = UpdateTaskOrderWorkflow( workflow = UpdateTaskOrderWorkflow(
g.current_user, http_request.form, screen, task_order_id, portfolio_id g.current_user, form_data, screen, task_order_id, portfolio_id
) )
if workflow.validate(): if workflow.validate():

View File

@ -19,6 +19,10 @@ export default {
initialData: { initialData: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
uploadErrors: {
type: Array,
default: () => ([])
} }
}, },
@ -28,6 +32,7 @@ export default {
clin_02 = 0, clin_02 = 0,
clin_03 = 0, clin_03 = 0,
clin_04 = 0, clin_04 = 0,
csp_estimate,
} = this.initialData } = this.initialData
return { return {
@ -35,6 +40,7 @@ export default {
clin_02, clin_02,
clin_03, clin_03,
clin_04, clin_04,
showUpload: !csp_estimate || this.uploadErrors.length > 0
} }
}, },
@ -57,6 +63,9 @@ export default {
const mask = createNumberMask({ prefix: '$', allowDecimal: true }) const mask = createNumberMask({ prefix: '$', allowDecimal: true })
return conformToMask(intValue.toString(), mask).conformedValue return conformToMask(intValue.toString(), mask).conformedValue
}, },
showUploadInput: function() {
this.showUpload = true
},
updateBudget: function() { updateBudget: function() {
document.querySelector('#to-target').innerText = this.totalBudgetStr document.querySelector('#to-target').innerText = this.totalBudgetStr
} }

View File

@ -137,7 +137,7 @@
<div class="panel__content"> <div class="panel__content">
{{ DocumentLink( {{ DocumentLink(
title="Cloud Services Estimate", title="Cloud Services Estimate",
link_url="#") }} link_url=task_order.csp_estimate and url_for("task_orders.download_csp_estimate", task_order_id=task_order.id) ) }}
{{ DocumentLink( {{ DocumentLink(
title="Market Research", title="Market Research",
link_url="#") }} link_url="#") }}

View File

@ -10,9 +10,9 @@
{% block form_action %} {% block form_action %}
{% if task_order_id %} {% if task_order_id %}
<form method='POST' action="{{ url_for('task_orders.new', screen=current, task_order_id=task_order_id) }}" autocomplete="off"> <form method='POST' action="{{ url_for('task_orders.new', screen=current, task_order_id=task_order_id) }}" autocomplete="off" enctype="multipart/form-data">
{% else %} {% else %}
<form method='POST' action="{{ url_for('task_orders.update', screen=current, portfolio_id=portfolio_id) }}" autocomplete="off"> <form method='POST' action="{{ url_for('task_orders.update', screen=current, portfolio_id=portfolio_id) }}" autocomplete="off" enctype="multipart/form-data">
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -12,7 +12,11 @@
{% block form %} {% block form %}
<funding inline-template v-bind:initial-data='{{ form.data|tojson }}'> <funding
inline-template
v-bind:initial-data='{{ form.data|mixedContentToJson }}'
v-bind:upload-errors='{{ form.csp_estimate.errors | list }}'
>
<div> <div>
<!-- Get Funding Section --> <!-- Get Funding Section -->
<h3 class="subheading">{{ "task_orders.new.funding.performance_period_title" | translate }}</h3> <h3 class="subheading">{{ "task_orders.new.funding.performance_period_title" | translate }}</h3>
@ -28,13 +32,22 @@
{{ Icon("link")}} Cloud Service Provider's estimate calculator {{ Icon("link")}} Cloud Service Provider's estimate calculator
</a></p> </a></p>
<p>{{ "task_orders.new.funding.estimate_usage_paragraph" | translate }}</p> <p>{{ "task_orders.new.funding.estimate_usage_paragraph" | translate }}</p>
<div class="usa-input"> <template v-if="showUpload">
<div class="usa-input__title"> <div class="usa-input {% if form.csp_estimate.errors %} usa-input--error {% endif %}">
{{ form.pdf.label }} {{ form.csp_estimate.label }}
{{ form.csp_estimate.description }}
{{ form.csp_estimate }}
{% for error in form.csp_estimate.errors %}
<span class="usa-input__message">{{error}}</span>
{% endfor %}
</div> </div>
{{ form.pdf.description }} </template>
<input type="file" disabled="disabled" /> <template v-else>
</div> <p>Uploaded {{ form.csp_estimate.data.filename }}</p>
<div>
<button type="button" v-on:click="showUploadInput">Change</button>
</div>
</template>
<hr> <hr>

View File

@ -113,7 +113,14 @@
<div class="row"> <div class="row">
{% call ReviewField(("task_orders.new.review.performance_period" | translate), task_order.performance_length, filter="translateDuration") %} {% call ReviewField(("task_orders.new.review.performance_period" | translate), task_order.performance_length, filter="translateDuration") %}
<p><a href="#" class='icon-link icon-link--left' download>{{ Icon('download') }} {{ "task_orders.new.review.usage_est_link" | translate }}</a></p> {% if task_order.csp_estimate %}
<p>
<a href="{{ url_for("task_orders.download_csp_estimate", task_order_id=task_order.id) }}" class='icon-link icon-link--left' download>{{ Icon('download') }} {{ "task_orders.new.review.usage_est_link"| translate }}</a>
</p>
{% else %}
<a href="{{ url_for("task_orders.download_csp_estimate", task_order_id=task_order.id) }}" class='icon-link icon-link--left icon-link--disabled' aria-disabled="true">{{ Icon('download') }} {{ "task_orders.new.review.usage_est_link"| translate }}</a>
{{ Icon('alert', classes='icon--red') }} <span class="task-order-invite-message not-sent">{{ "task_orders.new.review.not_uploaded"| translate }}</span>
{% endif %}
{% endcall %} {% endcall %}
<div class="col col--grow"> <div class="col col--grow">

View File

@ -44,3 +44,14 @@ def test_download(app, uploader, pdf_upload):
stream = uploader.download("abc") stream = uploader.download("abc")
stream_content = b"".join([b for b in stream]) stream_content = b"".join([b for b in stream])
assert pdf_content == stream_content assert pdf_content == stream_content
def test_downloading_uploaded_object(uploader, pdf_upload):
object_name = uploader.upload(pdf_upload)
stream = uploader.download(object_name)
stream_content = b"".join([b for b in stream])
pdf_upload.seek(0)
pdf_content = pdf_upload.read()
assert stream_content == pdf_content

View File

@ -2,6 +2,7 @@ import pytest
from atst.domain.task_orders import TaskOrders, TaskOrderError from atst.domain.task_orders import TaskOrders, TaskOrderError
from atst.domain.exceptions import UnauthorizedError from atst.domain.exceptions import UnauthorizedError
from atst.models.attachment import Attachment
from tests.factories import ( from tests.factories import (
TaskOrderFactory, TaskOrderFactory,
@ -26,10 +27,18 @@ def test_is_section_complete():
def test_all_sections_complete(): def test_all_sections_complete():
task_order = TaskOrderFactory.create() task_order = TaskOrderFactory.create()
attachment = Attachment(
filename="sample_attachment",
object_name="sample",
resource="task_order",
resource_id=task_order.id,
)
custom_attrs = {"csp_estimate": attachment}
for attr_list in TaskOrders.SECTIONS.values(): for attr_list in TaskOrders.SECTIONS.values():
for attr in attr_list: for attr in attr_list:
if not getattr(task_order, attr): if not getattr(task_order, attr):
setattr(task_order, attr, "str12345") setattr(task_order, attr, custom_attrs.get(attr, "str12345"))
task_order.scope = None task_order.scope = None
assert not TaskOrders.all_sections_complete(task_order) assert not TaskOrders.all_sections_complete(task_order)

View File

@ -7,6 +7,7 @@ import datetime
from faker import Faker as _Faker from faker import Faker as _Faker
from atst.forms import data from atst.forms import data
from atst.models.attachment import Attachment
from atst.models.environment import Environment from atst.models.environment import Environment
from atst.models.request import Request from atst.models.request import Request
from atst.models.request_revision import RequestRevision from atst.models.request_revision import RequestRevision
@ -375,6 +376,14 @@ class InvitationFactory(Base):
expiration_time = Invitations.current_expiration_time() expiration_time = Invitations.current_expiration_time()
class AttachmentFactory(Base):
class Meta:
model = Attachment
filename = factory.Faker("domain_word")
object_name = factory.Faker("domain_word")
class TaskOrderFactory(Base): class TaskOrderFactory(Base):
class Meta: class Meta:
model = TaskOrder model = TaskOrder
@ -401,6 +410,7 @@ class TaskOrderFactory(Base):
lambda *args: random_future_date(year_min=2, year_max=5) lambda *args: random_future_date(year_min=2, year_max=5)
) )
performance_length = random.randint(1, 24) performance_length = random.randint(1, 24)
csp_estimate = factory.SubFactory(AttachmentFactory)
ko_first_name = factory.Faker("first_name") ko_first_name = factory.Faker("first_name")
ko_last_name = factory.Faker("last_name") ko_last_name = factory.Faker("last_name")

BIN
tests/fixtures/sample.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,6 +1,11 @@
from werkzeug.datastructures import FileStorage
import pytest
from atst.models.attachment import Attachment
from atst.models.task_order import TaskOrder, Status from atst.models.task_order import TaskOrder, Status
from tests.factories import random_future_date, random_past_date from tests.factories import random_future_date, random_past_date
from tests.mocks import PDF_FILENAME
class TestTaskOrderStatus: class TestTaskOrderStatus:
@ -30,3 +35,34 @@ def test_is_submitted():
to = TaskOrder(number="42") to = TaskOrder(number="42")
assert to.is_submitted assert to.is_submitted
class TestCSPEstimate:
def test_setting_estimate_with_attachment(self):
to = TaskOrder()
attachment = Attachment(filename="sample.pdf", object_name="sample")
to.csp_estimate = attachment
assert to.attachment_id == attachment.id
def test_setting_estimate_with_file_storage(self):
to = TaskOrder()
with open(PDF_FILENAME, "rb") as fp:
fs = FileStorage(fp, content_type="application/pdf")
to.csp_estimate = fs
assert to.csp_estimate is not None
assert to.csp_estimate.filename == PDF_FILENAME
def test_setting_estimate_with_invalid_object(self):
to = TaskOrder()
with pytest.raises(TypeError):
to.csp_estimate = "invalid"
def test_removing_estimate(self):
attachment = Attachment(filename="sample.pdf", object_name="sample")
to = TaskOrder(csp_estimate=attachment)
assert to.csp_estimate is not None
to.csp_estimate = ""
assert to.csp_estimate is None

View File

@ -28,3 +28,46 @@ def test_download_summary(client, user_session):
for attr, val in task_order.to_dictionary().items(): for attr, val in task_order.to_dictionary().items():
assert attr in doc assert attr in doc
assert xml_translated(val) in doc assert xml_translated(val) in doc
class TestDownloadCSPEstimate:
def setup(self):
self.user = UserFactory.create()
self.portfolio = PortfolioFactory.create(owner=self.user)
self.task_order = TaskOrderFactory.create(
creator=self.user, portfolio=self.portfolio
)
def test_successful_download(self, client, user_session, pdf_upload):
self.task_order.csp_estimate = pdf_upload
user_session(self.user)
response = client.get(
url_for(
"task_orders.download_csp_estimate", task_order_id=self.task_order.id
)
)
assert response.status_code == 200
pdf_upload.seek(0)
expected_contents = pdf_upload.read()
assert expected_contents == response.data
def test_download_without_attachment(self, client, user_session):
self.task_order.attachment_id = None
user_session(self.user)
response = client.get(
url_for(
"task_orders.download_csp_estimate", task_order_id=self.task_order.id
)
)
assert response.status_code == 404
def test_download_with_wrong_user(self, client, user_session):
other_user = UserFactory.create()
user_session(other_user)
response = client.get(
url_for(
"task_orders.download_csp_estimate", task_order_id=self.task_order.id
)
)
assert response.status_code == 404

View File

@ -2,6 +2,7 @@ import pytest
from flask import url_for from flask import url_for
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.models.attachment import Attachment
from atst.routes.task_orders.new import ShowTaskOrderWorkflow, UpdateTaskOrderWorkflow from atst.routes.task_orders.new import ShowTaskOrderWorkflow, UpdateTaskOrderWorkflow
from tests.factories import UserFactory, TaskOrderFactory, PortfolioFactory from tests.factories import UserFactory, TaskOrderFactory, PortfolioFactory
@ -42,7 +43,7 @@ def serialize_dates(data):
# TODO: this test will need to be more complicated when we add validation to # TODO: this test will need to be more complicated when we add validation to
# the forms # the forms
def test_create_new_task_order(client, user_session): def test_create_new_task_order(client, user_session, pdf_upload):
creator = UserFactory.create() creator = UserFactory.create()
user_session(creator) user_session(creator)
@ -65,6 +66,7 @@ def test_create_new_task_order(client, user_session):
funding_data = slice_data_for_section(task_order_data, "funding") funding_data = slice_data_for_section(task_order_data, "funding")
funding_data = serialize_dates(funding_data) funding_data = serialize_dates(funding_data)
funding_data["csp_estimate"] = pdf_upload
response = client.post( response = client.post(
response.headers["Location"], data=funding_data, follow_redirects=False response.headers["Location"], data=funding_data, follow_redirects=False
) )
@ -124,8 +126,11 @@ def test_task_order_form_shows_errors(client, user_session):
def task_order(): def task_order():
user = UserFactory.create() user = UserFactory.create()
portfolio = PortfolioFactory.create(owner=user) portfolio = PortfolioFactory.create(owner=user)
attachment = Attachment(filename="sample_attachment", object_name="sample")
return TaskOrderFactory.create(creator=user, portfolio=portfolio) return TaskOrderFactory.create(
creator=user, portfolio=portfolio, csp_estimate=attachment
)
def test_show_task_order(task_order): def test_show_task_order(task_order):

View File

@ -214,8 +214,9 @@ forms:
label: Period of Performance length label: Period of Performance length
start_date_label: Start Date start_date_label: Start Date
end_date_label: End Date end_date_label: End Date
pdf_label: Upload a copy of your CSP Cost Estimate Research csp_estimate_label: Upload a copy of your CSP Cost Estimate Research
pdf_description: Upload a PDF or screenshot of your usage estimate from the calculator. csp_estimate_description: Upload a PDF or screenshot of your usage estimate from the calculator.
file_format_not_allowed: Only PDF or PNG files can be uploaded.
clin_01_label: 'CLIN 01 : Unclassified' clin_01_label: 'CLIN 01 : Unclassified'
clin_02_label: 'CLIN 02: Classified' clin_02_label: 'CLIN 02: Classified'
clin_03_label: 'CLIN 03: Unclassified' clin_03_label: 'CLIN 03: Unclassified'
@ -436,6 +437,7 @@ task_orders:
dod_id: 'DoD ID:' dod_id: 'DoD ID:'
invited: Invited invited: Invited
not_invited: Not Yet Invited not_invited: Not Yet Invited
not_uploaded: Not Uploaded
testing: testing:
example_string: Hello World example_string: Hello World
example_with_variables: 'Hello, {name}!' example_with_variables: 'Hello, {name}!'