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:
_PERMITTED_MIMETYPES = ["application/pdf"]
_PERMITTED_MIMETYPES = ["application/pdf", "image/png"]
def _enforce_mimetype(self, fyle):
# TODO: for hardening, we should probably use a better library for
@ -57,6 +57,7 @@ class RackspaceFileProvider(FileProviderInterface):
object_name = uuid4().hex
with NamedTemporaryFile() as tempfile:
tempfile.write(fyle.stream.read())
tempfile.seek(0)
self.container.upload_object(
file_path=tempfile.name,
object_name=object_name,

View File

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

View File

@ -54,12 +54,11 @@ def mixedContentToJson(value):
This coerces the file upload in form data to its filename
so that the data can be JSON serialized.
"""
if (
isinstance(value, dict)
and "legacy_task_order" in value
and hasattr(value["legacy_task_order"]["pdf"], "filename")
):
value["legacy_task_order"]["pdf"] = value["legacy_task_order"]["pdf"].filename
if isinstance(value, dict):
for k, v in value.items():
if hasattr(v, "filename"):
value[k] = v.filename
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.widgets import ListWidget, CheckboxInput
from wtforms.validators import Length
from flask_wtf.file import FileAllowed
from atst.forms.validators import IsNumber, PhoneNumber, RequiredIf
@ -86,9 +87,15 @@ class FundingForm(CacheableForm):
end_date = DateField(
translate("forms.task_order.end_date_label"), format="%m/%d/%Y"
)
pdf = FileField(
translate("forms.task_order.pdf_label"),
description=translate("forms.task_order.pdf_description"),
csp_estimate = FileField(
translate("forms.task_order.csp_estimate_label"),
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_02 = IntegerField(translate("forms.task_order.clin_02_label"))

View File

@ -2,10 +2,12 @@ from enum import Enum
import pendulum
from sqlalchemy import Column, Numeric, String, ForeignKey, Date, Integer
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.types import ARRAY
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):
@ -49,7 +51,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
end_date = Column(Date)
performance_length = Column(Integer)
attachment_id = Column(ForeignKey("attachments.id"))
pdf = relationship("Attachment")
_csp_estimate = relationship("Attachment")
clin_01 = Column(Numeric(scale=2))
clin_02 = 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
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
def is_submitted(self):
return self.number is not None

View File

@ -1,8 +1,9 @@
from io import BytesIO
from flask import g, Response
from flask import g, Response, current_app as app
from . import task_orders_bp
from atst.domain.task_orders import TaskOrders
from atst.domain.exceptions import NotFoundError
from atst.utils.docx import Docx
@ -17,3 +18,22 @@ def download_summary(task_order_id):
headers={"Content-Disposition": "attachment; filename={}".format(filename)},
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"]
)
def update(screen, task_order_id=None, portfolio_id=None):
form_data = {**http_request.form, **http_request.files}
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():

View File

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

View File

@ -137,7 +137,7 @@
<div class="panel__content">
{{ DocumentLink(
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(
title="Market Research",
link_url="#") }}

View File

@ -10,9 +10,9 @@
{% block form_action %}
{% 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 %}
<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 %}
{% endblock %}

View File

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

View File

@ -113,7 +113,14 @@
<div class="row">
{% 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 %}
<div class="col col--grow">

View File

@ -44,3 +44,14 @@ def test_download(app, uploader, pdf_upload):
stream = uploader.download("abc")
stream_content = b"".join([b for b in stream])
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.exceptions import UnauthorizedError
from atst.models.attachment import Attachment
from tests.factories import (
TaskOrderFactory,
@ -26,10 +27,18 @@ def test_is_section_complete():
def test_all_sections_complete():
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 in attr_list:
if not getattr(task_order, attr):
setattr(task_order, attr, "str12345")
setattr(task_order, attr, custom_attrs.get(attr, "str12345"))
task_order.scope = None
assert not TaskOrders.all_sections_complete(task_order)

View File

@ -7,6 +7,7 @@ import datetime
from faker import Faker as _Faker
from atst.forms import data
from atst.models.attachment import Attachment
from atst.models.environment import Environment
from atst.models.request import Request
from atst.models.request_revision import RequestRevision
@ -375,6 +376,14 @@ class InvitationFactory(Base):
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 Meta:
model = TaskOrder
@ -401,6 +410,7 @@ class TaskOrderFactory(Base):
lambda *args: random_future_date(year_min=2, year_max=5)
)
performance_length = random.randint(1, 24)
csp_estimate = factory.SubFactory(AttachmentFactory)
ko_first_name = factory.Faker("first_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 tests.factories import random_future_date, random_past_date
from tests.mocks import PDF_FILENAME
class TestTaskOrderStatus:
@ -30,3 +35,34 @@ def test_is_submitted():
to = TaskOrder(number="42")
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():
assert attr 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 atst.domain.task_orders import TaskOrders
from atst.models.attachment import Attachment
from atst.routes.task_orders.new import ShowTaskOrderWorkflow, UpdateTaskOrderWorkflow
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
# 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()
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 = serialize_dates(funding_data)
funding_data["csp_estimate"] = pdf_upload
response = client.post(
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():
user = UserFactory.create()
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):

View File

@ -214,8 +214,9 @@ forms:
label: Period of Performance length
start_date_label: Start Date
end_date_label: End Date
pdf_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_label: Upload a copy of your CSP Cost Estimate Research
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_02_label: 'CLIN 02: Classified'
clin_03_label: 'CLIN 03: Unclassified'
@ -436,6 +437,7 @@ task_orders:
dod_id: 'DoD ID:'
invited: Invited
not_invited: Not Yet Invited
not_uploaded: Not Uploaded
testing:
example_string: Hello World
example_with_variables: 'Hello, {name}!'