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
6 changes: 3 additions & 3 deletions uv.lock

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

45 changes: 30 additions & 15 deletions xblock_pdf/pdf.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
"""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
from xblock.core import XBlock
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")
@XBlock.wants("studio_user_permissions")
class PDFBlock(XBlock):
"""PDF XBlock. Allows authors to embed PDFs in their courses."""

Expand Down Expand Up @@ -69,6 +74,7 @@ def raw_settings(self):
"url": self.url,
"allow_download": self.allow_download,
"disable_all_download": is_all_download_disabled(),
"conversion_available": is_gotenberg_enabled(),
"source_text": self.source_text,
"source_url": self.source_url,
}
Expand Down Expand Up @@ -114,16 +120,25 @@ 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")
permissions_service = self.runtime.service(self, "studio_user_permissions")
if permissions_service is None:
return error_response({"error": _("This handler may only be run in studio.")})
if not permissions_service.can_write(self.context_key):
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_attrs = user_service.get_current_user().opt_attrs
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)
return {"url": asset.get_static_path_from_location(asset.location)}
57 changes: 1 addition & 56 deletions xblock_pdf/tests/test_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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."""
Expand Down
90 changes: 87 additions & 3 deletions xblock_pdf/utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,97 @@
"""Utility functions for PDF XBlock."""

import json
from io import BytesIO
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.uploadedfile import InMemoryUploadedFile
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: InMemoryUploadedFile,
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():
return update_course_run_asset(location.course_key, asset)
case LibraryUsageLocatorV2():
return add_library_block_static_asset_file(location, asset.name, asset, user)


def convert_to_pdf(doc_url: str, filename: str) -> InMemoryUploadedFile | 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)
source_filename = source_url.path.split("/")[-1]
source_doc_response = requests.get(doc_url, timeout=(10, 120))

pdf_response = requests.post(
conversion_url, files={"file": (source_filename, source_doc_response.content)}, timeout=(2, 120)
)
if pdf_response.status_code != 200:
return None
return InMemoryUploadedFile(
file=BytesIO(pdf_response.content),
field_name="file",
content_type="application/pdf",
size=len(pdf_response.content),
charset=None,
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",
)
Loading