Problems in the Django form library using the phone input field as an example

    As you know, Django includes a library for generating and maintaining html forms. Once upon a time, another form library came with Django, but then it was completely rewritten. Probably, then the developers solved a lot of architectural problems. But there are some difficulties when working with the current library. That's what I want to talk about.

    So the challenge. Users are very fond of leaving their phones and other private information on the sites. Moreover, they want to do this without thinking about how to enter it correctly: 8 (908) 1271669 or, say, 908 127 16 69. Website visitors are very fond of seeing the correct phones, preferably uniformly decorated: (+7 495) 722- 16-25, +7 968 ​​127-31-32. It turns out that you need to validate and store the numbers in a normalized form, that is, without registration. In the field about which I will talk, you can enter more than one phone number. The storage format is defined as a sequence of 11 digits separated by a space.

    For further narration, I need to briefly outline the principle of the work of forms. A form consists of the Form class and a set of fields included in the form (Field class). When the form is first created, the initial dictionary is passed to the form - the initial values ​​for the fields. When it comes to ModelForm, the initial dictionary is automatically created from the model instance passed when the form was created. The Form class provides an interface for generating code for the html form itself. The process is controlled by instances of the BoundField class, linking fields and data contained in the form. The html code itself is generated by widgets (Widget class). When the user submits the completed form, the data constructor is transferred to the form constructor - the contents of the POST request. Now the form fields should check the user input and make sure that all fields are filled in correctly. In case of an error, the form is generated again,

    As you can see, the data inside the form has three routes: from the application to the user (through initial when the form was first created), from user to user (re-display of erroneously entered data) and from user to application (if the data entered is correct). Well, the task seems simple. It is necessary to wedge into the first and third routes, formatting the phones for the user and normalizing for the application. Let's start with the last one.

    First, we’ll make a blank for the future field. Obviously, it must be inherited from CharField.

    class MultiplePhoneFormField(forms.CharField):
        # Если код города известен, нужно задать его в конструкторе формы.
        phone_code = ''
    

    The documentation describes all the methods involved in processing the value during validation. to_python () is used to cast to the correct data type for the application. But our data type is a string, so we will not use this method. Next, the validate () and run_validators () methods. They are used to verify the correctness of the entered value, but they cannot change it, therefore they are also not suitable. What remains is the clean () method of the field. In the base implementation, it calls the above methods in the correct order and returns the final value. So, here we will place the code.

        def clean(self, phones):
            phones = super(MultiplePhoneFormField, self).clean(phones)
            cleaned_phones = []
            for phone in phones.split(','):
                phone = re.sub(r'[\s +.()\-]', '', phone)
                if not phone:
                    continue
                if not phone.isdigit():
                    raise ValidationError(u'Можно использовать только цифры.')
                if len(phone) == 11:
                    pass
                elif len(phone) == 10:
                    phone = '7' + phone
                elif len(self.phone_code + phone) == 11:
                    phone = self.phone_code + phone
                else:
                    raise ValidationError(u'Проверьте количество цифр.')
                cleaned_phones.append(phone)
            return ' '.join(cleaned_phones)
    

    I will not describe in detail how the number is validated, I think everything is clear.

    Now the route from the application to the user. The documentation has an example implementation of the MultiEmailField field, which returns a list of email addresses to the application. But it’s not said how it displays this list to the user. Apparently, it is implied that this task rests with the application creating the form. There are no other examples either. But we are not proud, we can see in the source.

    The BoundField class has an as_widget () method, which passes the value of the field to be displayed to the real widget by calling its value () method. It is in this method that it is determined what is the source of the data - data or initial. And here we are waiting for a big disappointment: if the data is taken from initial, then the field cannot be built into the process and change the data. The value () method simply calls self.form.initial.get (self.name) and then, regardless of the data source, passes them to the prepare_value () method of the field. It turns out that all values ​​pass the same pipeline, at the end of which the “correct” value should be obtained.

    Either I didn’t understand something, or the Djangov forms are really designed so that only the application itself can prepare the data for output in the form. In the initial dictionary at the time of creating the form, there should already be data ready to be inserted into html.

    “But wait, how does DatetimeField work, which calmly accepts datetime as initial?” You say. So I thought how. It turned out that the value obtained from an unknown source is passed to the render () method of the DateTimeInput widget, which in turn passes it to its _format_value () method. And already this method, if it finds that the value is datetime, converts it to a string. Why can not it be done also in our case? Because the type of value passed from the application and received when the form was submitted is the same. In both cases, this is a string.

    Nevertheless, a solution is needed and there is one. If you look again at the BoundField.value () method, you will notice that the value received from the user is additionally passed to the bound_data () method. Therefore, in the prepare_value () method, where the value falls after, you can determine where it came from if you mark it first. So let's do it.

    class ValueFromDatadict(unicode):
        pass
    class MultiplePhoneFormField(forms.CharField):
        # Если код города известен, нужно задать его в конструкторе формы.
        phone_code = ''
        def bound_data(self, data, initial):
            return ValueFromDatadict(data)
        def prepare_value(self, value):
            if not value or isinstance(value, ValueFromDatadict):
                return value
            return ', '.join(format_phones(value, code=self.phone_code))
    

    Hurrah! Now the phones are formatted when they are displayed in the form for the first time, and do not change when the edited data comes from the user. And so you can format the phones.

    def format_phones(phones, code=None):
        for phone in filter(None, phones.split(' ')):
            if len(phone) != 11:
                # нестандартный телефон.
                pass
            elif phone[0:4] == '8800':
                # 8 800 100-31-32
                phone = u'8 800 %s-%s-%s' % (phone[4:7], phone[7:9], phone[9:11])
            elif code and phone.startswith(code):
                # (+7 351) 722-16-25
                # (+7 3512) 22-16-25
                phone = phone[len(code):]
                phone = u'(+%s %s) %s-%s-%s' % (code[0], code[1:], phone[:-4], phone[-4:-2], phone[-2:])
            else:
                # +7 968 127-31-32
                phone = u'+%s %s %s-%s-%s' % (phone[0], phone[1:4], phone[4:7], phone[7:9], phone[9:11])
            yield phone
    

    It remains only to indicate in the form designer the city code to which the edited object is attached.

    class RestaurantForm(forms.ModelForm):
        phone = MultiplePhoneFormField(label=u'Телефон', required=False,
            help_text=u'Можно написать несколько телефонов через запятую.')
        def __init__(self, *args, **kwargs):
            super(RestaurantForm, self).__init__(*args, **kwargs)
            if self.instance:
                self.fields['phone'].phone_code = self.instance.city.phone_code
    

    Well, to display the phones on the site, this filter is suitable:

    @register.filter
    def format_phones_with_code(phones, code):
        return mark_safe(u', '.join([u'%s' % phone
            for phone in format_phones(phones, code)]))
    

    Of course, we can assume that the task was solved. But obviously not without crutches. The same can be said of field implementations bundled with Django. For example, in the to_python () method of the same DateTimeField field, there is a check that the value is already of type datetime. In this case, the to_python () method is called only for values ​​obtained from the data dictionary. The  documentation for forms about the contents of the data dictionary explicitly says: "These will usually be strings, but there's no requirement that they be strings." Apparently, this makes some sense for validating something other than user input coming from a post request. But such flexibility introduces uncertainty and makes validation a heuristic rather than an algorithmic task.

    Also popular now: