Skip to content

Commit c0247f0

Browse files
committed
feat: gotenberg support for PDF block
1 parent 2da8a5b commit c0247f0

3 files changed

Lines changed: 317 additions & 76 deletions

File tree

xblock_pdf/pdf.py

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

33
import json
4+
from logging import getLogger
5+
from urllib.parse import urlparse
46

7+
from django.contrib.auth import get_user_model
58
from django.utils.translation import gettext_noop as _
9+
from requests import HTTPError, Timeout
610
from web_fragments.fragment import Fragment
711
from webob import Response
812
from xblock.core import XBlock
913
from xblock.fields import Boolean, Scope, String
1014
from xblock.utils.resources import ResourceLoader
1115

12-
from .utils import bool_from_str, is_all_download_disabled
16+
from .utils import (
17+
add_asset,
18+
convert_to_pdf,
19+
error_response,
20+
fetch_source_asset,
21+
is_all_download_disabled,
22+
is_gotenberg_enabled,
23+
)
1324

1425
resource_loader = ResourceLoader(__name__)
1526

27+
logger = getLogger(__name__)
1628

17-
@XBlock.needs("i18n")
29+
30+
@XBlock.needs("i18n", "user")
31+
@XBlock.wants("studio_user_permissions")
1832
class PDFBlock(XBlock):
1933
"""PDF XBlock. Allows authors to embed PDFs in their courses."""
2034

@@ -69,6 +83,7 @@ def raw_settings(self):
6983
"url": self.url,
7084
"allow_download": self.allow_download,
7185
"disable_all_download": is_all_download_disabled(),
86+
"conversion_available": is_gotenberg_enabled(),
7287
"source_text": self.source_text,
7388
"source_url": self.source_url,
7489
}
@@ -113,17 +128,41 @@ def load_pdf(self, *_args, **_kwargs):
113128
"""Get the PDF block's settings in JSON format."""
114129
return Response(json.dumps(self.raw_settings), content_type="application/json", charset="utf8")
115130

116-
@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"]
131+
def has_authoring_permissions(self) -> bool:
132+
"""
133+
Checks if the current user has authoring permissions.
134+
"""
135+
user_service = self.runtime.service(self, "user")
136+
permissions_service = self.runtime.service(self, "studio_user_permissions")
137+
if permissions_service and permissions_service.can_write(self.context_key):
138+
return True
139+
return user_service.get_current_user().opt_attrs.get("edx-platform.user_is_staff", False)
126140

127-
return {
128-
"result": "success",
129-
}
141+
@XBlock.json_handler
142+
def convert_pdf(self, data, suffix=""): # pylint: disable=unused-argument
143+
"""
144+
PDF Conversion handling. Basically just a frontend to the Gotenberg service which converts the given URL
145+
and then saves it to course assets, returning the URL.
146+
"""
147+
if not self.has_authoring_permissions():
148+
return error_response(
149+
{"error": _("You do not have permission to manage files for this block.")},
150+
status=403,
151+
)
152+
if not is_gotenberg_enabled():
153+
return error_response({"error": _("Gotenberg not enabled. PDF Conversion unavailable.")})
154+
user_service = self.runtime.service(self, "user")
155+
user_attrs = user_service.get_current_user().opt_attrs
156+
user = get_user_model().objects.get(id=user_attrs.get("edx-platform.user_id"))
157+
output_name = f"{self.scope_ids.usage_id}.pdf"
158+
try:
159+
file_bytes = fetch_source_asset(self.scope_ids.usage_id, data["url"])
160+
except (HTTPError, Timeout):
161+
logger.exception(_("Failed to fetch document at %(url)r.") % {"url": data["url"]})
162+
return error_response({"error": _("Could not fetch source document.")}, status=502)
163+
source_url = urlparse(data["url"])
164+
source_filename = source_url.path.split("/")[-1]
165+
result = convert_to_pdf(source_filename, file_bytes, output_name)
166+
if result is None:
167+
return error_response({"error": _("PDF Conversion failed.")}, status=500)
168+
return {"url": add_asset(self.scope_ids.usage_id, result, user)}

