Why doesn't Django enforce my unique_together constraint as a form.ValidationError instead of throwing an exception?
Asked Answered
M

2

8

Edit: While this post is a duplicate of Django's ModelForm unique_together validation, the accepted answer here of removing the 'exclude' from the ModelForm is a much cleaner solution than the accepted answer in the other question.

This is a follow-up to this question.

If I don't explicitly check the unique_together constraint in the clean_title() function, django throws an exception:

IntegrityError at /journal/journal/4

duplicate key value violates unique constraint "journal_journal_owner_id_key"

Request Method: POST

Request URL: http://localhost:8000/journal/journal/4

Exception Type: IntegrityError

Exception Value: duplicate key value violates unique constraint "journal_journal_owner_id_key"

Exception Location: /Library/Python/2.6/site-packages/django/db/backends/util.py in execute, line 19

However I was under the impression that Django would enforce this constraint nicely by raising a ValidationError, not with an exception I need to catch.

Below is my code with an additional clean_title() method I use as a work-around. But I want to know what I'm doing wrong such that django is not enforcing the constraint in the expected manner.

Thanks.

Model code:

class Journal (models.Model):
    owner = models.ForeignKey(User, related_name='journals')
    title = models.CharField(null=False, max_length=256)
    published = models.BooleanField(default=False)

    class Meta:
        unique_together = ("owner", "title")

    def __unicode__(self):
        return self.title 

Form code:

class JournalForm (ModelForm):
    class Meta:
        model = models.Journal
        exclude = ('owner',)

    html_input = forms.CharField(label=u'Journal Content:', widget=TinyMCE(attrs={'cols':'85', 'rows':'40'}, ), )

    def clean_title(self):
        title = self.cleaned_data['title']
        if self.instance.id:
            if models.Journal.objects.filter(owner=self.instance.owner, title=title).exclude(id=self.instance.id).count() > 0:
               raise forms.ValidationError(u'You already have a Journal with that title. Please change your title so it is unique.')
        else:
            if models.Journal.objects.filter(owner=self.instance.owner, title=title).count() > 0:
               raise forms.ValidationError(u'You already have a Journal with that title. Please change your title so it is unique.')
        return title

View Code:

def journal (request, id=''):
    if not request.user.is_active:
        return _handle_login(request)
    owner = request.user
    try:
        if request.method == 'GET':
            if '' == id:
                form = forms.JournalForm(instance=owner)
                return shortcuts.render_to_response('journal/Journal.html', { 'form':form, })
            journal = models.Journal.objects.get(id=id)
            if request.user.id != journal.owner.id:
                return http.HttpResponseForbidden('<h1>Access denied</h1>')
            data = {
                'title' : journal.title,
                'html_input' : _journal_fields_to_HTML(journal.id),
                'published' : journal.published
            }
            form = forms.JournalForm(data, instance=journal)
            return shortcuts.render_to_response('journal/Journal.html', { 'form':form, })
        elif request.method == 'POST':
            if LOGIN_FORM_KEY in request.POST:
                return _handle_login(request)
            else:
                if '' == id:
                    journal = models.Journal()
                    journal.owner = owner
                else:
                    journal = models.Journal.objects.get(id=id)
                form = forms.JournalForm(data=request.POST, instance=journal)
                if form.is_valid():
                    journal.owner = owner
                    journal.title = form.cleaned_data['title']
                    journal.published = form.cleaned_data['published']
                    journal.save()
                    if _HTML_to_journal_fields(journal, form.cleaned_data['html_input']):
                        html_memo = "Save successful."
                    else:
                        html_memo = "Unable to save Journal."
                    return shortcuts.render_to_response('journal/Journal.html', { 'form':form, 'saved':html_memo})
                else:
                    return shortcuts.render_to_response('journal/Journal.html', { 'form':form })
        return http.HttpResponseNotAllowed(['GET', 'POST'])
    except models.Journal.DoesNotExist:
        return http.HttpResponseNotFound('<h1>Requested journal not found</h1>')

UPDATE WORKING CODE: Thanks to Daniel Roseman.

Model code stays the same as above.

Form code - remove exclude statement and clean_title function:

