Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions nofos/bloom_nofos/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ DJANGO_ALLOWED_HOSTS=""

DOCRAPTOR_API_KEY="YOUR_API_KEY_HERE"

GRABZIT_APPLICATION_KEY="YOUR_APPLICATION_KEY_HERE"
GRABZIT_APPLICATION_SECRET="YOUR_APPLICATION_SECRET_HERE"

LOGIN_GOV_CLIENT_ID=
LOGIN_GOV_OIDC_URL=
LOGIN_GOV_REDIRECT_URI=
Expand Down
4 changes: 4 additions & 0 deletions nofos/bloom_nofos/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,10 @@
DOCRAPTOR_IPS = env.get_value("DOCRAPTOR_IPS", default="")
DOCRAPTOR_API_KEY = env.get_value("DOCRAPTOR_API_KEY", default="")

# Grabzit API keys for DOCX conversion
GRABZIT_APPLICATION_KEY = env.get_value("GRABZIT_APPLICATION_KEY", default="")
GRABZIT_APPLICATION_SECRET = env.get_value("GRABZIT_APPLICATION_SECRET", default="")

# Add a field for constance
CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend"

Expand Down
6 changes: 4 additions & 2 deletions nofos/composer/templates/composer/composer_preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ <h1 class="margin-bottom-1 margin-top-0 font-serif-xl">Preview “{{ document.ti
{% if show_download_button %}
<li class="usa-button-group__item">
<button
class="usa-button"
type="button"
class="usa-button usa"
type="submit"
name="action"
value="download"
>
Download
</button>
Expand Down
78 changes: 78 additions & 0 deletions nofos/composer/templates/composer/writer/docx_export.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{% extends 'base.html' %}
{% load static tz martortags add_footnote_ids add_captions_to_tables add_classes_to_lists add_classes_to_paragraphs convert_paragraphs_to_hrs replace_variable_keys_with_values %}

{% block css %}
<link href="{% static "styles-theme-base.css" %}" type="text/css" media="all" rel="stylesheet">
<link href="{% static "theme-orientation-portrait.css" %}" type="text/css" media="all" rel="stylesheet">
<link href="{% static "theme-opdiv-cdc.css" %}" type="text/css" media="all" rel="stylesheet">
<link href="{% static "theme-opdiv-cdc-blue.css" %}" type="text/css" media="all" rel="stylesheet">
{% endblock %}

{% block content %}
<div id="download_target" class="nofo_view">
<!-- Render each section with all its subsections -->
{% for section in sections %}
<section id="section--{{ section.html_id }}" class="section section--{{ forloop.counter }} {% if forloop.counter > 7 %}section--appendix {% endif %}section--{{ section.html_id }}{% if section.html_class %} {{ section.html_class }}{% endif %}">
<h2 id="{{ section.html_id }}" >{{ section.name }}</h2>
{% if forloop.counter == 1 %}
<div>
<ul>
<li>Opdiv: {{ document.opdiv }}</li>
<li>Agency: {{ document.agency }}</li>
<li>Subagency: {{ document.subagency }}</li>
<li>Subagency 2: {{ document.subagency2 }}</li>
<li>Opportunity name: {{ document.name }}</li>
<li>Opportunity number: {{ document.number }}</li>
<li>Application deadline: {{ document.application_deadline }}</li>
<li>Tagline: {{ document.tagline }}</li>
<li>Metadata author: {{ document.author }}</li>
<li>Metadata subject: {{ document.subject }}</li>
<li>Metadata keywords: {{ document.keywords }}</li>
</ul>

</div>
{% endif %}

<div class="section--content">
{% for subsection in section.ordered_subsections %}
{# skip "hidden" subsections #}
{% if not subsection.hidden %}
<div class="margin-top-4 margin-bottom-2">
{% if subsection.instructions %}
<div class="bg-primary-lighter padding-2">
{{ subsection.instructions|safe_markdown|add_classes_to_paragraphs|add_classes_to_lists|convert_paragraphs_to_hrs }}
</div>
{% endif %}
{% if subsection.callout_box %}
<table>
<tr>
<td>
<div class="{% if subsection.callout_box%}border margin-top-1 padding-x-2 padding-bottom-2 {%if not subsection.name %} padding-top-2{% endif %} {% endif %}">
{% with tag=subsection.tag content=subsection.name id=subsection.html_id class=subsection.html_class %}
{% include "includes/heading.html" with tag=tag content=content id=id class=class only %}
{% endwith %}
<div class="{% if not subsection.name and not subsection.callout_box %}margin-top-2{% endif %} styled-subsection-body{% if subsection.edit_mode == 'variables' %}--variables{% elif subsection.edit_mode == 'full' %}--full{% endif %}">
{{ subsection.body|safe_markdown|add_footnote_ids|add_classes_to_paragraphs|add_captions_to_tables|add_classes_to_lists|convert_paragraphs_to_hrs|replace_variable_keys_with_values:subsection.get_variables }}
</div>
</div>
</td>
</tr>
</table>
{% else %}
<div class="{% if subsection.callout_box%}border margin-top-1 padding-x-2 padding-bottom-2 {%if not subsection.name %} padding-top-2{% endif %} {% endif %}">
{% with tag=subsection.tag content=subsection.name id=subsection.html_id class=subsection.html_class %}
{% include "includes/heading.html" with tag=tag content=content id=id class=class only %}
{% endwith %}
<div class="{% if not subsection.name and not subsection.callout_box %}margin-top-2{% endif %} styled-subsection-body{% if subsection.edit_mode == 'variables' %}--variables{% elif subsection.edit_mode == 'full' %}--full{% endif %}">
{{ subsection.body|safe_markdown|add_footnote_ids|add_classes_to_paragraphs|add_captions_to_tables|add_classes_to_lists|convert_paragraphs_to_hrs|replace_variable_keys_with_values:subsection.get_variables }}
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
</div>
{% endblock %}
5 changes: 5 additions & 0 deletions nofos/composer/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,9 @@
views.WriterInstanceHistoryCompareView.as_view(),
name="writer_instance_history_compare",
),
path(
"writer/<uuid:pk>/export",
views.WriterInstanceExportView.as_view(),
name="writer_instance_export",
),
]
81 changes: 80 additions & 1 deletion nofos/composer/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
from typing import Dict

from bloom_nofos import settings
from bloom_nofos.html_diff import has_diff, html_diff
from bloom_nofos.logs import log_exception
from composer.utils import do_replace_variable_keys_with_values
Expand All @@ -15,12 +16,14 @@
from django.forms.models import model_to_dict
from django.http import (
Http404,
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
HttpResponseNotFound,
HttpResponseServerError,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.html import format_html
Expand All @@ -34,7 +37,9 @@
UpdateView,
View,
)
from GrabzIt import GrabzItClient, GrabzItDOCXOptions
from martor.utils import markdownify
from requests import request

from nofos.audits import get_audit_event_by_id, safe_get_changed_fields
from nofos.mixins import (
Expand Down Expand Up @@ -1762,8 +1767,52 @@ def post(self, request, *args, **kwargs):
)
return redirect(self.exit_url)

if action == "download":
return self._handle_download_request(document, request)

return HttpResponseBadRequest("Unknown action.")

def _handle_download_request(self, document, request):
"""
Handle the download action for a ContentGuideInstance.
Makes a call to the Grabzit conversion API to convert the HTML to a DOCX file.
"""
session_value = request.COOKIES["sessionid"]
csrf_value = request.COOKIES["csrftoken"]

grabzit = GrabzItClient.GrabzItClient(
settings.GRABZIT_APPLICATION_KEY, settings.GRABZIT_APPLICATION_SECRET
)

domain = request.get_host().split(":")[0] # Remove port if present
set_session_cookie = grabzit.SetCookie("sessionid", domain, session_value)
set_csrf_cookie = grabzit.SetCookie("csrftoken", domain, csrf_value)

if not set_session_cookie or not set_csrf_cookie:
return HttpResponseServerError(
"Failed to set cookies for Grabzit conversion."
)

export_url = request.build_absolute_uri(
reverse("composer:writer_instance_export", args=[document.pk])
)
options = GrabzItDOCXOptions.GrabzItDOCXOptions()
options.targetElement = "#download_target"
grabzit.URLToDOCX(export_url, options)

filePath = f"/tmp/{document.pk}.docx"
grabzit.SaveTo(filePath)

with open(filePath, "rb") as docx_file:
response = HttpResponse(
docx_file.read(),
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
response["Content-Disposition"] = (
f'attachment; filename="{document.short_name or document.title}.docx"'
)
return response


@method_decorator(staff_member_required, name="dispatch")
class WriterInstanceHistoryView(GroupAccessContentGuideMixin, BaseNofoHistoryView):
Expand Down Expand Up @@ -1854,3 +1903,33 @@ def _parse_variables(vars: str):
return {key: VariableInfo.from_dict(val) for key, val in data.items()}
except (json.JSONDecodeError, KeyError, TypeError):
return {}


class WriterInstanceExportView(GroupAccessContentGuideMixin, DetailView):
"""
The template view to use for conversion to docx via Grabzit conversion API.
"""

model = ContentGuideInstance
template_name = "composer/writer/docx_export.html"
context_object_name = "document"

def get_ordered_sections(self):
# Make sure sections are ordered
sections = self.object.sections.prefetch_related("subsections").order_by(
"order", "id"
)
# Make sure subsections are ordered
for section in sections:
section.ordered_subsections = sorted(
section.subsections.all(),
key=lambda subsection: (getattr(subsection, "order", 0), subsection.id),
)
return sections

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# attached sections and subsections for rendering
context["sections"] = self.get_ordered_sections()

return context
13 changes: 12 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ boto3 = "^1.42.24"
python-json-logger = "^4.0.0"
diff-match-patch = "^20241021"
pre-commit = "^4.5.0"
grabzit = "^3.5.7.1"

[tool.poetry.group.dev.dependencies]
black = "^25.12.0"
Expand Down