bureaucrat 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ (The MIT License)
2
+
3
+ Copyright (c) 2009 Bruno Deferrari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ 'Software'), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ Bureaucrat
2
+ ==========
3
+
4
+ Form handling for Ruby inspired by Django forms.
5
+
6
+ Structure
7
+ ---------
8
+
9
+ Form ----> as_<render_mode>, valid?, errors/cleaned_data
10
+ ______|________
11
+ / | \
12
+ Field Field Field ----> clean
13
+ | | |
14
+ Widget Widget Widget ----> render
15
+
16
+ - A Form has a list of Fields.
17
+ - A Field has a Widget.
18
+ - A Widget knows how to render itself.
19
+ - A Field knows how to validate an input value and convert it from a string to the required type.
20
+ - A Form knows how to render all its fields along with all the required error messages.
21
+ - After validation, a valid Form responds to 'cleaned_data' by returning a hash of validated values.
22
+ - After validation an invalid Form responds to 'errors' by returning a hash of field_name => error_messages
23
+
24
+ Usage examples
25
+ --------------
26
+
27
+ require 'bureaucrat'
28
+ require 'bureaucrat/quickfields'
29
+
30
+ class MyForm < Bureaucrat::Forms::Form
31
+ extend Bureaucrat::Quickfields
32
+
33
+ string :nickname, :max_length => 50
34
+ string :realname, :require => false
35
+ email :email
36
+ integer :age, :min_value => 0
37
+ boolean :newsletter, :required => false
38
+
39
+ def save
40
+ user = User.create!(cleaned_data)
41
+ Mailer.deliver_confirmation_mail(user)
42
+ user
43
+ end
44
+ end
45
+
46
+ # A Form initialized without parameters is an unbound Form.
47
+ unbound_form = MyForm.new
48
+ unbound_form.valid? # => false
49
+ unbound_form.errors # => {}
50
+ unbound_form.cleaned_data # => nil
51
+ puts unbound_form.as_p
52
+ # Prints:
53
+ # <p><label for="id_nickname">Nickname:</label> <input type="text" name="nickname" id="id_nickname" maxlength="50" /></p>
54
+ # <p><label for="id_realname">Realname:</label> <input type="text" name="realname" id="id_realname" /></p>
55
+ # <p><label for="id_email">Email:</label> <input type="text" name="email" id="id_email" /></p>
56
+ # <p><label for="id_age">Age:</label> <input type="text" name="age" id="id_age" /></p>
57
+ # <p><label for="id_newsletter">Newsletter:</label> <input type="checkbox" name="newsletter" id="id_newsletter" /></p>
58
+
59
+ invalid_bound_form = MyForm.new(:nickname => 'bureaucrat', :email => 'badformat', :age => '30')
60
+ invalid_bound_form.valid? # => false
61
+ invalid_bound_form.errors # {:email => ["Enter a valid e-mail address."]}
62
+ invalid_bound_form.cleaned_data # => nil
63
+ puts invalid_bound_form.as_table
64
+ # Prints:
65
+ # <tr><th><label for="id_nickname">Nickname:</label></th><td><input type="text" value="bureaucrat" name="nickname" id="id_nickname" maxlength="50" /></td></tr>
66
+ # <tr><th><label for="id_realname">Realname:</label></th><td><ul class="errorlist"><li>This field is required</li></ul><input type="text" name="realname" id="id_realname" /></td></tr>
67
+ # <tr><th><label for="id_email">Email:</label></th><td><ul class="errorlist"><li>Enter a valid e-mail address.</li></ul><input type="text" value="badformat" name="email" id="id_email" /></td></tr>
68
+ # <tr><th><label for="id_age">Age:</label></th><td><input type="text" value="30" name="age" id="id_age" /></td></tr>
69
+ # <tr><th><label for="id_newsletter">Newsletter:</label></th><td><input type="checkbox" name="newsletter" id="id_newsletter" /></td></tr>
70
+
71
+ valid_bound_form = MyForm.new(:nickname => 'bureaucrat', :email => 'valid@email.com', :age => '30')
72
+ valid_bound_form.valid? # => true
73
+ valid_bound_form.errors # {}
74
+ valid_bound_form.cleaned_data # => {:age => 30, :newsletter => false, :nickname => "bureaucrat", :realname => "", :email = >"valid@email.com"}
75
+ puts valid_bound_form.as_ul
76
+ # Prints:
77
+ # <li><label for="id_nickname">Nickname:</label> <input type="text" value="bureaucrat" name="nickname" id="id_nickname" maxlength="50" /></li>
78
+ # <li><ul class="errorlist"><li>This field is required</li></ul><label for="id_realname">Realname:</label> <input type="text" name="realname" id="id_realname" /></li>
79
+ # <li><label for="id_email">Email:</label> <input type="text" value="valid@email.com" name="email" id="id_email" /></li>
80
+ # <li><label for="id_age">Age:</label> <input type="text" value="30" name="age" id="id_age" /></li>
81
+ # <li><label for="id_newsletter">Newsletter:</label> <input type="checkbox" name="newsletter" id="id_newsletter" /></li>
82
+
83
+ valid_bound_form.save # A new User is created and a confirmation mail is delivered
84
+
85
+ Examples of different ways of defining forms
86
+ --------------
87
+
88
+ require 'bureaucrat'
89
+ require 'bureaucrat/quickfields'
90
+
91
+ class MyForm < Bureaucrat::Forms::Form
92
+ include Bureaucrat::Fields
93
+
94
+ field :nickname, CharField.new(:max_length => 50)
95
+ field :realname, CharField.new(:required => false)
96
+ field :email, EmailField.new
97
+ field :age, IntegerField.new(:min_value => 0)
98
+ field :newsletter, BooleanField.new(:required => false)
99
+ end
100
+
101
+ class MyFormQuick < Bureaucrat::Forms::Form
102
+ extend Bureaucrat::Quickfields
103
+
104
+ string :nickname, :max_length => 50
105
+ string :realname, :required => false
106
+ email :email
107
+ integer :age, :min_value => 0
108
+ boolean :newsletter, :required => false
109
+ end
110
+
111
+ def quicker_form
112
+ f = Class.new(Bureaucrat::Forms::Form)
113
+ f.extend(Bureaucrat::Quickfields)
114
+ yield f
115
+ f
116
+ end
117
+
118
+ MyFormQuicker = quicker_form do |f|
119
+ f.string :nickname, :max_length => 50
120
+ f.string :realname, :required => false
121
+ f.email :email
122
+ f.integer :age, :min_value => 0
123
+ f.boolean :newsletter, :required => false
124
+ end
@@ -0,0 +1,461 @@
1
+ require 'bureaucrat/utils'
2
+ require 'bureaucrat/validation'
3
+ require 'bureaucrat/widgets'
4
+
5
+ module Bureaucrat
6
+ module Fields
7
+
8
+ class ErrorList < Array
9
+ include Utils
10
+
11
+ def to_s
12
+ as_ul
13
+ end
14
+
15
+ def as_ul
16
+ ul = '<ul class="errorlist">%s</ul>'
17
+ li = '<li>%s</li>'
18
+ empty? ? '' : mark_safe(ul % map {|e| li % conditional_escape(e)}.join("\n"))
19
+ end
20
+
21
+ def as_text
22
+ empty? ? '' : map{|e| '* %s' % e}.join("\n")
23
+ end
24
+ end
25
+
26
+ class ErrorHash < Hash
27
+ include Utils
28
+
29
+ def to_s
30
+ as_ul
31
+ end
32
+
33
+ def as_ul
34
+ ul = '<ul class="errorlist">%s</ul>'
35
+ li = '<li>%s%s</li>'
36
+ empty? ? '' : mark_safe(ul % map {|k, v| li % [k, v]}.join)
37
+ end
38
+
39
+ def as_text
40
+ map do |k, v|
41
+ '* %s\n%s' % [k, v.map{|i| ' * %s'}.join("\n")]
42
+ end.join("\n")
43
+ end
44
+ end
45
+
46
+ class FieldValidationError < Exception
47
+ attr_reader :messages
48
+
49
+ def initialize(message)
50
+ message = [message] unless message.is_a?(Array)
51
+ @messages = ErrorList.new(message)
52
+ end
53
+
54
+ def to_s
55
+ @messages.inspect
56
+ end
57
+ end
58
+
59
+ class Field
60
+ include Validation::Validators
61
+ include Validation::Converters
62
+
63
+ class << self
64
+ attr_reader :default_error_messages
65
+
66
+ def set_error(key, template)
67
+ @default_error_messages ||= {}
68
+ @default_error_messages[key] = template
69
+ end
70
+
71
+ def widget(widget=nil)
72
+ @widget = widget unless widget.nil?
73
+ @widget
74
+ end
75
+
76
+ def hidden_widget(hidden_widget=nil)
77
+ @hidden_widget = hidden_widget unless hidden_widget.nil?
78
+ @hidden_widget
79
+ end
80
+
81
+ # Copy field properties to the child class
82
+ def inherited(c)
83
+ c.widget widget
84
+ c.hidden_widget hidden_widget
85
+ default_error_messages.each {|k, v| c.set_error k, v}
86
+ end
87
+ end
88
+
89
+ # Field properties
90
+ widget Widgets::TextInput
91
+ hidden_widget Widgets::HiddenInput
92
+ set_error :required, 'This field is required'
93
+ set_error :invalid, 'Enter a valid value'
94
+
95
+ attr_accessor :required, :label, :initial, :error_messages, :widget, :hidden_widget, :show_hidden_initial, :help_text
96
+
97
+ def initialize(options={})
98
+ @required = options.fetch(:required, true)
99
+ @show_hidden_initial = options.fetch(:show_hidden_initial, false)
100
+ @label = options[:label]
101
+ @initial = options[:initial]
102
+ @help_text = options.fetch(:help_text, '')
103
+ @widget = options.fetch(:widget, self.class.widget)
104
+
105
+ @widget = @widget.new if @widget.is_a?(Class)
106
+ extra_attrs = widget_attrs(@widget)
107
+ @widget.attrs.update(extra_attrs) if extra_attrs
108
+
109
+ @error_messages = self.class.default_error_messages.
110
+ merge(options.fetch(:error_messages, {}))
111
+ end
112
+
113
+ def validating
114
+ yield
115
+ rescue Validation::ValidationError => error
116
+ tpl = error_messages.fetch(error.error_code, error.error_code.to_s)
117
+ msg = Utils.format_string(tpl, error.parameters)
118
+ raise FieldValidationError.new(msg)
119
+ end
120
+
121
+ def clean(value)
122
+ validating { is_present(value) if @required }
123
+ value
124
+ end
125
+
126
+ def widget_attrs(widget)
127
+ # Override to add field specific attributes
128
+ end
129
+
130
+ def initialize_copy(original)
131
+ super(original)
132
+ @initial = original.initial ? original.initial.dup : original.initial
133
+ @label = original.label ? original.label.dup : original.label
134
+ @error_messages = original.error_messages.dup
135
+ end
136
+
137
+ end
138
+
139
+ class CharField < Field
140
+ set_error :max_length, 'Ensure this value has at most %(max)s characters (it has %(length)s).'
141
+ set_error :min_length, 'Ensure this value has at least %(min)s characters (it has %(length)s).'
142
+
143
+ attr_accessor :max_length, :min_length
144
+
145
+ def initialize(options={})
146
+ @max_length = options.delete(:max_length)
147
+ @min_length = options.delete(:min_length)
148
+ super(options)
149
+ end
150
+
151
+ def widget_attrs(widget)
152
+ return if @max_length.nil?
153
+ return {:maxlength => @max_length.to_s} if
154
+ widget.kind_of?(Widgets::TextInput) || widget.kind_of?(Widgets::PasswordInput)
155
+ end
156
+
157
+ def clean(value)
158
+ super(value)
159
+ return '' if empty_value?(value)
160
+
161
+ validating do
162
+ has_max_length(value, @max_length) if @max_length
163
+ has_min_length(value, @min_length) if @min_length
164
+ end
165
+
166
+ value
167
+ end
168
+ end
169
+
170
+ class IntegerField < Field
171
+ set_error :invalid, 'Enter a whole number.'
172
+ set_error :max_value, 'Ensure this value is less than or equal to %(max)s.'
173
+ set_error :min_value, 'Ensure this value is greater than or equal to %(min)s.'
174
+
175
+ def initialize(options={})
176
+ @max_value = options.delete(:max_value)
177
+ @min_value = options.delete(:min_value)
178
+ super(options)
179
+ end
180
+
181
+ def clean(value)
182
+ super(value)
183
+ return nil if empty_value?(value)
184
+
185
+ validating do
186
+ value = to_integer(value)
187
+ is_not_greater_than(value, @max_value) if @max_value
188
+ is_not_lesser_than(value, @min_value) if @min_value
189
+ end
190
+
191
+ value
192
+ end
193
+ end
194
+
195
+ class FloatField < Field
196
+ set_error :invalid, 'Enter a number.'
197
+ set_error :max_value, 'Ensure this value is less than or equal to %(max)s.'
198
+ set_error :min_value, 'Ensure this value is greater than or equal to %(min)s.'
199
+
200
+ def initialize(options={})
201
+ @max_value = options.delete(:max_value)
202
+ @min_value = options.delete(:min_value)
203
+ super(options)
204
+ end
205
+
206
+ def clean(value)
207
+ super(value)
208
+ return nil if empty_value?(value)
209
+
210
+ validating do
211
+ value = to_float(value)
212
+ is_not_greater_than(value, @max_value) if @max_value
213
+ is_not_lesser_than(value, @min_value) if @min_value
214
+ end
215
+
216
+ value
217
+ end
218
+ end
219
+
220
+ class BigDecimalField < Field
221
+ set_error :invalid, 'Enter a number.'
222
+ set_error :max_value, 'Ensure this value is less than or equal to %(max)s.'
223
+ set_error :min_value, 'Ensure this value is greater than or equal to %(min)s.'
224
+ set_error :max_digits, 'Ensure that there are no more than %(max)s digits in total.'
225
+ set_error :max_decimal_places, 'Ensure that there are no more than %(max)s decimal places.'
226
+ set_error :max_whole_digits, 'Ensure that there are no more than %(max)s digits before the decimal point.'
227
+
228
+ def initialize(options={})
229
+ @max_value = options.delete(:max_value)
230
+ @min_value = options.delete(:min_value)
231
+ @max_digits = options.delete(:max_digits)
232
+ @max_decimal_places = options.delete(:max_decimal_places)
233
+ @whole_digits = @max_digits - @decimal_places if @max_digits && @decimal_places
234
+ super(options)
235
+ end
236
+
237
+ def clean(value)
238
+ super(value)
239
+ return nil if !@required && empty_value?(value)
240
+
241
+ validating do
242
+ value = to_big_decimal(value.to_s.strip)
243
+ is_not_greater_than(value, @max_value) if @max_value
244
+ is_not_lesser_than(value, @min_value) if @min_value
245
+ has_max_digits(value, @max_digits) if @max_digits
246
+ has_max_decimal_places(value, @max_decimal_places) if @max_decimal_places
247
+ has_max_whole_digits(value, @max_whole_digits) if @max_whole_digits
248
+ end
249
+
250
+ value
251
+ end
252
+ end
253
+
254
+ # DateField
255
+ # TimeField
256
+ # DateTimeField
257
+
258
+ class RegexField < CharField
259
+ def initialize(regex, options={})
260
+ error_message = options.delete(:error_message)
261
+ if error_message
262
+ options[:error_messages] ||= {}
263
+ options[:error_messages][:invalid] = error_messages
264
+ end
265
+ super(options)
266
+ @regex = regex
267
+ end
268
+
269
+ def clean(value)
270
+ value = super(value)
271
+ return value if value.empty?
272
+ validating { matches_regex(value, @regex) }
273
+ value
274
+ end
275
+ end
276
+
277
+ class EmailField < RegexField
278
+ # Original from Django's EmailField:
279
+ # email_re = re.compile(
280
+ # r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom
281
+ # r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-011\013\014\016-\177])*"' # quoted-string
282
+ # r')@(?:[A-Z0-9]+(?:-*[A-Z0-9]+)*\.)+[A-Z]{2,6}$', re.IGNORECASE) # domain
283
+ EMAIL_RE = /(^[-!#\$%&'*+\/=?^_`{}|~0-9A-Z]+(\.[-!#\$%&'*+\/=?^_`{}|~0-9A-Z]+)*|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-011\013\014\016-\177])*")@(?:[A-Z0-9]+(?:-*[A-Z0-9]+)*\.)+[A-Z]{2,6}$/i
284
+
285
+ set_error :invalid, 'Enter a valid e-mail address.'
286
+
287
+ def initialize(options={})
288
+ super(EMAIL_RE, options)
289
+ end
290
+ end
291
+
292
+ # TODO: add tests
293
+ class FileField < Field
294
+ widget Widgets::FileInput
295
+ set_error :invalid, 'No file was submitted. Check the encoding type on the form.'
296
+ set_error :missing, 'No file was submitted.'
297
+ set_error :empty, 'The submitted file is empty.'
298
+ set_error :max_length, 'Ensure this filename has at most %(max)d characters (it has %(length)d).'
299
+
300
+ def initialize(options)
301
+ @max_length = options.delete(:max_length)
302
+ super(options)
303
+ end
304
+
305
+ def clean(data, initial=nil)
306
+ super(initial || data)
307
+
308
+ if !required && empty_value?(data)
309
+ return nil
310
+ elsif !data && initial
311
+ return initial
312
+ end
313
+
314
+ # TODO: file validators?
315
+ validating do
316
+ # UploadedFile objects should have name and size attributes.
317
+ begin
318
+ file_name = data.name
319
+ file_size = data.size
320
+ rescue NoMethodError
321
+ fail_with(:invalid)
322
+ end
323
+
324
+ fail_with(:max_length, :max => @max_length, :length => file_name.length) if
325
+ @max_length && file_name.length > @max_length
326
+
327
+ fail_with(:invalid) unless file_name
328
+ fail_with(:empty) unless file_size || file_size == 0
329
+ end
330
+
331
+ data
332
+ end
333
+ end
334
+
335
+ #class ImageField < FileField
336
+ #end
337
+
338
+ # URLField
339
+
340
+ class BooleanField < Field
341
+ widget Widgets::CheckboxInput
342
+
343
+ def clean(value)
344
+ value = to_bool(value)
345
+ super(value)
346
+ validating { is_true(value) if @required }
347
+ value
348
+ end
349
+ end
350
+
351
+ class NullBooleanField < BooleanField
352
+ widget Widgets::NullBooleanSelect
353
+
354
+ def clean(value)
355
+ case value
356
+ when true, 'true', '1' then true
357
+ when false, 'false', '0' then false
358
+ else nil
359
+ end
360
+ end
361
+ end
362
+
363
+ class ChoiceField < Field
364
+ widget Widgets::Select
365
+ set_error :invalid_choice, 'Select a valid choice. %(value)s is not one of the available choices.'
366
+
367
+ def initialize(choices=[], options={})
368
+ options[:required] = options.fetch(:required, true)
369
+ super(options)
370
+ self.choices = choices
371
+ end
372
+
373
+ def choices
374
+ @choices
375
+ end
376
+
377
+ def choices=(value)
378
+ @choices = @widget.choices = value.to_a
379
+ end
380
+
381
+ def clean(value)
382
+ value = super(value)
383
+ value = '' if empty_value?(value)
384
+ value = value.to_s
385
+
386
+ return value if value.empty?
387
+
388
+ validating do
389
+ fail_with(:invalid_choice, :value => value) unless valid_value?(value)
390
+ end
391
+
392
+ value
393
+ end
394
+
395
+ def valid_value?(value)
396
+ @choices.each do |k, v|
397
+ if v.is_a?(Array)
398
+ # This is an optgroup, so look inside the group for options
399
+ v.each do |k2, v2|
400
+ return true if value == k2.to_s
401
+ end
402
+ else
403
+ return true if value == k.to_s
404
+ end
405
+ end
406
+ false
407
+ end
408
+ end
409
+
410
+ # TODO: tests
411
+ class TypedChoiceField < ChoiceField
412
+ def initialize(choices=[], options={})
413
+ @coerce = options.delete(:coerce) || lambda{|val| val}
414
+ @empty_value = options.fetch(:empty_value, '')
415
+ options.delete(:empty_value)
416
+ super(choices, options)
417
+ end
418
+
419
+ def clean(value)
420
+ value = super(value)
421
+ return @empty_value if value == @empty_value || empty_value?(value)
422
+
423
+ begin
424
+ @coerce.call(value)
425
+ rescue
426
+ validating { fail_with(:invalid_choice, :value => value) }
427
+ end
428
+ end
429
+ end
430
+
431
+ # TODO: tests
432
+ class MultipleChoiceField < ChoiceField
433
+ widget Widgets::SelectMultiple
434
+ hidden_widget Widgets::MultipleHiddenInput
435
+ set_error :invalid_choice, 'Select a valid choice. %(value)s is not one of the available choices.'
436
+ set_error :invalid_list, 'Enter a list of values.'
437
+
438
+ def clean(value)
439
+ validating do
440
+ is_present(value) if @required
441
+ return [] if ! @required && ! value || value.empty?
442
+ is_array(value)
443
+ not_empty(value) if @required
444
+
445
+ new_value = value.map(&:to_s)
446
+ new_value.each do |val|
447
+ fail_with(:invalid_choice, :value => val) unless valid_value?(val)
448
+ end
449
+ end
450
+
451
+ new_value
452
+ end
453
+ end
454
+
455
+ # ComboField
456
+ # MultiValueField
457
+ # FilePathField
458
+ # SplitDateTimeField
459
+ # IPAddressField
460
+ # SlugField
461
+ end; end