Skip to content

Commit 098edae

Browse files
committed
feat: gotenberg support for PDF block
1 parent 582e240 commit 098edae

3 files changed

Lines changed: 107 additions & 74 deletions

File tree

xblock_pdf/pdf.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
"""pdfXBlock main Python class."""
22

33
import json
4+
from logging import getLogger
45

6+
from django.contrib.auth import get_user_model
57
from django.utils.translation import gettext_noop as _
68
from web_fragments.fragment import Fragment
79
from webob import Response
810
from xblock.core import XBlock
911
from xblock.fields import Boolean, Scope, String
1012
from xblock.utils.resources import ResourceLoader
1113

12-
from .utils import bool_from_str, is_all_download_disabled
14+
from .utils import add_asset, convert_to_pdf, error_response, is_all_download_disabled, is_gotenberg_enabled
1315

1416
resource_loader = ResourceLoader(__name__)
1517

18+
logger = getLogger(__name__)
1619

17-
@XBlock.needs("i18n")
20+
21+
@XBlock.needs("i18n", "user")
1822
class PDFBlock(XBlock):
1923
"""PDF XBlock. Allows authors to embed PDFs in their courses."""
2024

@@ -114,16 +118,24 @@ def load_pdf(self, *_args, **_kwargs):
114118
return Response(json.dumps(self.raw_settings), content_type="application/json", charset="utf8")
115119

116120
@XBlock.json_handler
117-
def save_pdf(self, data, suffix=""): # pylint: disable=unused-argument
118-
"""Save handler."""
119-
self.display_name = data["display_name"]
120-
self.url = data["url"]
121-
122-
if not is_all_download_disabled():
123-
self.allow_download = bool_from_str(data["allow_download"])
124-
self.source_text = data["source_text"]
125-
self.source_url = data["source_url"]
126-
127-
return {
128-
"result": "success",
129-
}
121+
def convert_pdf(self, data, suffix=""): # pylint: disable=unused-argument
122+
"""
123+
PDF Conversion handling. Basically just a frontend to the Gotenberg service which converts the given URL
124+
and then saves it to course assets, returning the URL.
125+
"""
126+
user_service = self.runtime.service(self, "user")
127+
user_attrs = user_service.get_current_user().opt_attrs
128+
if not user_attrs.get("edx-platform.user_is_staff"):
129+
return error_response({"error": _("You do not have permission to manage files for this block.")})
130+
if not is_gotenberg_enabled():
131+
return error_response({"error": _("Gotenberg not enabled. PDF Conversion unavailable.")})
132+
user = get_user_model().objects.get(id=user_attrs.get("edx-platform.user_id"))
133+
output_name = f"{self.location}.pdf"
134+
url = data["url"]
135+
result = convert_to_pdf(url, output_name)
136+
if result is None:
137+
return error_response({"error": _("PDF Conversion failed.")})
138+
asset = add_asset(self.location, result, user)
139+
print(asset)
140+
print(dir(asset))
141+
return {"url": asset.url}

xblock_pdf/tests/test_pdf.py

Lines changed: 1 addition & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from typing import Any
55
from unittest.mock import MagicMock, patch
66

7-
from django.test import override_settings
87
from xblock.field_data import DictFieldData
98
from xblock.fields import ScopeIds
109
from xblock.test.toy_runtime import ToyRuntime
@@ -52,7 +51,7 @@ def test_download_button():
5251

5352

5453
def test_source_url():
55-
"""Test rendering based on whether or not there's a source URL"""
54+
"""Test rendering based on whether there's a source URL"""
5655
block = make_block()
5756
get_student_content(block)
5857
content = get_student_content(block)
@@ -62,60 +61,6 @@ def test_source_url():
6261
assert "Download the source document" in content
6362

6463

