Skip to content

Commit bc97359

Browse files
authored
CR-156:Export consolidated conditions as PDF (#167)
* CR-156:Export consolidated conditions as PDF * CR-156:Export consolidated conditions as PDF Linting issues * CR-156:Export consolidated conditions as PDF Moved the PDF generation from frontend to the server side. * CR-156:Export consolidated conditions as PDF Linting error fix
1 parent afb3f0a commit bc97359

File tree

10 files changed

+671
-86
lines changed

10 files changed

+671
-86
lines changed

condition-api/src/condition_api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'development')):
7373
@app.before_request
7474
def set_origin():
7575
g.origin_url = request.environ.get('HTTP_ORIGIN', 'localhost')
76+
auth_header = request.headers.get('Authorization', None)
77+
if auth_header and auth_header.startswith('Bearer '):
78+
g.access_token = auth_header.split(' ')[1]
79+
else:
80+
g.access_token = None
7681

7782
build_cache(app)
7883

condition-api/src/condition_api/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ class _Config(): # pylint: disable=too-few-public-methods
7676
JWT_OIDC_CACHING_ENABLED = os.getenv('JWT_OIDC_CACHING_ENABLED', 'True')
7777
JWT_OIDC_JWKS_CACHE_TIMEOUT = 300
7878

79+
# DocGen service (external PDF/HTML rendering microservice)
80+
DOCGEN_SERVICE_URL = os.getenv('DOCGEN_SERVICE_URL', '')
81+
EAO_LOGO_URL = os.getenv('EAO_LOGO_URL', '')
82+
7983
# Service account details
8084
KEYCLOAK_BASE_URL = os.getenv('KEYCLOAK_BASE_URL')
8185
KEYCLOAK_REALMNAME = os.getenv('KEYCLOAK_REALMNAME', 'condition')

condition-api/src/condition_api/resources/consolidated_condition.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,61 @@
1313
# limitations under the License.
1414
"""API endpoints for managing a consolidated condition resource."""
1515

16+
import re
17+
from datetime import datetime
1618
from http import HTTPStatus
19+
from io import BytesIO
1720

18-
from flask import request
21+
from flask import current_app, request, send_file
1922
from flask_cors import cross_origin
2023
from flask_restx import Namespace, Resource
2124

2225
from marshmallow import ValidationError
2326

2427
from condition_api.services import authorization
2528
from condition_api.services.condition_service import ConditionService
29+
from condition_api.services.docgen_service import TEMPLATE_KEY, DocGenService
2630
from condition_api.utils.util import allowedorigins, cors_preflight
2731

2832
from .apihelper import Api as ApiHelper
2933
from ..auth import auth
3034

3135
API = Namespace("conditions", description="Endpoints for Consolidated Condition Management")
32-
"""Custom exception messages
33-
"""
36+
37+
38+
def _build_render_context(consolidated: dict) -> dict:
39+
"""Build the template context dict from consolidated conditions data."""
40+
conditions = consolidated.get("conditions", [])
41+
project_name = consolidated.get("project_name", "")
42+
43+
amendment_set = set()
44+
for cond in conditions:
45+
for part in (cond.get("amendment_names") or "").split(","):
46+
trimmed = part.strip()
47+
if trimmed:
48+
amendment_set.add(trimmed)
49+
50+
approved_count = sum(1 for c in conditions if c.get("is_approved"))
51+
now = datetime.now()
52+
53+
return {
54+
"project_name": project_name,
55+
"generated_on": now.strftime("%A, %B ") + str(now.day) + now.strftime(", %Y"),
56+
"generated_on_short": now.strftime("%B ") + str(now.day) + now.strftime(", %Y"),
57+
"total_conditions": len(conditions),
58+
"amendment_list": ", ".join(sorted(amendment_set)),
59+
"all_approved": all(cond.get("is_approved") for cond in conditions),
60+
"approved_count": approved_count,
61+
"awaiting_count": len(conditions) - approved_count,
62+
"logo_url": current_app.config.get("EAO_LOGO_URL", ""),
63+
"conditions": conditions,
64+
}
65+
66+
67+
def _safe_filename(project_name: str) -> str:
68+
"""Return a filesystem-safe version of the project name."""
69+
name = re.sub(r"[^a-z0-9]", "_", project_name.lower())
70+
return re.sub(r"_+", "_", name).strip("_")
3471

3572

3673
@cors_preflight("GET, OPTIONS")
@@ -71,3 +108,51 @@ def get(project_id):
71108