xblock_pdf/tests/test_pdf.py

Lines changed: 145 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,73 @@
11
"""Tests for the PDF Block"""
22

33
import json
4-
from typing import Any
4+
from dataclasses import dataclass
5+
from typing import Any, TypedDict
56
from unittest.mock import MagicMock, patch
67

8+
import pytest
9+
from django.contrib.auth.models import User
710
from django.test import override_settings
11+
from requests import Response
12+
from requests.exceptions import HTTPError
813
from xblock.field_data import DictFieldData
914
from xblock.fields import ScopeIds
1015
from xblock.test.toy_runtime import ToyRuntime
1116

1217
from xblock_pdf import PDFBlock
18+
from xblock_pdf.utils import error_response
1319

20+
MockOptValues = TypedDict("MockOptValues", {"edx-platform.user_is_staff": bool, "edx-platform.user_id": int})
1421

15-
def make_block(**fields: str) -> PDFBlock:
22+
23+
@dataclass
24+
class MockUser:
25+
opt_attrs: MockOptValues
26+
27+
28+
class ToyUserService:
29+
"""
30+
Toy version of the user service that implements just enough for us to work with.
31+
"""
32+
33+
def __init__(self, *, user_id: int, is_staff=False):
34+
self._user = MockUser(opt_attrs={"edx-platform.user_is_staff": is_staff, "edx-platform.user_id": user_id})
35+
36+
def get_current_user(self):
37+
return self._user
38+
39+
40+
class ToyPermissionsService:
41+
"""
42+
Toy version of the studio_user_permissions service.
43+
"""
44+
45+
def __init__(self, can_read=True, can_write=False):
46+
self._can_read = can_read
47+
self._can_write = can_write
48+
49+
def can_read(self, _context_key):
50+
return self._can_read
51+
52+
def can_write(self, _context_key):
53+
return self._can_write
54+
55+
56+
class ToyServiceRuntime(ToyRuntime):
57+
"""
58+
Modified toy runtime that includes custom services for mocking/testing.
59+
"""
60+
61+
def __init__(self, *, services: dict[str, Any] | None = None):
62+
super().__init__()
63+
if services is not None:
64+
self._services.update(services)
65+
66+
67+
def make_block(*, services: dict[str, Any] | None = None, **fields: str) -> PDFBlock:
1668
"""Build a block with specific fields set."""
1769
scope_ids = ScopeIds("1", "2", "3", "4")
18-
return PDFBlock(ToyRuntime(), scope_ids=scope_ids, field_data=DictFieldData(data=fields))
70+
return PDFBlock(ToyServiceRuntime(services=services), scope_ids=scope_ids, field_data=DictFieldData(data=fields))
1971

2072

2173
def get_student_content(block: PDFBlock) -> str:
@@ -52,7 +104,7 @@ def test_download_button():
52104

53105

54106
def test_source_url():
55-
"""Test rendering based on whether or not there's a source URL"""
107+
"""Test rendering based on whether there's a source URL"""
56108
block = make_block()
57109
get_student_content(block)
58110
content = get_student_content(block)
@@ -62,60 +114,6 @@ def test_source_url():
62114
assert "Download the source document" in content
63115

64116

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-
119117
@patch.object(ToyRuntime, "publish")
120118
def test_download_event_fires(mock_publish):
121119
"""Test that we fire a download event."""
@@ -138,3 +136,92 @@ def test_get_settings():
138136
request = mock_handle_request({}, method="GET")
139137
result = json.loads(block.load_pdf(request).body)
140138
assert result["display_name"] == "PDF"
139+
140+
141+
@override_settings(GOTENBERG_HOST=None)
142+
def test_convert_pdf_fails_no_gotenberg():
143+
"""
144+
Test that PDF conversion fails if Gotenberg is not available.
145+
"""
146+
block = make_block(services={"user": ToyUserService(is_staff=True, user_id=1)})
147+
request = mock_handle_request({"url": "https://example.com/thing.doc"})
148+
result = block.convert_pdf(request)
149+
assert result.status_code == 400
150+
assert b"Gotenberg not enabled. PDF Conversion unavailable." in result.body
151+
152+
153+
@override_settings(GOTENBERG_HOST="https://gotenberg/")
154+
def test_convert_fails_not_staff():
155+
"""
156+
Test that PDF conversion fails if user is not staff.
157+
"""
158+
block = make_block(services={"user": ToyUserService(is_staff=False, user_id=1)})
159+
request = mock_handle_request({"url": "https://example.com/thing.doc"})
160+
result = block.convert_pdf(request)
161+
assert result.status_code == 403
162+
assert b"You do not have permission to manage files for this block." in result.body
163+
164+
165+
@override_settings(GOTENBERG_HOST="https://gotenberg/")
166+
@patch("xblock_pdf.pdf.logger")
167+
@patch("xblock_pdf.pdf.fetch_source_asset")
168+
@pytest.mark.django_db
169+
def test_failed_fetch_logs(mock_fetch, mock_log):
170+
block = make_block(
171+
services={
172+
"user": ToyUserService(
173+
is_staff=True, user_id=User.objects.create(username="beep", email="beep@example.com").id
174+
)
175+
}
176+
)
177+
mock_fetch.side_effect = HTTPError(response=error_response({"error": "Failed."}, status=400))
178+
request = mock_handle_request({"url": "https://example.com/thing.doc"})
179+
result = block.convert_pdf(request)
180+
assert mock_log.exception.has_been_called()
181+
assert result.status_code == 502
182+
assert b"Could not fetch source document." in result.body
183+
184+
185+
@override_settings(GOTENBERG_HOST="https://gotenberg/")
186+
@patch("xblock_pdf.pdf.convert_to_pdf")
187+
@patch("xblock_pdf.pdf.fetch_source_asset")
188+
@pytest.mark.django_db
189+
def test_failed_conversion(mock_fetch, mock_convert):
190+
block = make_block(
191+
services={
192+
"user": ToyUserService(
193+
is_staff=True, user_id=User.objects.create(username="beep", email="beep@example.com").id
194+
)
195+
}
196+
)
197+
mock_fetch.return_value = b"beep"
198+
mock_convert.return_value = None
199+
request = mock_handle_request({"url": "https://example.com/thing.doc"})
200+
result = block.convert_pdf(request)
201+
assert result.status_code == 500
202+
assert b"PDF Conversion failed." in result.body
203+
204+
205+
@override_settings(GOTENBERG_HOST="https://gotenberg/")
206+
@patch("xblock_pdf.pdf.add_asset")
207+
@patch("xblock_pdf.utils.requests")
208+
@patch("xblock_pdf.pdf.fetch_source_asset")
209+
@pytest.mark.django_db
210+
def test_successful_conversion_with_perms_service(mock_fetch, mock_requests, mock_add_asset):
211+
block = make_block(
212+
services={
213+
"user": ToyUserService(
214+
is_staff=False, user_id=User.objects.create(username="beep", email="beep@example.com").id
215+
),
216+
"studio_user_permissions": ToyPermissionsService(can_write=True),
217+
}
218+
)
219+
mock_fetch.return_value = b"beep"
220+
mock_response = Response()
221+
mock_response.__setstate__({"status_code": 200, "_content": b"boop"})
222+
mock_requests.post.return_value = mock_response
223+
mock_add_asset.return_value = "https://example.com/exported.pdf"
224+
request = mock_handle_request({"url": "https://example.com/thing.doc"})
225+
result = block.convert_pdf(request)
226+
assert result.status_code == 200
227+
assert b"https://example.com/exported.pdf" in result.body

0 commit comments

Comments
 (0)