Pull to refresh

Проблемы в библиотеке форм Django на примере поля ввода телефонов

Reading time 5 min
Views 9.8K
Как известно, в состав Django входит библиотека для генерации и обслуживания html-форм. Когда-то с Django в комплекте шла другая библиотека форм, но потом она была полностью переписана. Наверное, тогда разработчики решили много архитектурных проблем. Но и при работе с текущей библиотекой есть некоторые сложности. Именно об этом я и хочу поговорить.

Итак, задача. Пользователи очень любят оставлять на сайтах свои телефоны и другую приватную информацию. Причем, делать это они хотят, не задумываясь о том, как правильно её ввести: 8(908)1271669 или, скажем, 908 127 16 69. Посетители сайта очень любят видеть правильные телефоны, желательно единообразно оформленные: (+7 495) 722-16-25, +7 968 127-31-32. Получается, нужно валидировать и хранить номера в нормализованном виде, то есть без оформления. В поле, про которое я буду рассказывать, можно ввести больше одного номера телефона. Формат хранения определим как последовательности из 11 цифр, разделенные пробелом.

Для дальнейшего повествования мне нужно кратко изложить принцип работы форм. Форма состоит из класса Form и набора полей, входящих в форму (класс Field). При первом создании форме передается словарь initial — начальные значения для полей. Если речь идет о ModelForm, словарь initial автоматически создается из переданного при создании формы экземпляра модели. Класс Form предоставляет интерфейс для генерации кода самой html-формы. Процессом заведуют экземпляры класса BoundField, связывающие поля и содержащиеся в форме данные. Сам html-код генерируют виджеты (класс Widget). Когда пользователь отправляет заполненную форму, конструктору формы передается словарь data — содержимое POST-запроса. Теперь поля формы должны проверить ввод пользователя и убедиться, что все поля заполнены верно. В случае ошибки форма генерируется еще раз, но в качестве значений для полей берется уже не словарь initial, а словарь пользовательского ввода data.

Как можно заметить, у данных внутри формы есть три маршрута: из приложения к пользователю (через initial при первом создании формы), от пользователя к пользователю (повторное отображение ошибочно введенных данных) и от пользователя к приложению (если введенные данные корректны). Что же, задача кажется простой. Нужно вклиниться в первый и третий маршрут, форматируя телефоны для пользователя и нормализуя для приложения. Начнем с последнего.

Для начала сделаем болванку для будущего поля. Очевидно, что оно должно быть унаследовано от CharField.

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

В документации описаны все методы, участвующие в обработке значения при валидации. to_python() служит для приведения к корректному для приложения типу данных. Но у нас тип данных — строка, поэтому этот метод использовать не будем. Далее методы validate() и run_validators(). Служат для проверки корректности введенного значения, но не могут его изменить, потому тоже не подходят. Остается метод clean() у поля. В базовой реализации он вызывает вышеописанные методы в правильном порядке и возвращает окончательное значение. Значит, тут и разместим код.

    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)

Не буду подробно расписывать, как именно валидируется номер, думаю и так все видно.

Теперь маршрут от приложения к пользователю. В документации есть пример реализации поля MultiEmailField, которое возвращает приложению список email-адресов. Но вот о том, как оно этот список выводит пользователю, не сказано. Видимо, подразумевается, что эта задача ложится на плечи приложения, создающего форму. Других примеров тоже нет. Но мы не гордые, можем и в исходниках посмотреть.

У класса BoundField есть метод as_widget(), который передает настоящему виджету значение поля, которое нужно отобразить, вызывая свой метод value(). Именно в этом методе определяется, что является источником данных — data или initial. И тут нас ждет большое разочарование: если данные берутся из initial, то полю никак нельзя встроиться в процесс и изменить данные. Метод value() просто вызывает self.form.initial.get(self.name) и потом вне зависимости от источника данных передает их методу prepare_value() поля. Получается, что все значения проходят один и тот же конвейер, в конце которого должно получиться «правильное» значение.

Либо я что-то не понял, либо Джанговские формы действительно спроектированы так, что только само приложение может подготовить данные для вывода в форме. В словаре initial в момент создания формы уже должны быть данные, готовые ко вставке в html.

«Но постойте, а как же работает DatetimeField, которое спокойно принимает datetime в качестве initial?» — скажете вы. Вот и я подумал, как. Оказалось, значение, полученное из неизвестного источника, передается в метод render() виджета DateTimeInput, который в свою очередь передает его в свой метод _format_value(). И уже этот метод, если находит, что значение является datetime, преобразует его в строку. Почему нельзя сделать также в нашем случае? Потому что тип значения, переданного из приложения и полученного при отправке формы, одинаковый. В обоих случаях это строка.

Тем не менее, решение нужно и оно есть. Если еще раз посмотреть на метод BoundField.value(), можно заметить, что значение, получаемое от пользователя, дополнительно передается в метод bound_data(). Следовательно, в методе prepare_value(), куда значение попадает после, можно определить, откуда оно получено, если предварительно его пометить. Так и сделаем.

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))

Ура! Теперь телефоны форматируются, когда выводятся в форме первый раз, и не меняются, когда отредактированные данные приходят от пользователя. А вот так можно отформатировать телефоны.

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

Осталось только указать в конструкторе формы код города, к которому привязан редактируемый объект.

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

Ну а для вывода телефонов на сайте подойдет такой фильтр:

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

Конечно, можно считать, что поставленную задачу решить удалось. Но явно не без костылей. То же самое можно сказать и о реализаций полей, идущих в комплекте с Django. Например, в методе to_python() того же поля DateTimeField есть проверка на то, что значение уже типа datetime. При этом метод to_python() вызывается только для значений, полученных из словаря data. В документации к формам про содержимое словаря data явно сказано: «These will usually be strings, but there's no requirement that they be strings». Видимо, это имеет какой-то смысл для валидации чего-то иного, нежели пользовательского ввода, пришедшего из post-запроса. Но такая гибкость вносит неопределенность и делает валидацию эвристической, а не алгоритмической задачей.
Tags:
Hubs:
+18
Comments 22
Comments Comments 22

Articles