-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsteps.py
More file actions
266 lines (198 loc) · 7.89 KB
/
Copy pathsteps.py
File metadata and controls
266 lines (198 loc) · 7.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
from functools import wraps
from collections import namedtuple
from typing import List, Any
from django.conf.urls import url
from django.template.loader import render_to_string
from django.shortcuts import render
class Steps:
'''
The `Steps` class makes it easier to consolidate the view logic of
multi-step workflows.
The `Steps` constructor takes a format string representing what
the template path for each step looks like:
>>> steps = Steps('data_capture/tests/my_step_{}.html')
The `@step` view decorator can be used to "register" steps in the
workflow. Each step's function name is expected to end with
a number, and the steps are expected to be defined in
ascending order, e.g.:
>>> @steps.step
... def step_1(request, step): pass
>>> @steps.step
... def step_2(request, step): pass
(What's that extra `step` argument? We'll get to that in a moment!)
Breaking this ordering, e.g. by skipping numbers, is not allowed:
>>> @steps.step
... def step_5(request, step): pass
Traceback (most recent call last):
...
ValueError: Expected "step_5" to end with the number 3
At this point, our example workflow has two steps:
>>> steps.num_steps
2
URL patterns for our steps are automatically defined, too, and can
be included via Django's standard `include()` function. The names
of each view will be identical to their view function's name:
>>> steps.urls
[<RegexURLPattern step_1 ^1$>, <RegexURLPattern step_2 ^2$>]
Now, what's that `step` argument passed into each view function?
It's a `StepRenderer` instance bound to the particular step that the
view represents, and it provides some tools that make it easy to
render steps.
`StepRenderer` instances can also be retrieved manually if needed:
>>> step1 = steps.get_step_renderer(1)
A `StepRenderer` instance can be used to easily access
commonly-used view logic, e.g.:
>>> step1.number
1
>>> step1.steps.num_steps
2
>>> step1.description
'step 1 of 2'
Tools for templates and their contexts are also available.
The `context()` function builds a context dictionary that always
contains a `step` key referring to the `StepRenderer` instance for
that step:
>>> ctx = step1.context({'foo': 'bar'})
>>> ctx['foo']
'bar'
>>> ctx['step']
<StepRenderer for step 1 of 2>
The `template_name` property always refers to the template for the step:
>>> step1.template_name
'data_capture/tests/my_step_1.html'
And `render`/`render_to_string` ties everything together:
>>> step1.render_to_string({'foo': 'bar'})
'Hello from step 1 of 2! foo is bar.'
Optionally, labels can be assigned to steps, which makes it
easy to create widgets that indicate the current step to
end-users:
>>> steps = Steps('step_{}.html')
>>> @steps.step(label='foo')
... def step_1(request, step): pass
The widget is easily accessible from templates:
>>> ctx = steps.get_step_renderer(1).context()
>>> ctx['step'].widget
<StepsWidget for step 1 of 1: foo>
The widget is a callable that returns HTML, so you can use it
in templates with something like {{ step.widget }}.
'''
def __init__(self, template_format, extra_ctx_vars=None,
extra_ctx_processors=None):
self.extra_ctx_vars = extra_ctx_vars or {}
self.extra_ctx_processors = extra_ctx_processors or []
self.template_format = template_format
self._views: List[Any] = []
def _build_step_view(self, func, label=None):
step_number = self.num_steps + 1
if not func.__name__.endswith(str(step_number)):
raise ValueError('Expected "{}" to end with the number {}'.format(
func.__name__,
step_number
))
@wraps(func)
def wrapper(*args, **kwargs):
kwargs['step'] = self.get_step_renderer(step_number)
return func(*args, **kwargs)
# We're ignoring the type checking here due to:
# https://github.com/python/mypy/issues/2087
wrapper.label = label # type: ignore
self._views.append(wrapper)
return wrapper
def step(self, func=None, **kwargs):
if func is None:
# We were called in the form `@step()` w/ possible kwargs.
return lambda f: self._build_step_view(f, **kwargs)
# We were called in the form `@step`, w/o kwargs.
return self._build_step_view(func)
def get_step_renderer(self, step_number):
return StepRenderer(self, step_number)
@property
def labels(self):
return [view.label for view in self._views]
@property
def urls(self):
urlpatterns = []
for i in range(self.num_steps):
view = self._views[i]
regex = r'^' + str(i + 1) + r'$'
urlpatterns.append(url(regex, view, name=view.__name__))
return urlpatterns
@property
def num_steps(self):
return len(self._views)
class StepRenderer:
'''
An object representing a particular step of a multi-step
workflow, used to easily access commonly-used view logic.
It's created automatically by `Steps`--you'll never
need to manually call the constructor yourself.
'''
def __init__(self, steps, step_number):
self.steps = steps
self.number = step_number
def context(self, context=None, request=None):
final_ctx = {'step': self}
final_ctx.update(self.steps.extra_ctx_vars)
if request is not None:
for processor in self.steps.extra_ctx_processors:
final_ctx.update(processor(request))
if context:
final_ctx.update(context)
return final_ctx
@property
def widget(self):
return StepsWidget(labels=self.steps.labels, current=self.number)
@property
def template_name(self):
return self.steps.template_format.format(self.number)
def render(self, request, context=None):
return render(request, self.template_name,
self.context(context, request))
def render_to_string(self, context=None):
return render_to_string(self.template_name, self.context(context))
@property
def description(self):
return "step {} of {}".format(
self.number,
self.steps.num_steps
)
def __repr__(self):
return '<{} for {}>'.format(
self.__class__.__name__,
self.description
)
class StepsWidget:
'''
The steps widget can be used to visualize the user's progress through a
multi-step process.
While it is described as a "widget", it is not actually a widget
in the Django sense--i.e., it is not used to present users with
a form UI.
'''
_Step = namedtuple('Step', ['label', 'number', 'current'])
def __init__(self, labels, current):
self.steps = [
self._Step(label=label, number=i, current=(i == current))
for label, i in zip(labels, range(1, len(labels) + 1))
]
self.current_step = self.steps[current - 1]
for step in self.steps:
if not step.label:
raise ValueError('Step {} has no label'.format(
step.number
))
def render(self):
return render_to_string('frontend/steps.html', self.__dict__)
def __call__(self):
'''
A convenience for rendering the widget directly from a Django
template.
'''
return self.render()
def __repr__(self):
return '<{} for step {} of {}: {}>'.format(
self.__class__.__name__,
self.current_step.number,
len(self.steps),
self.current_step.label
)