diff --git a/alembic/versions/1f690989e38e_add_pdf_to_task_order.py b/alembic/versions/1f690989e38e_add_pdf_to_task_order.py new file mode 100644 index 00000000..c22926da --- /dev/null +++ b/alembic/versions/1f690989e38e_add_pdf_to_task_order.py @@ -0,0 +1,36 @@ +"""Add PDF to Task Order + +Revision ID: 1f690989e38e +Revises: 0ff4c31c4d28 +Create Date: 2019-02-04 15:56:57.642156 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '1f690989e38e' +down_revision = '0ff4c31c4d28' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_orders', sa.Column('pdf_attachment_id', postgresql.UUID(as_uuid=True), nullable=True)) + op.drop_constraint('task_orders_attachments_attachment_id', 'task_orders', type_='foreignkey') + op.alter_column('task_orders', 'attachment_id', new_column_name='csp_attachment_id') + op.create_foreign_key('task_orders_attachments_pdf_attachment_id', 'task_orders', 'attachments', ['pdf_attachment_id'], ['id']) + op.create_foreign_key('task_orders_attachments_csp_attachment_id', 'task_orders', 'attachments', ['csp_attachment_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('task_orders_attachments_csp_attachment_id', 'task_orders', type_='foreignkey') + op.drop_constraint('task_orders_attachments_pdf_attachment_id', 'task_orders', type_='foreignkey') + op.alter_column('task_orders', 'csp_attachment_id', new_column_name='attachment_id') + op.create_foreign_key('task_orders_attachments_attachment_id', 'task_orders', 'attachments', ['attachment_id'], ['id']) + op.drop_column('task_orders', 'pdf_attachment_id') + # ### end Alembic commands ### diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 6ea523e2..170e3be4 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -51,8 +51,8 @@ class TaskOrder(Base, mixins.TimestampsMixin): start_date = Column(Date) # Period of Performance end_date = Column(Date) performance_length = Column(Integer) - attachment_id = Column(ForeignKey("attachments.id")) - _csp_estimate = relationship("Attachment") + csp_attachment_id = Column(ForeignKey("attachments.id")) + _csp_estimate = relationship("Attachment", foreign_keys=[csp_attachment_id]) clin_01 = Column(Numeric(scale=2)) clin_02 = Column(Numeric(scale=2)) clin_03 = Column(Numeric(scale=2)) @@ -72,6 +72,8 @@ class TaskOrder(Base, mixins.TimestampsMixin): so_email = Column(String) # Email so_phone_number = Column(String) # Phone Number so_dod_id = Column(String) # DOD ID + pdf_attachment_id = Column(ForeignKey("attachments.id")) + _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id]) number = Column(String, unique=True) # Task Order Number loa = Column(String) # Line of Accounting (LOA) custom_clauses = Column(String) # Custom Clauses @@ -82,16 +84,25 @@ class TaskOrder(Base, mixins.TimestampsMixin): @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 - elif new_csp_estimate: - raise TypeError("Could not set csp_estimate with invalid type") + self._csp_estimate = self._set_attachment(new_csp_estimate, "_csp_estimate") + + @hybrid_property + def pdf(self): + return self._pdf + + @pdf.setter + def pdf(self, new_pdf): + self._pdf = self._set_attachment(new_pdf, "_pdf") + + def _set_attachment(self, new_attachment, attribute): + if isinstance(new_attachment, Attachment): + return new_attachment + elif isinstance(new_attachment, FileStorage): + return Attachment.attach(new_attachment, "task_order", self.id) + elif not new_attachment and hasattr(self, attribute): + return None + else: + raise TypeError("Could not set attachment with invalid type") @property def is_submitted(self): diff --git a/atst/utils/json.py b/atst/utils/json.py index 4ce7bd8d..8e2a3217 100644 --- a/atst/utils/json.py +++ b/atst/utils/json.py @@ -1,4 +1,5 @@ from flask.json import JSONEncoder +from werkzeug.datastructures import FileStorage from datetime import date from atst.models.attachment import Attachment @@ -7,6 +8,8 @@ class CustomJSONEncoder(JSONEncoder): def default(self, obj): if isinstance(obj, Attachment): return obj.filename - if isinstance(obj, date): + elif isinstance(obj, date): return obj.strftime("%Y-%m-%d") + elif isinstance(obj, FileStorage): + return obj.filename return JSONEncoder.default(self, obj) diff --git a/js/components/forms/funding.js b/js/components/forms/funding.js index 20b25a2c..8e4497a6 100644 --- a/js/components/forms/funding.js +++ b/js/components/forms/funding.js @@ -4,6 +4,7 @@ import { conformToMask } from 'vue-text-mask' import FormMixin from '../../mixins/form' import textinput from '../text_input' import optionsinput from '../options_input' +import uploadinput from '../upload_input' export default { name: 'funding', @@ -13,6 +14,7 @@ export default { components: { textinput, optionsinput, + uploadinput, }, props: { @@ -32,7 +34,6 @@ export default { clin_02 = 0, clin_03 = 0, clin_04 = 0, - csp_estimate, } = this.initialData return { @@ -40,7 +41,6 @@ export default { clin_02, clin_03, clin_04, - showUpload: !csp_estimate || this.uploadErrors.length > 0, } }, @@ -63,9 +63,6 @@ export default { }, methods: { - showUploadInput: function() { - this.showUpload = true - }, updateBudget: function() { document.querySelector('#to-target').innerText = this.totalBudgetStr }, diff --git a/js/components/upload_input.js b/js/components/upload_input.js new file mode 100644 index 00000000..a9b31460 --- /dev/null +++ b/js/components/upload_input.js @@ -0,0 +1,41 @@ +import createNumberMask from 'text-mask-addons/dist/createNumberMask' +import { conformToMask } from 'vue-text-mask' + +import FormMixin from '../mixins/form' +import textinput from './text_input' +import optionsinput from './options_input' + +export default { + name: 'uploadinput', + + mixins: [FormMixin], + + components: { + textinput, + optionsinput, + }, + + props: { + initialData: { + type: String, + }, + uploadErrors: { + type: Array, + default: () => [], + }, + }, + + data: function() { + const pdf = this.initialData + + return { + showUpload: !pdf || this.uploadErrors.length > 0, + } + }, + + methods: { + showUploadInput: function() { + this.showUpload = true + }, + }, +} diff --git a/js/index.js b/js/index.js index f9673744..f2f82bc9 100644 --- a/js/index.js +++ b/js/index.js @@ -20,6 +20,7 @@ import NewApplication from './components/forms/new_application' import EditEnvironmentRole from './components/forms/edit_environment_role' import EditApplicationRoles from './components/forms/edit_application_roles' import funding from './components/forms/funding' +import uploadinput from './components/upload_input' import Modal from './mixins/modal' import selector from './components/selector' import BudgetChart from './components/charts/budget_chart' @@ -64,6 +65,7 @@ const app = new Vue({ RequestsList, ConfirmationPopover, funding, + uploadinput, DateSelector, EditOfficerForm, }, diff --git a/templates/components/upload_input.html b/templates/components/upload_input.html new file mode 100644 index 00000000..b6835484 --- /dev/null +++ b/templates/components/upload_input.html @@ -0,0 +1,24 @@ +{% macro UploadInput(field, show_label=False) -%} + +
+ + +
+
+{%- endmacro %} diff --git a/templates/portfolios/task_orders/review.html b/templates/portfolios/task_orders/review.html index 61b50e8c..e19054d4 100644 --- a/templates/portfolios/task_orders/review.html +++ b/templates/portfolios/task_orders/review.html @@ -9,6 +9,7 @@ {% from "components/text_input.html" import TextInput %} {% from "components/alert.html" import Alert %} {% from "components/review_field.html" import ReviewField %} +{% from "components/upload_input.html" import UploadInput %} {% block content %} @@ -16,7 +17,10 @@ {% include "fragments/flash.html" %} + {% block form_action %}
+ {% endblock %} + {{ form.csrf_token }} {% block form %} @@ -60,11 +64,7 @@
{{ "task_orders.ko_review.task_order_information"| translate }}
-
-
{{ form.pdf.label }}
- {{ form.pdf.description }} - {{ form.pdf }} -
+ {{ UploadInput(form.pdf) }} {{ TextInput(form.number) }} {{ TextInput(form.loa) }} {{ TextInput(form.custom_clauses, paragraph=True) }} diff --git a/templates/task_orders/new/app_info.html b/templates/task_orders/new/app_info.html index a5cd23d7..ee8fe008 100644 --- a/templates/task_orders/new/app_info.html +++ b/templates/task_orders/new/app_info.html @@ -11,6 +11,7 @@ {% block form %} +

{{ "task_orders.new.app_info.basic_info_title"| translate }}

{{ TextInput(form.portfolio_name, placeholder="The name of your office or organization", validation="portfolioName") }} diff --git a/templates/task_orders/new/funding.html b/templates/task_orders/new/funding.html index cc012474..cced51f7 100644 --- a/templates/task_orders/new/funding.html +++ b/templates/task_orders/new/funding.html @@ -3,6 +3,7 @@ {% from "components/text_input.html" import TextInput %} {% from "components/options_input.html" import OptionsInput %} {% from "components/date_input.html" import DateInput %} +{% from "components/upload_input.html" import UploadInput %} {% from "components/icon.html" import Icon %} @@ -32,22 +33,7 @@ {{ Icon("link")}} Go to Cloud Service Provider’s estimate calculator

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

- - + {{ UploadInput(form.csp_estimate, show_label=True) }}
diff --git a/tests/models/test_task_order.py b/tests/models/test_task_order.py index 560c6989..908daec2 100644 --- a/tests/models/test_task_order.py +++ b/tests/models/test_task_order.py @@ -47,7 +47,7 @@ class TestCSPEstimate: attachment = Attachment(filename="sample.pdf", object_name="sample") to.csp_estimate = attachment - assert to.attachment_id == attachment.id + assert to.csp_attachment_id == attachment.id def test_setting_estimate_with_file_storage(self): to = TaskOrder() @@ -77,3 +77,41 @@ class TestCSPEstimate: to.csp_estimate = "" assert to.csp_estimate is None + + +class TestPDF: + def test_setting_pdf_with_attachment(self): + to = TaskOrder() + attachment = Attachment(filename="sample.pdf", object_name="sample") + to.pdf = attachment + + assert to.pdf_attachment_id == attachment.id + + def test_setting_pdf_with_file_storage(self): + to = TaskOrder() + with open(PDF_FILENAME, "rb") as fp: + fs = FileStorage(fp, content_type="application/pdf") + to.pdf = fs + + assert to.pdf is not None + assert to.pdf.filename == PDF_FILENAME + + def test_setting_pdf_with_invalid_object(self): + to = TaskOrder() + with pytest.raises(TypeError): + to.pdf = "invalid" + + def test_setting_pdf_with_empty_value(self): + to = TaskOrder() + assert to.pdf is None + + to.pdf = "" + assert to.pdf is None + + def test_removing_pdf(self): + attachment = Attachment(filename="sample.pdf", object_name="sample") + to = TaskOrder(pdf=attachment) + assert to.pdf is not None + + to.pdf = "" + assert to.pdf is None diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index 6bb44b5e..5a66abd9 100644 --- a/tests/routes/portfolios/test_task_orders.py +++ b/tests/routes/portfolios/test_task_orders.py @@ -251,3 +251,41 @@ def test_cor_redirected_to_build_page(client, user_session): url_for("task_orders.new", screen=1, task_order_id=task_order.id) ) assert response.status_code == 200 + + +def test_submit_completed_ko_review_page(client, user_session, pdf_upload): + portfolio = PortfolioFactory.create() + ko = UserFactory.create() + PortfolioRoleFactory.create( + role=Roles.get("officer"), + portfolio=portfolio, + user=ko, + status=PortfolioStatus.ACTIVE, + ) + task_order = TaskOrderFactory.create(portfolio=portfolio, contracting_officer=ko) + user_session(ko) + form_data = { + "start_date": "02/10/2019", + "end_date": "03/10/2019", + "number": "1938745981", + "loa": "0813458013405", + "custom_clauses": "hi im a custom clause", + "pdf": pdf_upload, + } + + response = client.post( + url_for( + "portfolios.ko_review", + portfolio_id=portfolio.id, + task_order_id=task_order.id, + ), + data=form_data, + ) + + assert task_order.pdf + assert response.headers["Location"] == url_for( + "portfolios.view_task_order", + portfolio_id=portfolio.id, + task_order_id=task_order.id, + _external=True, + ) diff --git a/tests/routes/task_orders/test_index.py b/tests/routes/task_orders/test_index.py index 45db3a82..784e4488 100644 --- a/tests/routes/task_orders/test_index.py +++ b/tests/routes/task_orders/test_index.py @@ -53,7 +53,7 @@ class TestDownloadCSPEstimate: assert expected_contents == response.data def test_download_without_attachment(self, client, user_session): - self.task_order.attachment_id = None + self.task_order.csp_attachment_id = None user_session(self.user) response = client.get( url_for(