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

    Как известно, в состав 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-запроса. Но такая гибкость вносит неопределенность и делает валидацию эвристической, а не алгоритмической задачей.

    Похожие публикации

    Средняя зарплата в IT

    113 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 5 184 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 22

      +1
      На самом деле многое можно упростить:

      1. python-phonenumbers
      2. The “local flavor” add-ons
        +1
        Кстати, в версии Django 1.5 убрали localflavor из Django и перенесли в отдельные репозитории. Например для россии тут. Чтобы ими пользоваться придется их дополнительно устанавливать для каждой страны.
      –1
      Спасибо за статью. Небольшое примечание к стилистике:

      if not phone:
      

      ->

      if phone is None:
      


      то же самое с if not value ...
        +1
        Это же разные проверки. В данном случае все верно.
          0
          Вот уж не согласен. Вторая строка дзен Python: «Explicit is better than implicit.». В Python not X может означать всё что угодно, например:

          х = 0 
          not x       # тут всё просто
          >>> True
          
          x = ""
          not x       # а здесь уже работает len(x)
          >>> True
          
          х = []
          not x       # и здесь
          >>> True
          
          x = {}
          not x      # то же самое
          >>> True
          
          x = None 
          not x       # а вот так писать - прямая дорога в Питоний ад. 
          >>> True
          


          Я в своё время получил много головной, ожидая в функции строковый аргумент такого вида:

          def f(s = None):
              if not s:
                  print 's is None'
          


          Проблемы начинались тогда, когда s == "".
            +1
            Вы не согласны с чем? С тем что not и is None — разные проверки, которые дают разный результат? Откуда там None возмется?
              +2
              Да, с None я погорячился. Но тем не менее, при беглом просмотре кода, phone != '' мне скажет гораздо больше, чем not phone. При not phone я предполагаю, что phone содержит булевое значение.
        0
        «Я использую инпут для одной строки, хочу туда писать много строк. Джанга не удобна».

        Офигено. Вам дают отличные инструменты для работы с подобными задачами FormSet и InlineFormSet, почему их не юзаете?
          0
          Да нет же, строка одна насколько я понял… Я вот только не понимаю чем автору не угодило:
          1. хранить в базе готовые отформатированные номера
          2. отформатировать номер ровно 1 раз — при получении его от юзера (ну будет он в форме по новому выводится — дак и хрен с ним).
            0
            Если хранить отформатированные номера, то для отправки на них смс и для ссылок href=«tel:xxx» придется форматирование вырезать.
              0
              Ну а так приходится его добавлять при выводе. Какая разница? Вам принципиально надо было шишку на ровном месте набить? Поздравляю — Вы это сделали.
                0
                Разница в том, что в базе данных должны храниться данные.
            0
            Потому что искренне убежден, что пользователю удобнее ввести два телефона через запятую, чем нажимать плюсик и ставить фокус в следующее поле вода.
              0
              Ну так ставьте фокус автоматом, после нажатия на «плюсик».Да и потом, какая надобность ДВА телефона указывать в ОДНОМ поле? Обычная практика — мобильный и стационарный номер.
              Какой же ширины поле должно быть? А если не большой ширины, то что бы проверить то, что пользователь ввел — надо курсор двигать влево-вправо? Тоже не очень удобно.
                0
                Я не знаю что за обычная практика. В моем случае пользователь может указать нужное ему кол-во номеров, по которым ему могут позвонить клиенты. Домашних или рабочих номеров там нет. Тем не менее, большинство пользователей указывают один номер и второе поле и плюсик только усложняют форму.
                –1
                Ну добавляйте поля по клику пользователя на пробел, запятую и т.д. В чем проблема?
                  0
                  И где вы такое видели? И как это будет работать без js? И если нажать бекспейс в пустом поле, поле нужно убрать (пользователь удаляет виртуальную запятую, которую не видит)? И в чем будет отличие от .split(",") на сервере и от одного поля для пользователя?

                  Придумываете уже какой-то бред, лишь бы доказать первоначальный тезис, что я сделал что-то не так.
                    0
                    Без JS стандартный метод работы с Formset, всегда пара свободных полей дополнительно.

                    Вы действительно делает что-то не так )

                    Много телефонов, к одной модели. Это однозначно FK (если не M2M, зависит от данных). Зачем запихивать данные, которые должны храниться как FK в модель через CharField? Ошибка в структуре хранения данных.
                      0
                      > однозначно
                      > которые должны
                      > ошибка
                      Я так не считаю.
              0
              Согласен с тем, что в модели FK на телефонный номер, а в шаблоне формсет, кстати Вы можете оставить одно поле, если Вам так хочется, и вводить номера через запятую, а по сабмиту вызвать js, который преобразует всё в формсет. Но как по мне — то с плюсиком удобнее (с автофокусом).
                0
                вот снипет нашёл, наткнувшись на эту статью http://djangosnippets.org/snippets/2811/

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое