From 45cf1033b631a90cc4b679e7b196272a8966d3e7 Mon Sep 17 00:00:00 2001 From: Fox Danger Piacenti Date: Tue, 2 Jun 2026 12:50:41 -0500 Subject: [PATCH 1/2] refactor: fold web_fragments into project --- Makefile | 2 +- docs/fragments.rst | 11 + docs/index.rst | 1 + pyproject.toml | 6 +- tox.ini | 4 +- uv.lock | 11 - web_fragments/__init__.py | 3 + web_fragments/apps.py | 12 + web_fragments/examples/__init__.py | 0 web_fragments/examples/urls.py | 12 + web_fragments/examples/views.py | 25 ++ web_fragments/fragment.py | 269 ++++++++++++++++++ .../web_fragments/standalone_fragment.html | 9 + web_fragments/test_utils/__init__.py | 15 + web_fragments/tests/test_fragment.py | 250 ++++++++++++++++ web_fragments/tests/test_views.py | 94 ++++++ web_fragments/views.py | 57 ++++ xblock/test/settings.py | 15 +- xblock/test/test_core_capabilities.py | 10 +- 19 files changed, 783 insertions(+), 23 deletions(-) create mode 100644 docs/fragments.rst create mode 100644 web_fragments/__init__.py create mode 100644 web_fragments/apps.py create mode 100644 web_fragments/examples/__init__.py create mode 100644 web_fragments/examples/urls.py create mode 100644 web_fragments/examples/views.py create mode 100644 web_fragments/fragment.py create mode 100644 web_fragments/templates/web_fragments/standalone_fragment.html create mode 100644 web_fragments/test_utils/__init__.py create mode 100644 web_fragments/tests/test_fragment.py create mode 100644 web_fragments/tests/test_views.py create mode 100644 web_fragments/views.py diff --git a/Makefile b/Makefile index 9d9314bf9..4342b0ddf 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ help: ## display this help message quality: ## check coding style with pycodestyle and pylint pycodestyle - pylint xblock + pylint xblock web_fragments validate: test diff --git a/docs/fragments.rst b/docs/fragments.rst new file mode 100644 index 000000000..2993036b6 --- /dev/null +++ b/docs/fragments.rst @@ -0,0 +1,11 @@ +.. _Fragments API: + +############# +Fragments API +############# + +.. automodule:: web_fragments.fragment + :members: + +.. autoclass:: web_fragments.views.FragmentView + :members: diff --git a/docs/index.rst b/docs/index.rst index 629ce2428..bfbd1b137 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,5 +21,6 @@ in depth and guides developers through the process of creating an XBlock. runtime plugins exceptions + fragments xblock-tutorial/index xblock-utils/index diff --git a/pyproject.toml b/pyproject.toml index e307b7271..dc1213899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dependencies = [ "pytz", "pyyaml", "simplejson", - "web-fragments", "webob>=1.6.0", ] @@ -54,10 +53,15 @@ local_scheme = "no-local-version" include-package-data = true [tool.setuptools.packages.find] +include = [ + "xblock*", + "web_fragments*", +] [tool.setuptools.package-data] "xblock.utils" = ["public/*", "templates/*", "templatetags/*"] "xblock.test.utils" = ["data/*"] +"web_fragments" = ["templates/*"] [dependency-groups] # Base test packages, no Django version pinned diff --git a/tox.ini b/tox.ini index 2400723e6..eea317a84 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ requires = tox-uv>=1 [pytest] DJANGO_SETTINGS_MODULE = xblock.test.settings -addopts = --cov xblock +addopts = --cov xblock --cov web_fragments filterwarnings = always norecursedirs = .* docs requirements @@ -14,7 +14,7 @@ dependency_groups = django42: django42 django52: test commands = - python -Wd -m pytest {posargs:xblock} + python -Wd -m pytest {posargs:xblock web_fragments} python -m coverage xml allowlist_externals = make diff --git a/uv.lock b/uv.lock index 6272db8f9..423cbe2ba 100644 --- a/uv.lock +++ b/uv.lock @@ -1529,15 +1529,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, ] -[[package]] -name = "web-fragments" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/1c/938b2e2a7908937361dcaaeb7afe17ca0f1ca9e68c335c72820b772c5b24/web_fragments-4.0.0.tar.gz", hash = "sha256:e82488beb4e8666b9e37a10a81258142f404f4e1964b31d3010154896832f90b", size = 15590, upload-time = "2026-03-10T14:30:43.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/f8/28aa823f4a618481908bc3bb815e5961cc6e79682e7ceb568bcc4d6f10f4/web_fragments-4.0.0-py2.py3-none-any.whl", hash = "sha256:0d5f59c63b2ac5ee95f76f5904c2f20d0e83d6a1425680fcc676485b13f85d32", size = 15580, upload-time = "2026-03-10T14:30:41.406Z" }, -] - [[package]] name = "webob" version = "1.8.10" @@ -1563,7 +1554,6 @@ dependencies = [ { name = "pytz" }, { name = "pyyaml" }, { name = "simplejson" }, - { name = "web-fragments" }, { name = "webob" }, ] @@ -1691,7 +1681,6 @@ requires-dist = [ { name = "pytz" }, { name = "pyyaml" }, { name = "simplejson" }, - { name = "web-fragments" }, { name = "webob", specifier = ">=1.6.0" }, ] provides-extras = ["django"] diff --git a/web_fragments/__init__.py b/web_fragments/__init__.py new file mode 100644 index 000000000..41db97264 --- /dev/null +++ b/web_fragments/__init__.py @@ -0,0 +1,3 @@ +""" +Web fragments. +""" diff --git a/web_fragments/apps.py b/web_fragments/apps.py new file mode 100644 index 000000000..45c691585 --- /dev/null +++ b/web_fragments/apps.py @@ -0,0 +1,12 @@ +""" +Web Fragments Django application initialization. +""" +from django.apps import AppConfig + + +class WebFragmentsConfig(AppConfig): + """ + Configuration for the Web Fragments Django application. + """ + + name = 'web_fragments' diff --git a/web_fragments/examples/__init__.py b/web_fragments/examples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/web_fragments/examples/urls.py b/web_fragments/examples/urls.py new file mode 100644 index 000000000..990182c36 --- /dev/null +++ b/web_fragments/examples/urls.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python + +""" +Provides a URL for testing +""" +from django.urls import path + +from web_fragments.examples.views import EXAMPLE_FRAGMENT_VIEW_NAME, ExampleFragmentView + +urlpatterns = [ + path('test_fragment', ExampleFragmentView.as_view(), name=EXAMPLE_FRAGMENT_VIEW_NAME), +] diff --git a/web_fragments/examples/views.py b/web_fragments/examples/views.py new file mode 100644 index 000000000..b3a95233c --- /dev/null +++ b/web_fragments/examples/views.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +""" +Example fragment view. +""" +from web_fragments.fragment import Fragment +from web_fragments.test_utils import TEST_CSS, TEST_HTML, TEST_JS +from web_fragments.views import FragmentView + +EXAMPLE_FRAGMENT_VIEW_NAME = 'example_fragment_view' + + +class ExampleFragmentView(FragmentView): + """ + Simple fragment view for testing. + """ + + def render_to_fragment(self, request, **kwargs): + """ + Returns a simple fragment + """ + fragment = Fragment(TEST_HTML) + fragment.add_javascript(TEST_JS) + fragment.add_css(TEST_CSS) + return fragment diff --git a/web_fragments/fragment.py b/web_fragments/fragment.py new file mode 100644 index 000000000..6a3b5c6f7 --- /dev/null +++ b/web_fragments/fragment.py @@ -0,0 +1,269 @@ +""" +Python representation of a web fragment. +""" + +from collections import namedtuple + +FragmentResource = namedtuple("FragmentResource", "kind, data, mimetype, placement") + +JS_API_VERSION = 1 + + +class Fragment: + """ + A fragment of a web page to be included on another page. + + A fragment consists of HTML for the body of the page, and a series of + resources needed by the body. Resources are specified with a MIME type + (such as "application/javascript" or "text/css") that determines how they + are inserted into the page. The resource is provided either as literal + text, or as a URL. Text will be included on the page, wrapped + appropriately for the MIME type. URLs will be used as-is on the page. + + Resources are only inserted into the page once, even if many Fragments + in the page ask for them. Determining duplicates is done by simple text + matching. + """ + def __init__(self, content=None): + #: The html content for this Fragment + self.content = "" + + self._resources = [] + self.js_init_fn = None + self.js_init_version = None + self.json_init_args = None + + if content is not None: + self.add_content(content) + + @property + def resources(self): + """ + Returns list of unique FragmentResource named tuples by order of first + appearance. + """ + seen = set() + # seen.add always returns None, so 'not seen.add(x)' is always True, + # but will only be called if the value is not already in seen (because + # 'and' short-circuits) + return [x for x in self._resources if x not in seen and not seen.add(x)] + + def to_dict(self): + """ + Returns the fragment in a dictionary representation. + """ + return { + 'content': self.content, + 'resources': [r._asdict() for r in self.resources], + 'js_init_fn': self.js_init_fn, + 'js_init_version': self.js_init_version, + 'json_init_args': self.json_init_args + } + + @classmethod + def from_dict(cls, pods): + """ + Returns a new Fragment from a dictionary representation. + """ + frag = cls() + frag.content = pods['content'] + frag._resources = [FragmentResource(**d) for d in pods['resources']] + frag.js_init_fn = pods['js_init_fn'] + frag.js_init_version = pods['js_init_version'] + frag.json_init_args = pods['json_init_args'] + return frag + + def add_content(self, content): + """ + Add content to this fragment. + + `content` is a Unicode string, HTML to append to the body of the + fragment. It must not contain a ```` tag, or otherwise assume + that it is the only content on the page. + """ + assert isinstance(content, str) + self.content += content + + def _default_placement(self, mimetype): + """ + Decide where a resource will go, if the user didn't say. + """ + if mimetype == 'application/javascript': + return 'foot' + return 'head' + + def add_resource(self, text, mimetype, placement=None): + """ + Add a resource needed by this Fragment. + + Other helpers, such as :func:`add_css` or :func:`add_javascript` are + more convenient for those common types of resource. + + `text`: the actual text of this resource, as a unicode string. + + `mimetype`: the MIME type of the resource. + + `placement`: where on the page the resource should be placed: + + None: let the Fragment choose based on the MIME type. + + "head": put this resource in the ```` of the page. + + "foot": put this resource at the end of the ```` of the + page. + + """ + if not placement: + placement = self._default_placement(mimetype) + res = FragmentResource('text', text, mimetype, placement) + self._resources.append(res) + + def add_resource_url(self, url, mimetype, placement=None): + """ + Add a resource by URL needed by this Fragment. + + Other helpers, such as :func:`add_css_url` or + :func:`add_javascript_url` are more convenent for those common types of + resource. + + `url`: the URL to the resource. + + Other parameters are as defined for :func:`add_resource`. + """ + if not placement: + placement = self._default_placement(mimetype) + self._resources.append(FragmentResource('url', url, mimetype, placement)) + + def add_css(self, text): + """ + Add literal CSS to the Fragment. + """ + self.add_resource(text, 'text/css') + + def add_css_url(self, url): + """ + Add a CSS URL to the Fragment. + """ + self.add_resource_url(url, 'text/css') + + def add_javascript(self, text): + """ + Add literal Javascript to the Fragment. + """ + self.add_resource(text, 'application/javascript') + + def add_javascript_url(self, url): + """ + Add a Javascript URL to the Fragment. + """ + self.add_resource_url(url, 'application/javascript') + + def add_fragment_resources(self, fragment): + """ + Add all the resources from a single fragment to my resources. + + This is used to aggregate resources from another fragment that + should be considered part of the current fragment. + + The content from the Fragment is ignored. The caller must collect + together the content into this Fragment's content. + """ + self._resources.extend(fragment.resources) + + def add_resources(self, fragments): + """ + Add all the resources from `fragments` to my resources. + + This is used to aggregate resources from a sequence of fragments that + should be considered part of the current fragment. + + The content from the Fragments is ignored. The caller must collect + together the content into this Fragment's content. + """ + for fragment in fragments: + self.add_fragment_resources(fragment) + + def initialize_js(self, js_func, json_args=None): + """ + Register a Javascript function to initialize the Javascript resources. + + `js_func` is the name of a Javascript function defined by one of the + Javascript resources. As part of setting up the browser's runtime + environment, the function will be invoked, passing a runtime object + and a DOM element. + """ + self.js_init_fn = js_func + self.js_init_version = JS_API_VERSION + if json_args: + self.json_init_args = json_args + + def body_html(self): + """ + Get the body HTML for this Fragment. + + Returns a Unicode string, the HTML content for the ```` section + of the page. + """ + return self.content + + def head_html(self): + """ + Get the head HTML for this Fragment. + + Returns a Unicode string, the HTML content for the ```` section + of the page. + """ + return self.resources_to_html("head") + + def foot_html(self): + """ + Get the foot HTML for this Fragment. + + Returns a Unicode string, the HTML content for the end of the + ```` section of the page. + """ + return self.resources_to_html("foot") + + def resources_to_html(self, placement): + """ + Get some resource HTML for this Fragment. + + `placement` is "head" or "foot". + + Returns a unicode string, the HTML for the head or foot of the page. + """ + # - non url js could be wrapped in an anonymous function + # - non url css could be rewritten to match the wrapper tag + + return '\n'.join( + self.resource_to_html(resource) + for resource in self.resources + if resource.placement == placement + ) + + @staticmethod + def resource_to_html(resource): + """ + Returns `resource` wrapped in the appropriate html tag for it's mimetype. + """ + if resource.mimetype == "text/css": + if resource.kind == "text": + return f"" + if resource.kind == "url": + return f"" + + raise Exception(f"Unrecognized resource kind {resource.kind}") + + if resource.mimetype == "application/javascript": + if resource.kind == "text": + return f"" + if resource.kind == "url": + return f"" + + raise Exception(f"Unrecognized resource kind {resource.kind}") + + if resource.mimetype == "text/html": + assert resource.kind == "text" + return resource.data + + raise Exception("Unrecognized mimetype {resource.mimetype}") diff --git a/web_fragments/templates/web_fragments/standalone_fragment.html b/web_fragments/templates/web_fragments/standalone_fragment.html new file mode 100644 index 000000000..ce9e625b4 --- /dev/null +++ b/web_fragments/templates/web_fragments/standalone_fragment.html @@ -0,0 +1,9 @@ + + + {{ head_html|safe }} + + + {{ body_html|safe }} + {{ foot_html|safe }} + + diff --git a/web_fragments/test_utils/__init__.py b/web_fragments/test_utils/__init__.py new file mode 100644 index 000000000..c7a07aa51 --- /dev/null +++ b/web_fragments/test_utils/__init__.py @@ -0,0 +1,15 @@ +""" +Test utilities. +""" +TEST_HTML = '

