Eliezer Garza
Eliezer Garza

Reputation: 357

Handling Exceptions in Python Django Crispy Forms

I'm new in Python 3.8.2/Django 3.1.1, and I'm trying to figure out the correct way to add rules to models so they can be called from forms and web services, the issue I have is that some errors are not properly caught and they are generating an 500 Error.

This is my initial Model:

class TotalPoints(TimeStampedModel):
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='TotalPoints_Course', null=False, blank=False, verbose_name='Curso')
    kit = models.ForeignKey(Kit, on_delete=models.CASCADE, related_name='TotalPoints_Kit', null=False, blank=False, verbose_name='Kit')
    bought = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Comprados')
    assigned = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Asignados')
    available = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Disponibles')
    class Meta:
        db_table = 'app_totalpoints'
        constraints = [
            models.UniqueConstraint(fields=['course','kit'], name='app_totalpoints.course-kit')
        ]

fields are declared PositiveIntegerField to avoid negative values

this is my Form and CreateView

class TotalPointsForm(forms.ModelForm):
    class Meta:
        model = TotalPoints
        fields = ['id', 'course', 'kit', 'available', 'bought', 'assigned']

class TotalPointsCreateView(CreateView):
    model = TotalPoints
    form_class = TotalPointsForm
    template_name = 'app/form_base.html'
    success_url='../list/'

and this is my template:

{# app/templates/app/form_base.html #}
{% load crispy_forms_tags %}
<!doctype html>
<html lang="en">
    <head lang="es">
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
        <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.10.22/css/jquery.dataTables.css">
        <title>{% block page_title %}{% endblock %}</title>
    </head>
    <body>
        <div class="container-fluid">
            <div class="jumbotron">
                <h1 class="display-4">{% block form_title %}{% endblock %}</h1>
                <p class="lead">{% block form_subtitle %}{% endblock %}
                {% if user.is_authenticated %}
                    ( Usuario: {{ user.get_username }} )
                {% else %}
                    ( Usuario no registrado. )
                {% endif %}
                </p>
                <hr class="my-4">
                <div id='form-errors'>{{ form_errors }}</div>
                <form method="post">
                    {% csrf_token %}
                    {{ form|crispy }}
                    <button type="submit" class="btn btn-success">Grabar</button>
                    <!-- <a class="btn btn-primary" href="../list" role="button">Cancelar</a> -->
                    <a class="btn btn-primary" href="#" onclick="location.href = document.referrer; return false;" role="button">Cancelar</a>
                </form>
            </div>
        </div>
        <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
        <script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/1.10.22/js/jquery.dataTables.js"></script>
    </body>
</html>

everything works fine so far and saving a wrong value returns a message on the form: Negative Number Message

The rule to implement is: available = bought - assigned (Disponibles = Comprados - Asignados)

This is the first version I tried, modified Model:

class TotalPoints(TimeStampedModel):
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='TotalPoints_Course', null=False, blank=False, verbose_name='Curso')
    kit = models.ForeignKey(Kit, on_delete=models.CASCADE, related_name='TotalPoints_Kit', null=False, blank=False, verbose_name='Kit')
    bought = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Comprados')
    assigned = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Asignados')
    available = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Disponibles')
    class Meta:
        db_table = 'app_totalpoints'
        constraints = [
            models.UniqueConstraint(fields=['course','kit'], name='app_totalpoints.course-kit')
        ]
    def save(self, *args, **kwargs):
        #AVAILABLE = BOUGHT - ASSIGNED
        self.available = self.bought - self.assigned
        super(TotalPoints, self).save(*args, **kwargs)

the rule works and correctly calculates available if all values are positive Rule is working if bought is negative the correct error appears on the form, but if available becomes negative (bougth < assigned) the error is not catched enter image description here Trace:

Traceback (most recent call last):
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
psycopg2.errors.CheckViolation: el nuevo registro para la relación «app_totalpoints» viola la restricción «check» «app_totalpoints_available_check»
DETAIL:  La fila que falla contiene (36, 2020-10-19 21:16:05.356574+00, 2020-10-20 15:57:39.007548+00, 10, 12, 23, 1, -2).


