Merge pull request #606 from dod-ccpo/upload-pdf-on-ko-review
Upload PDF on KO Review Page
This commit is contained in:
		
							
								
								
									
										36
									
								
								alembic/versions/1f690989e38e_add_pdf_to_task_order.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								alembic/versions/1f690989e38e_add_pdf_to_task_order.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ### | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|     }, | ||||
|   | ||||
							
								
								
									
										41
									
								
								js/components/upload_input.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								js/components/upload_input.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
|     }, | ||||
|   }, | ||||
| } | ||||
| @@ -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, | ||||
|   }, | ||||
|   | ||||
							
								
								
									
										24
									
								
								templates/components/upload_input.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								templates/components/upload_input.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| {% macro UploadInput(field, show_label=False) -%} | ||||
| <uploadinput inline-template v-bind:initial-data='{{ field.data | tojson }}' v-bind:upload-errors='{{ field.errors | list }}'> | ||||
|   <div> | ||||
|     <template v-if="showUpload"> | ||||
|       <div class="usa-input {% if field.errors %} usa-input--error {% endif %}"> | ||||
|         {% if show_label %} | ||||
|           {{ field.label }} | ||||
|         {% endif %} | ||||
|         {{ field.description }} | ||||
|         {{ field }} | ||||
|         {% for error in field.errors %} | ||||
|           <span class="usa-input__message">{{error}}</span> | ||||
|         {% endfor %} | ||||
|       </div> | ||||
|     </template> | ||||
|     <template v-else> | ||||
|       <p>Uploaded {{ field.data.filename }}</p> | ||||
|       <div> | ||||
|         <button type="button" v-on:click="showUploadInput">Change</button> | ||||
|       </div> | ||||
|     </template> | ||||
|   </div> | ||||
| </uploadinput> | ||||
| {%- endmacro %} | ||||
| @@ -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 %} | ||||
|   <form method='POST' action="{{ url_for('portfolios.submit_ko_review', portfolio_id=portfolio.id, task_order_id=task_order.id, form=form) }}" autocomplete="off" enctype="multipart/form-data"> | ||||
|   {% endblock %} | ||||
|  | ||||
|   {{ form.csrf_token }} | ||||
|  | ||||
|   {% block form %} | ||||
| @@ -60,11 +64,7 @@ | ||||
|         <div class="h2">{{ "task_orders.ko_review.task_order_information"| translate }}</div> | ||||
|  | ||||
|         <div class="form__sub-fields"> | ||||
|           <div class="usa-input"> | ||||
|             <div class="usa-input__title">{{ form.pdf.label }}</div> | ||||
|             {{ form.pdf.description }} | ||||
|             {{ form.pdf }} | ||||
|           </div> | ||||
|           {{ UploadInput(form.pdf) }} | ||||
|           {{ TextInput(form.number) }} | ||||
|           {{ TextInput(form.loa) }} | ||||
|           {{ TextInput(form.custom_clauses, paragraph=True) }} | ||||
|   | ||||
| @@ -11,6 +11,7 @@ | ||||
|  | ||||
| {% block form %} | ||||
|  | ||||
|  | ||||
| <!-- App Info Section --> | ||||
| <h3 class="task-order-form__heading subheading">{{ "task_orders.new.app_info.basic_info_title"| translate }}</h3> | ||||
| {{ TextInput(form.portfolio_name, placeholder="The name of your office or organization", validation="portfolioName") }} | ||||
|   | ||||
| @@ -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 | ||||
|     </a></p> | ||||
|     <p>{{ "task_orders.new.funding.estimate_usage_paragraph" | translate }}</p> | ||||
|     <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> | ||||
|     </template> | ||||
|     <template v-else> | ||||
|       <p>Uploaded {{ form.csp_estimate.data.filename }}</p> | ||||
|       <div> | ||||
|         <button type="button" v-on:click="showUploadInput">Change</button> | ||||
|       </div> | ||||
|     </template> | ||||
|     {{ UploadInput(form.csp_estimate, show_label=True) }} | ||||
|  | ||||
|     <hr> | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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, | ||||
|     ) | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user