Alexander A. E. Full Stack Developer

Django: Ejemplo de Form + InlineFormset

A modo de práctica con los Form + Formset desarrollaré una aplicación para registrar recetas con sus respectivos ingredientes y procedimientos 1.

El propósito será poder editar todo el detalle de una receta incluidos los ingredientes y procedimiento en la misma pantalla, de modo similar a como se trabaja en el admin de django con los inlines 2.

1. MODELS

Necesitaremos 3 modelos: recetas, ingredientes y procedimientos.

::python
# models.py
from django.db import models


class Receta(models.Model):
    titulo = models.CharField(max_length=255)
    descripcion = models.TextField()


class Ingrediente(models.Model):
    receta = models.ForeignKey(Receta)
    descripcion = models.CharField(max_length=255)


class Instruccion(models.Model):
    receta = models.ForeignKey(Receta)
    numero = models.PositiveSmallIntegerField(help_text='Paso número')
    descripcion = models.TextField()

Como se ve, nada complicado para comenzar. El modelo "principal" es el de la receta. Tenemos también a los modelos Ingrediente e Intrucción con sus respectivos Foreign Key.

2. FORM:

Necesitamos un formulario para la receta:

::python
# forms.py
from django import forms
from .models import Receta


class RecetaForm(forms.ModelForm):
    class Meta:
        model = Receta

3. URL's

Necesitamos 3 url's para: el listado de recetas, registro y edición de las mismas.

::python
# urls.py
..
url(r'^$', 'recetas.views.recetas', name='lista'),
url(r'^recetas/registrar/$', 'recetas.views.registro_edicion', name='registrar'),
url(r'^recetas/(?P<receta_id>\d+)/$', 'recetas.views.registro_edicion', name='editar'),

4. VIEWS

Creamos dos vistas, una para listar nuestras recetas y la otra para manejar el registro y edición de las mismas.

Lo "complicado" se ve en la segunda vista:

  • Obtenemos una instancia de la receta actual en caso de que la estemos editando o creamos una nueva para el caso de un registro.
  • Inicializamos los dos InlineFormset, uno para los ingredientes y otro para las instrucciones.
  • En caso de que sea un pedido POST, validamos el formulario y los inlineformset, de ser válidos grabamos los cambios.
  • En caso de que sea un GET, inicializamos el form y los 2 formsets.
::python
# views.py
from django.shortcuts import render_to_response as render, redirect
from django.template import RequestContext as ctx
from django.forms.models import inlineformset_factory

from .models import Receta, Ingrediente, Instruccion
from .forms import RecetaForm


def recetas(request):
    recetas = Receta.objects.all()

    return render('recetas.html', locals(),
        context_instance=ctx(request))


def registro_edicion(request, receta_id=None):
    if receta_id:
        receta = Receta.objects.get(pk=receta_id)
    else:
        receta = Receta()

    IngredienteFormSet = inlineformset_factory(Receta, Ingrediente, extra=0, can_delete=True)
    InstruccionFormSet = inlineformset_factory(Receta, Instruccion, extra=0, can_delete=True)

    if request.method == 'POST':
        form = RecetaForm(request.POST, instance=receta)
        ingredienteFormset = IngredienteFormSet(request.POST, instance=receta)
        instruccionFormset = InstruccionFormSet(request.POST, instance=receta)

        if form.is_valid() and ingredienteFormset.is_valid() and instruccionFormset.is_valid():
            form.save()
            ingredienteFormset.save()
            instruccionFormset.save()
            return redirect('lista')
    else:
        form = RecetaForm(instance=receta)
        ingredienteFormset = IngredienteFormSet(instance=receta)
        instruccionFormset = InstruccionFormSet(instance=receta)

    return render('registro-edicion.html', locals(),
        context_instance=ctx(request))

5. TEMPLATES

Necesitamos un template para el listado de recetas:

::jinja
{% extends 'base.html' %}

{% block body %}
<h1>Recetas</h1>

<ol>
    {% for receta in recetas %}
        <li>
            <a href="{{ receta.get_absolute_url }}">{{ receta.titulo }}</a>
        </li>
    {% endfor %}
