diff --git a/pyproject.toml b/pyproject.toml index e307b7271..ce5f15d9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dependencies = [ "pytz", "pyyaml", "simplejson", - "web-fragments", "webob>=1.6.0", ] @@ -52,8 +51,10 @@ local_scheme = "no-local-version" [tool.setuptools] include-package-data = true - -[tool.setuptools.packages.find] +packages = [ + "xblock", + "web_fragments", +] [tool.setuptools.package-data] "xblock.utils" = ["public/*", "templates/*", "templatetags/*"] diff --git a/uv.lock b/uv.lock index b22312a44..75100dec6 100644 --- a/uv.lock +++ b/uv.lock @@ -654,6 +654,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, @@ -671,6 +672,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, @@ -688,6 +690,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, @@ -705,6 +708,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, ] [[package]] @@ -1525,15 +1529,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] -[[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.9" @@ -1559,7 +1554,6 @@ dependencies = [ { name = "pytz" }, { name = "pyyaml" }, { name = "simplejson" }, - { name = "web-fragments" }, { name = "webob" }, ] @@ -1687,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..1e7c0719a --- /dev/null +++ b/web_fragments/__init__.py @@ -0,0 +1,4 @@ +""" +Web fragments. +""" +__version__ = '4.1.0' 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..f815ac5b5 --- /dev/null +++ b/web_fragments/fragment.py @@ -0,0 +1,268 @@ +""" +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`s 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()