65-
@override_settings(PDFXBLOCK_DISABLE_ALL_DOWNLOAD=False)
66-
def test_saves_settings():
67-
"""Test that PDF settings are saved."""
68-
block = make_block()
69-
request = mock_handle_request(
70-
{
71-
"display_name": "Novel application of theory",
72-
"url": "https://example.com/nature_article.pdf",
73-
"allow_download": "false",
74-
"source_text": "Get educated",
75-
"source_url": "https://example.com/nature_article.tex",
76-
}
77-
)
78-
block.save_pdf(request)
79-
assert block.display_name == "Novel application of theory"
80-
assert block.url == "https://example.com/nature_article.pdf"
81-
assert not block.allow_download
82-
assert block.source_text == "Get educated"
83-
assert block.source_url == "https://example.com/nature_article.tex"
84-
85-
86-
@override_settings(PDFXBLOCK_DISABLE_ALL_DOWNLOAD=True)
87-
def test_saves_settings_omits_on_download_disabled_flag():
88-
"""
89-
Test that fields relating to download are ignored when the universal
90-
downloads disabled flag is set.
91-
"""
92-
block = make_block()
93-
request = mock_handle_request(
94-
{
95-
"display_name": "Novel application of theory",
96-
"url": "https://example.com/nature_article.pdf",
97-
# These fields shouldn't be visible on the front end,
98-
# but should be dropped if they somehow are.
99-
#
100-
# Potential future improvement would be saving these
101-
# but ignoring them when rendering. This is not currently
102-
# the case since the fields are entirely absent from the studio
103-
# render, and so would send blank data which would error out.
104-
"allow_download": "false",
105-
"source_text": "Get educated",
106-
"source_url": "https://example.com/nature_article.tex",
107-
}
108-
)
109-
block.save_pdf(request)
110-
assert block.display_name == "Novel application of theory"
111-
assert block.url == "https://example.com/nature_article.pdf"
112-
# Flag will be the default, which is True, even though download will be
113-
# disabled in practice.
114-
assert block.allow_download
115-
assert block.source_text == ""
116-
assert block.source_url == ""
117-
118-
11964
@patch.object(ToyRuntime, "publish")
12065
def test_download_event_fires(mock_publish):
12166
"""Test that we fire a download event."""

xblock_pdf/utils.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,89 @@
11
"""Utility functions for PDF XBlock."""
22

3+
import json
4+
from typing import Any
5+
from urllib.parse import urlparse
6+
7+
import requests
38
from django.conf import settings
9+
from django.contrib.auth.models import AbstractBaseUser
10+
from django.core.files.base import ContentFile
11+
from opaque_keys.edx.locator import BlockUsageLocator, LibraryUsageLocatorV2
12+
from webob import Response
13+
14+
15+
def is_gotenberg_enabled() -> bool:
16+
"""
17+
Returns if gotenberg is enabled.
18+
"""
19+
return bool(get_gotenberg_host())
20+
21+
22+
def get_gotenberg_host() -> str | None:
23+
"""
24+
Returns the hostname of the Gotenberg instance, if configured.
25+
Returns None if Gotenberg is not configured.
26+
"""
27+
return getattr(settings, "GOTENBERG_HOST", None)
28+
29+
30+
def get_conversion_url() -> str | None:
31+
"""
32+
Get the URL for sending a document for conversion by Gotenberg
33+
"""
34+
return (base_url := get_gotenberg_host()) and f"{base_url}/forms/libreoffice/convert"
435

536

6-
def bool_from_str(str_value):
7-
"""Convert string from submitted form to boolean."""
8-
return str_value.strip().lower() == "true"
37+
def add_asset(
38+
location: BlockUsageLocator | LibraryUsageLocatorV2,
39+
# Must have the 'name' attribute set.
40+
asset: ContentFile,
41+
user: AbstractBaseUser,
42+
) -> str | None:
43+
"""
44+
Adds an asset for this block. If we aren't in the studio environment, will create ImportErrors.
45+
Easily mocked for tests.
46+
"""
47+
from cms.djangoapps.contentstore.asset_storage_handlers import update_course_run_asset
48+
from openedx.core.djangoapps.content_libraries.api import add_library_block_static_asset_file
49+
50+
match location:
51+
case BlockUsageLocator():
52+
update_course_run_asset(location.course_key, asset, asset.name)
53+
case LibraryUsageLocatorV2():
54+
add_library_block_static_asset_file(location, asset.name, asset, user)
55+
56+
57+
def convert_to_pdf(doc_url: str, filename: str) -> ContentFile | None:
58+
"""
59+
Uses the Gotenberg service to convert the document at `doc_url` to a PDF file.
60+
"""
61+
if not (conversion_url := get_conversion_url()):
62+
return None
63+
source_url = urlparse(doc_url)
64+
filename = source_url.path.split("/")[-1]
65+
source_doc_response = requests.get(doc_url, timeout=(10, 120))
66+
67+
pdf_response = requests.post(
68+
conversion_url, files={"file": (filename, source_doc_response.content)}, timeout=(2, 120)
69+
)
70+
if pdf_response.status_code != 200:
71+
return None
72+
return ContentFile(pdf_response.content, name=filename)
973

1074

1175
def is_all_download_disabled():
1276
"""Check if all downloads are disabled or not."""
1377
return getattr(settings, "PDFXBLOCK_DISABLE_ALL_DOWNLOAD", False)
78+
79+
80+
def error_response(data: dict[Any, Any], status: int = 400):
81+
"""
82+
Returns a JSON response object with the appropriate status.
83+
"""
84+
return Response(
85+
json.dumps(data),
86+
status=status,
87+
content_type="application/json",
88+
charset="utf8",
89+
)

0 commit comments

Comments
 (0)