Hello, world!

' +TEST_CSS = 'body {background-color:red;}' +TEST_CSS_URL = '/css/test.css' +TEST_JS = 'window.alert("Hello");' +TEST_JS_URL = '/js/test.js' +TEST_JS_INIT_FN = 'mock_initialize' +TEST_JSON_INIT_ARGS = {'test_value': 1} + +CSS_EXPECTED_HTML = "" +CSS_LINK_EXPECTED_HTML = "" +JS_EXPECTED_HTML = "" +JS_LINK_EXPECTED_HTML = "" diff --git a/web_fragments/tests/test_fragment.py b/web_fragments/tests/test_fragment.py new file mode 100644 index 000000000..809b03046 --- /dev/null +++ b/web_fragments/tests/test_fragment.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python + +""" +Unit tests for the Fragment class. +""" +import ddt +import pytest + +from django.test import TestCase + +from web_fragments.fragment import Fragment, FragmentResource +from web_fragments.test_utils import ( + CSS_EXPECTED_HTML, + CSS_LINK_EXPECTED_HTML, + JS_EXPECTED_HTML, + JS_LINK_EXPECTED_HTML, + TEST_CSS, + TEST_CSS_URL, + TEST_HTML, + TEST_JS, + TEST_JS_INIT_FN, + TEST_JS_URL, + TEST_JSON_INIT_ARGS +) + +EXPECTED_JS_INIT_VERSION = 1 + +EXPECTED_RESOURCES = [ + { + 'kind': 'text', + 'data': TEST_CSS, + 'mimetype': 'text/css', + 'placement': 'head', + }, + { + 'kind': 'url', + 'data': TEST_CSS_URL, + 'mimetype': 'text/css', + 'placement': 'head', + }, + { + 'kind': 'text', + 'data': TEST_JS, + 'mimetype': 'application/javascript', + 'placement': 'foot', + }, + { + 'kind': 'url', + 'data': TEST_JS_URL, + 'mimetype': 'application/javascript', + 'placement': 'foot', + }, +] + + +@ddt.ddt +class TestFragment(TestCase): + """ + Unit tests for fragments. + """ + def create_test_fragment(self): + """ + Creates a fragment for use in unit tests. + """ + fragment = Fragment() + fragment.add_content(TEST_HTML) + fragment.add_css(TEST_CSS) + fragment.add_css_url(TEST_CSS_URL) + fragment.add_javascript(TEST_JS) + fragment.add_javascript_url(TEST_JS_URL) + fragment.initialize_js(TEST_JS_INIT_FN, json_args=TEST_JSON_INIT_ARGS) + return fragment + + def validate_fragment(self, fragment=None, fragment_dict=None): + """ + Validates that the fields of a fragment are all correct. + """ + fragment_dict = fragment_dict if fragment_dict else fragment.to_dict() + assert fragment_dict['content'] == TEST_HTML + assert fragment_dict['js_init_fn'] == TEST_JS_INIT_FN + assert fragment_dict['js_init_version'] == EXPECTED_JS_INIT_VERSION + assert fragment_dict['json_init_args'] == TEST_JSON_INIT_ARGS + assert fragment_dict['resources'] == EXPECTED_RESOURCES + + def test_to_dict(self): + """ + Tests the to_dict method. + """ + fragment = self.create_test_fragment() + fragment_dict = fragment.to_dict() + self.validate_fragment(fragment_dict=fragment_dict) + + def test_from_dict(self): + """ + Tests the from_dict method. + """ + test_dict = { + 'content': TEST_HTML, + 'resources': EXPECTED_RESOURCES, + 'js_init_fn': TEST_JS_INIT_FN, + 'js_init_version': EXPECTED_JS_INIT_VERSION, + 'json_init_args': TEST_JSON_INIT_ARGS, + } + fragment = Fragment.from_dict(test_dict) + self.validate_fragment(fragment) + + def test_body_html(self): + """ + Tests the body_html method. + """ + fragment = self.create_test_fragment() + html = fragment.body_html() + assert html == TEST_HTML + + def test_head_html(self): + """ + Tests the head_html method. + """ + fragment = self.create_test_fragment() + html = fragment.head_html().replace('\n', '') + assert CSS_EXPECTED_HTML.format(css=TEST_CSS) in html + assert CSS_LINK_EXPECTED_HTML.format(css_url=TEST_CSS_URL) in html + + def test_foot_html(self): + """ + Tests the foot_html method. + """ + fragment = self.create_test_fragment() + html = fragment.foot_html().replace('\n', '') + assert JS_EXPECTED_HTML.format(js=TEST_JS) in html + assert JS_LINK_EXPECTED_HTML.format(js_url=TEST_JS_URL) in html + + def test_add_resource(self): + """ + Tests the add_resource method. + """ + fragment = Fragment() + fragment.add_resource(TEST_CSS, 'text/css') + fragment.add_resource(TEST_JS, 'application/javascript') + fragment.add_resource(TEST_JS, 'application/javascript', placement='bottom') + assert fragment.to_dict()['resources'] == [ + { + 'kind': 'text', + 'data': TEST_CSS, + 'mimetype': 'text/css', + 'placement': 'head', + }, + { + 'kind': 'text', + 'data': TEST_JS, + 'mimetype': 'application/javascript', + 'placement': 'foot', + }, + { + 'kind': 'text', + 'data': TEST_JS, + 'mimetype': 'application/javascript', + 'placement': 'bottom', + }, + ] + + def test_add_resource_url(self): + """ + Tests the add_resource_url method. + """ + fragment = Fragment() + fragment.add_resource_url(TEST_CSS_URL, 'text/css') + fragment.add_resource_url(TEST_JS_URL, 'application/javascript') + fragment.add_resource_url(TEST_JS_URL, 'application/javascript', placement='bottom') + assert fragment.to_dict()['resources'] == [ + { + 'kind': 'url', + 'data': TEST_CSS_URL, + 'mimetype': 'text/css', + 'placement': 'head', + }, + { + 'kind': 'url', + 'data': TEST_JS_URL, + 'mimetype': 'application/javascript', + 'placement': 'foot', + }, + { + 'kind': 'url', + 'data': TEST_JS_URL, + 'mimetype': 'application/javascript', + 'placement': 'bottom', + }, + ] + + def test_add_resources(self): + """ + Tests the add_resources method. + """ + source_fragment = self.create_test_fragment() + test_fragment = Fragment('

