Merge pull request #508 from dod-ccpo/task-order-download
Task Order review and download
This commit is contained in:
commit
2c87738ac2
@ -57,9 +57,13 @@ class TaskOrder(Base, mixins.TimestampsMixin):
|
|||||||
self.number, self.budget, self.end_date, self.id
|
self.number, self.budget, self.end_date, self.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def portfolio_name(self):
|
||||||
|
return self.workspace.name
|
||||||
|
|
||||||
def to_dictionary(self):
|
def to_dictionary(self):
|
||||||
return {
|
return {
|
||||||
"portfolio_name": self.workspace.name,
|
"portfolio_name": self.portfolio_name,
|
||||||
**{
|
**{
|
||||||
c.name: getattr(self, c.name)
|
c.name: getattr(self, c.name)
|
||||||
for c in self.__table__.columns
|
for c in self.__table__.columns
|
||||||
|
@ -3,3 +3,5 @@ from flask import Blueprint
|
|||||||
task_orders_bp = Blueprint("task_orders", __name__)
|
task_orders_bp = Blueprint("task_orders", __name__)
|
||||||
|
|
||||||
from . import new
|
from . import new
|
||||||
|
from . import index
|
||||||
|
from . import invite
|
||||||
|
19
atst/routes/task_orders/index.py
Normal file
19
atst/routes/task_orders/index.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
from . import task_orders_bp
|
||||||
|
from atst.domain.task_orders import TaskOrders
|
||||||
|
from atst.utils.docx import Docx
|
||||||
|
|
||||||
|
|
||||||
|
@task_orders_bp.route("/task_orders/download_summary/<task_order_id>")
|
||||||
|
def download_summary(task_order_id):
|
||||||
|
task_order = TaskOrders.get(task_order_id)
|
||||||
|
byte_str = BytesIO()
|
||||||
|
Docx.render(byte_str, data=task_order.to_dictionary())
|
||||||
|
filename = "{}.docx".format(task_order.portfolio_name)
|
||||||
|
return Response(
|
||||||
|
byte_str,
|
||||||
|
headers={"Content-Disposition": "attachment; filename={}".format(filename)},
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
15
atst/routes/task_orders/invite.py
Normal file
15
atst/routes/task_orders/invite.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from flask import redirect, url_for
|
||||||
|
|
||||||
|
from . import task_orders_bp
|
||||||
|
from atst.domain.task_orders import TaskOrders
|
||||||
|
from atst.utils.flash import formatted_flash as flash
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: add a real implementation for this
|
||||||
|
@task_orders_bp.route("/task_orders/invite/<task_order_id>", methods=["POST"])
|
||||||
|
def invite(task_order_id):
|
||||||
|
task_order = TaskOrders.get(task_order_id)
|
||||||
|
flash("task_order_submitted", task_order=task_order)
|
||||||
|
return redirect(
|
||||||
|
url_for("workspaces.workspace_members", workspace_id=task_order.workspace.id)
|
||||||
|
)
|
@ -113,6 +113,7 @@ def new(screen, task_order_id=None):
|
|||||||
workflow.template,
|
workflow.template,
|
||||||
current=screen,
|
current=screen,
|
||||||
task_order_id=task_order_id,
|
task_order_id=task_order_id,
|
||||||
|
task_order=workflow.task_order,
|
||||||
screens=workflow.display_screens,
|
screens=workflow.display_screens,
|
||||||
form=workflow.form,
|
form=workflow.form,
|
||||||
)
|
)
|
||||||
|
57
atst/utils/docx.py
Normal file
57
atst/utils/docx.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
from zipfile import ZipFile
|
||||||
|
from flask import render_template, current_app as app
|
||||||
|
|
||||||
|
|
||||||
|
class Docx:
|
||||||
|
DOCUMENT_FILE = "word/document.xml"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _template_path(cls, docx_file):
|
||||||
|
return os.path.join(app.root_path, "..", "templates", docx_file)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _template(cls, docx_file):
|
||||||
|
return ZipFile(Docx._template_path(docx_file), mode="r")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _write(cls, docx_template, docx_file, document_content):
|
||||||
|
"""
|
||||||
|
This method takes an existing docx as its starting
|
||||||
|
point and copies over every file from it to a new zip
|
||||||
|
file, overwriting the document.xml file with new
|
||||||
|
document content.
|
||||||
|
|
||||||
|
zipfile.ZipFile does not provide a way to replace file
|
||||||
|
contents in a zip in-place, so we copy over the entire
|
||||||
|
zip archive instead.
|
||||||
|
|
||||||
|
docx_template: The source docx file we harvest from.
|
||||||
|
docx_file: A ZipFile instance that content from the docx_template is copied to
|
||||||
|
document_content: The new content for the document.xml file
|
||||||
|
"""
|
||||||
|
with docx_template as template:
|
||||||
|
for item in template.infolist():
|
||||||
|
if item.filename != Docx.DOCUMENT_FILE:
|
||||||
|
content = template.read(item.filename).decode()
|
||||||
|
else:
|
||||||
|
content = document_content
|
||||||
|
|
||||||
|
docx_file.writestr(item, content)
|
||||||
|
|
||||||
|
return docx_file
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(
|
||||||
|
cls,
|
||||||
|
file_like,
|
||||||
|
doc_template="docx/document.xml",
|
||||||
|
file_template="docx/template.docx",
|
||||||
|
**args,
|
||||||
|
):
|
||||||
|
document = render_template(doc_template, **args)
|
||||||
|
with ZipFile(file_like, mode="w") as docx_file:
|
||||||
|
docx_template = Docx._template(file_template)
|
||||||
|
Docx._write(docx_template, docx_file, document)
|
||||||
|
file_like.seek(0)
|
||||||
|
return file_like
|
@ -101,6 +101,13 @@ MESSAGES = {
|
|||||||
"message_template": "",
|
"message_template": "",
|
||||||
"category": "success",
|
"category": "success",
|
||||||
},
|
},
|
||||||
|
"task_order_submitted": {
|
||||||
|
"title_template": "Task Order Form Submitted",
|
||||||
|
"message_template": """
|
||||||
|
Your task order form for {{ task_order.portfolio_name }} has been submitted.
|
||||||
|
""",
|
||||||
|
"category": "success",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
8
templates/components/edit_link.html
Normal file
8
templates/components/edit_link.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{% from "components/icon.html" import Icon %}
|
||||||
|
|
||||||
|
{% macro EditLink(url) -%}
|
||||||
|
<a class='icon-link' href='{{ url }}'>
|
||||||
|
{{ Icon('edit') }}
|
||||||
|
<span>edit</span>
|
||||||
|
</a>
|
||||||
|
{% endmacro %}
|
3
templates/components/required_label.html
Normal file
3
templates/components/required_label.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{% macro RequiredLabel() -%}
|
||||||
|
<span class='label label--error'>Response Required</span>
|
||||||
|
{%- endmacro %}
|
12
templates/docx/document.xml
Normal file
12
templates/docx/document.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<w:document xmlns:ve="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
|
||||||
|
<w:body>
|
||||||
|
{% for key,val in data.items() %}
|
||||||
|
<w:p>
|
||||||
|
<w:r>
|
||||||
|
<w:t>{{ key }}: {{ val }}</w:t>
|
||||||
|
</w:r>
|
||||||
|
</w:p>
|
||||||
|
{% endfor %}
|
||||||
|
</w:body>
|
||||||
|
</w:document>
|
BIN
templates/docx/template.docx
Normal file
BIN
templates/docx/template.docx
Normal file
Binary file not shown.
@ -1,8 +1,8 @@
|
|||||||
{% extends 'task_orders/_new.html' %}
|
{% extends 'task_orders/_new.html' %}
|
||||||
|
|
||||||
{% from "components/text_input.html" import TextInput %}
|
{% from "components/edit_link.html" import EditLink %}
|
||||||
{% from "components/options_input.html" import OptionsInput %}
|
{% from "components/required_label.html" import RequiredLabel %}
|
||||||
{% from "components/date_input.html" import DateInput %}
|
{% from "components/icon.html" import Icon %}
|
||||||
|
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
Review & Download
|
Review & Download
|
||||||
@ -12,6 +12,143 @@
|
|||||||
|
|
||||||
{% include "fragments/flash.html" %}
|
{% include "fragments/flash.html" %}
|
||||||
|
|
||||||
<a href="#">Download your Task Order Packet.</a>
|
{% macro TOEditLink(screen=1) %}
|
||||||
|
{% if task_order %}
|
||||||
|
{{ EditLink(url_for("task_orders.new", screen=screen, task_order_id=task_order.id)) }}
|
||||||
|
{% else %}
|
||||||
|
{{ EditLink(url_for("task_orders.new", screen=screen)) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Scope (Statement of Work) {{ TOEditLink() }}</h3>
|
||||||
|
<p>
|
||||||
|
{{ task_order.scope or RequiredLabel() }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col--grow">
|
||||||
|
<h3>Period of Performance length {{ TOEditLink(screen=2) }}</h3>
|
||||||
|
{{ task_order.period or RequiredLabel() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col col--grow">
|
||||||
|
<h3>Total funding requested {{ TOEditLink(screen=2) }}</h3>
|
||||||
|
{{ task_order.budget }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Generated Documents</h2>
|
||||||
|
|
||||||
|
<ul class="usa-unstyled-list">
|
||||||
|
<li>
|
||||||
|
<a href="#" download>
|
||||||
|
{{ Icon('download') }}
|
||||||
|
Cover Sheet
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a href="#" download>
|
||||||
|
{{ Icon('download') }}
|
||||||
|
Market Research
|
||||||
|
</a>
|
||||||
|
</li
|
||||||
|
>
|
||||||
|
|
||||||
|
{% if task_order %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('task_orders.download_summary', task_order_id=task_order.id) }}" download>
|
||||||
|
{{ Icon('download') }}
|
||||||
|
Task Order Draft
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a href="#" download>
|
||||||
|
{{ Icon('download') }}
|
||||||
|
DD 254
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Invite Signatories/Collaborators</h2>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-col">
|
||||||
|
<div class="usa-input">
|
||||||
|
<fieldset class="usa-input__choices">
|
||||||
|
<legend>Financial Oversight</legend>
|
||||||
|
<p>
|
||||||
|
{% if task_order.ko_first_name %}
|
||||||
|
{{ task_order.ko_first_name }}
|
||||||
|
{{ task_order.ko_last_name }}
|
||||||
|
{% else %}
|
||||||
|
{{ RequiredLabel() }}
|
||||||
|
{% endif %}
|
||||||
|
(Contracting Officer)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% if task_order.ko_first_name %}
|
||||||
|
{{ task_order.cor_first_name }}
|
||||||
|
{{ task_order.cor_last_name }}
|
||||||
|
{% else %}
|
||||||
|
{{ RequiredLabel() }}
|
||||||
|
{% endif %}
|
||||||
|
(Contracting Officer Representative)
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-col">
|
||||||
|
<div class="usa-input">
|
||||||
|
<fieldset class="usa-input__choices">
|
||||||
|
<legend>Invite?</legend>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-col">
|
||||||
|
<div class="usa-input">
|
||||||
|
<fieldset class="usa-input__choices">
|
||||||
|
<legend>Security Officer</legend>
|
||||||
|
<p>
|
||||||
|
{% if task_order.so_first_name %}
|
||||||
|
{{ task_order.so_first_name }}
|
||||||
|
{{ task_order.so_last_name }}
|
||||||
|
{% else %}
|
||||||
|
{{ RequiredLabel() }}
|
||||||
|
{% endif %}
|
||||||
|
(Security Officer)
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-col">
|
||||||
|
<div class="usa-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block next %}
|
||||||
|
<div class='action-group'>
|
||||||
|
<input type='submit' class='usa-button usa-button-primary' value='Send Invitations' />
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block form_action %}
|
||||||
|
<form method='POST' action="{{ url_for('task_orders.invite', task_order_id=task_order_id) }}" autocomplete="off">
|
||||||
|
{% endblock %}
|
||||||
|
@ -377,8 +377,8 @@ class TaskOrderFactory(Base):
|
|||||||
defense_component = factory.LazyFunction(random_service_branch)
|
defense_component = factory.LazyFunction(random_service_branch)
|
||||||
app_migration = random_choice(data.APP_MIGRATION)
|
app_migration = random_choice(data.APP_MIGRATION)
|
||||||
native_apps = random.choices(["yes", "no", "not_sure"])
|
native_apps = random.choices(["yes", "no", "not_sure"])
|
||||||
complexity = random_choice(data.PROJECT_COMPLEXITY)
|
complexity = [random_choice(data.PROJECT_COMPLEXITY)]
|
||||||
dev_team = random_choice(data.DEV_TEAM)
|
dev_team = [random_choice(data.DEV_TEAM)]
|
||||||
team_experience = random_choice(data.TEAM_EXPERIENCE)
|
team_experience = random_choice(data.TEAM_EXPERIENCE)
|
||||||
|
|
||||||
scope = factory.Faker("sentence")
|
scope = factory.Faker("sentence")
|
||||||
|
28
tests/routes/task_orders/test_index.py
Normal file
28
tests/routes/task_orders/test_index.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from flask import url_for
|
||||||
|
from io import BytesIO
|
||||||
|
import re
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from atst.utils.docx import Docx
|
||||||
|
|
||||||
|
from tests.factories import TaskOrderFactory
|
||||||
|
|
||||||
|
|
||||||
|
def xml_translated(val):
|
||||||
|
return re.sub("'", "'", str(val))
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_summary(client, user_session):
|
||||||
|
user_session()
|
||||||
|
task_order = TaskOrderFactory.create()
|
||||||
|
response = client.get(
|
||||||
|
url_for("task_orders.download_summary", task_order_id=task_order.id)
|
||||||
|
)
|
||||||
|
bytes_str = BytesIO(response.data)
|
||||||
|
zip_ = ZipFile(bytes_str, mode="r")
|
||||||
|
doc = zip_.read(Docx.DOCUMENT_FILE).decode()
|
||||||
|
for attr, val in task_order.to_dictionary().items():
|
||||||
|
assert attr in doc
|
||||||
|
if not xml_translated(val) in doc:
|
||||||
|
__import__("ipdb").set_trace()
|
||||||
|
assert xml_translated(val) in doc
|
13
tests/utils/test_docx.py
Normal file
13
tests/utils/test_docx.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from atst.utils.docx import Docx
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_docx():
|
||||||
|
data = {"droid_class": "R2"}
|
||||||
|
byte_str = BytesIO()
|
||||||
|
docx_file = Docx.render(byte_str, data=data)
|
||||||
|
zip_ = ZipFile(docx_file, mode="r")
|
||||||
|
document = zip_.read(Docx.DOCUMENT_FILE)
|
||||||
|
assert b"droid_class: R2" in document
|
Loading…
x
Reference in New Issue
Block a user