forked from twitter/opensource-website
-
Notifications
You must be signed in to change notification settings - Fork 1
/
user-friendlier-model-forms.html
275 lines (220 loc) · 15.9 KB
/
user-friendlier-model-forms.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
<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<title>User-friendlier model forms [ 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 = "user-friendlier-model-forms.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="./user-friendlier-model-forms.html" class="slabtext">User-friendlier model forms</a></h1>
<h6><span class="permalink">Published: <a href="./user-friendlier-model-forms.html">06-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/python.html">python</a> <a href="./tag/forms.html">forms</a> <a href="./tag/models.html">models</a> </span>
</h6>
</header>
<p>Recently, in our large client project, we had need of fields, in a model form, that accepted multiple types of input, but sanitized the data for the model. For example, the <tt class="docutils literal">rent</tt> field, on the form, needs to handle a rent range (e.g. 900-1200), a single amount, or be overridden or extended by other bits of information, like "call for details" or "on approved credit". Obviously we don't want to have to parse this out every time we read the data. So, enter our fields that tear data apart and put it together every time it passes through.</p>
<div class="section" id="model">
<h2>Model</h2>
<p>Let's go over our <tt class="docutils literal">Rent</tt> model first. It's an abstract model so we can use it in multiple places (we have more than one logical model in the system that needs to deal with rent, this way we can use it multiple places without having to hold on to a huge amount of joins). We have several other abstract models that perform the same actions as our <tt class="docutils literal">Rent</tt> model, but I won't show them here.</p>
<div class="highlight"><pre><span class="x">from django.core.exceptions import ValidationError</span>
<span class="x">from django.db import models</span>
<span class="x">class Rent(models.Model):</span>
<span class="x"> rent_low = models.PositiveIntegerField()</span>
<span class="x"> rent_high = models.PositiveIntegerField(blank=True, null=True)</span>
<span class="x"> rent_percent_income = models.FloatField(blank=True, null=True)</span>
<span class="x"> rent_oac = models.BooleanField(default=False)</span>
<span class="x"> rent_call_for_details = models.BooleanField(default=False)</span>
<span class="x"> rent_up_to = models.PositiveIntegerField(blank=True, null=True)</span>
<span class="x"> class Meta:</span>
<span class="x"> abstract = True</span>
<span class="x"> def clean(self):</span>
<span class="x"> super(Rent, self).clean()</span>
<span class="x"> if self.rent_high and self.rent_high <= self.rent_low:</span>
<span class="x"> raise ValidationError("Invalid rent range.")</span>
<span class="x"> if self.rent_percent_income or self.rent_call_for_details or \</span>
<span class="x"> self.rent_up_to:</span>
<span class="x"> self.rent_low = 0</span>
<span class="x"> self.rent_high = None</span>
<span class="x"> @property</span>
<span class="x"> def rent(self):</span>
<span class="x"> if self.rent_call_for_details:</span>
<span class="x"> return u"Call for details."</span>
<span class="x"> if self.rent_up_to:</span>
<span class="x"> return u"Up to $%d" % self.rent_up_to</span>
<span class="x"> response = ""</span>
<span class="x"> if self.rent_percent_income:</span>
<span class="x"> response += u"%g%% of income." % self.rent_percent_income</span>
<span class="x"> if self.rent_high:</span>
<span class="x"> response += u"%d-%d" % (self.rent_low, self.rent_high)</span>
<span class="x"> if self.rent_low > 0 and not self.rent_high:</span>
<span class="x"> response += u"%d" % self.rent_low</span>
<span class="x"> if self.rent_oac:</span>
<span class="x"> response += " On approved credit."</span>
<span class="x"> return response</span>
</pre></div>
<p>The one "gotcha" here, that you may not get right away, is the <tt class="docutils literal">super(Rent, <span class="pre">self).clean()</span></tt> at the top of the <tt class="docutils literal">clean()</tt>. We explicitly call it here to make sure the cleaning continues up the chain in our models that extend <tt class="docutils literal">Rent</tt> and the other extended models (as mentioned, we have several models created and used this way). You'll notice in the model we have a field for each of our states, the low and high values of rent, and the other fields that override the rent output value. We also have a class property of <tt class="docutils literal">rent</tt> that we can call on the extending models to get the computed rent value.</p>
<p>That property doesn't do anything really interesting except return a value based on the field values. The clean is a little more interesting for how it sets <tt class="docutils literal">rent_low</tt> to 0 and empties out <tt class="docutils literal">rent_high</tt> when their values no longer matter.</p>
</div>
<div class="section" id="form">
<h2>Form</h2>
<div class="highlight"><pre>from django.core.validators import RegexValidator
[...]
integer_range = RegexValidator(
regex=re.compile(r"^[0-9]*(-[0-9]*)?$"),
message="Please enter a valid number, or a range in the format: 100-200",
code="invalid"
)
class FloorplanBaseForm(CommunityKwargModelFormMixin, UserKwargModelFormMixin,
forms.ModelForm):
rent = forms.CharField(max_length=75, required=False,
validators=[integer_range])
class Meta:
model = Floorplan
def __init__(self, *args, **kwargs):
super(FloorplanBaseForm, self).__init__(*args, **kwargs)
[...]
if self.instance.pk:
set_custom_fields(self, ["rent", "deposit", "promo", "sq_ft"])
def clean(self):
super(FloorplanBaseForm, self).clean()
data = self.cleaned_data
[...]
if data.get("rent", None) and not data["rent_call_for_details"] and not\
data["rent_percent_income"] and not data["rent_up_to"]:
split_ranges(self, "rent")
clean_custom_fields(self, data, ["rent", "rent_call_for_details",
"rent_up_to", "rent_percent_income"],
"You must enter a value for rent.", "rent")
return data
</pre></div>
<p>I've removed bits of the form that deal with other fields like <tt class="docutils literal">rent</tt> since I'm not showing anything about them. This is, more or less, an abstract form. We never render it, but we extend it to support our specific floorplan types. In those extending forms, we tell <tt class="docutils literal">rent_low</tt> and <tt class="docutils literal">rent_high</tt> to be excluded. In this form, though, we provide a single <tt class="docutils literal">rent</tt> field that has a regular expression validator on it to ensure that it contains an interger or two integers separated by a hyphen. This lets the users enter data as more-or-less natural text instead of having to tab through a bunch of fields or enter the data in a weird format.</p>
<p>You'll notice three custom methods being called, <tt class="docutils literal">set_custom_fields</tt>, <tt class="docutils literal">split_ranges</tt>, and <tt class="docutils literal">clean_custom_fields</tt>. We'll cover them next.</p>
</div>
<div class="section" id="custom-methods">
<h2>Custom methods</h2>
<p>Let's go over these one at a time.</p>
<div class="highlight"><pre><span class="x">def clean_custom_fields(form, cleaned_data, fields, error_msg, field):</span>
<span class="x"> """</span>
<span class="x"> Make sure at least one required option has been supplied.</span>
<span class="x"> """</span>
<span class="x"> if not any([cleaned_data.get(f, None) for f in fields]):</span>
<span class="x"> form.errors[field] = form.error_class([error_msg])</span>
</pre></div>
<p>Since we have more than one field to clean, but they can be used in several different combinations, we have to make sure that at least one of the fields is provided. The <tt class="docutils literal">any</tt> method from the Python standard library is amazingly useful for this. We pass in the form, because, again, we use this multiple places, our form's cleaned data, the fields we want checked, an error message, and the field to highlight if none of them are provided. This is a fairly useful and flexible solution that has, so far, fulfilled all of our needs.</p>
<p>Next is the <tt class="docutils literal">split_ranges</tt> field.</p>
<div class="highlight"><pre><span class="x">def split_ranges(form, field):</span>
<span class="x"> """</span>
<span class="x"> Split custom range fields into model fields.</span>
<span class="x"> """</span>
<span class="x"> try:</span>
<span class="x"> low, high = form.cleaned_data[field].split("-")</span>
<span class="x"> setattr(form.instance, field + "_low", int(low))</span>
<span class="x"> setattr(form.instance, field + "_high", int(high))</span>
<span class="x"> except ValueError:</span>
<span class="x"> setattr(form.instance, field + "_low", int(form.cleaned_data[field]))</span>
<span class="x"> setattr(form.instance, field + "_high", None)</span>
</pre></div>
<p>This small little method takes our unified field in the form and splits it out into the <tt class="docutils literal">high</tt> and <tt class="docutils literal">low</tt> fields on the model. Since our fields are named reliably and similarly, we're able to set fields without knowing all the names.</p>
<p>Also, notice how we use the <tt class="docutils literal">ValueError</tt> that'll be thrown by not having a <tt class="docutils literal">high</tt> value to set on the form to trigger it being set to <tt class="docutils literal">None</tt>, exactly what our model is expecting already.</p>
<div class="highlight"><pre><span class="x">def set_custom_fields(form, fields):</span>
<span class="x"> """</span>
<span class="x"> Combine low/high fields into the range fields.</span>
<span class="x"> """</span>
<span class="x"> for field in fields:</span>
<span class="x"> if getattr(form.instance, field + "_high", None):</span>
<span class="x"> form.fields[field].initial = u"%d-%d" % (</span>
<span class="x"> getattr(form.instance, field + "_low")</span>
<span class="x"> getattr(form.instance, field + "_high")</span>
<span class="x"> )</span>
<span class="x"> if getattr(form.instance, field + "_low", None) > 0 and not \</span>
<span class="x"> getattr(form.instance, field + "_high", None):</span>
<span class="x"> form.fields[field].initial = gettar(form.instance, field + "_low")</span>
</pre></div>
<p>This method is the reverse of the one above. We look at the initial data that is passed in when editing a model instance and combine our values so they match what the user would have already entered.</p>
<p>So, that model and that form combined with those methods lets us handle natural language entries for somewhat complex data. Granted, our use case would be negated by adding an extra field, but it's less friendly. One of our biggest goals on any client work we do is to make it user-friendly and a solid user experience all the way around. This bit of extra work has helped us do that quickly and easily.</p>
<p>Hopefully this gives you some ideas on how to make forms more user-friendly while maintaining solid model data on the backend. If you see something we could be doing better, please let us know in the comments.</p>
<p>Thanks to Kevin Diale for pointing out our oversight on <tt class="docutils literal">getattr</tt>/<tt class="docutils literal">setattr</tt>.</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>