</ol>

<a href="{% url 'registrar' %}">Registrar receta</a>
{% endblock body %}

Observen que hemos añadido un método get_absolute_url al modelo Receta:

::python
    def get_absolute_url(self):
        return reverse('editar', kwargs={'receta_id': self.id})

Y el template para la parte del registro/edición que consta de:

  • Sección de administración para los formset (por ejemplo un campo lleva la cuenta del número actual de inlines y otro el del número inicial de los mismos)
  • El formulario de la receta
  • Los formularios para cada formset
  • Un template para cada formset que se toma como base para el proceso de "Añadir un nuevo Ingrediente o Instrucción". Nótese que para clonar este template estoy utilizando un poco de jquery.
  • Código javascript que maneja el proceso de clonación

El template es el que sigue:

::jinja
{% extends 'base.html' %}

{% block body %}
<form method="post" action="#">
    {% csrf_token %}

    {{ ingredienteFormset.management_form }}
    {{ instruccionFormset.management_form }}

    <fieldset class="form ">
        {% for field in form %}
            <div class="form-row">
                <div class="field-box">
                    {{ field.errors }}
                    {{ field.label_tag }}: {{ field }}
                </div>
            </div>
        {% endfor %}
    </fieldset>

    <fieldset>
        <legend>Ingredientes</legend>
        <ul id='formset-ingredientes'>
            {% for form in ingredienteFormset %}
                {{ form.id }}
                <li>
                    {{ form.as_ul }}
                </li>
            {% endfor %}
        </ul>

        <button id='btnIngrediente'>Añadir Ingrediente</button>
    </fieldset>

    <fieldset>
        <legend>Instrucciones</legend>
        <ul id='formset-instrucciones'>
            {% for form in instruccionFormset %}
                {{ form.id }}
                <li>
                    {{ form.as_ul }}
                </li>
            {% endfor %}
        </ul>

        <button id='btnInstruccion'>Añadir Instrucción</button>
    </fieldset>

    <button>Enviar</button>
</form>

<script type='template/ingrediente'>
    {{ ingredienteFormset.empty_form.as_ul }}
</script>

<script type='template/instruccion'>
    {{ instruccionFormset.empty_form.as_ul }}
</script>

<script>
$(function(){

    // Reemplaza todas las coincidencias en vez de solo la primera
    function replaceAll(text, busca, reemplaza){
          while (text.toString().indexOf(busca) != -1)
            text = text.toString().replace(busca, reemplaza);
          return text;
    }

    var $totalIngredientes = $('#id_ingrediente_set-TOTAL_FORMS');

    $('#btnIngrediente').click(function(event) {
        event.preventDefault();
        var total = parseInt($totalIngredientes.val(), 10);
        var clon = $('script[type="template/ingrediente"]').html();
        clon_html = replaceAll(clon, '__prefix__', (total).toString());
        $('#formset-ingredientes').append(clon_html);
        $totalIngredientes.val(total +  1);
    });

    var $totalInstrucciones = $('#id_instruccion_set-TOTAL_FORMS');

    $('#btnInstruccion').click(function(event) {
        event.preventDefault();
        var total = parseInt($totalInstrucciones.val(), 10);
        var clon = $('script[type="template/instruccion"]').html();
        clon_html = replaceAll(clon, '__prefix__', (total).toString());
        $('#formset-instrucciones').append(clon_html);
        $totalInstrucciones.val(total +  1);
    });
})
</script>
{% endblock body %}


Y con ello concluye el proceso.. claro, sin olvidar que hay que ensamblar las partes3.

Por cierto, si bien he utilizado vistas basadas en funciones, sé que es posible realizar lo mismo con las vistas basadas en clases.

* Aunque tengo mis dudas respecto la complejidad del código en ese otro caso.


Referencias

  1. La idea la tomé de este post

  2. En el admin de django es posible editar un modelo con sus "inline models" mediante los TabularInline y StackedInline

  3. pero aquello queda de tarea 

Comentarios !

comments powered by Disqus