Merge pull request #566 from dod-ccpo/upload-csp-estimate
Upload proof of CSP estimate
This commit is contained in:
commit
f553c9a9ea
@ -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,
|
||||
|
@ -25,7 +25,7 @@ class TaskOrders(object):
|
||||
],
|
||||
"funding": [
|
||||
"performance_length",
|
||||
# "pdf",
|
||||
"csp_estimate",
|
||||
"clin_01",
|
||||
"clin_02",
|
||||
"clin_03",
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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"))
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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():
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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="#") }}
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
BIN
tests/fixtures/sample.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
@ -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
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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}!'
|
||||
|
Loading…
x
Reference in New Issue
Block a user