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
|
||||
)
|
||||
|
||||
@property
|
||||
def portfolio_name(self):
|
||||
return self.workspace.name
|
||||
|
||||
def to_dictionary(self):
|
||||
return {
|
||||
"portfolio_name": self.workspace.name,
|
||||
"portfolio_name": self.portfolio_name,
|
||||
**{
|
||||
c.name: getattr(self, c.name)
|
||||
for c in self.__table__.columns
|
||||
|
@ -3,3 +3,5 @@ from flask import Blueprint
|
||||
task_orders_bp = Blueprint("task_orders", __name__)
|
||||
|
||||
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,
|
||||
current=screen,
|
||||
task_order_id=task_order_id,
|
||||
task_order=workflow.task_order,
|
||||
screens=workflow.display_screens,
|
||||
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": "",
|
||||
"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' %}
|
||||
|
||||
{% from "components/text_input.html" import TextInput %}
|
||||
{% from "components/options_input.html" import OptionsInput %}
|
||||
{% from "components/date_input.html" import DateInput %}
|
||||
{% from "components/edit_link.html" import EditLink %}
|
||||
{% from "components/required_label.html" import RequiredLabel %}
|
||||
{% from "components/icon.html" import Icon %}
|
||||
|
||||
{% block heading %}
|
||||
Review & Download
|
||||
@ -12,6 +12,143 @@
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% 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)
|
||||
app_migration = random_choice(data.APP_MIGRATION)
|
||||
native_apps = random.choices(["yes", "no", "not_sure"])
|
||||
complexity = random_choice(data.PROJECT_COMPLEXITY)
|
||||
dev_team = random_choice(data.DEV_TEAM)
|
||||
complexity = [random_choice(data.PROJECT_COMPLEXITY)]
|
||||
dev_team = [random_choice(data.DEV_TEAM)]
|
||||
team_experience = random_choice(data.TEAM_EXPERIENCE)
|
||||
|
||||
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