forked from twitter/opensource-website
-
Notifications
You must be signed in to change notification settings - Fork 1
/
change-request-workflow.html
384 lines (331 loc) · 22.5 KB
/
change-request-workflow.html
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
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<title>Change Request Workflow [ brack3t ]</title>
<meta name="description" content="">
<meta name="author" content="Brack3t, aka Kenneth Love and Chris Jones">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/ico" href="./brack3t-theme/assets/favicon.ico">
<link href="./feeds/all.atom.xml" type="application/atom+xml" rel="alternate" title="brack3t ATOM Feed">
<!-- Le HTML5 shim, for IE6-8 support of HTML elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
<!-- Le styles -->
<link href="http://fonts.googleapis.com/css?family=Exo:200,300,500,700,900,200italic,300italic,500italic,700italic,900italic" rel="stylesheet">
<link href="./brack3t-theme/assets/bootstrap/css/bootstrap.css" rel="stylesheet">
<link href="./brack3t-theme/assets/github.css" rel="stylesheet">
<link href="./brack3t-theme/assets/bootstrap/css/brack3t.css" rel="stylesheet">
<script>
var _gaq = _gaq || [];
_gaq.push(["_setAccount", "UA-4642386-4"]);
_gaq.push(["_trackPageview"]);
(function() {
var ga = document.createElement("script"); ga.type = "text/javascript"; ga.async = true;
ga.src = ("https:" == document.location.protocol ? "https://ssl" : "http://www") + ".google-analytics.com/ga.js";
var s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ga, s);
})();
</script>
<script type="text/javascript">
var disqus_identifier = "change-request-workflow.html";
(function() {
var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true;
dsq.src = 'http://brack3t.disqus.com/embed.js';
(document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);
})();
</script>
</head>
<body>
<div class="container">
<div class="row-fluid">
<div class="span8">
<header id="logo" role="banner">
<h1><a href="/">Brack3t</a></h1>
<p>Two guys… and Python.</p>
</header>
</div>
<aside class="span2" id="sidebar" role="complementary">
<nav>
<ul class="unstyled">
<li><a href="./pages/projects.html">Projects</a></li>
<li><a href="./archives.html">Archives</a></li>
<li><a href="./tags.html">Tags</a></li>
</ul>
</nav>
</aside>
</div>
<div class="row-fluid">
<div class="span7 offset1" id="main" role="main">
<article>
<header>
<h1><a href="./change-request-workflow.html" class="slabtext">Change Request Workflow</a></h1>
<h6><span class="permalink">Published: <a href="./change-request-workflow.html">02-26-2012</a></span>
<span class="author">by <strong>Kenneth</strong></span>
<span class="tags">tags: <a href="./tag/django.html">django</a> <a href="./tag/workflow.html">workflow</a> <a href="./tag/python.html">python</a> </span>
</h6>
</header>
<p>Before we start, let me explain a bit about what the app we're covering here is. It's a geo-spatial database, basically, of
Points of Interest (POIs) for housing communities that we developed for a client of ours (or, rather, are still developing).
Users and editors can both enter Points into the database, which is PostgreSQL with PostGIS, and then they can be associated
with any community. Obviosuly, though, that leads to the problem of Community A editing a POI and Community B showing that data
without their knowledge, so we'd like to have an editor look at the changes first. That's the need that lead to our workflow.</p>
<div class="section" id="models">
<h2>Models</h2>
<p>First, let's start with the models.</p>
<div class="highlight"><pre><span class="x">class POIAbstract(LumberjackModel, models.Model):</span>
<span class="x"> category = models.ForeignKey(Category, related_name="%(class)s_points")</span>
<span class="x"> name = models.CharField(max_length=255)</span>
<span class="x"> address = models.CharField(max_length=255)</span>
<span class="x"> address2 = models.CharField(max_length=255, blank=True)</span>
<span class="x"> city = models.CharField(max_length=100)</span>
<span class="x"> state = USPostalCodeField()</span>
<span class="x"> zip_code = models.CharField(max_length=10)</span>
<span class="x"> phone = PhoneNumberField(blank=True, default="")</span>
<span class="x"> url = models.URLField(blank=True, default="")</span>
<span class="x"> point = models.PointField(blank=True, null=True, editable=False)</span>
<span class="x"> objects = models.GeoManager()</span>
<span class="x"> class Meta:</span>
<span class="x"> abstract = True</span>
<span class="x"> def __unicode__(self):</span>
<span class="x"> return self.name</span>
<span class="x"> @property</span>
<span class="x"> def coords(self):</span>
<span class="x"> """</span>
<span class="x"> Return tuple of lat,lng</span>
<span class="x"> """</span>
<span class="x"> if self.point:</span>
<span class="x"> return (self.point.get_coords()[1], self.point.get_coords()[0])</span>
<span class="x"> return (None, None)</span>
<span class="x"> @property</span>
<span class="x"> def full_address(self):</span>
<span class="x"> """</span>
<span class="x"> Return a string of the full address</span>
<span class="x"> """</span>
<span class="x"> addresses = [self.address, self.address2, self.city, self.state,</span>
<span class="x"> self.zip_code, "USA"]</span>
<span class="x"> return ", ".join(filter(lambda x: len(x) > 0, addresses))</span>
<span class="x">class POI(POIAbstract):</span>
<span class="x"> """</span>
<span class="x"> Points of Interest model.</span>
<span class="x"> """</span>
<span class="x"> pass</span>
<span class="x">class POIChange(POIAbstract):</span>
<span class="x"> """</span>
<span class="x"> Holds proposed changes to POIs</span>
<span class="x"> """</span>
<span class="x"> STATUS_CHOICES = (</span>
<span class="x"> (0, "Pending"),</span>
<span class="x"> (1, "Denied"),</span>
<span class="x"> (2, "Approved")</span>
<span class="x"> )</span>
<span class="x"> poi = models.ForeignKey(POI, related_name="changes")</span>
<span class="x"> user = models.ForeignKey(User, related_name="poi_changes")</span>
<span class="x"> submitted_on = models.DateField(auto_now_add=True, editable=False)</span>
<span class="x"> approved_by = models.ForeignKey(User, related_name="poi_approvals",</span>
<span class="x"> blank=True, null=True, editable=False)</span>
<span class="x"> approved_on = models.DateField(blank=True, null=True, editable=False)</span>
<span class="x"> status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES,</span>
<span class="x"> default=0, editable=False)</span>
<span class="x"> class Meta:</span>
<span class="x"> ordering = ["status", "-submitted_on"]</span>
</pre></div>
<p>As you can see, there's not really anything too interesting about the models. We have an abstract model that we inherit both of our other models from. The approved record model is just the abstract model without it's
<tt class="docutils literal">abstract = True</tt> setting. The change model, though, adds a few fields.</p>
<p>First we point to the record we're changing. Then we hold on to the user that submitted the changes, and the time of the request. We also want to have a record of who approved/denied it and when. And, of course, we
need to know what the status of the change is. That'll let us change our minds later on.</p>
</div>
<div class="section" id="forms">
<h2>Forms</h2>
<p>We usually end up building forms after we build models (more on this when we finish <a class="reference external" href="http://gettingstartedwithdjango.com">GSWD</a>), so let's look at them next.</p>
<div class="highlight"><pre><span class="x">class POIForm(forms.ModelForm):</span>
<span class="x"> latitude = forms.FloatField(required=False,</span>
<span class="x"> widget=forms.HiddenInput())</span>
<span class="x"> longitude = forms.FloatField(required=False,</span>
<span class="x"> widget=forms.HiddenInput())</span>
<span class="x"> class Meta:</span>
<span class="x"> model = POI</span>
<span class="x">class POIChangeForm(forms.ModelForm):</span>
<span class="x"> latitude = forms.FloatField(required=False,</span>
<span class="x"> widget=forms.HiddenInput())</span>
<span class="x"> longitude = forms.FloatField(required=False,</span>
<span class="x"> widget=forms.HiddenInput())</span>
<span class="x"> class Meta:</span>
<span class="x"> model = POIChange</span>
<span class="x"> widgets = {</span>
<span class="x"> "poi": forms.HiddenInput(),</span>
<span class="x"> "user": forms.HiddenInput()</span>
<span class="x"> }</span>
</pre></div>
<p>I've left out some of the boilerplate and <tt class="docutils literal">Layout</tt> bits from <a class="reference external" href="https://github.com/pydanny/django-uni-form">django-uni-form</a> (we haven't upgraded to <a class="reference external" href="https://github.com/maraujop/django-crispy-forms">django-crispy-forms</a> yet) but you get the general idea. Honestly, we could have made the second form inherit from
the first and saved a bit of typing/space, but I guess we missed that. Both forms, ultimately, show the same thing. The latter form, though, holds onto a few extra fields that we need and that we'll set in the view.</p>
<p>Speaking of views, let's check them out.</p>
</div>
<div class="section" id="views">
<h2>Views</h2>
<p>We're not going to look at the view that creates the original POI. It's just a standard
<tt class="docutils literal">CreateView</tt> that specifies our <tt class="docutils literal">POIForm</tt> as the <tt class="docutils literal">form_class</tt>. We have a couple of handy
mixins on the views that let us control permissions and redirects, but we'll talk about them in
another blog post.</p>
<p>The view we <em>do</em> want to look at is our <tt class="docutils literal">POIUpdateView</tt> which is the one that let's users submit
changes for a particular POI. Now, this view is the one that's linked to for each record on the
list page; we never link to a view where a user can directly update a POI, not even for
editors/superusers. So, here's our <tt class="docutils literal">POIUpdateView</tt>:</p>
<blockquote>
<span class="label label-info">note</span> We use a few mixins below that aren't part of the standard Django library: <tt class="docutils literal">LoginRequiredMixin</tt>, <tt class="docutils literal">PermissionRequiredMixin</tt>, <tt class="docutils literal">SuccessURLRedirectListMixin</tt>, and <tt class="docutils literal">SetHeadlineMixin</tt>.</blockquote>
<div class="highlight"><pre><span class="x">class POIUpdateView(LoginRequiredMixin, PermissionRequiredMixin,</span>
<span class="x"> SuccessURLRedirectListMixin, SetHeadlineMixin, CreateView):</span>
<span class="x"> """</span>
<span class="x"> View allows users to propose changes to current POIs.</span>
<span class="x"> """</span>
<span class="x"> form_class = POIChangeForm</span>
<span class="x"> headline = "Edit point of interest"</span>
<span class="x"> model = POIChange</span>
<span class="x"> permission_required = "points.change_poi"</span>
<span class="x"> success_list_url = "cms_points_list"</span>
<span class="x"> template_name = "cms/points/poi_form_edit.html"</span>
<span class="x"> def get_initial(self):</span>
<span class="x"> """</span>
<span class="x"> Do you believe in magic, in a young devs heart?</span>
<span class="x"> How the code can free 'em whenever it starts,</span>
<span class="x"> and it's magic, if the code is groovy.</span>
<span class="x"> Use POI information for initial data in POIChangeForm.</span>
<span class="x"> """</span>
<span class="x"> poi = POI.objects.get(pk=self.kwargs["pk"])</span>
<span class="x"> initial = poi.__dict__.copy()</span>
<span class="x"> del initial["_state"]</span>
<span class="x"> initial.update({</span>
<span class="x"> "category": poi.category,</span>
<span class="x"> "latitude": poi.point.get_coords()[1],</span>
<span class="x"> "longitude": poi.point.get_coords()[0],</span>
<span class="x"> "user": self.request.user,</span>
<span class="x"> "poi": poi</span>
<span class="x"> })</span>
<span class="x"> return initial</span>
<span class="x"> def post(self, request, pk, *args, **kwargs):</span>
<span class="x"> response = super(POIUpdateView, self).post(request, pk, *args, **kwargs)</span>
<span class="x"> url = settings.CMS_URL + reverse("cms_points_change_detail",</span>
<span class="x"> kwargs={"pk": self.object.pk})</span>
<span class="x"> message = render_to_string("cms/points/email/admin_email.html", {"user":</span>
<span class="x"> self.object.user.get_full_name(), "url": url})</span>
<span class="x"> mail_admins("POI Change Request", message)</span>
<span class="x"> return response</span>
</pre></div>
<p>I think how this view works is pretty cool. It's a fairly standard <tt class="docutils literal">CreateView</tt> that points to
our <tt class="docutils literal">POIChange</tt> model. We don't just start with a blank <tt class="docutils literal">POIChange</tt>, though. By overriding
<tt class="docutils literal">get_initial</tt> to load the <tt class="docutils literal">POI</tt> with the <tt class="docutils literal">PK</tt> that comes through in the URL, we can set the
beginning data of the record. We fetch the instance, update our initial data with its values, and
then pass it on through to the form.</p>
<p>Once the form is valid, a method I don't show above, called <tt class="docutils literal">form_valid</tt>, is fired by Django as part of its form-based generic view workflow and then we log the change in our logger, send a message to the user
through Django's <tt class="docutils literal">messages</tt> app, and then our <tt class="docutils literal">post</tt> method gets called. Learning the workflow order of <tt class="docutils literal">CreateView</tt> and <tt class="docutils literal">UpdateView</tt> (and, ultimately, <tt class="docutils literal">FormView</tt>) will save you a huge amount of time when
you start customizing these things. In our <tt class="docutils literal">post</tt> method, we render out an email to the admins and then return our response, which, thanks to our <tt class="docutils literal">SuccessURLRedirectListMixin</tt> will redirect the user to the route
named in <tt class="docutils literal">success_list_url</tt>.</p>
<p>Now, all we've really done is create a new record. It still has to be approved. We do that in our
next view, <tt class="docutils literal">POIChangeApprovalView</tt>, which the editor/superuser gets to through another list
view. They can also reach it by clicking the link provided to them in the email.</p>
<div class="highlight"><pre><span class="x">class POIChangeApprovalView(LoginRequiredMixin, SuperuserRequiredMixin,</span>
<span class="x"> DetailView):</span>
<span class="x"> model = POIChange</span>
<span class="x"> template_name = "cms/points/poi_change_detail.html"</span>
<span class="x"> def post(self, request, pk):</span>
<span class="x"> approval = request.POST.get("approval", None)</span>
<span class="x"> if approval:</span>
<span class="x"> if approval == "approve":</span>
<span class="x"> self._approved()</span>
<span class="x"> else:</span>
<span class="x"> self._denied()</span>
<span class="x"> return HttpResponseRedirect(reverse("cms_points_change_list"))</span>
<span class="x"> return HttpResponseForbidden()</span>
<span class="x"> def _approved(self):</span>
<span class="x"> """</span>
<span class="x"> It's approved!</span>
<span class="x"> """</span>
<span class="x"> poi = self.get_object()</span>
<span class="x"> data = poi.__dict__.copy()</span>
<span class="x"> del data["_state"]</span>
<span class="x"> data.update({</span>
<span class="x"> "category": poi.category.pk,</span>
<span class="x"> "latitude": poi.coords[0],</span>
<span class="x"> "longitude": poi.coords[1],</span>
<span class="x"> })</span>
<span class="x"> form = POIForm(data, instance=poi.poi)</span>
<span class="x"> if form.is_valid():</span>
<span class="x"> form.save()</span>
<span class="x"> poi.status = 2</span>
<span class="x"> poi.approved_by = self.request.user</span>
<span class="x"> poi.approved_on = date.today()</span>
<span class="x"> poi.save()</span>
<span class="x"> messages.success(self.request, "Point of interest updated.")</span>
<span class="x"> if poi.user.email:</span>
<span class="x"> message = render_to_string("cms/points/email/approved.html",</span>
<span class="x"> {"poi_name": poi.name})</span>
<span class="x"> send_mail("OUR CLIENT - Change Request Approved",</span>
<span class="x"> message, settings.EMAIL_HOST_USER, [poi.user.email])</span>
<span class="x"> def _denied(self):</span>
<span class="x"> """</span>
<span class="x"> No way Jose</span>
<span class="x"> """</span>
<span class="x"> poi = self.get_object()</span>
<span class="x"> poi.status = 1</span>
<span class="x"> poi.approved_by = self.request.user</span>
<span class="x"> poi.approved_on = date.today()</span>
<span class="x"> poi.save()</span>
<span class="x"> messages.success(self.request,</span>
<span class="x"> "Point of interest '%s' has not been updated." % poi.poi.name)</span>
<span class="x"> if poi.user.email:</span>
<span class="x"> message = render_to_string("cms/points/email/denied.html",</span>
<span class="x"> {"poi_name": poi.name})</span>
<span class="x"> send_mail("OUR CLIENT - Change Request Denied",</span>
<span class="x"> message, settings.EMAIL_HOST_USER, [poi.user.email])</span>
</pre></div>
<p>This view is really straightfoward. The editor clicks one of two buttons, both of which point to
this view. One contains a <tt class="docutils literal">POST</tt> variable indicating approval, the other indicating that the
change has been denied. Then, based on the value, we peform the same action on the <tt class="docutils literal">POIChange</tt>.</p>
<p>If the change was denied, we just set the status on the change to our denied flag, set the date
and user, and then save it.</p>
<p>If it was approved, we create an instance of the <tt class="docutils literal">POIForm</tt> with the changed <tt class="docutils literal">POI</tt> as the
edited instance and our <tt class="docutils literal">POIChange</tt>'s <tt class="docutils literal">__dict__</tt> as the new data. Since they're copies of each
other, aside from the changes in the change model, of course, only the changed data really gets
updated. We make sure the form is still valid (some <a class="reference external" href="http://geodjango.org">GeoDjango</a> stuff I left out of the form above)
and then save the updated instance. We also update the <tt class="docutils literal">POIChange</tt> so it holds the new status,
the approving user and date.</p>
<p>Regardless of the action taken, we send off an email to the user that submitted the change,
letting him or her know what happened.</p>
</div>
<div class="section" id="summary">
<h2>Summary</h2>
<p>This has, so far, been a great workflow for our users. They're able to trust that the data going
out is verified and safe, but if anything gets out of date, we can change it ourselves or let the community of users tell us about the new data.</p>
<p>There is a lot of stuff I didn't cover, what the <tt class="docutils literal">Point</tt> field holds on to, how to actually use GeoDjango, what each of our custom mixins does (we're planning on releasing these as a package soon), and lots of other
stuff. If you have questions/comments, hit us up on <a class="reference external" href="http://twitter.com/brack3t">Twitter</a>. Also, thanks to <a class="reference external" href="http://pydanny.github.com">Daniel Greenfeld</a> for a couple of edits.</p>
</div>
</article>
<section>
<header>
<h1>Comments</h1>
</header>
<div id="disqus_thread"></div>
</section>
</div>
</div>
<footer><p>© Brack3t. All rights reserved. <a href="./feeds/all.atom.xml">ATOM feed</a></p></footer>
</div> <!-- /container -->
<!-- Le javascript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script src="./brack3t-theme/assets/jquery-1.8.2.min.js"></script>
<script src="./brack3t-theme/assets/modernizr.js"></script>
<script src="./brack3t-theme/assets/jquery.slabtext.min.js"></script>
<script src="./brack3t-theme/assets/jquery.fittext.js"></script>
<script src="./brack3t-theme/assets/highlight.pack.js"></script>
<script>
$(function() {
$(".slabtext").slabText({
"maxFontSize": 200,
"viewportBreakpoint": 768
});
$(".highlight pre").each(function(i, e) {hljs.highlightBlock(e, " ")});
});
</script>
</body>
</html>