72109
except ValidationError as err:
73110
return {"message": str(err)}, HTTPStatus.BAD_REQUEST
111+
112+
113+
@cors_preflight("POST, OPTIONS")
114+
@API.route("/project/<string:project_id>/render", methods=["POST", "OPTIONS"])
115+
class ConsolidatedConditionRenderResource(Resource):
116+
"""Resource for rendering consolidated conditions as a PDF via the DocGen service."""
117+
118+
@staticmethod
119+
@ApiHelper.swagger_decorators(API, endpoint_description="Render consolidated conditions as PDF")
120+
@API.response(HTTPStatus.BAD_REQUEST, "Bad Request")
121+
@API.response(HTTPStatus.NOT_FOUND, "No conditions found")
122+
@auth.optional
123+
@cross_origin(origins=allowedorigins())
124+
def post(project_id):
125+
"""Generate a PDF of consolidated conditions for a project."""
126+
try:
127+
output_format = "pdf"
128+
if request.is_json:
129+
output_format = request.json.get("output_format", "pdf")
130+
131+
consolidated = ConditionService.get_consolidated_conditions(
132+
project_id,
133+
all_conditions=True,
134+
include_condition_attributes=False,
135+
user_is_internal=True,
136+
)
137+
138+
if not consolidated:
139+
return {"message": "No conditions found for this project"}, HTTPStatus.NOT_FOUND
140+
141+
context = _build_render_context(consolidated)
142+
docgen_response = DocGenService.render_template(TEMPLATE_KEY, context, output_format)
143+
144+
if output_format == "pdf":
145+
safe_name = _safe_filename(consolidated.get("project_name", ""))
146+
return send_file(
147+
BytesIO(docgen_response.content),
148+
mimetype="application/pdf",
149+
as_attachment=True,
150+
download_name=f"Consolidated_Conditions_{safe_name}.pdf",
151+
)
152+
153+
return docgen_response.json(), HTTPStatus.OK
154+
155+
except ValidationError as err:
156+
return {"message": str(err)}, HTTPStatus.BAD_REQUEST
157+
except Exception as err: # pylint: disable=broad-except
158+
return {"message": f"Failed to generate PDF: {str(err)}"}, HTTPStatus.INTERNAL_SERVER_ERROR

condition-api/src/condition_api/services/condition_service.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -961,9 +961,13 @@ def _process_internal_conditions(condition_data):
961961
"year_issued": row.year_issued,
962962
"effective_document_id": row.effective_document_id,
963963
"source_document": row.amendment_name if row.amendment_name
964-
and row.condition_type == ConditionType.ADD else row.document_label
964+
and row.condition_type == ConditionType.ADD else row.document_label,
965+
"subconditions": []
965966
}
966967

968+
for cond_id in conditions_map:
969+
ConditionService._populate_subconditions(conditions_map, cond_id)
970+
967971
return ProjectDocumentConditionSchema().dump(
968972
{
969973
"project_name": condition_data[0].project_name,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Copyright © 2024 Province of British Columbia
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the 'License');
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an 'AS IS' BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Service for generating documents via the DocGen service."""
15+
16+
import time
17+
18+
import requests
19+
from flask import current_app, g, json
20+
21+
TEMPLATE_KEY = "CONSOLIDATED_CONDITIONS_TEMPLATE"
22+
APP_NAME = "CONDITION"
23+
24+
_MAX_RETRIES = 3
25+
_RETRY_WAIT = 2 # seconds
26+
27+
28+
class DocGenService:
29+
"""Service for generating PDF/HTML documents via the external DocGen service."""
30+
31+
@staticmethod
32+
def render_template(template_key: str, context: dict, output_type: str = "html"):
33+
"""Render a registered template with the given context data."""
34+
return _request_docgen_service(
35+
"templates/render?use_total_pages=true",
36+
{
37+
"template_key": template_key,
38+
"app": APP_NAME,
39+
"data": context,
40+
"output_type": output_type,
41+
},
42+
)
43+
44+
45+
def _request_docgen_service(relative_url: str, data: dict = None):
46+
"""Make a POST request to the DocGen service with simple retry logic."""
47+
token = getattr(g, "access_token", None)
48+
docgen_service_url = current_app.config.get("DOCGEN_SERVICE_URL", "")
49+
50+
headers = {
51+
"Content-Type": "application/json",
52+
"Authorization": f"Bearer {token}",
53+
}
54+
55+
url = f"{docgen_service_url}/api/{relative_url}"
56+
57+
last_exc = None
58+
for attempt in range(_MAX_RETRIES):
59+
try:
60+
response = requests.post(
61+
url=url, headers=headers, data=json.dumps(data), timeout=60
62+
)
63+
response.raise_for_status()
64+
return response
65+
except requests.exceptions.RequestException as exc:
66+
last_exc = exc
67+
if attempt < _MAX_RETRIES - 1:
68+
time.sleep(_RETRY_WAIT)
69+
70+
raise last_exc

0 commit comments

Comments
 (0)