Search code examples
pythondjangomany-to-manyinline-formset

Django Duplicate object with inline object


I have recipe model with ingredients and ingredients quantities.


MEAL_TYPES = (("midi", "Midi"), ("soir", "Soir"), ('all', 'Non spécifié'))

class Ingredient(models.Model):
    name = models.CharField(max_length=100)
    # Vous pouvez ajouter d'autres champs pour stocker des informations supplémentaires sur les ingrédients

    def __str__(self):
        return self.name


class Recipe(models.Model):
    """
    A model to create and manage recipes
    """

    user = models.ForeignKey(
        User, related_name="recipe_owner", on_delete=models.CASCADE
    )
    title = models.CharField(max_length=300, null=False, blank=False)
    description = models.CharField(max_length=500, null=False, blank=False)
    instructions = RichTextField(max_length=10000, null=False, blank=False)
    ingredients = RichTextField(max_length=10000, null=False, blank=False)
    image = ResizedImageField(
        size=[400, None],
        quality=75,
        upload_to="recipes/",
        force_format="WEBP",
        blank=False,
        null=False,
    )
    image_alt = models.CharField(max_length=100, default="Recipe image")
    meal_type = models.CharField(max_length=50, choices=MEAL_TYPES, default="all")

    calories = models.IntegerField(default=0)
    posted_date = models.DateTimeField(auto_now=True)
    newingredient = models.ManyToManyField(Ingredient, through='IngredientQuantite')

    class Meta:
        ordering = ["-posted_date"]

    def __str__(self):
        return str(self.title)



    
class IngredientQuantite(models.Model):
    recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
    ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE)
    quantity = models.FloatField(default=0)
    unite = models.CharField(default="g", max_length=20, choices=[("g", "g"),('mg', 'mg'), ("ml", "ml"),('kg', "kg"), ('cl', "cl"), ('l', "l"), ('caf', "cuillère à café"), ('cas', "cuillère à soupe"), ('verre', "verre"), ('bol', "bol"), ('pincee', "pincée"), ('unite', "unité")])
    # Le champ "quantity" stocke la quantité de cet ingrédient dans la recette.
    # 'g', 'kg', 'ml', 'cl', 'l', 'cuillère à café', 'cuillère à soupe', 'verre', 'bol', 'pincée', 'unité'
    def __str__(self):
        return f"{self.quantity} {self.ingredient} in {self.recipe}"

To add new recipes, I have an AddRecipe view and a DuplicateRecipe view which work perfectly before I introduce the newIngredients.

The inlinesforms looks like this: end of my form with inlines form

It works for all the fields except the inline ones.

Here my views.py code


class AddRecipe(LoginRequiredMixin, CreateView):
    template_name = "recipes/add_recipe.html"
    model = Recipe
    form_class = RecipeForm
    success_url = "/recipes/"

    def get_context_data(self, **kwargs):
        ctx=super().get_context_data(**kwargs)
        if self.request.POST:
            # ctx['form']=RecipeForm(self.request.POST)
            ctx['inlines']=IngredientQuantiteFormSet(self.request.POST)
        else:
            # ctx['form']=RecipeForm()
            ctx['inlines']=IngredientQuantiteFormSet()
        return ctx    
    
    def form_valid(self, form):
        form.instance.user = self.request.user
        ctx = self.get_context_data()
        inlines = ctx['inlines']
        
        if inlines.is_valid() and form.is_valid():
            req = form.save()
            inlines.instance = req
            print(inlines.instance)
            inlines.save()
        return super(AddRecipe, self).form_valid(form)


class DuplicateRecipe(LoginRequiredMixin, CreateView):
    template_name = "recipes/duplicate_recipe.html"
    model = Recipe
    form_class = RecipeForm
    success_url = "/recipes/"  # Redirigez l'utilisateur vers la liste des recettes après la duplication

    def get_initial(self):
        # Récupérez la recette d'origine par clé primaire
        original_recipe = get_object_or_404(Recipe, pk=self.kwargs["pk"])

        # Créez un dictionnaire d'initialisation pour le formulaire
        initial = {
            "title": f"Copy of {original_recipe.title}",
            "description": original_recipe.description,
            "ingredients": original_recipe.ingredients,
            "instructions": original_recipe.instructions,
            # Ajoutez d'autres champs liés à votre modèle Recipe ici
            "image": original_recipe.image,
            "image alt": original_recipe.image_alt,
            "meal_type": original_recipe.meal_type,
            "calories": original_recipe.calories,
        }

        return initial

    # def form_valid(self, form, ):
    #     form.instance.user = self.request.user
    #     return super(DuplicateRecipe, self).form_valid(form)
    
    
    def get_context_data(self, **kwargs):
        ctx=super().get_context_data(**kwargs)
        original_recipe = get_object_or_404(Recipe, pk=self.kwargs["pk"])
        if self.request.POST:
            # ctx['form']=RecipeForm(self.request.POST)
            ctx['inlines']=IngredientQuantiteFormSet(self.request.POST)
        else:
            # ctx['form']=RecipeForm()
            ctx['inlines']=IngredientQuantiteFormSet(instance=original_recipe)
        return ctx    
    
    def form_valid(self, form):
        form.instance.user = self.request.user
        ctx = self.get_context_data()
        inlines = ctx['inlines']
        
        if inlines.is_valid() and form.is_valid():
            req = form.save()
            inlines.instance = req
            inlines.save()
        return super(DuplicateRecipe, self).form_valid(form)

    # def form_invalid(self, form, formset):
    #     return self.render_to_response(self.get_context_data(form=form, formset=formset))

