|
1 """ |
|
2 Form rendering and handling |
|
3 """ |
|
4 |
|
5 from svv.html import tags |
|
6 from svv import database as db |
|
7 |
|
8 import collections |
|
9 import datetime |
|
10 import logging |
|
11 |
|
12 try : |
|
13 # Python2.6 stdlib |
|
14 import json |
|
15 |
|
16 except ImportError : |
|
17 # simplejson, API should be similar |
|
18 import simplejson as json |
|
19 |
|
20 |
|
21 log = logging.getLogger('svv.forms') |
|
22 |
|
23 class FormError (Exception) : |
|
24 """ |
|
25 A user-level error in a form field |
|
26 """ |
|
27 |
|
28 def __init__ (self, field, value, error) : |
|
29 """ |
|
30 field - name of field with error |
|
31 value - the errenous value in the form that we recieved it |
|
32 may be None if it was the *lack* of a value that caused the issue |
|
33 error - descriptive text for user |
|
34 """ |
|
35 |
|
36 self.field = field |
|
37 self.value = value |
|
38 |
|
39 super(FormError, self).__init__(error) |
|
40 |
|
41 class BaseForm (object) : |
|
42 # any POST data we have processed, updated from process() |
|
43 data = None |
|
44 |
|
45 def __init__ (self, app) : |
|
46 """ |
|
47 app - bind this form to the app state (db etc) |
|
48 """ |
|
49 |
|
50 self.app = app |
|
51 |
|
52 # accumulated errors |
|
53 self.errors = collections.defaultdict(list) |
|
54 |
|
55 def defaults (self) : |
|
56 """ |
|
57 Update our attributes with default values |
|
58 """ |
|
59 |
|
60 raise NotImplementedError() |
|
61 |
|
62 def fail_field (self, form_error, field=None) : |
|
63 """ |
|
64 Mark the field mentioned inside the given FormError as failed. |
|
65 |
|
66 form_error - the FormError to store |
|
67 field - the name of the field to store the error under, if not the same as in form_error |
|
68 """ |
|
69 |
|
70 field = field or form_error.field |
|
71 |
|
72 log.warn("Marking field %s as failed: %s", field, form_error) |
|
73 |
|
74 self.errors[field].append(form_error) |
|
75 |
|
76 def build_name_for_armed_input (self, name) : |
|
77 """ |
|
78 Return the name used for the checkbox associated with the named armed text input |
|
79 """ |
|
80 |
|
81 return name + '_enabled' |
|
82 |
|
83 |
|
84 def process_raw_field (self, name, default=None, required=None) : |
|
85 """ |
|
86 Process a generic incoming data field. |
|
87 |
|
88 default - value to return if no value was present |
|
89 required - raise a FormError if no value present |
|
90 |
|
91 Returns the value as a str, or default |
|
92 """ |
|
93 |
|
94 if name in self.data : |
|
95 return self.data[name] |
|
96 |
|
97 elif required : |
|
98 raise FormError(name, None, "Required field") |
|
99 |
|
100 else : |
|
101 return default |
|
102 |
|
103 def process_text_field (self, name, default=None, required=None, strip=True) : |
|
104 """ |
|
105 Process a generic incoming string field. |
|
106 |
|
107 Trims extra whitespace from around the value, unless strip=False is given. |
|
108 |
|
109 Returns the value as unicode, or default. |
|
110 """ |
|
111 |
|
112 value = self.process_raw_field(name, required=required) |
|
113 |
|
114 if value is None : |
|
115 return default |
|
116 |
|
117 try : |
|
118 # XXX: decode somehow, or can werkzeug handle that? |
|
119 value = unicode(value) |
|
120 |
|
121 except UnicodeDecodeError : |
|
122 raise FormError(name, value, "Failed to decode Unicode characters") |
|
123 |
|
124 if strip : |
|
125 value = value.strip() |
|
126 |
|
127 return value |
|
128 |
|
129 def process_integer_field (self, name, default=None, required=None) : |
|
130 """ |
|
131 Process a generic incoming int field. |
|
132 |
|
133 Returns the value as int, or default. |
|
134 """ |
|
135 |
|
136 value = self.process_raw_field(name, required=required) |
|
137 |
|
138 if value is None : |
|
139 return default |
|
140 |
|
141 try : |
|
142 return int(value) |
|
143 |
|
144 except ValueError : |
|
145 raise FormError(name, value, "Must be a number") |
|
146 |
|
147 DATETIME_FORMAT = "%d.%m.%Y %H:%M" |
|
148 |
|
149 def process_datetime_field (self, name, default=None, required=None, format=DATETIME_FORMAT) : |
|
150 """ |
|
151 Process an incoming datetime field. |
|
152 |
|
153 Returns the value as datetime, or default. |
|
154 """ |
|
155 |
|
156 value = self.process_raw_field(name, required=required) |
|
157 |
|
158 if value is None : |
|
159 return default |
|
160 |
|
161 try : |
|
162 return datetime.datetime.strptime(value, format) |
|
163 |
|
164 except ValueError, ex : |
|
165 raise FormError(name, value, "Invalid date/time value: " + str(ex)) |
|
166 |
|
167 def process_checkbox_field (self, name, required=None) : |
|
168 """ |
|
169 Process an incoming checkbox input's value. |
|
170 |
|
171 Any non-empty/non-whitespace value will be accepted as True. |
|
172 """ |
|
173 |
|
174 value = self.process_raw_field(name, required) |
|
175 |
|
176 if not value and required : |
|
177 raise FormError(name, value, "Must be checked") |
|
178 |
|
179 elif value and value.strip() : |
|
180 # checked |
|
181 return True |
|
182 |
|
183 else : |
|
184 # unchecked |
|
185 return False |
|
186 |
|
187 def process_armed_text_field (self, name, required=None) : |
|
188 """ |
|
189 A text field that must be enabled by a checkbox before being used. |
|
190 |
|
191 If not enabled, returns False. Otherwise, text field value, and fails if empty but required. |
|
192 """ |
|
193 |
|
194 checkbox_name = self.build_name_for_armed_input(name) |
|
195 |
|
196 if self.process_checkbox_field(checkbox_name) : |
|
197 # use input value |
|
198 return self.process_text_field(name, required=required) |
|
199 |
|
200 else : |
|
201 # not selected |
|
202 return False |
|
203 |
|
204 |
|
205 def process_multifield (self, table, id, fields) : |
|
206 """ |
|
207 Process a set of user-given field values for an object with an unique id, and some set of additional fields. |
|
208 |
|
209 If the id is given, look up the corresponding field values, and return those. |
|
210 |
|
211 If any of the fields are given, either look up a matching row, or create a new one, and return its id. |
|
212 |
|
213 Returns an (id_name, field_name, ...) N-tuple. |
|
214 """ |
|
215 |
|
216 id_name, id_col, id_value = id |
|
217 |
|
218 if id_value : |
|
219 # look up object from db |
|
220 columns = [col for name, col, value in fields] |
|
221 |
|
222 sql = db.select(columns, (id_col == id_value)) |
|
223 |
|
224 for row in self.app.query(sql) : |
|
225 # XXX: sanity-check row values vs our values |
|
226 |
|
227 # new values |
|
228 fields = tuple( |
|
229 (name, col, row[col]) for name, col, value in fields |
|
230 ) |
|
231 |
|
232 # ok, just use the first one |
|
233 break |
|
234 |
|
235 else : |
|
236 # not found! |
|
237 raise FormError(id_name, id_value, "Item selected does not seem to exist") |
|
238 |
|
239 log.info("Lookup %s=%d -> %s", id_name, id_value, dict((name, value) for name, col, value in fields)) |
|
240 |
|
241 elif any(value for name, col, value in fields) : |
|
242 # look up identical object from db? |
|
243 sql = db.select([id_col], db.and_(*[(col == value) for name, col, value in fields])) |
|
244 |
|
245 for row in self.app.query(sql) : |
|
246 if id_value : |
|
247 log.warn("Duplicate %s=%d for %s", id_name, id_value, dict((name, value) for name, col, value in fields)) |
|
248 |
|
249 # found object's id |
|
250 id_value = row[id_col] |
|
251 |
|
252 log.info("Found %s -> %d", dict((name, value) for name, col, value in fields), id_value) |
|
253 |
|
254 # create new object? |
|
255 if not id_value : |
|
256 sql = db.insert(table).values(dict((col, value) for name, col, value in fields)) |
|
257 |
|
258 id_value, = self.app.insert(sql) |
|
259 |
|
260 log.info("Create %s -> %d", dict((name, value) for name, col, value in fields), id_value) |
|
261 |
|
262 else : |
|
263 # not known |
|
264 log.debug("No %s known for order...", id_name) |
|
265 |
|
266 # return full set of values |
|
267 return (id_value, ) + tuple(value for name, col, value in fields) |
|
268 |
|
269 def process (self, data) : |
|
270 """ |
|
271 Bind ourselves to the given incoming POST data, and update our order field attributes. |
|
272 |
|
273 data - the submitted POST data as a MultiDict |
|
274 |
|
275 Returns True if all fields were processed without errors, False otherwise. |
|
276 """ |
|
277 |
|
278 raise NotImplmentedError() |
|
279 |
|
280 return not self.errors |
|
281 |
|
282 def render_text_input (self, name, value=None, multiline=False, rows=10, autoscale=True) : |
|
283 """ |
|
284 Render HTML for a generic text field input. |
|
285 |
|
286 name - field name, as used for POST |
|
287 value - default field value |
|
288 multiline - use a multi-line <textarea> instead |
|
289 rows - number of rows for <textarea> per default |
|
290 autoscale - automatically scale the <textarea> to the number of lines of text |
|
291 """ |
|
292 |
|
293 if multiline : |
|
294 if autoscale and value : |
|
295 rows = value.count('\n') * 5 / 4 |
|
296 |
|
297 # XXX: textarea can't be self-closing for some reason? |
|
298 return tags.textarea(name=name, id=name, rows=rows, _selfclosing=False, _whitespace_sensitive=True)(value) |
|
299 |
|
300 else : |
|
301 return tags.input(type='text', name=name, id=name, value=value) |
|
302 |
|
303 def render_select_input (self, name, options, value=None) : |
|
304 """ |
|
305 Render HTML for a generic select control. |
|
306 |
|
307 name - field name, as used for POST |
|
308 options - sequence of (value, title) options. `value` may be None to omit. |
|
309 value - the selected value |
|
310 """ |
|
311 |
|
312 return tags.select(name=name, id=name)( |
|
313 ( |
|
314 tags.option(value=opt_value, selected=('selected' if opt_value == value else None))(opt_title) |
|
315 ) for opt_value, opt_title in options |
|
316 ) |
|
317 |
|
318 def render_datetime_input (self, name, value=None) : |
|
319 """ |
|
320 Render HTML for a generic datetime control (using jQuery). |
|
321 |
|
322 name - field name, as used for POST |
|
323 value - selected date |
|
324 """ |
|
325 |
|
326 return ( |
|
327 self.render_text_input(name, (value.strftime(self.DATETIME_FORMAT) if value else None)), |
|
328 |
|
329 tags.script("$(document).ready(function () { $('#" + name + "').datetimepicker(); });"), |
|
330 ) |
|
331 |
|
332 def render_checkbox_input (self, name, checked=None) : |
|
333 """ |
|
334 Render HTML for a checkbox. |
|
335 """ |
|
336 |
|
337 return tags.input(type="checkbox", name=name, id=name, value="1", |
|
338 checked=("checked" if checked else None) |
|
339 ) |
|
340 |
|
341 def render_armed_text_input (self, name, value=False, default=None) : |
|
342 """ |
|
343 Render HTML for a text field that must be enabled by a checkbox before being used. |
|
344 |
|
345 value - the three-state value |
|
346 False - not checked, use default as value |
|
347 None - checked, use empty value |
|
348 str - checked, ues value |
|
349 """ |
|
350 |
|
351 checkbox_name = self.build_name_for_armed_input(name) |
|
352 |
|
353 if value is False : |
|
354 checked = False |
|
355 value = default |
|
356 |
|
357 else : |
|
358 checked = True |
|
359 |
|
360 return ( |
|
361 self.render_checkbox_input(checkbox_name, checked), |
|
362 self.render_text_input(name, value), |
|
363 |
|
364 tags.script("$(document).ready(function () { $('#" + name + "').formEnabledBy($('#" + checkbox_name + "')); });") |
|
365 ) |
|
366 |
|
367 |
|
368 def render_form_field (self, name, title, description, inputs) : |
|
369 """ |
|
370 Render the label, input control, error note and description for a single field, along with their containing <li>. |
|
371 """ |
|
372 |
|
373 # any errors for this field? |
|
374 errors = self.errors[name] |
|
375 |
|
376 return tags.li(class_='field' + (' failed' if errors else ''))( |
|
377 tags.label(( |
|
378 title, |
|
379 tags.strong(u"(Virheellinen)") if errors else None, |
|
380 ), for_=name), |
|
381 |
|
382 inputs, |
|
383 |
|
384 tags.p(description), |
|
385 |
|
386 # possible errors |
|
387 tags.ul(class_='errors')(tags.li(error.message) for error in errors) if errors else None, |
|
388 ) |
|
389 |
|
390 def render (self, action, submit=u"Tallenna") : |
|
391 """ |
|
392 Render the entire <form>, using any loaded/processed values. |
|
393 |
|
394 action - the target URL for the form to POST to |
|
395 submit - label for the submit button |
|
396 """ |
|
397 |
|
398 raise NotImplementedError() |
|
399 |
|
400 |
|
401 |