class JournalForm (ModelForm):
    class Meta:
        model = models.Journal

    html_input = forms.CharField(label=u'Journal Content:', widget=TinyMCE(attrs={'cols':'85', 'rows':'40'},),)

View Code - add custom uniqueness error message:

def journal (request, id=''):
    if not request.user.is_active:
        return _handle_login(request)
    try:
        if '' != id:
            journal = models.Journal.objects.get(id=id)
            if request.user.id != journal.owner.id:
                return http.HttpResponseForbidden('<h1>Access denied</h1>')
        if request.method == 'GET':
            if '' == id:
                form = forms.JournalForm()
            else:
                form = forms.JournalForm(initial={'html_input':_journal_fields_to_HTML(journal.id)},instance=journal)
            return shortcuts.render_to_response('journal/Journal.html', { 'form':form, })
        elif request.method == 'POST':
            if LOGIN_FORM_KEY in request.POST:
                return _handle_login(request)
            data = request.POST.copy()
            data['owner'] = request.user.id
            if '' == id:
                form = forms.JournalForm(data)
            else:
                form = forms.JournalForm(data, instance=journal)
            if form.is_valid():
                journal = form.save()
                if _HTML_to_journal_fields(journal, form.cleaned_data['html_input']):
                    html_memo = "Save successful."
                else:
                    html_memo = "Unable to save Journal."
                return shortcuts.render_to_response('journal/Journal.html', { 'form':form, 'saved':html_memo})
            else:
                if form.unique_error_message:
                    err_message = u'You already have a Lab Journal with that title. Please change your title so it is unique.'
                else:
                    err_message = form.errors
                return shortcuts.render_to_response('journal/Journal.html', { 'form':form, 'error_message':err_message})
        return http.HttpResponseNotAllowed(['GET', 'POST'])
    except models.Journal.DoesNotExist:
        return http.HttpResponseNotFound('<h1>Requested journal not found</h1>')
Maximalist answered 27/10, 2010 at 15:28 Comment(1)
Possible duplicate of Django's ModelForm unique_together validationEnravish
S
9

The trouble is that you're specifically excluding one of the fields involved in the unique check, and Django won't run the check in this circumstance - see the _get_unique_checks method in line 722 of django.db.models.base.

Instead of excluding the owner field, I would consider just leaving it out of the template and setting the value explicitly on the data you're passing in on instantiation:

 data = request.POST.copy()
 data['owner'] = request.user.id
 form = JournalForm(data, instance=journal)

Note that you're not really using the power of the modelform here. You don't need to explicitly set the data dictionary on the initial GET - and, in fact, you shouldn't pass a data parameter there, as it triggers validation: if you need to pass in values that are different to the instance's, you should use initial instead. But most of the time, just passing instance is enough.

And, on POST, again you don't need to set the values explicitly: you can just do:

journal = form.save()

which will update the instance correctly and return it.

Somnifacient answered 28/10, 2010 at 9:40 Comment(2)
Thanks, that all makes sense. One thing about setting the data dictionary on the initial get - each journal has a one-to-many relationship with the content. On save I parse the HTML returned and break it into chunks and save those. On load (GET) I "reconstitute" those chunks into a single HTML block. Is there a more Django-native or "Pythonic" way to define this data transformation to leverage the ModelForm?Maximalist
Btw this problem is reported as django bug 13091 code.djangoproject.com/ticket/13091Enidenigma
R
2

I think the philosophy here is that unique_together is an ORM concept, not a property of a form. If you want to enforce unique_together for a particular form, you can write your own clean method, which is easy, straightforward, and very flexible:

http://docs.djangoproject.com/en/dev/ref/forms/validation/#cleaning-and-validating-fields-that-depend-on-each-other

This will replace the clean_title method you have written.

Rugging answered 27/10, 2010 at 19:48 Comment(2)
Except that only one of the unique_together fields, title, is editable. The other is basically private - it's the foreign key linking a journal to the current user. So from that perspective I should keep the validation in the clean_title() since it's the only form field I'm validating, right?Maximalist
I see. In that case, you might try a ComboField? docs.djangoproject.com/en/dev/ref/forms/fields/…Rugging

© 2022 - 2024 — McMap. All rights reserved.