The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/core/handlers/base.py", line 179, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/contrib/auth/decorators.py", line 21, in _wrapped_view
    return view_func(request, *args, **kwargs)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/views/generic/edit.py", line 194, in post
    return super().post(request, *args, **kwargs)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/views/generic/edit.py", line 142, in post
    return self.form_valid(form)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/views/generic/edit.py", line 125, in form_valid
    self.object = form.save()
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/forms/models.py", line 460, in save
    self.instance.save()
  File "/home/egarza/projects/python/DjangoRESTABC/abc_project/app/models.py", line 138, in save
    super(TotalPoints, self).save(*args, **kwargs)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/models/base.py", line 753, in save
    self.save_base(using=using, force_insert=force_insert,
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/models/base.py", line 790, in save_base
    updated = self._save_table(
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/models/base.py", line 872, in _save_table
    updated = self._do_update(base_qs, using, pk_val, values, update_fields,
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/models/base.py", line 926, in _do_update
    return filtered._update(values) > 0
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/models/query.py", line 803, in _update
    return query.get_compiler(self.db).execute_sql(CURSOR)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/models/sql/compiler.py", line 1522, in execute_sql
    cursor = super().execute_sql(result_type)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/models/sql/compiler.py", line 1156, in execute_sql
    cursor.execute(sql, params)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/backends/utils.py", line 98, in execute
    return super().execute(sql, params)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/backends/utils.py", line 66, in execute
    return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/backends/utils.py", line 75, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/utils.py", line 90, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/home/egarza/projects/python/DjangoRESTABC/django-rest-abc-env/lib/python3.8/site-packages/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
django.db.utils.IntegrityError: el nuevo registro para la relación «app_totalpoints» viola la restricción «check» «app_totalpoints_available_check»
DETAIL:  La fila que falla contiene (36, 2020-10-19 21:16:05.356574+00, 2020-10-20 15:57:39.007548+00, 10, 12, 23, 1, -2).

I switched to a rule in pre_save using signals, but the outcome is the same: version 02 using signals:

class TotalPoints(TimeStampedModel):
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='TotalPoints_Course', null=False, blank=False, verbose_name='Curso')
    kit = models.ForeignKey(Kit, on_delete=models.CASCADE, related_name='TotalPoints_Kit', null=False, blank=False, verbose_name='Kit')
    bought = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Comprados')
    assigned = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Asignados')
    available = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Disponibles')
    class Meta:
        db_table = 'app_totalpoints'
        constraints = [
            models.UniqueConstraint(fields=['course','kit'], name='app_totalpoints.course-kit')
        ]

@receiver(pre_save, sender=TotalPoints, dispatch_uid="totalpoints_presave")
def totalpoints_presave(sender, instance, **kwargs):
    instance.available = instance.bought - instance.assigned

so where are these rules supposed to be implemented so crispy forms and Django can properly handle the error?

Added:

I get that I'm doing the calculations way to ahead for the form to catch the errors, does that means that I have to replicate the rule on the form and always have duplicate validations?

Isn't there an standard event on the model where this calculations should be performed? (I'm also calling model via web service so I need the rule to be universal)

I know I could use a @property, but I need the field on the database since It will be used later by another process (a non Python One)

I'm and old C#/ Java Programmer being recycled, I may be making very obvious mistakes, so please educate me, and don't assume I understand the full context.

Thank you in advance.

Partial Answer

I revied Melvyns comment and overrided the clean method:

class TotalPoints(TimeStampedModel):
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='TotalPoints_Course', null=False, blank=False, verbose_name='Curso')
    kit = models.ForeignKey(Kit, on_delete=models.CASCADE, related_name='TotalPoints_Kit', null=False, blank=False, verbose_name='Kit')
    bought = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Comprados')
    assigned = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Asignados')
    available = models.PositiveIntegerField(default=0, null=False, blank=False, verbose_name='Disponibles')
    class Meta:
        db_table = 'app_totalpoints'
        constraints = [
            models.UniqueConstraint(fields=['course','kit'], name='app_totalpoints.course-kit')
        ]
    def clean(self):
        super().clean()
        if self.assigned > self.bought:
            #OPTION 1 Raise on the Form
            #raise ValidationError('Asignados debe ser menor o igual a Comprados')
            #OPTION 2 Raise on the field
            raise ValidationError({'assigned': 'Asignados no debe ser mayor a Comprados.'})

Now I get the correct error message on the form, but it's a roundabout way of validating the data: enter image description here

Upvotes: 0

Views: 757

Answers (1)

user1600649
user1600649

Reputation:

Integrity errors occur when you're saving something that your database considers to be wrong, because of constraints. This is too late for the form to catch it.

ModelForms call full_clean() on the model and will catch any validation errors to be handled as if it was a form error. The order of calls is:

  1. forms.forms.Form.is_valid()
  2. forms.forms.Form.full_clean()
  3. forms.models.ModelForm._post_clean()
  4. instance.full_clean() => ValidationErrors go to form.errors
    1. clean fields
    2. instance.clean()
    3. validate unique
  5. instance.save() => too far down to bubble back up

So you can validate at instance.clean() and be sure to raise ValidationErrors. Relevant reading:

Form validation:

Form validation happens when the data is cleaned. If you want to customize this process, there are various places to make changes, each one serving a different purpose. Three types of cleaning methods are run during form processing

Model validation

There are three steps involved in validating a model:

Validate the model fields - Model.clean_fields() Validate the model as a whole - Model.clean() Validate the field uniqueness - Model.validate_unique()

All three steps are performed when you call a model’s full_clean() method.

Upvotes: 1

Related Questions