Custom cleaning in a CreateView

I have a Foo table and a Tags table - foo and tags are a many to many relationship

When I create a Foo - I would like to use a CreateView form view. But I would like the entry for the tags to be a text widget.

I thought I could set tags to be a TextInput widget and add a “clean_tags” method on the FooCreateViewForm (where I could create new tags if I needed to - but also convert the tag names into tags to be used by the CreateView) - but that clean method doesn’t seem to be called and the form says “Enter a list of values” for the Tags field.

class FooCreateViewForm(forms.ModelForm):
    class Meta:
        model = Foo
        fields = ["name", "description", "tags"]
        widgets = {
            "description": Textarea(attrs={"cols": 80, "rows": 10}),
            "tags": TextInput()
        }

    def clean_tags(self):
        data = self.cleaned_data['tags']
        form_tags = []
        with transaction.atomic():
            for tag_string in data:
                tag = Tag.objects.filter(name=tag_string).first()
                if tag:
                    form_tags.append(tag)
                else:
                    tag_id = uuid.uuid4()
                    new_tag = Tag(id=tag_id, name=tag_string)
                    new_tag.save()
                    form_tags.append(new_tag)
        return form_tags



class FooCreateView(
    SuccessMessageMixin, LoginRequiredMixin, CreateView
):
    template_name = "foos/foo_create.html"
    model = Foo
    form_class = FooCreateViewForm
    success_message = "%(name)s foo was created successfully"

    def get_success_url(self):
        return Foo.get_absolute_url(self.object.id)

    def get_success_message(self, cleaned_data):
        return self.success_message % dict(cleaned_data)


    def form_valid(self, form):
        # not really worried about this yet - as the clean function isn't called
        clean_data = form.cleaned_data
        return super().form_valid(form)

can we see what you get as a result and your model as well?

SInce your tags field is not a direct association with the tags field in the model, you don’t want to make that a model form field. You want to create an extra field in the form for the tags, and then parse/process those tags as individual entries within the form.

Once you’ve validated what has been submitted, you would then want to save the form for Foo such that you get a valid Foo object, and then add the individual instances of Tags to Foo.

1 Like

Thanks Ken - exactly what I needed. It takes a little while to understand where the logic should go - the form or the view.

class FooCreateViewForm(forms.ModelForm):
    tag_string = CharField(help_text="Use single words separated with spaces. "
                                     "Use dashes for multi-word tags.")

    class Meta:
        model = Foo
        fields = ["name", "description"]
        widgets = {
            "description": Textarea(attrs={"cols": 80, "rows": 10}),
        }

    def save(self, commit=True):
        cleaned_data = self.cleaned_data
        data = cleaned_data['tag_string'].split(" ")
        form_tags = []
        with transaction.atomic():
            for tag_string in data:
                tag = Tag.objects.filter(name=tag_string).first()
                if tag:
                    form_tags.append(tag)
                else:
                    tag_id = uuid.uuid4()
                    new_tag = Tag(id=tag_id, name=tag_string)
                    new_tag.save()
                    form_tags.append(new_tag)
        m = super().save(commit=commit)
        m.tags.set([t.id for t in form_tags])
        m.save()
        return m

Creating forms from models | Django documentation | Django - shows that fields excluded from the form are not saved - and can either be passed in as an instance - or mutated after the save (just set commit to false to prevent double mutations).

This works locally.

1 Like

Side note: The set method on the objects should accept an iterable of the objects themselves. I don’t believe you need to iterate over your list to produce a list of IDs to add.
I believe you should be able to do this: m.tags.set(form_tags). (See Related objects reference | Django documentation | Django)

Also, you could probably replace this:

With something like this:

tag, created = Tag.objects.get_or_create(name=tag_string)
form_tags.append(tag)

… assuming you set the right default for the id field in the Tag model. (Aside from actually creating the tag, I don’t see where you “care” in this code whether a new Tag was created or an existing one used.)

Side note #2: There’s a race condition here where you could end up with more than one instance of a Tag with the same name - possibly creating a situation where a search for a tag doesn’t retrieve all the matching instances.

1 Like

Just to be clear - the race condition would be cleared up if you use the get_or_create method and the transaction atomic? Or is there more to think about.

Yes, my reading of the get_or_create method looks to me like it resolves the race condition. You can see the source for it in django.db.models.query.QuerySet - you might find it to be an interesting read.

However, you still might want to either:

  • Remove the id field from Tag and make name the primary key
  • Add a Unique Constraint on name