Вложенные формсеты в Django
Задача стояла следующая:
сделать карточку создания-редактирования тренировки, которое состоит из упражнений, каждое из которых состоит из подходов. При этом должна быть возможность создавать тренировку по шаблону, при этом должны выводиться формы упражнений с предзаполненными данными, а также пустые определённого количества форм для ввода подходов (количество задаётся в шаблоне). Также должна сохраниться валидация всех форм, невозможность сохранения тренировок без упражнений и упражнений без подходов.
Для этого понадобилась одна ModelForm и два основательно переопределёных InlineFormSet.
Остановлюсь на некоторых особенностях формсетов:
-
Методы, отвечающие за выяснение того, по данным каких форм нужно создавать новые модели подходов, а какие нужны для изменения информации о старых. По умолчанию эти методы основываются на количестве форм self.initial_form_count(), а мне нужно было уметь перемещать эти формы, поэтому решил основываться на том, есть ли у формы заполненный первичный ключ.
123456789def _get_initial_forms(self):"""Возвращает все начальные формы"""return [form for form in self.forms if form.instance.training_exercise_id is not None and hasattr(form, 'cleaned_data') and form.cleaned_data[self._pk_field.name] is not None]initial_forms = property(_get_initial_forms)def _get_extra_forms(self):"""Возвращает все добавленные формы"""return [form for form in self.forms if form.instance.training_exercise_id is None]extra_forms = property(_get_extra_forms) -
Сохранение существующей модели переделано, т.к. в противном случае будет создаваться новая модель (не будет найден первичный ключ)
1234def save_existing( self, form, instance, commit = True ):"""Сохраняет существующую модель."""setattr(form.instance, self._pk_field.name, getattr(instance, instance._meta.pk.attname))return super( BaseTrainingExerciseSetFormset, self ).save_existing(form, instance, commit=commit) -
Добавление полей в форму упражнения добавляет ещё и определённое в шаблоне тренировки количество пустых форм для добавления данных по подходам упражнения, разное для разных упражнений. Для этого при создании формсета задаётся self._nested_extras — словарь с ключом — номером формы и значением — количеством форм для ввода подходов.
Если на странице пользователь добавлет упражнение, то посылается POST-запрос, поэтому self.data будет заполнено, а данных по вложенному формсету подходов для создаваемого упражнения в это свойстве нет, от чего у Django возникает когнитивный диссонанс, поэтому при создании формсета задаётся self._nested_allow_using_data — словарь с ключами-номерами форм и булевым значением, искать ли данные для вложенного формсета подходов в POST-е при валидации форм.
Используются эти параметры так:
1234567891011121314def add_fields(self, form, index):...# при создании упражнения по шаблону необходимо вывести заданное в шаблоне количество форм для подходовextra = self._nested_extras.get(index, 1)# использовать для вложенного формсета данные, переданные POST-ом.# нужно запретить при добавлении упражнения, чтобы не было ошибок, связанных с отсутсвием management_form для вложенного формсетаallow_using_data = self._nested_allow_using_data.get(index, True)# кладём вложенный формсет в свойство nestedform.nested = getTrainingExerciseSetFormset(extra)(data = self.data if allow_using_data else None,instance = instance,prefix = 'sets_%s' % pk_value) -
Сохранение формсета упражнений: переопределённый метод save_all сначала сохраняет формы без коммита, чтобы переопределённый метод save_new для каждой вложенной формы подхода заполнил внешний ключ — ссылку на модель упражнения. После этого сохраняются все формы и вложенные формы.
12345678910111213141516171819def save_all(self, commit=True):objects = self.save(commit=False)if commit:for o in objects:o.save()if not commit:self.save_m2m()for form in set(self.initial_forms + self.saved_forms):if self.should_delete(form):continueform.nested.save(commit=commit)def save_new( self, form, commit = True ):instance = super( BaseTrainingExerciseFormset, self ).save_new( form, commit = commit )form.instance = instanceform.nested.instance = instancefor cd in form.nested.cleaned_data:cd[form.nested.fk.name] = instancereturn instance
Вот полный код:
views.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 |
from django.forms.models import inlineformset_factory, BaseInlineFormSet from django.forms.formsets import DELETION_FIELD_NAME, TOTAL_FORM_COUNT from django.forms import formsets from django.core.exceptions import ObjectDoesNotExist ### Формсет подходов ### class BaseTrainingExerciseSetFormset( BaseInlineFormSet ): def should_delete(self, form): if self.can_delete: raw_delete_value = form._raw_value(DELETION_FIELD_NAME) should_delete = form.fields[DELETION_FIELD_NAME].clean(raw_delete_value) return should_delete def save_existing( self, form, instance, commit = True ): """Сохраняет существующую модель.""" setattr(form.instance, self._pk_field.name, getattr(instance, instance._meta.pk.attname)) return super( BaseTrainingExerciseSetFormset, self ).save_existing(form, instance, commit=commit) def _get_initial_forms(self): """Возвращает все начальные формы""" return [form for form in self.forms if form.instance.training_exercise_id is not None and hasattr(form, 'cleaned_data') and form.cleaned_data[self._pk_field.name] is not None] initial_forms = property(_get_initial_forms) def _get_extra_forms(self): """Возвращает все добавленные формы""" return [form for form in self.forms if form.instance.training_exercise_id is None] extra_forms = property(_get_extra_forms) def getTrainingExerciseSetFormset(extra=1): return inlineformset_factory(TrainingExercise, TrainingExerciseSet, formset=BaseTrainingExerciseSetFormset, extra=extra) ### Формсет тренировок ### class BaseTrainingExerciseFormset( BaseInlineFormSet ): # Использовать для вложенного формсета данные, переданные POST-ом (по умолчанию всем можно) _nested_allow_using_data = {} # для каждого вложенного формсета определяет кол-во пустых форм для отображения (для создания тренировки по шаблону) _nested_extras = {} # начальные данные для заполнения форм _initials = [] def _construct_form(self, i, **kwargs): """ Переопределяем, чтобы поменять параметр empty_permitted, и для задания начальных значений полей упражнений """ try: kwargs['initial'] = self._initials[i] except IndexError: pass form = super(BaseInlineFormSet, self)._construct_form(i, **kwargs) form.empty_permitted = False return form def add_fields(self, form, index): # надкласс создаёт поля как обычно super( BaseTrainingExerciseFormset, self ).add_fields( form, index ) # создание вложенного формсета try: instance = self.get_queryset()[index] pk_value = instance.pk except IndexError: instance=None pk_value = hash(form.prefix) # при создании упражнения по шаблону необходимо вывести заданное в шаблоне количество форм для подходов extra = self._nested_extras.get(index, 1) # использовать для вложенного формсета данные, переданные POST-ом. # нужно запретить при добавлении упражнения, чтобы не было ошибок, связанных с отсутствием management_form для вложенного формсета allow_using_data = self._nested_allow_using_data.get(index, True) # кладём вложенный формсет в свойство nested form.nested = getTrainingExerciseSetFormset(extra)( data = self.data if allow_using_data else None, instance = instance, prefix = 'sets_%s' % pk_value ) def is_valid( self ): result = super( BaseTrainingExerciseFormset, self ).is_valid() for form in self.forms: if hasattr(form, 'nested'): # проверяем на валидность каждую вложенную форму result = result and form.nested.is_valid() return result def clean(self): """Нужен для проверки хотя бы одного упражнения в тренировке и хотя бы одного подхода в каждом упражнении""" super(BaseTrainingExerciseFormset, self).clean() count_valid_forms = 0 for form in self.forms: if not self.should_delete(form): count_valid_nested_forms = 0 try: if form.cleaned_data: count_valid_forms += 1 except AttributeError: pass if hasattr(form, 'nested') and form.nested.forms: for nested_form in form.nested.forms: if nested_form.errors: break try: if nested_form.cleaned_data and not form.nested.should_delete(nested_form): count_valid_nested_forms += 1 break except AttributeError: # если вложенная форма не валидна, Django возбуждает # исключение AttributeError для cleaned_data pass if not nested_form.errors and count_valid_nested_forms < 1: raise forms.ValidationError( 'в каждом упражнении должен быть хотя бы один подход' ) else: raise forms.ValidationError( 'в каждом упражнении должен быть хотя бы один подход' ) if count_valid_forms < 1: raise forms.ValidationError( 'в тренировке должно быть хотя бы одно упражнение' ) def save_all(self, commit=True): """Сохранение всех формсетов с вложенными формсетами.""" # Сохраняем без коммита, чтобы получить self.saved_forms # для доступа к вложенным формсетам objects = self.save(commit=False) # Сохраняем модели, если commit=True if commit: for o in objects: o.save() # сохраняем поля многие-ко-многим if not commit: self.save_m2m() # сохраняем вложенные формы for form in set(self.initial_forms + self.saved_forms): if self.should_delete(form): continue form.nested.save(commit=commit) def save_new( self, form, commit = True ): """Создаёт модель по данным формы и возвращает её.""" instance = super( BaseTrainingExerciseFormset, self ).save_new( form, commit = commit ) # обновляем ссылку на объект формы form.instance = instance # обновляем ссылку у вложенных форм form.nested.instance = instance # проходимся по cleaned_data вложенных формсетов и обновляем ссылку в foreignkey for cd in form.nested.cleaned_data: cd[form.nested.fk.name] = instance return instance def save_existing( self, form, instance, commit = True ): """Изменяет существующую модель.""" setattr(form.instance, self._pk_field.name, getattr(instance, instance._meta.pk.attname)) return super( BaseTrainingExerciseFormset, self ).save_existing(form, instance, commit=commit) def total_form_count(self): """Переопределяем, чтобы учесть самописные initials""" if self.data or self.files: return self.management_form.cleaned_data[TOTAL_FORM_COUNT] + self.extra try: initials_len = len(self._initials) except TypeError: initials_len = 0 return initials_len + self.initial_form_count() + self.extra def should_delete(self, form): """Определяет по данным формы, нужно ли её удалять""" if self.can_delete: raw_delete_value = form._raw_value(DELETION_FIELD_NAME) should_delete = form.fields[DELETION_FIELD_NAME].clean(raw_delete_value) return should_delete return False def _get_initial_forms(self): """Возвращает список всех начальных форм формсета.""" return [form for form in self.forms if form.instance.training_id is not None and hasattr(form, 'cleaned_data') and form.cleaned_data[self._pk_field.name] is not None] initial_forms = property(_get_initial_forms) def _get_extra_forms(self): """Возвращает список всех добавленных форм формсета.""" return [form for form in self.forms if form.instance.training_id is None or not hasattr(form, 'cleaned_data') or (hasattr(form, 'cleaned_data') and form.cleaned_data[self._pk_field.name] is None)] extra_forms = property(_get_extra_forms) @classmethod def set_nested_extras(cls, nested_extras): """Костыль для задания кол-ва пустых вложенных форм для каждой формы""" cls._nested_extras = nested_extras @classmethod def set_initials(cls, initials): """Костыль для задания начальных данных форм""" cls._initials = initials @classmethod def set_nested_allow_using_data(cls, nested_allow_using_data): """Костыль для запрета использования данных POST-а при сохранении некоторых вложенных форм""" cls._nested_allow_using_data = nested_allow_using_data def getTrainingExerciseFormset(extra, nested_extras={}, initials=[], nested_allow_using_data={}): """Обёртка над inlineformset_factory для установления всех дополнительных параметров: количества пустых форм для ввода упражнений, количества пустых форм для ввода подходов для каждого упражнения и начальных данных для форм ввода упражнения""" BaseTrainingExerciseFormset.set_nested_extras(nested_extras) BaseTrainingExerciseFormset.set_initials(initials) BaseTrainingExerciseFormset.set_nested_allow_using_data(nested_allow_using_data) return inlineformset_factory(Training, TrainingExercise, formset=BaseTrainingExerciseFormset, extra=extra) @login_required def trainings_add( request, trainingTemplateId = None ): """Добавление тренировки""" user = request.user try: trainingTemplate = TrainingTemplate.objects.get( id = int(trainingTemplateId) ) if trainingTemplateId else None except ObjectDoesNotExist: trainingTemplate = None training = Training() if request.method == 'POST': nameForm = TrainingNameForm(request.POST, instance=training) formset = getTrainingExerciseFormset(extra=0)(request.POST, instance=training) if nameForm.is_valid() and formset.is_valid(): nameData = nameForm.cleaned_data training.user = user training.name = nameData['name'] training.description = nameData["description"] training.training_at = nameData['training_at'] if trainingTemplate: training.trainingTemplate = trainingTemplate training.user_weight = user.get_profile().weight training.save() formset.save_all() return HttpResponseRedirect( "/trainings/%s/card/" % training.id ) else: if trainingTemplate is not None: trainingTemplateExercises = trainingTemplate.trainingtemplateexercise_set.order_by('position') training.name = trainingTemplate.name training.description = trainingTemplate.description training.user = user training.training_at = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') initials = [] for position, templateExercise in enumerate(trainingTemplateExercises): initials.append({ 'exercise': templateExercise.exercise, 'position': templateExercise.position }) nested_extras=dict((p, tE.sets) for p, tE in enumerate(trainingTemplateExercises)) formset = getTrainingExerciseFormset(extra=0, nested_extras=nested_extras, initials=initials)(instance=training) else: formset = getTrainingExerciseFormset(extra=1)(instance=training) nameForm = TrainingNameForm( instance = training ) return render_to_response( "trainings/add.html", { "training": training, "trainingName": nameForm, "exercises": formset, "hide_exercises": "" }, context_instance = RequestContext( request ) ) @login_required def trainings_add_exercise(request, trainingId=None): """Экшн для добавления формы упражнения""" user = request.user training = Training.objects.get(id=trainingId, user=user) if trainingId is not None else Training() if request.method == 'POST': nested_allow_using_post_data = {int(request.POST.get('trainingexercise_set-TOTAL_FORMS', 0)): False} formset = getTrainingExerciseFormset(extra=1, nested_allow_using_data=nested_allow_using_post_data)(request.POST, instance=training) else: formset = getTrainingExerciseFormset(extra=1)(instance=training) formset.forms = formset.forms[-1:] return render_to_response("trainings/exercise_forms.html", {"exercises": formset}, context_instance=RequestContext(request)) |
Соответствующий шаблон:
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 |
{% block content %} <div class="training"> {% csrf_token %} <ul> {{ trainingName.as_ul }} {{ exercises.management_form }} </ul> {{ exercises.non_form_errors }} {% for exerciseForm in exercises.forms %} <ul class="exercise"> {{ exerciseForm.as_ul }} {% if exerciseForm.nested %} {{ exerciseForm.nested.management_form }} <ul class="exercise-sets"> <li>Подходы:</li> {% for exerciseSetForm in exerciseForm.nested.forms %} {{ exerciseSetForm.as_ul }} {% endfor %} </ul> {% endif %} </ul> {% endfor %} </div> <input type="hidden" name="hide_exercises" value="{{ hide_exercises }}" /> {% endblock %} |
Использованы статьи:
http://docs.djangoproject.com/en/dev/topics/forms/modelforms/#inline-formsets ;)
http://yergler.net/blog/2009/09/27/nested-formsets-with-django/
http://stackoverflow.com/questions/877723/inline-form-validation-in-django/1884760
http://stackoverflow.com/questions/1206903/how-do-i-require-an-inline-in-the-django-admin
http://stackoverflow.com/questions/442040/pre-populate-an-inline-formset
Similar Posts
LEAVE A COMMENT
Для отправки комментария вам необходимо авторизоваться.
2 Responses so far.