diff --git a/uv.lock b/uv.lock index 37974de9..194afc3a 100644 --- a/uv.lock +++ b/uv.lock @@ -9,14 +9,14 @@ conflicts = [[ { package = "xblocks-contrib", group = "django42" }, { package = "xblocks-contrib", group = "test" }, ], [ - { package = "xblocks-contrib", group = "dev" }, { package = "xblocks-contrib", group = "django42" }, + { package = "xblocks-contrib", group = "quality" }, ], [ + { package = "xblocks-contrib", group = "dev" }, { package = "xblocks-contrib", group = "django42" }, - { package = "xblocks-contrib", group = "doc" }, ], [ { package = "xblocks-contrib", group = "django42" }, - { package = "xblocks-contrib", group = "quality" }, + { package = "xblocks-contrib", group = "doc" }, ]] [manifest] diff --git a/xblock_pdf/pdf.py b/xblock_pdf/pdf.py index ee20032f..4fe13e07 100644 --- a/xblock_pdf/pdf.py +++ b/xblock_pdf/pdf.py @@ -1,7 +1,9 @@ """pdfXBlock main Python class.""" import json +from logging import getLogger +from django.contrib.auth import get_user_model from django.utils.translation import gettext_noop as _ from web_fragments.fragment import Fragment from webob import Response @@ -9,12 +11,14 @@ from xblock.fields import Boolean, Scope, String from xblock.utils.resources import ResourceLoader -from .utils import bool_from_str, is_all_download_disabled +from .utils import add_asset, convert_to_pdf, error_response, is_all_download_disabled, is_gotenberg_enabled resource_loader = ResourceLoader(__name__) +logger = getLogger(__name__) -@XBlock.needs("i18n") + +@XBlock.needs("i18n", "user") class PDFBlock(XBlock): """PDF XBlock. Allows authors to embed PDFs in their courses.""" @@ -114,16 +118,24 @@ def load_pdf(self, *_args, **_kwargs): return Response(json.dumps(self.raw_settings), content_type="application/json", charset="utf8") @XBlock.json_handler - def save_pdf(self, data, suffix=""): # pylint: disable=unused-argument - """Save handler.""" - self.display_name = data["display_name"] - self.url = data["url"] - - if not is_all_download_disabled(): - self.allow_download = bool_from_str(data["allow_download"]) - self.source_text = data["source_text"] - self.source_url = data["source_url"] - - return { - "result": "success", - } + def convert_pdf(self, data, suffix=""): # pylint: disable=unused-argument + """ + PDF Conversion handling. Basically just a frontend to the Gotenberg service which converts the given URL + and then saves it to course assets, returning the URL. + """ + user_service = self.runtime.service(self, "user") + user_attrs = user_service.get_current_user().opt_attrs + if not user_attrs.get("edx-platform.user_is_staff"): + return error_response({"error": _("You do not have permission to manage files for this block.")}) + if not is_gotenberg_enabled(): + return error_response({"error": _("Gotenberg not enabled. PDF Conversion unavailable.")}) + user = get_user_model().objects.get(id=user_attrs.get("edx-platform.user_id")) + output_name = f"{self.location}.pdf" + url = data["url"] + result = convert_to_pdf(url, output_name) + if result is None: + return error_response({"error": _("PDF Conversion failed.")}) + asset = add_asset(self.location, result, user) + print(asset) + print(dir(asset)) + return {"url": asset.url} diff --git a/xblock_pdf/tests/test_pdf.py b/xblock_pdf/tests/test_pdf.py index 114f08a3..0e6df2de 100644 --- a/xblock_pdf/tests/test_pdf.py +++ b/xblock_pdf/tests/test_pdf.py @@ -4,7 +4,6 @@ from typing import Any from unittest.mock import MagicMock, patch -from django.test import override_settings from xblock.field_data import DictFieldData from xblock.fields import ScopeIds from xblock.test.toy_runtime import ToyRuntime @@ -52,7 +51,7 @@ def test_download_button(): def test_source_url(): - """Test rendering based on whether or not there's a source URL""" + """Test rendering based on whether there's a source URL""" block = make_block() get_student_content(block) content = get_student_content(block) @@ -62,60 +61,6 @@ def test_source_url(): assert "Download the source document" in content -@override_settings(PDFXBLOCK_DISABLE_ALL_DOWNLOAD=False) -def test_saves_settings(): - """Test that PDF settings are saved.""" - block = make_block() - request = mock_handle_request( - { - "display_name": "Novel application of theory", - "url": "https://example.com/nature_article.pdf", - "allow_download": "false", - "source_text": "Get educated", - "source_url": "https://example.com/nature_article.tex", - } - ) - block.save_pdf(request) - assert block.display_name == "Novel application of theory" - assert block.url == "https://example.com/nature_article.pdf" - assert not block.allow_download - assert block.source_text == "Get educated" - assert block.source_url == "https://example.com/nature_article.tex" - - -@override_settings(PDFXBLOCK_DISABLE_ALL_DOWNLOAD=True) -def test_saves_settings_omits_on_download_disabled_flag(): - """ - Test that fields relating to download are ignored when the universal - downloads disabled flag is set. - """ - block = make_block() - request = mock_handle_request( - { - "display_name": "Novel application of theory", - "url": "https://example.com/nature_article.pdf", - # These fields shouldn't be visible on the front end, - # but should be dropped if they somehow are. - # - # Potential future improvement would be saving these - # but ignoring them when rendering. This is not currently - # the case since the fields are entirely absent from the studio - # render, and so would send blank data which would error out. - "allow_download": "false", - "source_text": "Get educated", - "source_url": "https://example.com/nature_article.tex", - } - ) - block.save_pdf(request) - assert block.display_name == "Novel application of theory" - assert block.url == "https://example.com/nature_article.pdf" - # Flag will be the default, which is True, even though download will be - # disabled in practice. - assert block.allow_download - assert block.source_text == "" - assert block.source_url == "" - - @patch.object(ToyRuntime, "publish") def test_download_event_fires(mock_publish): """Test that we fire a download event.""" diff --git a/xblock_pdf/utils.py b/xblock_pdf/utils.py index 079c58b0..161d794a 100644 --- a/xblock_pdf/utils.py +++ b/xblock_pdf/utils.py @@ -1,13 +1,89 @@ """Utility functions for PDF XBlock.""" +import json +from typing import Any +from urllib.parse import urlparse + +import requests from django.conf import settings +from django.contrib.auth.models import AbstractBaseUser +from django.core.files.base import ContentFile +from opaque_keys.edx.locator import BlockUsageLocator, LibraryUsageLocatorV2 +from webob import Response + + +def is_gotenberg_enabled() -> bool: + """ + Returns if gotenberg is enabled. + """ + return bool(get_gotenberg_host()) + + +def get_gotenberg_host() -> str | None: + """ + Returns the hostname of the Gotenberg instance, if configured. + Returns None if Gotenberg is not configured. + """ + return getattr(settings, "GOTENBERG_HOST", None) + + +def get_conversion_url() -> str | None: + """ + Get the URL for sending a document for conversion by Gotenberg + """ + return (base_url := get_gotenberg_host()) and f"{base_url}/forms/libreoffice/convert" -def bool_from_str(str_value): - """Convert string from submitted form to boolean.""" - return str_value.strip().lower() == "true" +def add_asset( + location: BlockUsageLocator | LibraryUsageLocatorV2, + # Must have the 'name' attribute set. + asset: ContentFile, + user: AbstractBaseUser, +) -> str | None: + """ + Adds an asset for this block. If we aren't in the studio environment, will create ImportErrors. + Easily mocked for tests. + """ + from cms.djangoapps.contentstore.asset_storage_handlers import update_course_run_asset + from openedx.core.djangoapps.content_libraries.api import add_library_block_static_asset_file + + match location: + case BlockUsageLocator(): + update_course_run_asset(location.course_key, asset, asset.name) + case LibraryUsageLocatorV2(): + add_library_block_static_asset_file(location, asset.name, asset, user) + + +def convert_to_pdf(doc_url: str, filename: str) -> ContentFile | None: + """ + Uses the Gotenberg service to convert the document at `doc_url` to a PDF file. + """ + if not (conversion_url := get_conversion_url()): + return None + source_url = urlparse(doc_url) + filename = source_url.path.split("/")[-1] + source_doc_response = requests.get(doc_url, timeout=(10, 120)) + + pdf_response = requests.post( + conversion_url, files={"file": (filename, source_doc_response.content)}, timeout=(2, 120) + ) + if pdf_response.status_code != 200: + return None + return ContentFile(pdf_response.content, name=filename) def is_all_download_disabled(): """Check if all downloads are disabled or not.""" return getattr(settings, "PDFXBLOCK_DISABLE_ALL_DOWNLOAD", False) + + +def error_response(data: dict[Any, Any], status: int = 400): + """ + Returns a JSON response object with the appropriate status. + """ + return Response( + json.dumps(data), + status=status, + content_type="application/json", + charset="utf8", + )