Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve support for forms and other complex Python/Django constructs (v2) #141

Merged
merged 7 commits into from
May 26, 2021
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added support for 'context modifiers' - A way to modify template contexts with Python [#141](https://github.com/torchbox/django-pattern-library/pull/141)

## [0.4.0] - 2021-05-20

### Added
Expand Down
151 changes: 149 additions & 2 deletions docs/guides/defining-template-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Let's assume you have the following template:
{% endif %}
```

You might define a `yaml` file similar to this to provide fake data:
You might define a YAML file similar to this to provide fake data:

```yaml
name: My example pattern
Expand All @@ -56,4 +56,151 @@ context:
link: /page2
```

You can define a list or a dict or anything that [`PyYAML`](http://pyyaml.org/wiki/PyYAMLDocumentation) allows you to create in `yaml` format without creating a custom objects.
You can define a list or a dict or anything that [`PyYAML`](http://pyyaml.org/wiki/PyYAMLDocumentation) allows you to create in YAML format without creating a custom objects.


## Modifying template contexts with Python

While most objects can be faked with YAML, Django has a few common constructs that are difficult to replicate. For example: `Form` and `Paginator` instances. To help with this, django-pattern-library allows you to register any number of 'context modifiers'. Context modifiers are simply Python functions that accept the `context` dictionary generated from the YAML file, and can make additions or updates to it as necessary. For convenience, they also receive the current `HttpRequest` as `request`.

Context modifiers can easily be registered using the `register_context_modifier` decorator. Here is a simple example:

```python

# myproject/core/pattern_contexts.py

from pattern_library import register_context_modifier
from myproject.core.forms import SearchForm, SignupForm

@register_context_modifier
def add_common_forms(context, request):
if 'search_form' not in context:
context["search_form"] = SearchForm()
if 'signup_form' not in context:
context["signup_form"] = SignupForm()

```

Context modifiers are also great for reducing the amount of template tag patching that is needed. The following examples are from a Wagtail project:

```python

# myproject/core/pattern_contexts.py

from django.core.paginator import Paginator
from wagtail.images import get_image_model
from pattern_library import register_context_modifier


@register_context_modifier
def add_page_images(context, request):
"""
Replace some common 'image' field values on pages with real `Image`
instances, so that the {% image %} template tag will work.
"""
Image = get_image_model()
if "page" in context:
if "hero_image" in context["page"]:
context["hero_image"] = Image.objects.all().order("?").first()
if "main_image" in context["page"]:
context["main_image"] = Image.objects.all().order("?").first()


@register_context_modifier
def replicate_pagination(context, request):
"""
Replace lists of items using the 'page_obj.object_list' key
with a real Paginator page, and add a few other pagination-related
things to the context (like Django's `ListView` does).
"""
object_list = context.pop('page_obj.object_list', None)
if object_list is None:
return

original_length = len(object_list)

# add dummy items to force pagination
for i in range(50):
object_list.append(None)

# paginate and add ListView-like values
paginator = Paginator(object_list, original_length)
context.update(
paginator=paginator,
page_obj=paginator.page(1),
is_paginated=True,
object_list=object_list
)
```

### Registering a context modifier for a specific template

By default, context modifiers are applied to all pattern library templates. If you only wish for a context modifier to be applied to a specific pattern, you can use the ``template`` parameter to indicate this. For example:

```python

# myproject/accounts/pattern_contexts.py

from pattern_library import register_context_modifier
from my_app.accounts.forms import SubscribeForm


@register_context_modifier(template="patterns/subscribe/form.html")
def add_subscribe_form(context, request):
"""
Adds an unbount form to 'form.html'
"""
context["form"] = SubscribeForm()


@register_context_modifier(template="patterns/subscribe/form_invalid.html")
def add_invalid_subscribe_form(context, request):
"""
Adds a bound form with invalid data to 'form_invalid.html'
"""
context["form"] = SubscribeForm(data={
"email": 'invalid-email',
"name": ''
})
```

### Controlling the order in which context modifiers are applied

By default, context modifiers are applied in the order they were registered (which can be difficult to predict accross multiple apps), with generic context modifiers being applied first, followed by template-specific ones. If you need to control the order in which a series of context modifiers are applied, you can use the `order` parameter to do this.

In the following example, a generic context modifier is registered with an `order` value of `1`, while others recieve the default value of `0`. Because `1` is higher than `0`, the generic context modifier will be applied **after** the others.

```python

# myproject/sums/pattern_contexts.py


from pattern_library import register_context_modifier


@register_context_modifier(template='patterns/sums/single_number.html')
def add_single_number(context, request):
context['first_number'] = 933


@register_context_modifier(template='patterns/sums/two_numbers.html')
def add_two_numbers(context, request):
context['first_number'] = 125
context['second_number'] = 22


@register_context_modifier(template='patterns/sums/three_numbers.html')
def add_three_numbers(context, request):
context['first_number'] = 125
context['second_number'] = 22
context['third_number'] = 9


@register_context_modifier(order=1)
def add_total(context, request):
if 'total' not in context:
first_num = context.get('first_number', 0)
second_num = context.get('second_number', 0)
third_num = context.get('third_number', 0)
context['total'] = first_num + second_num + third_num
```
13 changes: 13 additions & 0 deletions pattern_library/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
from .context_modifiers import register_context_modifier

default_app_config = 'pattern_library.apps.PatternLibraryAppConfig'

__all__ = [
'DEFAULT_SETTINGS',
'get_setting',
'get_pattern_template_suffix',
'get_pattern_base_template_name',
'get_base_template_names',
'get_sections',
'get_pattern_context_var_name',
'register_context_modifier',
]

DEFAULT_SETTINGS = {
# PATTERN_BASE_TEMPLATE_NAME is the template that fragments will be wrapped with.
# It should include any required CSS and JS and output
Expand Down
16 changes: 16 additions & 0 deletions pattern_library/cm_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

import inspect
from typing import Callable


def accepts_kwarg(func: Callable, kwarg: str) -> bool:
"""
Returns a boolean indicating whether the callable ``func`` has
a signature that accepts the keyword argument ``kwarg``.
"""
signature = inspect.signature(func)
try:
signature.bind_partial(**{kwarg: None})
return True
except TypeError:
return False
58 changes: 58 additions & 0 deletions pattern_library/context_modifiers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from collections import defaultdict
from operator import attrgetter
from typing import Callable

from django.core.exceptions import ImproperlyConfigured

from .cm_utils import accepts_kwarg

GENERIC_CM_KEY = "__generic__"
ORDER_ATTR_NAME = "__cm_order"

__all__ = [
"ContextModifierRegistry",
"register_context_modifier"
]


class ContextModifierRegistry(defaultdict):
def __init__(self):
super().__init__(list)

def register(self, func: Callable, template: str = None, order: int = 0) -> None:
"""
Adds a context modifier to the registry.
"""
if not callable(func):
raise ImproperlyConfigured(
f"Context modifiers must be callables. {func} is a {type(func).__name__}."
)
if not accepts_kwarg(func, "context"):
raise ImproperlyConfigured(
f"Context modifiers must accept a 'context' keyword argument. {func} does not."
)
if not accepts_kwarg(func, "request"):
raise ImproperlyConfigured(
f"Context modifiers must accept a 'request' keyword argument. {func} does not."
)

key = template or GENERIC_CM_KEY
if func not in self[key]:
setattr(func, ORDER_ATTR_NAME, order)
self[key].append(func)
self[key].sort(key=attrgetter(ORDER_ATTR_NAME))

return func

def register_decorator(self, func: Callable = None, **kwargs):
if func is None:
return lambda func: self.register(func, **kwargs)
return self.register(func, **kwargs)

def get_for_template(self, template: str):
modifiers = self[GENERIC_CM_KEY] + self[template]
return sorted(modifiers, key=attrgetter(ORDER_ATTR_NAME))


registry = ContextModifierRegistry()
register_context_modifier = registry.register_decorator
3 changes: 3 additions & 0 deletions pattern_library/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pattern_library import (
get_pattern_context_var_name, get_pattern_template_suffix, get_sections
)
from pattern_library.context_modifiers import registry
from pattern_library.exceptions import TemplateIsNotPattern


Expand Down Expand Up @@ -202,6 +203,8 @@ def render_pattern(request, template_name, allow_non_patterns=False):

context = get_pattern_context(template_name)
context[get_pattern_context_var_name()] = True
for modifier in registry.get_for_template(template_name):
modifier(context=context, request=request)
return render_to_string(template_name, request=request, context=context)


Expand Down
Loading