From 2b0e25f409c2a9a7a71b099defd74dbaee04177b Mon Sep 17 00:00:00 2001 From: Fox Danger Piacenti Date: Fri, 23 Jan 2026 15:41:40 -0600 Subject: [PATCH 1/3] feat: pdf block --- README.rst | 1 + setup.py | 1 + xblocks_contrib/__init__.py | 1 + xblocks_contrib/pdf/__init__.py | 5 + xblocks_contrib/pdf/pdf.py | 143 +++++++++++++++ xblocks_contrib/pdf/static/js/pdf_edit.js | 28 +++ xblocks_contrib/pdf/static/js/pdf_view.js | 17 ++ .../pdf/templates/html/pdf_edit.html | 58 ++++++ .../pdf/templates/html/pdf_view.html | 21 +++ xblocks_contrib/pdf/tests/test_pdf.py | 168 ++++++++++++++++++ xblocks_contrib/pdf/utils.py | 19 ++ 11 files changed, 462 insertions(+) create mode 100644 xblocks_contrib/pdf/__init__.py create mode 100644 xblocks_contrib/pdf/pdf.py create mode 100644 xblocks_contrib/pdf/static/js/pdf_edit.js create mode 100644 xblocks_contrib/pdf/static/js/pdf_view.js create mode 100644 xblocks_contrib/pdf/templates/html/pdf_edit.html create mode 100644 xblocks_contrib/pdf/templates/html/pdf_view.html create mode 100644 xblocks_contrib/pdf/tests/test_pdf.py create mode 100644 xblocks_contrib/pdf/utils.py diff --git a/README.rst b/README.rst index a68fe686..29a424ca 100644 --- a/README.rst +++ b/README.rst @@ -21,6 +21,7 @@ These are the XBlocks being moved here, and each of their statuses: * ``word_cloud`` -- Ready to Use * ``annotatable`` -- Ready to Use * ``lti`` -- In Development +* ``pdf`` -- In Development * ``html`` -- Ready to Use * ``discussion`` -- Placeholder * ``problem`` -- In Development diff --git a/setup.py b/setup.py index dfae3d08..2d8f7905 100644 --- a/setup.py +++ b/setup.py @@ -204,6 +204,7 @@ def package_data(pkg, sub_roots): "_problem_extracted = xblocks_contrib:ProblemBlock", "_video_extracted = xblocks_contrib:VideoBlock", "_word_cloud_extracted = xblocks_contrib:WordCloudBlock", + "_pdf_extracted = xblocks_contrib:PDFBlock", ] }, package_data=package_data("xblocks_contrib", ["static", "public", "templates"]), diff --git a/xblocks_contrib/__init__.py b/xblocks_contrib/__init__.py index 7d3fc681..196ab7c5 100644 --- a/xblocks_contrib/__init__.py +++ b/xblocks_contrib/__init__.py @@ -4,6 +4,7 @@ from .discussion import DiscussionXBlock from .html import HtmlBlock from .lti import LTIBlock +from .pdf import PDFBlock from .poll import PollBlock from .problem import ProblemBlock from .video import VideoBlock diff --git a/xblocks_contrib/pdf/__init__.py b/xblocks_contrib/pdf/__init__.py new file mode 100644 index 00000000..f2765d7e --- /dev/null +++ b/xblocks_contrib/pdf/__init__.py @@ -0,0 +1,5 @@ +""" +Init for PDFBlock. +""" + +from .pdf import PDFBlock diff --git a/xblocks_contrib/pdf/pdf.py b/xblocks_contrib/pdf/pdf.py new file mode 100644 index 00000000..1ed189de --- /dev/null +++ b/xblocks_contrib/pdf/pdf.py @@ -0,0 +1,143 @@ +""" pdfXBlock main Python class""" + +from django.utils.translation import gettext_noop as _ +from xblock.core import XBlock +from xblock.fields import Boolean, Scope, String +from xblock.fragment import Fragment +from xblock.utils.resources import ResourceLoader + +from .utils import bool_from_str, is_all_download_disabled + +resource_loader = ResourceLoader(__name__) + + +@XBlock.needs('i18n') +class PDFBlock(XBlock): + """ + PDF XBlock. Allows authors to embed PDFs in their courses. + """ + + icon_class = "other" + + display_name = String( + display_name=_("Display Name"), + default=_("PDF"), + scope=Scope.settings, + help=_("This name appears in the horizontal navigation at the top of the page.") + ) + + url = String( + display_name=_("PDF URL"), + default=_("https://tutorial.math.lamar.edu/pdf/Trig_Cheat_Sheet.pdf"), + scope=Scope.content, + help=_("The URL for your PDF.") + ) + + allow_download = Boolean( + display_name=_("PDF Download Allowed"), + default=True, + scope=Scope.content, + help=_("Display a download button for this PDF.") + ) + + source_text = String( + display_name=_("Source document button text"), + default="", + scope=Scope.content, + help=_( + "Add a download link for the source file of your PDF. " + "Use it for example to provide the PowerPoint file used to create this PDF." + ) + ) + + source_url = String( + display_name=_("Source document URL"), + default="", + scope=Scope.content, + help=_( + "Add a download link for the source file of your PDF. " + "Use it for example to provide the PowerPoint file used to create this PDF." + ) + ) + + def student_view(self, context=None): + """ + The primary view of the XBlock, shown to students + when viewing courses. + """ + context = { + 'display_name': self.display_name, + 'url': self.url, + 'allow_download': self.allow_download, + 'disable_all_download': is_all_download_disabled(), + 'source_text': self.source_text, + 'source_url': self.source_url, + } + html = resource_loader.render_django_template( + 'templates/html/pdf_view.html', + context=context, + i18n_service=self.runtime.service(self, "i18n"), + ) + + event_type = 'edx.pdf.loaded' + event_data = { + 'url': self.url, + 'source_url': self.source_url, + } + self.runtime.publish(self, event_type, event_data) + frag = Fragment(html) + frag.add_javascript(resource_loader.load_unicode("static/js/pdf_view.js")) + frag.initialize_js('pdfXBlockInitView') + return frag + + def studio_view(self, context=None): + """ + The secondary view of the XBlock, shown to teachers + when editing the XBlock. + """ + context = { + 'display_name': self.display_name, + 'url': self.url, + 'allow_download': self.allow_download, + 'disable_all_download': is_all_download_disabled(), + 'source_text': self.source_text, + 'source_url': self.source_url + } + html = resource_loader.render_django_template( + 'templates/html/pdf_edit.html', + context=context, + i18n_service=self.runtime.service(self, "i18n"), + ) + frag = Fragment(html) + frag.add_javascript(resource_loader.load_unicode("static/js/pdf_edit.js")) + frag.initialize_js('pdfXBlockInitEdit') + return frag + + @XBlock.json_handler + def on_download(self, data, suffix=''): # pylint: disable=unused-argument + """ + The download file event handler + """ + event_type = 'edx.pdf.downloaded' + event_data = { + 'url': self.url, + 'source_url': self.source_url, + } + self.runtime.publish(self, event_type, event_data) + + @XBlock.json_handler + def save_pdf(self, data, suffix=''): # pylint: disable=unused-argument + """ + The saving 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', + } diff --git a/xblocks_contrib/pdf/static/js/pdf_edit.js b/xblocks_contrib/pdf/static/js/pdf_edit.js new file mode 100644 index 00000000..73a537cd --- /dev/null +++ b/xblocks_contrib/pdf/static/js/pdf_edit.js @@ -0,0 +1,28 @@ +/* Javascript for pdfXBlock. */ +function pdfXBlockInitEdit(runtime, element) { + $(element).find('.action-cancel').bind('click', function () { + runtime.notify('cancel', {}); + }); + + $(element).find('.action-save').bind('click', function () { + var data = { + 'display_name': $('#pdf_edit_display_name').val(), + 'url': $('#pdf_edit_url').val(), + 'allow_download': $('#pdf_edit_allow_download').val() || '', + 'source_text': $('#pdf_edit_source_text').val() || '', + 'source_url': $('#pdf_edit_source_url').val() || '' + }; + + runtime.notify('save', { state: 'start' }); + + var handlerUrl = runtime.handlerUrl(element, 'save_pdf'); + $.post(handlerUrl, JSON.stringify(data)).done(function (response) { + if (response.result === 'success') { + runtime.notify('save', { state: 'end' }); + } + else { + runtime.notify('error', { msg: response.message }); + } + }); + }); +} diff --git a/xblocks_contrib/pdf/static/js/pdf_view.js b/xblocks_contrib/pdf/static/js/pdf_view.js new file mode 100644 index 00000000..4a6a8739 --- /dev/null +++ b/xblocks_contrib/pdf/static/js/pdf_view.js @@ -0,0 +1,17 @@ +/* Javascript for pdfXBlock. */ +function pdfXBlockInitView(runtime, element) { + /* Weird behaviour : + * In the LMS, element is the DOM container. + * In the CMS, element is the jQuery object associated* + * So here I make sure element is the jQuery object */ + if (element.innerHTML) { + element = $(element); + } + + $(function () { + element.find('.pdf-download-button').on('click', function () { + var handlerUrl = runtime.handlerUrl(element, 'on_download'); + $.post(handlerUrl, '{}'); + }); + }); +} diff --git a/xblocks_contrib/pdf/templates/html/pdf_edit.html b/xblocks_contrib/pdf/templates/html/pdf_edit.html new file mode 100644 index 00000000..908f7eb9 --- /dev/null +++ b/xblocks_contrib/pdf/templates/html/pdf_edit.html @@ -0,0 +1,58 @@ +{% load i18n %} +
+ + + +
diff --git a/xblocks_contrib/pdf/templates/html/pdf_view.html b/xblocks_contrib/pdf/templates/html/pdf_view.html new file mode 100644 index 00000000..9dc37809 --- /dev/null +++ b/xblocks_contrib/pdf/templates/html/pdf_view.html @@ -0,0 +1,21 @@ +{% load i18n %} +
+

