From b27e33b33ff33c5b27a3bd287d9234aac48fc1f0 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 22 Dec 2021 00:40:55 +0000 Subject: [PATCH 1/4] Add API endpoint for pattern rendering via POST --- pattern_library/monkey_utils.py | 5 ++--- pattern_library/urls.py | 5 ++++- pattern_library/utils.py | 10 ++++++++-- pattern_library/views.py | 19 ++++++++++++++++++- .../patterns/molecules/button/button.html | 4 ++++ .../patterns/molecules/button/button.yaml | 7 +++++++ tests/templatetags/test_tags.py | 7 +++++++ 7 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 tests/templates/patterns/molecules/button/button.html create mode 100644 tests/templates/patterns/molecules/button/button.yaml diff --git a/pattern_library/monkey_utils.py b/pattern_library/monkey_utils.py index 705fd4c1..f893a27a 100644 --- a/pattern_library/monkey_utils.py +++ b/pattern_library/monkey_utils.py @@ -3,7 +3,6 @@ from django.template.library import SimpleNode from pattern_library.utils import ( - get_pattern_config, is_pattern_library_context, render_pattern, ) @@ -33,7 +32,7 @@ def node_render(context): # Load pattern's config current_template_name = parser.origin.template_name - pattern_config = get_pattern_config(current_template_name) + tag_overrides = context.get("__pattern_library_tag_overrides", {}) # Extract values for lookup from the token bits = token.split_contents() @@ -41,7 +40,7 @@ def node_render(context): arguments = " ".join(bits[1:]).strip() # Get config for a specific tag - tag_config = pattern_config.get("tags", {}).get(tag_name, {}) + tag_config = tag_overrides.get(tag_name, {}) if tag_config: # Get config for specific arguments tag_config = tag_config.get(arguments, {}) diff --git a/pattern_library/urls.py b/pattern_library/urls.py index 1b9b51a3..b2504b72 100644 --- a/pattern_library/urls.py +++ b/pattern_library/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import re_path, path from pattern_library import get_pattern_template_suffix, views @@ -19,4 +19,7 @@ views.RenderPatternView.as_view(), name="render_pattern", ), + + # API rendering + path('api/v1/render-pattern', views.render_pattern_api, name='render_pattern_api'), ] diff --git a/pattern_library/utils.py b/pattern_library/utils.py index 4e2856a1..ce99c584 100644 --- a/pattern_library/utils.py +++ b/pattern_library/utils.py @@ -210,12 +210,18 @@ def get_pattern_markdown(template_name): return markdown.markdown(f.read()) -def render_pattern(request, template_name, allow_non_patterns=False): +def render_pattern(request, template_name, allow_non_patterns=False, config=None): if not allow_non_patterns and not is_pattern(template_name): raise TemplateIsNotPattern - context = get_pattern_context(template_name) + if not config: + config = get_pattern_config(template_name) + + context = config.get('context', {}) + tags = config.get('tags', {}) + mark_context_strings_safe(context) context[get_pattern_context_var_name()] = True + context['__pattern_library_tag_overrides'] = tags for modifier in registry.get_for_template(template_name): modifier(context=context, request=request) return render_to_string(template_name, request=request, context=context) diff --git a/pattern_library/views.py b/pattern_library/views.py index 7003e5b2..70e48c90 100644 --- a/pattern_library/views.py +++ b/pattern_library/views.py @@ -1,9 +1,12 @@ +import json + from django.http import Http404, HttpResponse from django.template.loader import get_template from django.utils.decorators import method_decorator from django.utils.html import escape from django.views.decorators.clickjacking import xframe_options_sameorigin -from django.views.generic.base import TemplateView +from django.views.decorators.csrf import csrf_exempt +from django.views.generic.base import TemplateView, View from pattern_library import get_base_template_names, get_pattern_base_template_name from pattern_library.exceptions import PatternLibraryEmpty, TemplateIsNotPattern @@ -99,3 +102,17 @@ def get(self, request, pattern_template_name=None): return self.render_to_response(context) return HttpResponse(rendered_pattern) + + +@csrf_exempt +def render_pattern_api(request): + data = json.loads(request.body.decode("utf-8")) + template_name = data["template_name"] + config = data["config"] + + try: + rendered_pattern = render_pattern(request, template_name, allow_non_patterns=False, config=config) + except TemplateIsNotPattern: + raise Http404 + + return HttpResponse(rendered_pattern) diff --git a/tests/templates/patterns/molecules/button/button.html b/tests/templates/patterns/molecules/button/button.html new file mode 100644 index 00000000..006451f3 --- /dev/null +++ b/tests/templates/patterns/molecules/button/button.html @@ -0,0 +1,4 @@ +{% load test_tags %} + + {% if label %}{{ label }}{% else %}{{ target_page.title }}{% endif %} + diff --git a/tests/templates/patterns/molecules/button/button.yaml b/tests/templates/patterns/molecules/button/button.yaml new file mode 100644 index 00000000..4546360f --- /dev/null +++ b/tests/templates/patterns/molecules/button/button.yaml @@ -0,0 +1,7 @@ +context: + target_page: + title: Get started +tags: + pageurl: + target_page: + raw: /get-started diff --git a/tests/templatetags/test_tags.py b/tests/templatetags/test_tags.py index 1326eb08..496935f6 100644 --- a/tests/templatetags/test_tags.py +++ b/tests/templatetags/test_tags.py @@ -27,6 +27,12 @@ def default_html_tag_falsey(arg=None): raise Exception("default_tag raised an exception") +@register.simple_tag() +def pageurl(page): + """Approximation of wagtail built-in tag for realistic example.""" + return "/page/url" + + # Get widget type of a field @register.filter(name="widget_type") def widget_type(bound_field): @@ -36,3 +42,4 @@ def widget_type(bound_field): override_tag(register, "error_tag") override_tag(register, "default_html_tag", default_html="https://potato.com") override_tag(register, "default_html_tag_falsey", default_html=None) +override_tag(register, "pageurl") From 95e70813c152830b051ffa3978de09fd69dda5b2 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Tue, 25 Jan 2022 10:14:33 +0000 Subject: [PATCH 2/4] Add missing docs and tests for API endpoint --- docs/recipes/api-rendering.md | 25 +++++++++++++++++++++++++ mkdocs.yml | 1 + pattern_library/monkey_utils.py | 5 ++--- tests/tests/test_views.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 docs/recipes/api-rendering.md diff --git a/docs/recipes/api-rendering.md b/docs/recipes/api-rendering.md new file mode 100644 index 00000000..9617f96e --- /dev/null +++ b/docs/recipes/api-rendering.md @@ -0,0 +1,25 @@ +# API rendering + +For additional flexibility, django-pattern-library supports rendering patterns via an API endpoint. +This can be useful when implementing a custom UI while still using the pattern library’s Django rendering features. + +The API endpoint is available at `api/v1/render-pattern`. It accepts POST requests with a JSON payload containing the following fields: + +- `template_name` – the path of the template to render +- `config` – the configuration for the template, with the same data structure as the configuration files (`context` and `tags`). + +Here is an example, with curl: + +```bash +echo '{"template_name": "patterns/molecules/button/button.html", "config": {"context": {"target_page": {"title": "API"}}, "tags": {"pageurl":{"target_page":{"raw": "/hello-api"}}}}}' | curl -d @- http://localhost:8000/api/v1/render-pattern +``` + +The response will be the pattern’s rendered HTML: + +```html + + API + +``` + +Note compared to iframe rendering, this API always renders the pattern’s HTML standalone, never within a base template. diff --git a/mkdocs.yml b/mkdocs.yml index b5b06784..381b9992 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - 'Inclusion tags': 'recipes/inclusion-tags.md' - 'Looping for tags': 'recipes/looping-for-tags.md' - 'Pagination': 'recipes/pagination.md' + - 'API rendering': 'recipes/api-rendering.md' - 'Reference': - 'API and settings': 'reference/api.md' - 'Concepts': 'reference/concepts.md' diff --git a/pattern_library/monkey_utils.py b/pattern_library/monkey_utils.py index f893a27a..b7c8f9b1 100644 --- a/pattern_library/monkey_utils.py +++ b/pattern_library/monkey_utils.py @@ -30,8 +30,7 @@ def node_render(context): tag_overridden = False result = "" - # Load pattern's config - current_template_name = parser.origin.template_name + # Get overriden tag config. tag_overrides = context.get("__pattern_library_tag_overrides", {}) # Extract values for lookup from the token @@ -87,7 +86,7 @@ def node_render(context): logger.warning( 'No default or stub data defined for the "%s" tag in the "%s" template', tag_name, - current_template_name, + parser.origin.template_name, ) return original_node_render(context) diff --git a/tests/tests/test_views.py b/tests/tests/test_views.py index abd5ddb4..92c3469d 100644 --- a/tests/tests/test_views.py +++ b/tests/tests/test_views.py @@ -116,3 +116,32 @@ def test_fragment_extended_from_variable(self): ), "base content - extended content", ) + + +class APIViewsTestCase(SimpleTestCase): + def test_renders_with_tag_overrides(self): + api_endpoint = reverse("pattern_library:render_pattern_api") + response = self.client.post( + api_endpoint, + content_type="application/json", + data={ + "template_name": "patterns/molecules/button/button.html", + "config": { + "context": {"target_page": {"title": "API"}}, + "tags": {"pageurl": {"target_page": {"raw": "/hello-api"}}}, + }, + }, + ) + self.assertContains(response, '/hello-api') + + def test_404(self): + api_endpoint = reverse("pattern_library:render_pattern_api") + response = self.client.post( + api_endpoint, + content_type="application/json", + data={ + "template_name": "doesnotexist.html", + "config": {}, + }, + ) + self.assertEqual(response.status_code, 404) From 28f6924f9532b938c97ad4a925e73ffca600f2f6 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Tue, 25 Jan 2022 10:21:44 +0000 Subject: [PATCH 3/4] Update existing test case relying on full context object --- tests/tests/test_context_modifiers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/tests/test_context_modifiers.py b/tests/tests/test_context_modifiers.py index 6a646567..12ddd9f5 100644 --- a/tests/tests/test_context_modifiers.py +++ b/tests/tests/test_context_modifiers.py @@ -125,6 +125,7 @@ def test_applied_by_render_pattern(self, render_to_string): context={ "atom_var": "atom_var value from test_atom.yaml", "is_pattern_library": True, + "__pattern_library_tag_overrides": {}, "foo": "bar", "beep": "boop", }, From 0b316e60cefd0a07f850332f697c725ee9388623 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Tue, 25 Jan 2022 13:31:46 +0000 Subject: [PATCH 4/4] Reformat code with black and isort --- pattern_library/monkey_utils.py | 5 +---- pattern_library/urls.py | 5 ++--- pattern_library/utils.py | 6 +++--- pattern_library/views.py | 6 ++++-- tests/tests/test_views.py | 2 +- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/pattern_library/monkey_utils.py b/pattern_library/monkey_utils.py index b7c8f9b1..d6ad07ba 100644 --- a/pattern_library/monkey_utils.py +++ b/pattern_library/monkey_utils.py @@ -2,10 +2,7 @@ from django.template.library import SimpleNode -from pattern_library.utils import ( - is_pattern_library_context, - render_pattern, -) +from pattern_library.utils import is_pattern_library_context, render_pattern logger = logging.getLogger(__name__) UNSPECIFIED = object() diff --git a/pattern_library/urls.py b/pattern_library/urls.py index b2504b72..4887dfe3 100644 --- a/pattern_library/urls.py +++ b/pattern_library/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path, path +from django.urls import path, re_path from pattern_library import get_pattern_template_suffix, views @@ -19,7 +19,6 @@ views.RenderPatternView.as_view(), name="render_pattern", ), - # API rendering - path('api/v1/render-pattern', views.render_pattern_api, name='render_pattern_api'), + path("api/v1/render-pattern", views.render_pattern_api, name="render_pattern_api"), ] diff --git a/pattern_library/utils.py b/pattern_library/utils.py index ce99c584..b1a81b8d 100644 --- a/pattern_library/utils.py +++ b/pattern_library/utils.py @@ -217,11 +217,11 @@ def render_pattern(request, template_name, allow_non_patterns=False, config=None if not config: config = get_pattern_config(template_name) - context = config.get('context', {}) - tags = config.get('tags', {}) + context = config.get("context", {}) + tags = config.get("tags", {}) mark_context_strings_safe(context) context[get_pattern_context_var_name()] = True - context['__pattern_library_tag_overrides'] = tags + context["__pattern_library_tag_overrides"] = tags for modifier in registry.get_for_template(template_name): modifier(context=context, request=request) return render_to_string(template_name, request=request, context=context) diff --git a/pattern_library/views.py b/pattern_library/views.py index 70e48c90..4e3ab01d 100644 --- a/pattern_library/views.py +++ b/pattern_library/views.py @@ -6,7 +6,7 @@ from django.utils.html import escape from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.csrf import csrf_exempt -from django.views.generic.base import TemplateView, View +from django.views.generic.base import TemplateView from pattern_library import get_base_template_names, get_pattern_base_template_name from pattern_library.exceptions import PatternLibraryEmpty, TemplateIsNotPattern @@ -111,7 +111,9 @@ def render_pattern_api(request): config = data["config"] try: - rendered_pattern = render_pattern(request, template_name, allow_non_patterns=False, config=config) + rendered_pattern = render_pattern( + request, template_name, allow_non_patterns=False, config=config + ) except TemplateIsNotPattern: raise Http404 diff --git a/tests/tests/test_views.py b/tests/tests/test_views.py index 92c3469d..e8f9d00b 100644 --- a/tests/tests/test_views.py +++ b/tests/tests/test_views.py @@ -132,7 +132,7 @@ def test_renders_with_tag_overrides(self): }, }, ) - self.assertContains(response, '/hello-api') + self.assertContains(response, "/hello-api") def test_404(self): api_endpoint = reverse("pattern_library:render_pattern_api")