Skip to content

Commit ebcc296

Browse files
author
Ben Dickinson
authored
Merge pull request #141 from ababic/feature/context-modifiers
Improve support for forms and other complex Python/Django constructs (v2)
2 parents 01431a9 + 480d60d commit ebcc296

File tree

7 files changed

+374
-2
lines changed

7 files changed

+374
-2
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- Added support for 'context modifiers' - A way to modify template contexts with Python [#141](https://github.com/torchbox/django-pattern-library/pull/141)
8+
59
## [0.4.0] - 2021-05-20
610

711
### Added

docs/guides/defining-template-context.md

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Let's assume you have the following template:
4040
{% endif %}
4141
```
4242

43-
You might define a `yaml` file similar to this to provide fake data:
43+
You might define a YAML file similar to this to provide fake data:
4444

4545
```yaml
4646
name: My example pattern
@@ -56,4 +56,151 @@ context:
5656
link: /page2
5757
```
5858
59-
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.
59+
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.
60+
61+
62+
## Modifying template contexts with Python
63+
64+
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`.
65+
66+
Context modifiers can easily be registered using the `register_context_modifier` decorator. Here is a simple example:
67+
68+
```python
69+
70+
# myproject/core/pattern_contexts.py
71+
72+
from pattern_library import register_context_modifier
73+
from myproject.core.forms import SearchForm, SignupForm
74+
75+
@register_context_modifier
76+
def add_common_forms(context, request):
77+
if 'search_form' not in context:
78+
context["search_form"] = SearchForm()
79+
if 'signup_form' not in context:
80+
context["signup_form"] = SignupForm()
81+
82+
```
83+
84+
Context modifiers are also great for reducing the amount of template tag patching that is needed. The following examples are from a Wagtail project:
85+
86+
```python
87+
88+
# myproject/core/pattern_contexts.py
89+
90+
from django.core.paginator import Paginator
91+
from wagtail.images import get_image_model
92+
from pattern_library import register_context_modifier
93+
94+
95+
@register_context_modifier
96+
def add_page_images(context, request):
97+
"""
98+
Replace some common 'image' field values on pages with real `Image`
99+
instances, so that the {% image %} template tag will work.
100+
"""
101+
Image = get_image_model()
102+
if "page" in context:
103+
if "hero_image" in context["page"]:
104+
context["hero_image"] = Image.objects.all().order("?").first()
105+
if "main_image" in context["page"]:
106+
context["main_image"] = Image.objects.all().order("?").first()
107+
108+
109+
@register_context_modifier
110+
def replicate_pagination(context, request):
111+
"""
112+
Replace lists of items using the 'page_obj.object_list' key
113+
with a real Paginator page, and add a few other pagination-related
114+
things to the context (like Django's `ListView` does).
115+
"""
116+
object_list = context.pop('page_obj.object_list', None)
117+
if object_list is None:
118+
return
119+
120+
original_length = len(object_list)
121+
122+
# add dummy items to force pagination
123+
for i in range(50):
124+
object_list.append(None)
125+
126+
# paginate and add ListView-like values
127+
paginator = Paginator(object_list, original_length)
128+
context.update(
129+
paginator=paginator,
130+
page_obj=paginator.page(1),
131+
is_paginated=True,
132+
object_list=object_list
133+
)
134+
```
135+
136+
### Registering a context modifier for a specific template
137+
138+
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:
139+
140+
```python
141+
142+
# myproject/accounts/pattern_contexts.py
143+
144+
from pattern_library import register_context_modifier
145+
from my_app.accounts.forms import SubscribeForm
146+
147+
148+
@register_context_modifier(template="patterns/subscribe/form.html")
149+
def add_subscribe_form(context, request):
150+
"""
151+
Adds an unbount form to 'form.html'
152+
"""
153+
context["form"] = SubscribeForm()
154+
155+
156+
@register_context_modifier(template="patterns/subscribe/form_invalid.html")
157+
def add_invalid_subscribe_form(context, request):
158+
"""
159+
Adds a bound form with invalid data to 'form_invalid.html'
160+
"""
161+
context["form"] = SubscribeForm(data={
162+
"email": 'invalid-email',
163+
"name": ''
164+
})
165+
```
166+
167+
### Controlling the order in which context modifiers are applied
168+
169+
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.
170+
171+
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.
172+
173+
```python
174+
175+
# myproject/sums/pattern_contexts.py
176+
177+
178+
from pattern_library import register_context_modifier
179+
180+
181+
@register_context_modifier(template='patterns/sums/single_number.html')
182+
def add_single_number(context, request):
183+
context['first_number'] = 933
184+
185+
186+
@register_context_modifier(template='patterns/sums/two_numbers.html')
187+
def add_two_numbers(context, request):
188+
context['first_number'] = 125
189+
context['second_number'] = 22
190+
191+
192+
@register_context_modifier(template='patterns/sums/three_numbers.html')
193+
def add_three_numbers(context, request):
194+
context['first_number'] = 125
195+
context['second_number'] = 22
196+
context['third_number'] = 9
197+
198+
199+
@register_context_modifier(order=1)
200+
def add_total(context, request):
201+
if 'total' not in context:
202+
first_num = context.get('first_number', 0)
203+
second_num = context.get('second_number', 0)
204+
third_num = context.get('third_number', 0)
205+
context['total'] = first_num + second_num + third_num
206+
```

pattern_library/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1+
from .context_modifiers import register_context_modifier
2+
13
default_app_config = 'pattern_library.apps.PatternLibraryAppConfig'
24

5+
__all__ = [
6+
'DEFAULT_SETTINGS',
7+
'get_setting',
8+
'get_pattern_template_suffix',
9+
'get_pattern_base_template_name',
10+
'get_base_template_names',
11+
'get_sections',
12+
'get_pattern_context_var_name',
13+
'register_context_modifier',
14+
]
15+
316
DEFAULT_SETTINGS = {
417
# PATTERN_BASE_TEMPLATE_NAME is the template that fragments will be wrapped with.
518
# It should include any required CSS and JS and output

pattern_library/cm_utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
import inspect
3+
from typing import Callable
4+
5+
6+
def accepts_kwarg(func: Callable, kwarg: str) -> bool:
7+
"""
8+
Returns a boolean indicating whether the callable ``func`` has
9+
a signature that accepts the keyword argument ``kwarg``.
10+
"""
11+
signature = inspect.signature(func)
12+
try:
13+
signature.bind_partial(**{kwarg: None})
14+
return True
15+
except TypeError:
16+
return False

pattern_library/context_modifiers.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from collections import defaultdict
2+
from operator import attrgetter
3+
from typing import Callable
4+
5+
from django.core.exceptions import ImproperlyConfigured
6+
7+
from .cm_utils import accepts_kwarg
8+
9+
GENERIC_CM_KEY = "__generic__"
10+
ORDER_ATTR_NAME = "__cm_order"
11+
12+
__all__ = [
13+
"ContextModifierRegistry",
14+
"register_context_modifier"
15+
]
16+
17+
18+
class ContextModifierRegistry(defaultdict):
19+
def __init__(self):
20+
super().__init__(list)
21+
22+
def register(self, func: Callable, template: str = None, order: int = 0) -> None:
23+
"""
24+
Adds a context modifier to the registry.
25+
"""
26+
if not callable(func):
27+
raise ImproperlyConfigured(
28+
f"Context modifiers must be callables. {func} is a {type(func).__name__}."
29+
)
30+
if not accepts_kwarg(func, "context"):
31+
raise ImproperlyConfigured(
32+
f"Context modifiers must accept a 'context' keyword argument. {func} does not."
33+
)
34+
if not accepts_kwarg(func, "request"):
35+
raise ImproperlyConfigured(
36+
f"Context modifiers must accept a 'request' keyword argument. {func} does not."
37+
)
38+
39+
key = template or GENERIC_CM_KEY
40+
if func not in self[key]:
41+
setattr(func, ORDER_ATTR_NAME, order)
42+
self[key].append(func)
43+
self[key].sort(key=attrgetter(ORDER_ATTR_NAME))
44+
45+
return func
46+
47+
def register_decorator(self, func: Callable = None, **kwargs):
48+
if func is None:
49+
return lambda func: self.register(func, **kwargs)
50+
return self.register(func, **kwargs)
51+
52+
def get_for_template(self, template: str):
53+
modifiers = self[GENERIC_CM_KEY] + self[template]
54+
return sorted(modifiers, key=attrgetter(ORDER_ATTR_NAME))
55+
56+
57+
registry = ContextModifierRegistry()
58+
register_context_modifier = registry.register_decorator

pattern_library/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pattern_library import (
1717
get_pattern_context_var_name, get_pattern_template_suffix, get_sections
1818
)
19+
from pattern_library.context_modifiers import registry
1920
from pattern_library.exceptions import TemplateIsNotPattern
2021

2122

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

203204
context = get_pattern_context(template_name)
204205
context[get_pattern_context_var_name()] = True
206+
for modifier in registry.get_for_template(template_name):
207+
modifier(context=context, request=request)
205208
return render_to_string(template_name, request=request, context=context)
206209

207210

0 commit comments

Comments
 (0)