{{ display_name }}

+ + {% if not disable_all_download %} + + {% endif %} +
diff --git a/xblocks_contrib/pdf/tests/test_pdf.py b/xblocks_contrib/pdf/tests/test_pdf.py new file mode 100644 index 00000000..0d958c91 --- /dev/null +++ b/xblocks_contrib/pdf/tests/test_pdf.py @@ -0,0 +1,168 @@ +""" +Tests for the PDF Block +""" +import json +from typing import Any, Optional +from unittest.mock import MagicMock, patch + +from django.test import TestCase, override_settings +from xblock.field_data import DictFieldData +from xblock.fields import ScopeIds +from xblock.test.toy_runtime import ToyRuntime + +from xblocks_contrib import PDFBlock + + +def make_block(**fields: dict[str, Any]) -> PDFBlock: + """Build a block with specific fields set.""" + scope_ids = ScopeIds("1", "2", "3", "4") + return PDFBlock(ToyRuntime(), scope_ids=scope_ids, field_data=DictFieldData(data=fields)) + + +def get_student_content(block: PDFBlock) -> str: + """Get the contents of a student render for a block.""" + frag = block.student_view() + as_dict = frag.to_dict() + return as_dict["content"] + + +def get_studio_content(block: PDFBlock) -> str: + """Get the contents of the studio render for a block.""" + frag = block.studio_view() + as_dict = frag.to_dict() + return as_dict["content"] + + +def mock_handle_request(data: Optional[dict[str, Any]] = None, method: str = "POST"): + """ + Return a request object compatible with an xblock_handler. + """ + data + mock_request = MagicMock() + mock_request.method = method + mock_request.body = json.dumps(data).encode("utf-8") + return mock_request + + +class TestPDFXBlock(TestCase): + """Tests for the PDF XBlock""" + + def test_defaults_render(self): + """Test the basic view loads.""" + scope_ids = ScopeIds("1", "2", "3", "4") + block = make_block() + content = get_student_content(block) + self.assertIn( + '