Résolue

[Django] Gestion du message d'erreur pour UniqueConstraint

# Résolution d'erreurs # Bases de données # Django

Bonjour,

Dans le modèle Product j'ai créé une contrainte d'unicité qui vérifie que l'utilisateur ne puisse pas ajouter un article qui aurait un titre déjà présent dans son dressing.

Je cherche maintenant à gérer le message d'erreur à afficher dans le template.

UniqueConstraint dispose d'un paramètre violation_error_message mais qui ne peut pas etre utilisé pour les contraintes avec fields et sans condition comme c'est le cas ici. violation_error_message

Dans l'état actuel, lorsque la contrainte n'est pas respectée, ça affiche une page d'erreur :

IntegrityError at /dressing/product/new
UNIQUE constraint failed: dressing_product.user_id, dressing_product.title
...

J'ai réussi à gérer le message d'erreur en levant une exception dans la vue si il y a une IntegrityError.
Ça fonctionne mais je ne sais pas si c'est une bonne façon de gérer l'affaire !

Donc ma question, c'est : est-ce qu'il y a une meilleur façon de gérer ce message d'erreur ?

J'ai essayé de voir aussi une solution dans la documentation Validation d'objets, ils parlent d'utiliser la méthode full_clean() si on souhaite s'occuper soi-même des erreurs de validation. Mais j'ai du mal à comprendre.

Merci :)

def create_product(request):

    if request.method == "POST":
        form = ProductForm(request.POST)
        formset = ProductImageFormset(request.POST, request.FILES)

        if form.is_valid() and formset.is_valid():
            try:
                product = form.save(commit=False)
                product.user = request.user
                product.save()

                formset.forms[0].instance.is_main = True
                formset.instance = product
                formset.save()

                messages.success(request, "L'article a bien été ajouté.")

                return HttpResponseRedirect(reverse("dressing:dressing"))

            except IntegrityError:
                form.add_error('title', "Vous avez déjà un produit avec ce titre.")
    else:
        form = ProductForm()
        formset = ProductImageFormset()

    context = {
        "form": form,
        "formset": formset
    }

    return render(request, "dressing/create_product.html", context)

Salut !

Tout d'abord, bravo pour avoir pris l'initiative de gérer l'erreur plutôt que de laisser tes utilisateurs face à une page d'erreur brutale. Utiliser l'exception IntegrityError peut être une solution valide dans certains cas, mais tu as raison de chercher à améliorer ta gestion des erreurs.

En fait, la distinction ici se fait entre les erreurs au niveau de la base de données (comme les erreurs d'intégrité) et les erreurs de validation au niveau de Django. Idéalement, tu veux attraper les erreurs avant qu'elles ne touchent la base de données en utilisant la validation de Django.

Voici une manière de gérer ce problème :

  1. Créer une méthode de validation personnalisée dans ton modèle Product ou dans ton formulaire ProductForm qui vérifie si un produit avec le même titre existe déjà pour l'utilisateur.

  2. Si un tel produit existe, tu peux alors ajouter une erreur à ton formulaire avant même de tenter de sauvegarder ton objet en base de données.

  3. Appeler full_clean() dans ta vue avant d'appeler save() va s'assurer que ta méthode de validation personnalisée est utilisée.

Voici un exemple de ce à quoi cela pourrait ressembler :

# Dans ton formulaire, disons ProductForm
from django.core.exceptions import ValidationError

def clean_title(self):
    title = self.cleaned_data['title']
    user = self.instance.user if self.instance.pk else None

    if user and Product.objects.filter(user=user, title=title).exists():
        raise ValidationError("Vous avez déjà un produit avec ce titre.")

    return title

# Et dans ta vue, tu peux faire :

def create_product(request):
    # ...
    if form.is_valid() and formset.is_valid():
        try:
            product = form.save(commit=False)
            product.user = request.user
            product.full_clean()  # Ceci va appeler clean_title
            product.save()
            # ...

Cela implique que tu n'auras jamais à faire face à une IntegrityError pour cette raison, parce que le problème sera détecté plus tôt dans le cycle de vie de la requête. De plus, il s'agit d'une façon plus "Django-esque" de gérer les erreurs de validation. Enfin, cela permet aussi de renvoyer l'erreur au formulaire, ce qui est utile pour afficher le message d'erreur à côté du champ en question sur ton formulaire HTML.

J'espère que cette solution sera plus en ligne avec ce que tu recherches !

En gros le full_clean va chercher automatiquement toutes les méthodes qui sont au format clean_attribut .

Dis nous si ça fonctionne bien. Bon courage.

Hello,

Merci pour la réponse.

J'ai du nouveau avec UniqueConstraint et on est pas loin du résultat recherché.

Si on ne trouve pas de solution, je m'orienterai vers la voie que tu proposes.

Voici un aperçu du modèle avec la contrainte d'unicité :

class Product(models.Model):
    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
    title = models.CharField(
        max_length=150,
        validators=[MinLengthValidator(5, 'Le titre doit contenir 5 caractères minimum.')],
        verbose_name="Titre"
    )
...
    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["user", "title"],
                name="title_product_is_unique_for_user",
            )
        ]
