Skip to content
Open
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
33 changes: 19 additions & 14 deletions xblock_pdf/pdf.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""pdfXBlock main Python class."""

import json
from logging import getLogger

from django.utils.translation import gettext_noop as _
from web_fragments.fragment import Fragment
Expand All @@ -9,10 +10,12 @@
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")
class PDFBlock(XBlock):
Expand Down Expand Up @@ -114,16 +117,18 @@ 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.
"""
# TODO: Needs permission check.
if not is_gotenberg_enabled():
return error_response({"error": _("Gotenberg not enabled. PDF Conversion unavailable.")})
output_path = f"{self.location}.pdf"
url = data["url"]
result = convert_to_pdf(url)
if result is None:
return error_response({"error": _("PDF Conversion failed.")})
asset = add_asset(self.location, result, output_path)
return {"url": asset.url}
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
82 changes: 79 additions & 3 deletions xblock_pdf/utils.py
Original file line number Diff line number Diff line change
@@ -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.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 add_asset(location: BlockUsageLocator | LibraryUsageLocatorV2, asset: ContentFile, asset_name: str) -> 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

def bool_from_str(str_value):
"""Convert string from submitted form to boolean."""
return str_value.strip().lower() == "true"
match location:
case BlockUsageLocator():
update_course_run_asset(location.course_key, asset, asset_name)
case LibraryUsageLocatorV2():
add_library_block_static_asset_file(location.lib_key, asset, asset_name)


def convert_to_pdf(doc_url: str) -> ContentFile | None:
"""
Uses the Gotenberg service to convert the document at `doc_url` to a PDF file.

Parameters:
doc_url (str): The path or URL to the document to be converted.

Returns:
ContentFile: A django ContentFile prepared for storage.
"""
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)


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