If I go the addRecipe form, the form is empty at the start and the validation form create the recipe as expected. If i go the DuplicateRecipe form, all the fields are already complete when I valid the duplicateRecipe form, all fields are saved in a new recipe except the inlines. If I remove the "instance=original_recipe" it works as well as addRecipe but without the autocompletion that I expect for a duplication.

Anyone have an idea that why it doesn't work with the instance=original_recipe ? or how to fix the form validation problem?


Solution

  • In case anyone has the same problem later, I will detail the solution I found.

    Thanks this link, I managed to refactor my code more cleanly. I then redid the duplicate function using get_initial which I already had as well as using a custom inlineformset_factory with the "initial" parameter.

    #views.py
    from typing import Any
    from django.db.models.query import QuerySet
    from django.shortcuts import render, get_object_or_404, redirect
    from django.views.generic import (
        CreateView,
        ListView,
        DetailView,
        DeleteView,
        UpdateView,
    )
    
    from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
    
    # Create your views here.
    from .models import Recipe, IngredientQuantite
    from .forms import RecipeForm, IngredientQuantiteFormSet, IngredientQuantiteEditFormSet
    from django.template.loader import render_to_string
    
    
    from django.db.models import Q
    
    class RecipeInline():
        form_class = RecipeForm
        model = Recipe
        template_name = "recipes/add_recipe_bis.html"
    
        def form_valid(self, form):
            form.instance.user = self.request.user
            named_formsets = self.get_named_formsets()
            if not all((x.is_valid() for x in named_formsets.values())):
                return self.render_to_response(self.get_context_data(form=form))
    
            self.object = form.save()
    
            for name, formset in named_formsets.items():
                formset_save_func = self.formset_ingredients_valid
                if formset_save_func is not None:
                    formset_save_func(formset)
                else:
                    formset.save()
            return redirect('recipes')
    
        def formset_ingredients_valid(self, formset):
            """
            Hook for custom formset saving.Useful if you have multiple formsets
            """
            ingredientQuantity = formset.save(commit=False)  # self.save_formset(formset, contact)
            for obj in formset.deleted_objects:
                obj.delete()
            for iq in ingredientQuantity:
                iq.recipe = self.object
                iq.save()
    
    
    
    class RecipeCreate(RecipeInline, CreateView):
        
        def get_context_data(self, **kwargs):
            ctx = super(RecipeCreate, self).get_context_data(**kwargs)
            ctx['inlines'] = self.get_named_formsets()
            ctx['title'] = "Create Recipe"
            return ctx
    
        def get_named_formsets(self):
            if self.request.method == "GET":
                return {
                    'ingredients': IngredientQuantiteFormSet(prefix='ingredients'),
                }
            else:
                return {
                    'ingredients': IngredientQuantiteFormSet(self.request.POST or None, self.request.FILES or None, prefix='ingredients'),
                }
    
    class RecipeUpdate(RecipeInline, UpdateView):
        def get_context_data(self, **kwargs):
            ctx = super(RecipeUpdate, self).get_context_data(**kwargs)
            ctx['inlines'] = self.get_named_formsets()
            ctx['title'] = "Edit Recipe"
            return ctx
    
        def get_named_formsets(self):
            return {
                'ingredients': IngredientQuantiteEditFormSet(self.request.POST or None, self.request.FILES or None, instance=self.object, prefix='ingredients'),
            }
    
    
    from django.shortcuts import render, get_object_or_404, redirect
    from django.forms import inlineformset_factory
    
    class RecipeCopy(RecipeInline, CreateView):
        
        def get_initial(self):
            original_recipe = get_object_or_404(Recipe, pk=self.kwargs["pk"])
    
            # Créez un dictionnaire d'initialisation pour le formulaire
            initial = {
                "title": f"Copy of {original_recipe.title}",
                "description": original_recipe.description,
                "ingredients": original_recipe.ingredients,
                "instructions": original_recipe.instructions,
                "image": original_recipe.image,
                "image alt": original_recipe.image_alt,
                "meal_type": original_recipe.meal_type,
                "calories": original_recipe.calories,
            }
    
            return initial
        
        def get_context_data(self, **kwargs):
            ctx = super(RecipeCopy, self).get_context_data(**kwargs)
            ctx['inlines'] = self.get_named_formsets()
            ctx['title'] = "Copy Recipe"
            return ctx
    
        def get_named_formsets(self):
            original_recipe = get_object_or_404(Recipe, pk=self.kwargs["pk"])
            
            ingredients = IngredientQuantite.objects.all().filter(recipe=original_recipe)
            
            ingredientInits=[]
            for ingre in ingredients:
                ingredientToDict = {
                    'ingredient': ingre.ingredient,
                    'quantity': ingre.quantity,
                    'unite': ingre.unite,
                }
                ingredientInits.append(ingredientToDict)
            
            print("ingredientInits", ingredientInits)
            
            
            IngredientQuantiteFormSet = inlineformset_factory(
                Recipe, IngredientQuantite, form=IngredientQuantiteForm,
                extra=len(ingredientInits)+1, can_delete=False,
                can_delete_extra=True
            )
            
            if self.request.method == "GET":
                return {
                    'ingredients': IngredientQuantiteFormSet(prefix='ingredients', initial=ingredientInits),
                }
            else:
                return {
                    'ingredients': IngredientQuantiteFormSet(self.request.POST or None, self.request.FILES or None, prefix='ingredients'),
                }
    

    The template code add_recipe_bis.html:

    {% extends "base.html" %}
    
    
    {% block title %}{{title}}{% endblock title %} 
    
    {% block content %}
    <div class="">
        <form method="post" enctype="multipart/form-data" class="p-2 form">
            <h1 class="text-center">{{title}}</h1>
            {% csrf_token %}
            {{ form.media }}
            {{ form|crispy }}
            {{ inlines.ingredients.management_form }}
            <div class="flex mb-3">
                <h2 class="">Ingredients</h2>
            </div>
            <div id="form_set" class="mb-3">
                {% for form in inlines.ingredients.forms %}
                <div class="form-row">
                        <div class='ingredients-container'>
                            {{ form|crispy }}
                            <input class="delete btn btn-danger" value="Delete" type="button" formnovalidate></input>
                        </div>
                </div>
                {% endfor %}
            </div>
            <div class="centered-button-container">
                <input class="btn btn-secondary addmore-button" type="button" value="Add an Item +" id="add_more">
            </div>
            <div id="empty_form" style="display:none">
                <div class="form-row">
                    <div class='ingredients-container'>
                        {{ inlines.ingredients.empty_form|crispy }}
                        <input class="delete btn btn-danger" value="Delete" type="button" formnovalidate></input>
                    </div>
                </div>
            </div>
            <div class="text-center">
                <button type="submit" class="btn btn-primary mt-2">{{title}}</button>
            </div>
        </form>
    </div>
    
    
    <script>
    
        $('#add_more').click(function(ev) {
            // var form_idx = $('#id_form-TOTAL_FORMS').val();
            // $('#form_set').append($('#empty_form').html().replace(/__prefix__/g, form_idx));
            // $('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
          ev.preventDefault();
          var count = $('#form_set').children().length;
          var tmplMarkup = $('#empty_form').html();
          var compiledTmpl = tmplMarkup.replace(/__prefix__/g, count);
          $('div#form_set').append(compiledTmpl);
          $('#id_ingredients-TOTAL_FORMS').attr('value', count+1);
        });
    
        if (`{{title}}`=="Edit Recipe") 
        {
            $('div > div[id*="DELETE"]').closest('.mb-3').parent().css('display', 'none');
        }
    
        $(document).on("click", ".delete", function() {
            var delcount = $('#form_set').children().length;
            $('#id_ingredients-TOTAL_FORMS').attr('value', delcount-1);
    
            if (`{{title}}`=="Edit Recipe") 
            {
                var $parent = $(this).closest('.ingredients-container'); // Parent element to hide
                var $checkbox = $parent.find('input[type="checkbox"]'); // Checkbox to check
                $checkbox.prop('checked', true); // Check the sibling checkbox
                $parent.css('display', 'none'); // Hide the parent element
            }
            else
            {
                $(this).parent().parent().remove();
            }
            
        });
    </script>
    
    {% endblock content %}