Merge pull request #606 from dod-ccpo/upload-pdf-on-ko-review

Upload PDF on KO Review Page
This commit is contained in:
montana-mil 2019-02-07 14:53:23 -05:00 committed by GitHub
commit 31f8c0f381
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 218 additions and 41 deletions

View 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 ###

View File

@ -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):

View File

@ -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)

View File

@ -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
},

View 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
},
},
}

View File

@ -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,
},

View 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 %}

View File

@ -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) }}

View File

@ -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") }}

View File

@ -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 Providers 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>

View File

@ -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

View File

@ -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,
)

View File

@ -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(