new fragment

') + test_fragment.add_resources([source_fragment]) + + @ddt.data( + ( + FragmentResource('text', TEST_HTML, 'text/html', 'body'), + TEST_HTML + ), + ( + FragmentResource('text', TEST_CSS, 'text/css', 'head'), + CSS_EXPECTED_HTML.format(css=TEST_CSS)), + ( + FragmentResource('url', TEST_CSS_URL, 'text/css', 'head'), + CSS_LINK_EXPECTED_HTML.format(css_url=TEST_CSS_URL) + ), + ( + FragmentResource('text', TEST_JS, 'application/javascript', 'body'), + JS_EXPECTED_HTML.format(js=TEST_JS)), + ( + FragmentResource('url', TEST_JS_URL, 'application/javascript', 'foot'), + JS_LINK_EXPECTED_HTML.format(js_url=TEST_JS_URL) + ), + ) + @ddt.unpack + def test_resource_to_html(self, resource, expected_html): + """ + Tests the resource_to_html method. + """ + actual_html = Fragment.resource_to_html(resource).replace('\n', '') + assert actual_html == expected_html + + @ddt.data( + FragmentResource('unknown', TEST_HTML, 'text/html', 'body'), + FragmentResource('text', TEST_HTML, 'text/unknown', 'body'), + FragmentResource('unknown', TEST_CSS, 'text/css', 'head'), + FragmentResource('unknown', TEST_JS, 'application/javascript', 'body'), + FragmentResource('text', TEST_HTML, 'unknown', 'body'), + ) + def test_resource_to_html_exception(self, resource): + """ + Tests the resource_to_html method. + """ + with pytest.raises(Exception): + Fragment.resource_to_html(resource) + + def test_initialize_js(self): + """ + Tests for initialize_js method. + """ + fragment = Fragment() + fragment.initialize_js(TEST_JS_INIT_FN) + fragment_dict = fragment.to_dict() + assert fragment_dict['js_init_fn'] == TEST_JS_INIT_FN + assert fragment_dict['js_init_version'] == EXPECTED_JS_INIT_VERSION + assert fragment_dict['json_init_args'] is None diff --git a/web_fragments/tests/test_views.py b/web_fragments/tests/test_views.py new file mode 100644 index 000000000..011781255 --- /dev/null +++ b/web_fragments/tests/test_views.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python + +""" +Unit tests for web fragment views +""" +import json + +import ddt +import pytest + +from django.test import TestCase +from django.test.client import RequestFactory +from django.urls import reverse + +from web_fragments.examples.views import EXAMPLE_FRAGMENT_VIEW_NAME, ExampleFragmentView +from web_fragments.test_utils import TEST_HTML +from web_fragments.views import FragmentView + + +@ddt.ddt +class TestViews(TestCase): + """ + Unit tests for web fragment views. + """ + + def setUp(self): + super().setUp() + self.requests_factory = RequestFactory() + + def create_mock_request(self, method=None, arguments=None, http_accept='text/html'): + """ + Creates a mock request to the test fragment view. + """ + url = reverse(EXAMPLE_FRAGMENT_VIEW_NAME) + ('/?' + arguments if arguments else '') + method = method if method else self.requests_factory.get + return method(url, HTTP_ACCEPT=http_accept) + + def invoke_test_view(self, method=None, arguments=None, http_accept='text/html', expected_status_code=200): + """ + Invokes the test view with the specified arguments (if provided). + """ + request = self.create_mock_request(method=method, arguments=arguments, http_accept=http_accept) + response = ExampleFragmentView.as_view()(request) + assert response.status_code == expected_status_code + return response + + @ddt.data( + ('format=json', 'text/html'), + (None, 'application/web-fragment'), + ) + @ddt.unpack + def test_get_json(self, arguments, http_accept): + """ + Test that the view returns the correct JSON when requested. + """ + response = self.invoke_test_view(arguments=arguments, http_accept=http_accept) + fragment_json = json.loads(response.content.decode(response.charset)) + assert fragment_json['content'] == TEST_HTML + + @ddt.data( + ('format=html', 'text/html'), + (None, 'text/html'), + ) + @ddt.unpack + def test_get_html(self, arguments, http_accept): + """ + Test fragment getter when html is requested + """ + response = self.invoke_test_view(arguments=arguments, http_accept=http_accept) + assert TEST_HTML in response.content.decode(response.charset) + + def test_render_fragment_error(self): + """ + Verifies that render_fragment throws an unimplemented error on the base class. + """ + class MockFragmentView(FragmentView): + """ + Mock fragment view to verify the default render_fragment method + """ + def render_to_fragment(self, request, **kwargs): # pylint: disable=useless-super-delegation + super().render_to_fragment(request, **kwargs) + + view = MockFragmentView() + request = self.create_mock_request() + with pytest.raises(NotImplementedError): + view.render_to_fragment(request) + + def test_render_with_no_fragment(self): + """ + Verifies that a fragment view can render with no fragment. + """ + request = self.create_mock_request() + response = ExampleFragmentView().render_standalone_response(request, None) + assert response.status_code == 204 diff --git a/web_fragments/views.py b/web_fragments/views.py new file mode 100644 index 000000000..d60a438ea --- /dev/null +++ b/web_fragments/views.py @@ -0,0 +1,57 @@ +""" +Django view implementation of web fragments. +""" +from abc import ABCMeta, abstractmethod + +from django.http import HttpResponse, JsonResponse +from django.template.loader import get_template +from django.views.generic import View + +WEB_FRAGMENT_RESPONSE_TYPE = 'application/web-fragment' +STANDALONE_TEMPLATE_NAME = 'web_fragments/standalone_fragment.html' + + +class FragmentView(View, metaclass=ABCMeta): + """ + Base class for Django web fragment views. + """ + + def get(self, request, *args, **kwargs): + """ + Render a fragment to HTML or return JSON describing it, based on the request. + """ + fragment = self.render_to_fragment(request, **kwargs) + response_format = request.GET.get('format') or request.POST.get('format') or 'html' + if response_format == 'json' or WEB_FRAGMENT_RESPONSE_TYPE in request.headers.get('accept', 'text/html'): + return JsonResponse(fragment.to_dict()) + + return self.render_standalone_response(request, fragment, **kwargs) + + def render_standalone_response(self, request, fragment, **kwargs): + """ + Renders a standalone page as a response for the specified fragment. + """ + if fragment is None: + return HttpResponse(status=204) + + html = self.render_to_standalone_html(request, fragment, **kwargs) + return HttpResponse(html) + + def render_to_standalone_html(self, request, fragment, **kwargs): + """ + Render the specified fragment to HTML for a standalone page. + """ + template = get_template(STANDALONE_TEMPLATE_NAME) + context = { + 'head_html': fragment.head_html(), + 'body_html': fragment.body_html(), + 'foot_html': fragment.foot_html(), + } + return template.render(context) + + @abstractmethod + def render_to_fragment(self, request, **kwargs): + """ + Render this view to a fragment. + """ + raise NotImplementedError() diff --git a/xblock/test/settings.py b/xblock/test/settings.py index 9f349dd4d..cf0acd1ca 100644 --- a/xblock/test/settings.py +++ b/xblock/test/settings.py @@ -99,12 +99,9 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - - # Uncomment the next line to enable the admin: 'django.contrib.admin', - # Uncomment the next line to enable admin documentation: - # 'django.contrib.admindocs', + 'web_fragments', ) # A sample logging configuration. The only tangible logging @@ -139,6 +136,16 @@ } } +ROOT_URLCONF = 'web_fragments.examples.urls' + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + }, +] + WORKBENCH = { 'reset_state_on_restart': ( os.environ.get('WORKBENCH_RESET_STATE_ON_RESTART', "false").lower() == "true" diff --git a/xblock/test/test_core_capabilities.py b/xblock/test/test_core_capabilities.py index 0919a35ee..c822ec9da 100644 --- a/xblock/test/test_core_capabilities.py +++ b/xblock/test/test_core_capabilities.py @@ -27,13 +27,15 @@ class AttrAssertionMixin(TestCase): """ A mixin to add attribute assertion methods to TestCases. """ - def assertHasAttr(self, obj, attr): + def assertHasAttr(self, obj, name, msg=None): "Assert that `obj` has the attribute named `attr`." - self.assertTrue(hasattr(obj, attr), f"{obj!r} doesn't have attribute {attr!r}") + msg = msg or f"{obj!r} doesn't have attribute {name!r}" + self.assertTrue(hasattr(obj, name), msg) - def assertNotHasAttr(self, obj, attr): + def assertNotHasAttr(self, obj, name, msg=None): "Assert that `obj` doesn't have the attribute named `attr`." - self.assertFalse(hasattr(obj, attr), f"{obj!r} has attribute {attr!r}") + msg = msg or f"{obj!r} has attribute {name!r}" + self.assertFalse(hasattr(obj, name), msg) class TestScopedStorage(AttrAssertionMixin, TestCase): From 49abf4ad85bf269872c2e89a199d6ade985d7bf1 Mon Sep 17 00:00:00 2001 From: Fox Danger Piacenti Date: Tue, 2 Jun 2026 13:08:47 -0500 Subject: [PATCH 2/2] chore: bump version --- CHANGELOG.rst | 6 ++++++ xblock/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2b6a3b0f4..85ee6cd6a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,12 @@ Change history for XBlock Unreleased ---------- +6.2.0 - 2026-06-09 +------------------ + +* Migrated web_fragments into XBlock's project. You should remove web_fragments as a separate dependency if you + currently depend on it directly. + 5.3.0 - 2025-12-19 ------------------ diff --git a/xblock/__init__.py b/xblock/__init__.py index 1890416b1..444f981b4 100644 --- a/xblock/__init__.py +++ b/xblock/__init__.py @@ -2,4 +2,4 @@ XBlock Courseware Components """ -__version__ = '6.1.0' +__version__ = '6.2.0'