...
  • Première chose : la page d'erreur IntegrityError était provoquée car il est nécessaire que les champs concernés par la contrainte soient présent dans le formulaire. Donc ici, il s'agit des champs user et title.

  • Ensuite, arpès moutle recherches, j'ai trouvé que le message d'erreur que retourne UniqueConstraint est dans non_field_errors .

Dans la documenation, ils expliquent comment surcharger le message, comme ceci :

Considérations sur les messages d’erreur des modèles

 class ProductForm(forms.ModelForm):

    category = TreeNodeChoiceField(
        queryset=Category.objects.all(), 
        level_indicator="---",
        label="Catégorie"
    )

    class Meta:
        model = Product
        fields = [
            "user",
            "title",
            "category",
            "color",
            "material",
            "size",
            "condition"
        ]
        error_messages = {
            NON_FIELD_ERRORS: {
                "unique_together": "Vous avez déjà un produit avec ce titre.",
            }
        }

Là comme ça, ça fonctionne nickel ... Mais !

Le champ user n'est pas censé être affiché dans le formulaire dans la mesure où ce n'est pas à l'utilisateur de le renseigner, le champ est traité automatiquement dans la vue et, rappel, si on le retire du formulaire, ça nous retourne la page avec l'erreur IntegrityError.

Y a-t-il une possibilité d'utliser UniqueConstraint en impliquant un champ non présent dans le formulaire ?

Salut Cam !

Je prends le relai :)

Si tu tiens à utiliser UniqueConstraint et que tu veux impliquer un champ qui n'est pas présent dans le formulaire, tu peux inclure le formulaire en HiddenInput et le remplir toi-même dans la vue (c'est une pratique que tu peux faire même sans la problématique de UniqueConstraint d'ailleurs).

L'idée c'est de conserver ton champ user dans le formulaire mais de le rendre caché et le remplir automatiquement. Pour ça, tu peux utiliser le champ HiddenInput de Django dans le formulaire, et tu peux le pré-remplir dans ta vue avant d'afficher le formulaire.

Dans ton formulaire ça donnerait ça :

from django import forms

class ProductForm(forms.ModelForm):

    # Le reste de ta définition de formulaire...

    user = forms.ModelChoiceField(
        queryset=get_user_model().objects.all(),
        widget=forms.HiddenInput(),  # Le HiddenInput c'est un widget
        required=False
    )

    class Meta:
        model = Product
        fields = [
            "user",
            "title",
            # autres champs...
        ]
        error_messages = {
            'NON_FIELD_ERRORS': {
               "unique_together": "Vous avez déjà un produit avec ce titre.",
            }
        }
    # Le reste de ta définition...

Puis, dans ta vue, tu fais en sorte de pré-remplir le champ user avant d'afficher le formulaire :

def create_product(request):
    if request.method == 'POST':
        form = ProductForm(request.POST)
        # ...
    else:
        form = ProductForm(initial={'user': request.user})  👈 ici
        # ...

    # Le reste de ta méthode...

En utilisant initial={'user': request.user}, le champ user sera automatiquement rempli avec l'utilisateur actuellement authentifié, de sorte qu'il n'ait pas besoin de le fournir lui-même, et tu évites la IntegrityError car le champ est présent lors de la soumission du formulaire ;)

Salut,

J'avais testé cette approche aussi, mais il y a une petite faille.

