diff --git a/atst/domain/csp/files.py b/atst/domain/csp/files.py index 9fbb545e..a74403e5 100644 --- a/atst/domain/csp/files.py +++ b/atst/domain/csp/files.py @@ -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, diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 95a20d62..65e9b8dd 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -25,7 +25,7 @@ class TaskOrders(object): ], "funding": [ "performance_length", - # "pdf", + "csp_estimate", "clin_01", "clin_02", "clin_03", diff --git a/atst/filters.py b/atst/filters.py index 1c368aee..da7047b3 100644 --- a/atst/filters.py +++ b/atst/filters.py @@ -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) diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 5d1157d6..5196e9d0 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -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")) diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 04260b7b..d1d32206 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -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 diff --git a/atst/routes/task_orders/index.py b/atst/routes/task_orders/index.py index 74393e75..6abf34fd 100644 --- a/atst/routes/task_orders/index.py +++ b/atst/routes/task_orders/index.py @@ -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/") +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") diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 496657ca..f0b90c23 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -272,8 +272,9 @@ def new(screen, task_order_id=None, portfolio_id=None): "/portfolios//task_orders/new/", 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(): diff --git a/js/components/forms/funding.js b/js/components/forms/funding.js index d75ee74b..cad96abc 100644 --- a/js/components/forms/funding.js +++ b/js/components/forms/funding.js @@ -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 } diff --git a/templates/portfolios/task_orders/show.html b/templates/portfolios/task_orders/show.html index 4a487bf1..11e45c4d 100644 --- a/templates/portfolios/task_orders/show.html +++ b/templates/portfolios/task_orders/show.html @@ -137,7 +137,7 @@
{{ 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="#") }} diff --git a/templates/task_orders/_new.html b/templates/task_orders/_new.html index 10694ef7..b80bced9 100644 --- a/templates/task_orders/_new.html +++ b/templates/task_orders/_new.html @@ -10,9 +10,9 @@ {% block form_action %} {% if task_order_id %} -
+ {% else %} - + {% endif %} {% endblock %} diff --git a/templates/task_orders/new/funding.html b/templates/task_orders/new/funding.html index be24a652..3f026272 100644 --- a/templates/task_orders/new/funding.html +++ b/templates/task_orders/new/funding.html @@ -12,7 +12,11 @@ {% block form %} - +

{{ "task_orders.new.funding.performance_period_title" | translate }}

@@ -28,13 +32,22 @@ {{ Icon("link")}} Cloud Service Provider's estimate calculator

{{ "task_orders.new.funding.estimate_usage_paragraph" | translate }}

-
-
- {{ form.pdf.label }} + +
diff --git a/templates/task_orders/new/review.html b/templates/task_orders/new/review.html index 440c58ad..840a5f10 100644 --- a/templates/task_orders/new/review.html +++ b/templates/task_orders/new/review.html @@ -113,7 +113,14 @@
{% call ReviewField(("task_orders.new.review.performance_period" | translate), task_order.performance_length, filter="translateDuration") %} -

{{ Icon('download') }} {{ "task_orders.new.review.usage_est_link" | translate }}

+ {% if task_order.csp_estimate %} +

+ {{ Icon('download') }} {{ "task_orders.new.review.usage_est_link"| translate }} +

+ {% else %} + {{ Icon('download') }} {{ "task_orders.new.review.usage_est_link"| translate }} + {{ Icon('alert', classes='icon--red') }} {{ "task_orders.new.review.not_uploaded"| translate }} + {% endif %} {% endcall %}
diff --git a/tests/domain/csp/test_files.py b/tests/domain/csp/test_files.py index 0f50cd11..66830a63 100644 --- a/tests/domain/csp/test_files.py +++ b/tests/domain/csp/test_files.py @@ -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 diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index d992050b..ab92e024 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -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) diff --git a/tests/factories.py b/tests/factories.py index b456ee1d..f18e2332 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -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") diff --git a/tests/fixtures/sample.png b/tests/fixtures/sample.png new file mode 100644 index 00000000..49909d23 Binary files /dev/null and b/tests/fixtures/sample.png differ diff --git a/tests/models/test_task_order.py b/tests/models/test_task_order.py index 9c0adf40..447ad258 100644 --- a/tests/models/test_task_order.py +++ b/tests/models/test_task_order.py @@ -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 diff --git a/tests/routes/task_orders/test_index.py b/tests/routes/task_orders/test_index.py index 9ca5ea64..45db3a82 100644 --- a/tests/routes/task_orders/test_index.py +++ b/tests/routes/task_orders/test_index.py @@ -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 diff --git a/tests/routes/task_orders/test_new_task_order.py b/tests/routes/task_orders/test_new_task_order.py index 53374ed6..16a14c63 100644 --- a/tests/routes/task_orders/test_new_task_order.py +++ b/tests/routes/task_orders/test_new_task_order.py @@ -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): diff --git a/translations.yaml b/translations.yaml index f7af1404..2cf3d4e7 100644 --- a/translations.yaml +++ b/translations.yaml @@ -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}!'