diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 53afe102..85e4f4ba 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -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 diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py index 2de14f43..15395177 100644 --- a/atst/routes/task_orders/__init__.py +++ b/atst/routes/task_orders/__init__.py @@ -3,3 +3,5 @@ from flask import Blueprint task_orders_bp = Blueprint("task_orders", __name__) from . import new +from . import index +from . import invite diff --git a/atst/routes/task_orders/index.py b/atst/routes/task_orders/index.py new file mode 100644 index 00000000..97364476 --- /dev/null +++ b/atst/routes/task_orders/index.py @@ -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/") +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", + ) diff --git a/atst/routes/task_orders/invite.py b/atst/routes/task_orders/invite.py new file mode 100644 index 00000000..c43f14b5 --- /dev/null +++ b/atst/routes/task_orders/invite.py @@ -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/", 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) + ) diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 507f2471..9cf07f05 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -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, ) diff --git a/atst/utils/docx.py b/atst/utils/docx.py new file mode 100644 index 00000000..f5e644e4 --- /dev/null +++ b/atst/utils/docx.py @@ -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 diff --git a/atst/utils/flash.py b/atst/utils/flash.py index 7e9a879c..2d294e8e 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -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", + }, } diff --git a/templates/components/edit_link.html b/templates/components/edit_link.html new file mode 100644 index 00000000..01f4fc0d --- /dev/null +++ b/templates/components/edit_link.html @@ -0,0 +1,8 @@ +{% from "components/icon.html" import Icon %} + +{% macro EditLink(url) -%} + + {{ Icon('edit') }} + edit + +{% endmacro %} diff --git a/templates/components/required_label.html b/templates/components/required_label.html new file mode 100644 index 00000000..926a136f --- /dev/null +++ b/templates/components/required_label.html @@ -0,0 +1,3 @@ +{% macro RequiredLabel() -%} + Response Required +{%- endmacro %} diff --git a/templates/docx/document.xml b/templates/docx/document.xml new file mode 100644 index 00000000..810064ad --- /dev/null +++ b/templates/docx/document.xml @@ -0,0 +1,12 @@ + + + + {% for key,val in data.items() %} + + + {{ key }}: {{ val }} + + + {% endfor %} + + diff --git a/templates/docx/template.docx b/templates/docx/template.docx new file mode 100644 index 00000000..7d4e3042 Binary files /dev/null and b/templates/docx/template.docx differ diff --git a/templates/task_orders/new/review.html b/templates/task_orders/new/review.html index 6296fe91..34a8d28c 100644 --- a/templates/task_orders/new/review.html +++ b/templates/task_orders/new/review.html @@ -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" %} -Download your Task Order Packet. +{% 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 %} + +
+

Scope (Statement of Work) {{ TOEditLink() }}

+

+ {{ task_order.scope or RequiredLabel() }} +

+ +
+
+

Period of Performance length {{ TOEditLink(screen=2) }}

+ {{ task_order.period or RequiredLabel() }} +
+ +
+

Total funding requested {{ TOEditLink(screen=2) }}

+ {{ task_order.budget }} +
+
+
+ +
+ +
+

Generated Documents

+ + +
+ +
+ +
+

Invite Signatories/Collaborators

+ +
+
+
+
+ Financial Oversight +

+ {% if task_order.ko_first_name %} + {{ task_order.ko_first_name }} + {{ task_order.ko_last_name }} + {% else %} + {{ RequiredLabel() }} + {% endif %} + (Contracting Officer) +

+

+ {% if task_order.ko_first_name %} + {{ task_order.cor_first_name }} + {{ task_order.cor_last_name }} + {% else %} + {{ RequiredLabel() }} + {% endif %} + (Contracting Officer Representative) +

+
+
+
+
+
+
+ Invite? +
+
+
+
+
+
+
+
+ Security Officer +

+ {% if task_order.so_first_name %} + {{ task_order.so_first_name }} + {{ task_order.so_last_name }} + {% else %} + {{ RequiredLabel() }} + {% endif %} + (Security Officer) +

+
+
+
+
+
+
+
+
+
{% endblock %} + +{% block next %} +
+ +
+{% endblock %} + +{% block form_action %} +
+{% endblock %} diff --git a/tests/factories.py b/tests/factories.py index d2b9e82a..fb2779dd 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -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") diff --git a/tests/routes/task_orders/test_index.py b/tests/routes/task_orders/test_index.py new file mode 100644 index 00000000..53b8e9be --- /dev/null +++ b/tests/routes/task_orders/test_index.py @@ -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 diff --git a/tests/utils/test_docx.py b/tests/utils/test_docx.py new file mode 100644 index 00000000..9b643609 --- /dev/null +++ b/tests/utils/test_docx.py @@ -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