En fait, au niveau du template, il me semble que le champ caché user est obligé d'être présent car sinon ça retourne une page d'erreur.

En intégrant le champ caché user au template, il devient visible dans l'inspecteur et il est tout à fait possible de modifier la valeur de value et ainsi créer des articles pour un autre utilisateur !

<input id="id_user" name="user" type="hidden" value="1"/>

On doit pouvoir ajouter une vérification qui contrôle si l'utilisateur qui crée le produit correcpond bien à l'utilisateur qui est connecté et retourner une erreur si ce n'est pas le cas ?

Après ça fait peut-être un peu trop, la proposition de PA paraît plus accessible ?

Thibault houdon

Mentor

Salut Cam !

Peu importe la façon dont tu gères les choses, il faudra de toute façon faire une vérification en backend : ne jamais faire confiance aux données envoyées par le front et l'utilisateur. N'importe quel champ de formulaire peut être manipulé, et rappelle-toi que n'importe qui peut faire une requête POST vers une URL même sans formulaire !

Si dans ta vue, tu récupères des données POST un champ "user_id" par exemple, et que tu ne fais pas de vérifications de sécurité pour t'assurer que seule la personne connectée peut impacter ses données, n'importe qui faisant une requête POST avec le bon "user_id" dans les données POST pourrait modifier les données d'autres utilisateurs.

Dans la pratique normalement le jeton CSRF empêche ce genre de problèmes (le jeton est généré côté serveur et côté client et s'il n'y a pas de "match" entre les deux, la requête est rejetée), mais je te conseille quand même de toujours faire les vérifications nécessaires côté backend.

D'accord, bien compris !

Je suis content, en fouillant dans la documentation j'ai trouvé la fonction qui permet de vérifier si les données de request.POST sont différentes de celles renseignées dans initial :)

Il s'agit de Form.has_changed()

Du coup je fais la vérification dans la vue, et retourne l'erreur dans non_field_errors comme ceci :

@login_required
def create_product(request):

    if request.method == 'POST':
        form = ProductForm(request.POST)
        formset = ProductImageFormset(request.POST, request.FILES)

        if form.is_valid() and formset.is_valid():
            if form.has_changed(): 👈 ici
                form.add_error(None, "Vous n'êtes pas autorisé à modifier l'utilisateur.") 
            else:
                product = form.save(commit=False)
                product.save()

                formset.forms[0].instance.is_main = True
                formset.instance = product
                formset.save()

                messages.success(request, "L'article a bien été ajouté.")

                return HttpResponseRedirect(reverse('dressing:dressing'))

    else:
        form = ProductForm(initial={'user': request.user})
        formset = ProductImageFormset()

    context = {
        'form': form,
        'formset': formset
    }

    return render(request, 'dressing/create_product.html', context)

Petite dernière question, est-ce préférable de faire la vérification dans la vue comme ici ou bien dans le formulaire ?

Edit :
En fait ça ne fonctionne pas, même si je ne modifie pas value ça renvoie le message d'erreur.

Je continue de chercher ...

J'ai comrpis mon erreur par rapport au fait de pouvoir créer des articles pour d'autre utilisateur en modifiant la value dans l'input.

Dans la vue, j'avais retiré la ligne product.user = request.user en pensant qu'elle n'était plus nécéssaire vue que l'on attribuait l'utilisateur en amont dans les données initiales. Or c'est cette ligne qui va nous permettre de s'assurer que le champ user correspondra bien à l'utilisateur connecté. Ce qui fait que même si on modifie value dans l'input, juste avant de sauvegarder le formulaire, ça réapplique le bon utilisateur.

Pour pratiquer, j'ai testé aussi la façon proposée par PA, en passant par la méthode clean() du formulaire.

Après réflexion, je vais laissé libre les utilisateurs de pouvoir créer des articles ayant des titres similaires entre eux ... Ça m'aura permis d'expérimenter :)

Je laisse encore le ticket ouvert au cas où vous voudriez faire un commentaire, sinon c'est Ok, on pourra clôturer.

Merci à vous deux 👍

Inscris-toi

(c'est gratuit !)

Inscris-toi

Tu dois créer un compte pour participer aux discussions.

Créer un compte

Rechercher sur le site

Formulaire de contact

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.