/
helpers.py
364 lines (298 loc) · 13.1 KB
/
helpers.py
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
"""
Helper functions for the account/profile Python APIs.
This is NOT part of the public API.
"""
import json
import logging
import traceback
from collections import defaultdict
from functools import wraps
import six
from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.encoding import force_text
from django.utils.functional import Promise
LOGGER = logging.getLogger(__name__)
def intercept_errors(api_error, ignore_errors=None):
"""
Function decorator that intercepts exceptions
and translates them into API-specific errors (usually an "internal" error).
This allows callers to gracefully handle unexpected errors from the API.
This method will also log all errors and function arguments to make
it easier to track down unexpected errors.
Arguments:
api_error (Exception): The exception to raise if an unexpected error is encountered.
Keyword Arguments:
ignore_errors (iterable): List of errors to ignore. By default, intercept every error.
Returns:
function
"""
def _decorator(func):
"""
Function decorator that intercepts exceptions and translates them into API-specific errors.
"""
@wraps(func)
def _wrapped(*args, **kwargs):
"""
Wrapper that evaluates a function, intercepting exceptions and translating them into
API-specific errors.
"""
try:
return func(*args, **kwargs)
except Exception as ex:
# Raise and log the original exception if it's in our list of "ignored" errors
for ignored in ignore_errors or []:
if isinstance(ex, ignored):
msg = (
u"A handled error occurred when calling '{func_name}' "
u"with arguments '{args}' and keyword arguments '{kwargs}': "
u"{exception}"
).format(
func_name=func.__name__,
args=args,
kwargs=kwargs,
exception=ex.developer_message if hasattr(ex, 'developer_message') else repr(ex)
)
LOGGER.warning(msg)
raise
caller = traceback.format_stack(limit=2)[0]
# Otherwise, log the error and raise the API-specific error
msg = (
u"An unexpected error occurred when calling '{func_name}' "
u"with arguments '{args}' and keyword arguments '{kwargs}' from {caller}: "
u"{exception}"
).format(
func_name=func.__name__,
args=args,
kwargs=kwargs,
exception=ex.developer_message if hasattr(ex, 'developer_message') else repr(ex),
caller=caller.strip(),
)
LOGGER.exception(msg)
raise api_error(msg)
return _wrapped
return _decorator
class InvalidFieldError(Exception):
"""The provided field definition is not valid. """
class FormDescription(object):
"""Generate a JSON representation of a form. """
ALLOWED_TYPES = ["text", "email", "select", "textarea", "checkbox", "plaintext", "password", "hidden"]
ALLOWED_RESTRICTIONS = {
"text": ["min_length", "max_length"],
"password": ["min_length", "max_length", "min_upper", "min_lower",
"min_punctuation", "min_symbol", "min_numeric", "min_alphabetic"],
"email": ["min_length", "max_length", "readonly"],
}
FIELD_TYPE_MAP = {
forms.CharField: "text",
forms.PasswordInput: "password",
forms.ChoiceField: "select",
forms.TypedChoiceField: "select",
forms.Textarea: "textarea",
forms.BooleanField: "checkbox",
forms.EmailField: "email",
}
OVERRIDE_FIELD_PROPERTIES = [
"label", "type", "defaultValue", "placeholder",
"instructions", "required", "restrictions",
"options", "supplementalLink", "supplementalText"
]
def __init__(self, method, submit_url):
"""Configure how the form should be submitted.
Args:
method (unicode): The HTTP method used to submit the form.
submit_url (unicode): The URL where the form should be submitted.
"""
self.method = method
self.submit_url = submit_url
self.fields = []
self._field_overrides = defaultdict(dict)
def add_field(
self, name, label=u"", field_type=u"text", default=u"",
placeholder=u"", instructions=u"", required=True, restrictions=None,
options=None, include_default_option=False, error_messages=None,
supplementalLink=u"", supplementalText=u""
):
"""Add a field to the form description.
Args:
name (unicode): The name of the field, which is the key for the value
to send back to the server.
Keyword Arguments:
label (unicode): The label for the field (e.g. "E-mail" or "Username")
field_type (unicode): The type of the field. See `ALLOWED_TYPES` for
acceptable values.
default (unicode): The default value for the field.
placeholder (unicode): Placeholder text in the field
(e.g. "user@example.com" for an email field)
instructions (unicode): Short instructions for using the field
(e.g. "This is the email address you used when you registered.")
required (boolean): Whether the field is required or optional.
restrictions (dict): Validation restrictions for the field.
See `ALLOWED_RESTRICTIONS` for acceptable values.
options (list): For "select" fields, a list of tuples
(value, display_name) representing the options available to
the user. `value` is the value of the field to send to the server,
and `display_name` is the name to display to the user.
If the field type is "select", you *must* provide this kwarg.
include_default_option (boolean): If True, include a "default" empty option
at the beginning of the options list.
error_messages (dict): Custom validation error messages.
Currently, the only supported key is "required" indicating
that the messages should be displayed if the user does
not provide a value for a required field.
supplementalLink (unicode): A qualified URL to provide supplemental information
for the form field. An example may be a link to documentation for creating
strong passwords.
supplementalText (unicode): The visible text for the supplemental link above.
Raises:
InvalidFieldError
"""
if field_type not in self.ALLOWED_TYPES:
msg = u"Field type '{field_type}' is not a valid type. Allowed types are: {allowed}.".format(
field_type=field_type,
allowed=", ".join(self.ALLOWED_TYPES)
)
raise InvalidFieldError(msg)
field_dict = {
"name": name,
"label": label,
"type": field_type,
"defaultValue": default,
"placeholder": placeholder,
"instructions": instructions,
"required": required,
"restrictions": {},
"errorMessages": {},
"supplementalLink": supplementalLink,
"supplementalText": supplementalText
}
field_override = self._field_overrides.get(name, {})
if field_type == "select":
if options is not None:
field_dict["options"] = []
# Get an existing default value from the field override
existing_default_value = field_override.get('defaultValue')
# Include an empty "default" option at the beginning of the list;
# preselect it if there isn't an overriding default.
if include_default_option:
field_dict["options"].append({
"value": "",
"name": "--",
"default": existing_default_value is None
})
field_dict["options"].extend([
{
'value': option_value,
'name': option_name,
'default': option_value == existing_default_value
} for option_value, option_name in options
])
else:
raise InvalidFieldError("You must provide options for a select field.")
if restrictions is not None:
allowed_restrictions = self.ALLOWED_RESTRICTIONS.get(field_type, [])
for key, val in six.iteritems(restrictions):
if key in allowed_restrictions:
field_dict["restrictions"][key] = val
else:
msg = u"Restriction '{restriction}' is not allowed for field type '{field_type}'".format(
restriction=key,
field_type=field_type
)
raise InvalidFieldError(msg)
if error_messages is not None:
field_dict["errorMessages"] = error_messages
# If there are overrides for this field, apply them now.
# Any field property can be overwritten (for example, the default value or placeholder)
field_dict.update(field_override)
self.fields.append(field_dict)
def to_json(self):
"""Create a JSON representation of the form description.
Here's an example of the output:
{
"method": "post",
"submit_url": "/submit",
"fields": [
{
"name": "cheese_or_wine",
"label": "Cheese or Wine?",
"defaultValue": "cheese",
"type": "select",
"required": True,
"placeholder": "",
"instructions": "",
"options": [
{"value": "cheese", "name": "Cheese", "default": False},
{"value": "wine", "name": "Wine", "default": False}
]
"restrictions": {},
"errorMessages": {},
},
{
"name": "comments",
"label": "comments",
"defaultValue": "",
"type": "text",
"required": False,
"placeholder": "Any comments?",
"instructions": "Please enter additional comments here."
"restrictions": {
"max_length": 200
}
"errorMessages": {},
},
...
]
}
If the field is NOT a "select" type, then the "options"
key will be omitted.
Returns:
unicode
"""
return json.dumps({
"method": self.method,
"submit_url": self.submit_url,
"fields": self.fields
}, cls=LocalizedJSONEncoder)
def override_field_properties(self, field_name, **kwargs):
"""Override properties of a field.
The overridden values take precedence over the values provided
to `add_field()`.
Field properties not in `OVERRIDE_FIELD_PROPERTIES` will be ignored.
Arguments:
field_name (str): The name of the field to override.
Keyword Args:
Same as to `add_field()`.
"""
# Transform kwarg "field_type" to "type" (a reserved Python keyword)
if "field_type" in kwargs:
kwargs["type"] = kwargs["field_type"]
# Transform kwarg "default" to "defaultValue", since "default"
# is a reserved word in JavaScript
if "default" in kwargs:
kwargs["defaultValue"] = kwargs["default"]
self._field_overrides[field_name].update({
property_name: property_value
for property_name, property_value in six.iteritems(kwargs)
if property_name in self.OVERRIDE_FIELD_PROPERTIES
})
class LocalizedJSONEncoder(DjangoJSONEncoder):
"""
JSON handler that evaluates ugettext_lazy promises.
"""
# pylint: disable=method-hidden
def default(self, obj):
"""
Forces evaluation of ugettext_lazy promises.
"""
if isinstance(obj, Promise):
return force_text(obj)
super(LocalizedJSONEncoder, self).default(obj)
def serializer_is_dirty(preference_serializer):
"""
Return True if saving the supplied (Raw)UserPreferenceSerializer would change the database.
"""
return (
preference_serializer.instance is None or
preference_serializer.instance.value != preference_serializer.validated_data['value']
)