Reputation: 357
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:
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
if bought is negative the correct error appears on the form, but if available becomes negative (bougth < assigned) the error is not catched
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:
Upvotes: 0
Views: 757
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:
So you can validate at instance.clean() and be sure to raise ValidationErrors. Relevant reading:
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
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