miércoles, 31 de enero de 2024

25.- Creando un sistemas de descuento o cupones para la tienda.

Muchas tiendas en línea ofrecen cupones a los clientes que pueden canjearse por descuentos en sus compras. Un cupón en línea generalmente consta de un código que se proporciona a los usuarios y es válido por un período de tiempo específico. Vamos a crear un sistema de cupones para nuestra tienda. Tus cupones serán válidos para los clientes durante un cierto período de tiempo. Los cupones no tendrán limitaciones en cuanto al número de veces que pueden ser canjeados y se aplicarán al valor total del carrito de compras.

Para esta funcionalidad, necesitarás crear un modelo para almacenar el código del cupón, un marco de tiempo válido y el descuento a aplicar. Crea una nueva aplicación dentro del proyecto "PracticaDjango" utilizando el siguiente comando:

python manage.py startapp Cupones

Como siempre que creamos una nueva aplicación tenemos que registrarla. Para ello entra en el directorio "PracticaDjango" y regístrala en el archivo settings.py:

PracticaDjango/Orders/admin.py

#...
INSTALLED_APPS = [
    # Nuestras aplicaciones
    'Proyecto_web_app.apps.ProyectoWebAppConfig',
    'Servicios.apps.ServiciosConfig',
    'Blog.apps.BlogConfig',
    'Contacto.apps.ContactoConfig',
    'Tienda.apps.TiendaConfig',
    'Carro.apps.CarroConfig',
    'Autentificacion.apps.AutentificacionConfig',
    'Orders.apps.OrdersConfig',
    'Payment.apps.PaymentConfig',
    'Cupones.apps.CuponesConfig',
#...


Construyendo el modelo para los Cupones. 

Edita el archivo models.py de esta nueva aplicación "Cupones" y añade el siguiente código:

PracticaDjango/Cupones/models.py

from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator

# Create your models here.

class Cupon(models.Model):
    codigo = models.CharField(max_length=50, unique=True)
    valido_desde = models.DateTimeField()
    valido_hasta = models.DateTimeField()
    descuento = models.IntegerField(validators=[MinValueValidator(
        0), MaxValueValidator(100)], help_text='Valor del porcentaje (0-100)')
    activo = models.BooleanField()

    def __str__(self):
        return self.codigo
Este es modelo que vamos a usar para guardar los cupones. Contiene los siguientes campos:

codigo - el código que el usuario tendrá que introducir para que se le aplique el descuento en la compra.
valido_desde - la fecha desde la que es válido el cupón.
valido_hasta - la fecha de caducidad de el cupón.
descuento - el porcentaje que aplicaremos de descuento al producto. Es un porcentaje, así que usaremos validadores para asegurarnos de que no sea inferior a cero, ni superior a 100.
activo - un campo booleano para indicar si el cupón está activo.

Como siempre que modificamos o creamos algo en models.py hay que realizar las migraciones correspondientes con:

python manage.py makemigrations
python manage.py migrate

Una vez que hemos aplicado los cambios a la base de datos vamos a añadir el modelo al panel de administración. Ejecutamos el servidor de administración con:

python manage.py runserver

vamos a la página de administración, creamos un cupón y vemos que todo funciona.


Aplicando el cupón al carro de la compra.


Lo que tenemos que implementar es una forma para que los clientes apliquen los cupones a sus compras. La funcionalidad para aplicar un cupón sería la siguiente:

1. El usuario agrega productos al carrito de compras.
2. El usuario puede ingresar un código de cupón en un formulario que se muestra en la página de detalles del carrito de compras.
3. Cuando el usuario ingresa un código de cupón y envía el formulario, buscas un cupón existente con el código proporcionado que esté actualmente válido. Debes verificar que el código del cupón coincida con el ingresado por el usuario, que el atributo activo sea True y que la fecha y hora actual esté entre los valores de valido_desde y valido_hasta.
4. Si se encuentra un cupón, lo guardas en la sesión del usuario y muestras el carrito, incluyendo el descuento aplicado y el monto total actualizado.
5. Cuando el usuario realiza un pedido, guardas el cupón en el pedido proporcionado.

Crea un nuevo archivo dentro del directorio de la aplicación "Cupones" y nómbralo forms.py. Agrega el siguiente código a él: 

PracticaDjango/Cupones/forms.py

from django import forms

class CuponFormulario(forms.Form):
    codigo = forms.CharField(max_length=50)

Este es formulario que usaremos para introducir el cupón. Edita el archivo views.py de la aplicación y añade el siguiente código en el:

PracticaDjango/Cupones/views.py

from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_POST
from .models import Cupon
from .forms import CuponFormulario


@require_POST
def aplicar_cupon(request):
    now = timezone.now()
    form = CuponFormulario(request.POST)
    if form.is_valid():
        codigo = form.cleaned_data['codigo']
        try:
            cupon = Cupon.objects.get(codigo__iexact=codigo,
                                      valido_desde__lte=now,
                                      valido_hasta__gte=now,
                                      activo=True)
            request.session['cupon_id'] = cupon.id
        except Cupon.DoesNotExist:
            request.session['cupon_id'] = None
    return redirect('cart:cart_detail')
Esta vista valida el cupón y lo guarda en la sesión del usuario. Aplicamos el decorador @require_POST a esta vista para que solo acepte las peticiones POST a esta página. En la vista realizamos las siguientes tareas:

  1. Creamos una instancia del formulario "CuponFormulario" usando los datos enviados en la petición POST y comprobamos que es válido.
  2. Si el formulario es válido, conseguimos el código introducido por el usuario del diccionario cleaned_data que nos facilita el formulario. Luego intentamos obtener el cupón a través del código facilitado por el usuario. Usamos __iexact para que el código introducido sea igual exactamente al código existente, que no se distinga entre minúsculas o mayúsculas. Tiene que ser exactamente igual. Por otra parte el cupón tiene que estar activo y que sea válido en el periodo de tiempo que hemos establecido. Usamos la función de Django timezone.now() para conseguir la fecha actual de la zona en la que estamos y la comparamos con los valores que hemos establecido en valido_desde y valido_hasta. Para ello usamos __lte (menor o igual que) y __gte (mayor o igual a).
  3. Guardamos el cupón en la sesión del usuario.
  4. Redirigimos al usuario hacia la url cart_detail para que se muestre el carro con el cupón aplicado.


Necesitamos crear un patrón de URL para la vista que hemos creado. Vamos con ello. Crea un archivo nuevo llamado urls.py dentro de la aplicación Cupones y añade el siguiente código:

PracticaDjango/Cupones/urls.py

from django.urls import path
from . import views

app_name = 'cupones'

urlpatterns = [
    path('aplicar/', views.aplicar_cupon, name='aplicar'),
]
Luego edita el archivo principal urls.py de la aplicación PracticaDjango e incluye este patrón.

PracticaDjango/Cupones/urls.py

#...
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('Proyecto_web_app.urls')),
    path('servicios/', include('Servicios.urls')),
    path('blog/', include('Blog.urls')),
    path('contacto/', include('Contacto.urls')),
    path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
    path('payment/', include('Payment.urls', namespace='payment')),
    path('cupones/', include('Cupones.urls', namespace='cupones')),
    path('tienda/', include('Tienda.urls')),
    path('carro/', include('Carro.urls')),
    path('cuenta/', include('Autentificacion.urls')),
    path('social-auth/', include('social_django.urls', namespace='social')),
    path('orders/', include('Orders.urls', namespace='orders')),
    path('__debug__/', include('debug_toolbar.urls')),    
]
#...

Recuerda colocarlo antes de los patrones de la tienda.

Ahora edita el archivo carro.py que está dentro de la aplicación Carro. Importa el modelo que hemos creado con la siguiente línea de código:

PracticaDjango/Carro/carro.py

# Para aplicar el cupón
from Cupones.models import Cupon
#...

Luego añade esta línea en el iniciador de la clase carro:

PracticaDjango/Carro/carro.py

#...
def __init__(self, request) -> None:
        """
        Inicializa el carro.
        """
        self.session = request.session
        # construimos un carro de la compra para esta sesión.
        carro = self.session.get(settings.CART_SESSION_ID)
        if not carro:
            # save an empty cart in the session
            carro = self.session[settings.CART_SESSION_ID] = {}
            # guardamos el carro en la sesión y va a ser un diccionario
            # con los productos que el usuario va a comprar
            # la clave es el id del producto y el valor son las características del producto
        self.carro = carro
        # guardamos el cupon a aplicar en la sesión
        self.cupon_id = self.session.get('cupon_id')


En este código, intentamos obtener el cupon_id de la sesión actual y almacenar su valor en el objeto carro. Añade los siguientes métodos resaltados al objeto carro:

PracticaDjango/Carro/carro.py

#...
class Carro:
    #...
    @property
    def cupon(self):
        if self.cupon_id:
            try:
                return Cupon.objects.get(id=self.cupon_id)
            except Cupon.DoesNotExist:
                pass
        return None

    def conseguir_descuento(self):
        if self.cupon:
            return (self.cupon.descuento / Decimal(100)) * self.get_total_price()
        return Decimal(0)

    def precio_total_despues_de_descuento(self):
        return self.get_total_price() - self.conseguir_descuento()

Estos métodos son los siguientes:

• cupon(): Defines este método como una propiedad. Si el carrito contiene un atributo cupon_id, se devuelve el objeto Cupon con el ID proporcionado.

• conseguir_descuento(): Si el carrito contiene un cupón, se recupera su tasa de descuento y se devuelve la cantidad a restar del monto total del carrito.

• precio_total_despues_de_descuento(): Devuelves el monto total del carrito después de restar la cantidad devuelta por el método conseguir_descuento().


La clase Carro está ahora preparada para manejar un cupón aplicado a la sesión actual y aplicar el descuento correspondiente.

Ahora incluyamos el sistema de cupones en la vista detallada del carrito. Edita el archivo views.py de la aplicación del carrito y agrega la siguiente importación en la parte superior del archivo y el código resaltado en la vista mostrar_carro:

PracticaDjango/Carro/carro.py

#...
# para aplicar un descuento
from Cupones.forms import CuponFormulario
#...
# vista para mostrar los productos del carro
def mostrar_carro(request):
    productos = Producto.objects.all()
    carro = Carro(request)
    formulario_cupon = CuponFormulario()
    contexto = {
        "productos": productos,
        "carro": carro,
        "formulario_cupon": formulario_cupon,
    }
    return render(request, "Tienda/carro_detalle.html", contexto)


Bien, ahora busca la plantilla html del carro que está dentro de la aplicación Tienda en el archivo Tienda/carro_detalle.html. Edítalo y localiza las siguientes líneas:

PracticaDjango/Tienda/templates/Tienda/carro_detalle.html

#...
<tr class="total">
    <td>Total</td>
    <td colspan="4"></td>
    <td class="num">{{ carro.get_total_price }} €</td>
</tr> 

Remplázalas con el siguiente código:

PracticaDjango/Tienda/templates/Tienda/carro_detalle.html

#...
 {% if carro.cupon %}
            <tr class="subtotal">
                <td>Subtotal</td>
                <td colspan="4"></td>
                <td class="num">{{ carro.get_total_price|floatformat:2 }} €</td>
            </tr>
            <tr>
                <td>
                    "{{ carro.cupon.codigo }}" cupón
                    ({{ carro.cupon.descuento}}% descuento)
                </td>
                <td colspan="4"></td>
                <td class="num neg">
                    - {{ carro.conseguir_descuento|floatformat:2 }} €
                </td>
            </tr>
            {% endif %}
            <tr class="Total">
                <td>Total</td>
                <td colspan="4"></td>
                <td class="num">
                    {{ carro.precio_total_despues_de_descuento|floatformat:2 }} €
                </td>
            </tr>

Este es el código para mostrar el cupón si existe y el descuento asociado al mismo. Si el carro contiene un cupón se muestra la primera fila, incluyendo la cantidad total del carro y el subtotal. En una segunda fila se muestra el descuento aplicado. Finalmente mostramos el precio total, incluyendo el descuento llamando al método precio_total_despues_de_descuento del objeto carro.

En este mismo archivo incluye el siguiente código, que nos servirá para poder introducir el código, después de la etiqueta html </table>:

PracticaDjango/Tienda/templates/Tienda/carro_detalle.html

#...
 </table>
    <div class="container">
        <div class="row">
            <div class="col-md-6">
                <p>¿Tienes un cupón?:</p>
                <form action="{% url 'cupones:aplicar' %}" method="post">
                    {{ formulario_cupon }}
                    <input type="submit" value="Aplicar" class="btn btn-primary">
                    {% csrf_token %}
                </form>
            </div>
            <div class="col-md-6">
                <div class="d-flex justify-content-end">
                    <p class="text-right">
                        <a href="{% url 'Tienda:tienda' %}" class="btn btn-primary">Continua comprando</a>
                        <a href="{% url 'orders:order_create' %}" class="btn btn-success ml-2">Checkout</a>
                    </p>
                </div>
            </div>
        </div>
    </div>   

Ahora abre el navegador y ve a la dirección http://127.0.0.1:8000/tienda y añade algún producto al carro. Luego pulsa en el carrito para ver su detalle. Deberías ver algo parecido a esto:


carro sin descuento aplicado

En el campo Cupón, introduce el cupón que creamos previamente a través de panel de administración.


dto en el carro de la compra


Ahora añadiremos el cupón al siguiente paso en el proceso de compra. Edita la plantilla Orders/order/create.html de la aplicación Orders y localiza las siguientes líneas:

PracticaDjango/Orders/templates/Orders/order/create.html

<ul>
    {% for item in cart %}
       <li>
          {{ item.cantidad }}x {{ item.producto.nombre }}
          <span>{{ item.precio_total }} €</span>
       </li>
    {% endfor %}                    
</ul>

Remplázalo con el siguiente código:

PracticaDjango/Orders/templates/Orders/order/create.html

<ul>
    {% for item in cart %}
        <li>
           {{ item.cantidad }}x {{ item.producto.nombre }}
           <span>{{ item.precio_total }} €</span>
        </li>
    {% endfor %}
    {% if carro.cupon %}
         <li>
            "{{ carro.cupon.codigo }}" ({{ carro.cupon.descuento }}% descuento)
            <span class="neg">- {{ carro.conseguir_descuento|floatformat:2 }} €</span>
         </li>
     {% endif %}
</ul>

El resumen del pedido debería mostrar el cupón aplicado, si hay alguno. Ahora encuentra la siguiente línea, que esta justo debajo:

 <p>Total: {{ cart.get_total_price }} €</p>

Y cámbialo por la siguiente:

<p>Total: {{ carro.precio_total_despues_de_descuento|floatformat:2 }} €</p>

Con esto el precio total también tendrá en cuenta el descuento aplicado. Si tienes algún articulo comprado en el carro, le has aplicado un cupón y vas a la página http://127.0.0.1:8000/orders/create/ deberías ver algo parecido a esto:


dto aplicado en el pedido

Con esto los usuarios ya pueden aplicar los descuentos a los productos. Sin embargo aún nos queda aplicar esto a la finalización de los pedidos y a la orden de pago.


Aplicando los cupones a los pedidos.

Vamos a guardar el cupón que se ha aplicado a cada pedido. Lo primero que necesitamos es modificar el modelo Order para guardar el objeto Cupón, si es que hay alguno.  Para ello edita el archivo models.py de la aplicación Orders y añade las siguientes importaciones:

PracticaBlog/PracticaDjango/Orders/models.py

#...
from decimal import Decimal
from django.core.validators import MinValueValidator, MaxValueValidator
from Cupones.models import Cupon

Luego añade los siguientes campos al modelo:

PracticaBlog/PracticaDjango/Orders/models.py

#...
class Order(models.Model):
    #...
    cupon = models.ForeignKey(
        Cupon, related_name='orders', null=True, blank=True, on_delete=models.SET_NULL)
    descuento = models.IntegerField(
        default=0, validators=[MinValueValidator(0), MaxValueValidator(100)])
Estos campos nos permitirán guardar un posible cupón para el pedido y el porcentaje de descuento que se aplica con el cupón. El descuento está guardado es su respectivo objeto Cupon, pero podemos incluirlo en el modelo de Pedidos para prevenir si el cupón ha sido borrado o modificado. Hemos usado para el cupón on_delete = models.SET_NULL para que si borramos el cupón, este campo se establezca como NULL, pero preservemos el descuento. Como siempre que modificamos el archivo models.py hay que hacer las correspondientes migraciones:

- python manage.py makemigrations
- python manage.py migrate

Ahora edita de nuevo el archivo models.py y añade dos nuevos métodos:

coste_total_antes_del_descuento y aplicar_descuento

y modifica get_total_cost

PracticaDjango/Orders/models.py

#...
def __str__(self):
        return f"Orden {self.id}"
    
    def coste_total_antes_del_descuento(self):
        return sum(item.get_cost() for item in self.items.all())

    def aplicar_descuento(self):
        coste_total = self.coste_total_antes_del_descuento()
        if self.descuento:
            return coste_total * (self.descuento / Decimal(100)) 

    def get_total_cost(self):
        coste_total = self.coste_total_antes_del_descuento()
        return coste_total - self.aplicar_descuento()        

    def get_stripe_url(self):
#...

Ahora get_total_cost reflejará o tendrá en cuenta el descuento que se ha aplicado si es que hay alguno.

Edita el archivo views.py de la aplicación Orders y modifica la vista order_create para grabar el cupón y el descuento al procesar una nueva orden o pedido. Añade el siguiente código resaltado en la vista:

PracticaDjango/Orders/views.py

#...
def order_create(request):
    carro = Carro(request)
    if request.method == "POST":
        form = OrderCreateForm(request.POST)
        if form.is_valid():
            order = form.save(commit=False)
            if carro.cupon:
                order.cupon = carro.cupon
                order.descuento = carro.cupon.descuento
                order.save()
            for item in carro:
                OrderItem.objects.create(
                    order=order,
                    product=item["producto"],
                    price=item["precio"],
                    quantity=item["cantidad"],
                )
            # Limpia el carro
            carro.clear()
            # launch asynchronous task
            # order_created.delay(order.id)
            # return render(request, "Orders/order/created.html", {"order": order})
            order_created.delay(order.id)
            # guarda el pedido en la sesión
            request.session['order_id'] = order.id
            # redirecciona para hacer el pago
            return redirect(reverse('payment:process'))
    else:
        form = OrderCreateForm()
    return render(request, "Orders/order/create.html", {"cart": carro, "form": form})

En el nuevo código, creas un objeto de Pedido utilizando el método save() del formulario OrderCreateForm. Evitas guardarlo en la base de datos por el momento utilizando commit=False. Si el carrito contiene un cupón, almacenas el cupón relacionado y el descuento que se aplicó. Luego, guardas el objeto de pedido en la base de datos. 

Edita la plantilla payment/process.html de la aplicación de pagos y localiza las siguientes líneas:

            <tr>
                <td>Total</td>
                <td colspan="3"></td>
                <td class="num">{{ order.get_total_cost }} €</td>
            </tr>

Remplázalo con el siguiente código. Los elementos nuevos están resaltados en azul.

PracticaDjango/Payment/templates/Payment/process.html

            {% if order.cupon %}
            <tr class="subtotal">
                <td>Subtotal</td>
                <td colspan="3"></td>
                <td class="num">
                    {{ order.coste_total_antes_del_descuento|floatformat:2 }} €
                </td>
            </tr>
            <tr>
                <td>
                    "{{ order.cupon.codigo }}" cupón
                    ({{ order.descuento }}% descuento)
                </td>
                <td colspan="3"></td>
                <td class="num neg">
                    - {{ order.aplicar_descuento|floatformat:2 }} €
                </td>
            </tr>
            {% endif %}
            <tr>
                <td>Total</td>
                <td colspan="3"></td>
                <td class="num">{{ order.get_total_cost|floatformat:2 }} €</td>
            </tr>

Con esto hemos actualizado el resumen del pedido antes de realizar el pago. Ejecuta el servidor y carga todas las instrucciones necesarias para ejecutar el pago (puedes encontrarlas si no las recuerdas en el archivo start_app.py en github). Si realizas una compra verás algo similar a esto en el resumen del pedido:


resumen del pedido con descuento aplicado

Sin embargo, si haces clic en pagar, verás que la página de la pasarela de pago, no recoge el descuento, porque no se lo hemos pasado. 

pasarela de pago no recoge el descuento



Vamos a ello.


Creación de cupones para Stripe Checkout

Stripe te permite definir cupones de descuento y vincularlos a pagos únicos. Puedes encontrar más información sobre la creación de descuentos para Stripe Checkout en https://stripe.com/docs/payments/checkout/discounts.

Editemos la vista payment_process para crear un cupón para Stripe Checkout. Edita el archivo views.py de la aplicación de pagos y agrega el siguiente código resaltado en negrita a la vista payment_process:


PracticaDjango/Payment/views.py

#...
def payment_process(request):
    order_id = request.session.get('order_id', None)
    order = get_object_or_404(Order, id=order_id)

    if request.method == 'POST':
        success_url = request.build_absolute_uri(reverse('payment:completed'))
        cancel_url = request.build_absolute_uri(reverse('payment:canceled'))

        # Stripe checkout session data
        session_data = {
            'mode': 'payment',
            'client_reference_id': order.id,
            'success_url': success_url,
            'cancel_url': cancel_url,
            'line_items': []
        }
        # add order items to the Stripe checkout session
        for item in order.items.all():
            session_data['line_items'].append({
                'price_data': {
                    'unit_amount': int(item.price * Decimal('100')),
                    'currency': 'eur',
                    'product_data': {
                        'name': item.product.nombre,
                    },
                },
                'quantity': item.quantity,
            })
        # Stripe coupon
        if order.cupon:
            stripe_coupon = stripe.Coupon.create(
                name=order.cupon.codigo, percent_off=order.descuento, duration='once')
            session_data['discounts'] = [{'coupon': stripe_coupon.id}]

        # create Stripe checkout session
        session = stripe.checkout.Session.create(**session_data)
        # redirect to Stripe payment form
        return redirect(session.url, code=303)
    else:
        return render(request, 'Payment/process.html', locals())
#...

En el nuevo código, verificas si el pedido tiene un cupón asociado. En ese caso, utilizas el SDK de Stripe para crear un cupón de Stripe utilizando stripe.Coupon.create(). Utilizas los siguientes atributos para el cupón:

- name: Se utiliza el código del cupón relacionado con el objeto de pedido.

- percent_off: Se emite el descuento del objeto de pedido.

- duration: Se utiliza el valor "once". Esto indica a Stripe que este es un cupón para un pago único.

Después de crear el cupón, su ID se agrega al diccionario session_data utilizado para crear la sesión de Stripe Checkout. Esto vincula el cupón a la sesión de pago.


Abre http://127.0.0.1:8000/ en tu navegador y completa una compra utilizando el cupón que creaste. Cuando te redirijan a la página de Stripe Checkout, verás que se aplica el cupón.


dto aplicado en la pasarela de pago

Ahora completa el pago y ve al panel de administración. Si compruebas ese pedido verás que aparece el cupón y el descuento aplicado.


cupón y descuento en el panel de admon


Hemos logrado almacenar cupones para pedidos y procesar pagos con descuentos con éxito. A continuación, agregaremos los cupones a la vista de detalle del pedido en el sitio de administración y a las facturas PDF de los pedidos.


Agregar cupones a los pedidos en el sitio de administración y a las facturas PDF

Vamos a añadir el cupón a la página de detalle del pedido en el sitio de administración. Edita la plantilla admin/orders/order/detail.html de la aplicación de pedidos y agrega el siguiente código resaltado en negrita:

PracticaDjango/Orders/templates/admin/Orders/order/detail.html

 <tbody>
            {% for item in order.items.all %}
            <tr class="row{% cycle '1' '2' %}">
                <td>{{ item.product.nombre }}</td>
                <td class="num">{{ item.price }} €</td>
                <td class="num">{{ item.quantity }}</td>
                <td class="num">{{ item.get_cost }} €</td>
            </tr>
            {% endfor %}

            {% if order.cupon %}
            <tr class="subtotal">
                <td colspan="3">Subtotal</td>
                <td class="num">
                    {{ order.coste_total_antes_del_descuento|floatformat:2 }} €
                </td>
            </tr>
            <tr>
                <td colspan="3">
                    "{{ order.cupon.codigo }}" cupón
                    ({{ order.descuento }}% descuento)
                </td>
                <td class="num neg">
                    - {{ order.aplicar_descuento|floatformat:2 }} €
                </td>
            </tr>
            {% endif %}
            <tr class="total">
                <td colspan="3">Total</td>
                <td class="num">{{ order.get_total_cost }} €</td>
            </tr>
        </tbody>        

Ve a la dirección http://127.0.0.1:8000/admin/Orders/order/ y haz clic en el link view que está al final a la derecha y que nos muestra el detalle del último pedido pedido (order detail). 

vista del detalle del pedido


En la compra realizada ahora si aparece el cupón y el descuento aplicado de forma similar a como se ve en la imagen


resumen compra realizada con descuento

Ahora modificaremos la plantilla de la factura para que también refleje el descuento. Edita la plantilla Orders/order/pdf.html y añade las líneas resaltadas en azul:

PracticaDjango/Orders/templates/admin/Orders/order/pdf.html

<tbody>
            {% for item in order.items.all %}
            <tr class="row{% cycle '1' '2' %}">
                <td>{{ item.product.nombre }}</td>
                <td class="num">{{ item.price }} €</td>
                <td class="num">{{ item.quantity }}</td>
                <td class="num">{{ item.get_cost }} €</td>
            </tr>
            {% endfor %}

            {% if order.cupon %}
            <tr class="subtotal">
                <td colspan="3">Subtotal</td>
                <td class="num">
                    {{ order.coste_total_antes_del_descuento|floatformat:2 }} €
                </td>
            </tr>
            <tr>
                <td colspan="3">
                    "{{ order.cupon.codigo }}" cupon
                    ({{ order.descuento }}% descuento)
                </td>
                <td class="num neg">
                    - {{ order.aplicar_descuento|floatformat:2 }} €
                </td>
            </tr>
            {% endif %}

            <tr class="total">
                <td colspan="3">Total</td>
                <td class="num">{{ order.get_total_cost|floatformat:2}} €</td>
            </tr>
        </tbody>
    </table>   


Ve a la dirección http://127.0.0.1:8000/admin/Orders/order/ y haz click en el link PDF que está al final a la derecha y que nos muestra la factura en PDF.


fra con descuento

Código en GITHUB del capítulo.

domingo, 14 de enero de 2024

24.- Exportando los pedidos a archivos CSV. Personalización del panel de Administración y generación y envio de PDFs.

 A veces, es posible que desees exportar la información contenida en un modelo a un archivo para poder importarla en otro sistema. Uno de los formatos más ampliamente utilizados para exportar/importar datos es el de Valores Separados por Comas (CSV). Un archivo CSV es un archivo de texto plano que consta de varios registros. Normalmente, hay un registro por línea y algún carácter delimitador, generalmente una coma literal, que separa los campos del registro. Vamos a personalizar el sitio de administración para poder exportar pedidos a archivos CSV.


Añadiendo acciones personalizadas al sitio de administración 


Django ofrece una amplia gama de opciones para personalizar el sitio de administración. Vas a modificar la vista de lista de objetos para incluir una acción de administración personalizada. Puedes implementar acciones de administración personalizadas para permitir que los usuarios apliquen acciones a varios elementos a la vez en la vista de lista de cambios. 

Una acción de administración funciona de la siguiente manera: un usuario selecciona objetos de la página de lista de objetos de administración con casillas de verificación, luego selecciona una acción para realizar en todos los elementos seleccionados y ejecuta las acciones. La siguiente imagen muestra dónde se encuentran las acciones en el sitio de administración:


The drop-down menu for Django administration actions



Puedes crear una acción personalizada escribiendo una función regular que reciba los siguientes parámetros:

• El ModelAdmin actual que se está mostrando
• El objeto de solicitud actual como una instancia de HttpRequest
• Un QuerySet para los objetos seleccionados por el usuario

Esta función se ejecutará cuando se active la acción desde el sitio de administración.

Vamos a crear una acción personalizada de administración para descargar una lista de pedidos como un archivo CSV.

Edita el archivo admin.py de la aplicación de pedidos y agrega el siguiente código antes de la clase OrderAdmin:

PracticaDjango/Orders/admin.py

# Para exportar los pedidos como archivos csv
import csv
import datetime
from django.http import HttpResponse

def export_to_csv(modeladmin, request, queryset):
    opts = modeladmin.model._meta
    content_disposition = f'attachment; filename={opts.verbose_name}.csv'
    response = HttpResponse(content_type='text/csv')
    response['Content-Disposition'] = content_disposition
    writer = csv.writer(response)
    fields = [field for field in opts.get_fields() if not \
              field.many_to_many and not field.one_to_many]
    # Escribe la primera línea con información de la cabecera
    writer.writerow([field.verbose_name for field in fields])
    # Escribe las filas con los datos
    for obj in queryset:
        data_row = []
        for field in fields:
            value = getattr(obj, field.name)
            if isinstance(value, datetime.datetime):
                value = value.strftime('%d/%m/%Y')
            data_row.append(value)
        writer.writerow(data_row)
    return response
export_to_csv.short_description = 'Exportar a CSV'


En este código, realizas las siguientes tareas:

1. Creas una instancia de HttpResponse, especificando el tipo de contenido text/csv, para indicar al navegador que la respuesta debe tratarse como un archivo CSV. También agregas una cabecera Content-Disposition para indicar que la respuesta HTTP contiene un archivo adjunto.

2. Creas un objeto escritor CSV que escribirá en el objeto de respuesta.

3. Obtienes dinámicamente los campos del modelo utilizando el método get_fields() de las opciones _meta del modelo. Excluyes las relaciones muchos a muchos y uno a muchos.

4. Escribe una fila de encabezado que incluye los nombres de los campos.

5. Iteras sobre el conjunto de consultas dado y escribes una fila para cada objeto devuelto por el conjunto de consultas. Te ocupas del formato de objetos datetime porque el valor de salida para CSV debe ser una cadena o string.

6. Personalizas el nombre de visualización para la acción en el elemento desplegable de acciones del sitio de administración estableciendo un atributo short_description en la función.

Has creado una acción de administración genérica que se puede agregar a cualquier clase ModelAdmin.

Finalmente, añade la nueva acción de administración export_to_csv a la clase OrderAdmin, de la siguiente manera. El nuevo código está resaltado en azul:

PracticaDjango/Orders/admin.py

#...
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['id', 'first_name', 'last_name', 'email',
                    'address', 'postal_code', 'city', 'paid', order_payment,
                    'created', 'updated']
    list_filter = ['paid', 'created', 'updated']
    inlines = [OrderItemInline]
    actions = [export_to_csv]

Abre la siguiente dirección en tu navegador http://127.0.0.1:8000/admin/Orders/order/. Si has introducido pedidos, deberías ver algo como esto:

Using the custom Export to CSV administration action

Selecciona algunos pedidos y haz clic en "Exportar a CSV" en el menú que pone "acción" en la parte superior, y haz clic en el botón "Ir". Tu navegador descargará el archivo CSV que hemos generado con el nombre de order.csv. Abre este archivo usando un editor de texto. Verás algo parecido a esto:

archivo css

Como puedes ver, crear acciones personalizadas en el panel de administración es bastante sencillo. Puedes obtener más información sobre la generación de archivos CSV con Django en https://docs.djangoproject.com/en/5.0/howto/outputting-csv/.

A continuación, personalizaremos aún más el panel de administración mediante la creación de una vista personalizada.


Ampliando el sitio de administración con vistas personalizadas

En ocasiones, es posible que desees personalizar el sitio de administración más allá de lo que es posible mediante la configuración de ModelAdmin, la creación de acciones personalizadas de administración y la anulación de plantillas de administración. Puede que desees implementar funcionalidades adicionales que no estén disponibles en las vistas o plantillas de administración existentes. Si este es el caso, necesitas crear una vista de administración personalizada. Con una vista personalizada, puedes construir cualquier funcionalidad que desees; solo asegúrate de que solo los usuarios con privilegios de staff puedan acceder a tu vista y de que mantengas el aspecto y la sensación de administración al hacer que tu plantilla extienda una plantilla de administración.

Vamos a crear una vista personalizada para mostrar información sobre un pedido. Edita el archivo views.py de la aplicación de pedidos y agrega el siguiente código resaltado en azul:

PracticaDjango/Orders/views.py

#...
# Para crear una vista personalizada en el panel de administración.
from django.shortcuts import get_object_or_404
from django.contrib.admin.views.decorators import staff_member_required
from .models import Order

def order_create(request):
    #...

@staff_member_required
def admin_order_detail(request, order_id):
    order = get_object_or_404(Order, id=order_id)
    return render(request, 'admin/Orders/order/detail.html', {'order': order})

El decorador `staff_member_required` verifica que tanto los campos `is_active` como `is_staff` del usuario que solicita la página estén configurados como Verdaderos. En esta vista, obtienes el objeto Order con el ID proporcionado y renderizas una plantilla para mostrar la orden.

A continuación, edita el archivo urls.py de la aplicación de órdenes y agrega el siguiente patrón de URL resaltado en azul:

PracticaDjango/Orders/urls.py

from django.urls import path
from . import views

app_name = 'orders'

urlpatterns = [
path('create/', views.order_create, name='order_create'),
path('admin/Order/<int:order_id>/', views.admin_order_detail,
name='admin_order_detail'),
]
Crea la siguiente estructura de directorios dentro de las plantillas (templates) de la aplicación de pedidos:

admin/

    Order/

        order/

            detail.html

Edita la plantilla "detail.html" que hemos creado y añade el siguiente código:

PracticaDjango/Orders/templates/admin/Orders/order/detail.html

<html>

<body>
    <h1>Unikgame</h1>
    <p>
        Factura no. {{ order.id }}<br>
        <span class="secondary">
            {{ order.created|date:"M d, Y" }}
        </span>
    </p>
    <h3>Facturar a:</h3>
    <p>
        {{ order.first_name }} {{ order.last_name }}<br>
        {{ order.email }}<br>
        {{ order.address }}<br>
        {{ order.postal_code }}, {{ order.city }}
    </p>
    <h3>Elementos Comprados</h3>
    <table>
        <thead>
            <tr>
                <th>Producto</th>
                <th>Precio</th>
                <th>Cantidad</th>
                <th>Coste</th>
            </tr>
        </thead>
        <tbody>
            {% for item in order.items.all %}
            <tr class="row{% cycle '1' '2' %}">
                <td>{{ item.product.name }}</td>
                <td class="num">{{ item.price }} €</td>
                <td class="num">{{ item.quantity }}</td>
                <td class="num">{{ item.get_cost }} €</td>
            </tr>
            {% endfor %}
            <tr class="total">
                <td colspan="3">Total</td>
                <td class="num">{{ order.get_total_cost }} €</td>
            </tr>
        </tbody>
    </table>
    <span class="{% if order.paid %}paid{% else %}pending{% endif %}">
        {% if order.paid %}Pagado{% else %}Pendiente de Pago{% endif %}
    </span>
</body>

</html>

Asegúrate de que ninguna etiqueta de plantilla esté dividida en varias líneas.

Este es el modelo para mostrar los detalles de un pedido en el sitio de administración. Este modelo extiende el modelo admin/base_site.html del sitio de administración de Django, que contiene la estructura principal de HTML y estilos CSS. Utilizas los bloques definidos en el modelo padre para incluir tu propio contenido. Muestras información sobre el pedido y los artículos comprados.

Cuando deseas extender un modelo de administración, necesitas conocer su estructura e identificar los bloques existentes. Puedes encontrar todos los modelos de administración en https://github.com/django/django/tree/5.0/django/contrib/admin/templates/admin.

También puedes anular un modelo de administración si es necesario. Para hacerlo, copia un modelo en tu directorio de plantillas (templates/), manteniendo la misma ruta y nombre de archivo. El sitio de administración de Django utilizará tu modelo personalizado en lugar del predeterminado.

Finalmente, agreguemos un enlace a cada objeto Order en la página de visualización de la lista del sitio de administración. Edita el archivo admin.py de la aplicación de pedidos y agrega el siguiente código, encima de la clase OrderAdmin:

PracticaBlog/PracticaDjango/Orders/admin.py

#...
from django.urls import reverse

#...

def order_detail(obj):
    url = reverse('orders:admin_order_detail', args=[obj.id])
    return mark_safe(f'<a href="{url}">View</a>')


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    #...

Esta es una función que toma un objeto Order como argumento y devuelve un enlace HTML para la URL admin_order_detail. Django escapa la salida HTML de forma predeterminada. Debes utilizar la función mark_safe para evitar el autoescape.

Luego, edita la clase OrderAdmin para mostrar el enlace de la siguiente manera. El código nuevo está resaltado en azul:

PracticaBlog/PracticaDjango/Orders/admin.py

#...
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['id', 'first_name', 'last_name', 'email',
                    'address', 'postal_code', 'city', 'paid', order_payment,
                    'created', 'updated', order_detail]
    list_filter = ['paid', 'created', 'updated']
    inlines = [OrderItemInline]
    actions = [export_to_csv]
Para finalizar ejecuta el servidor de desarrollo con :
env()$ python manage.py runserver

Abre la dirección http://127.0.0.1:8000/admin/Orders/order/ en tu navegador. Cada fila de datos incluye un link view a la derecha del todo, como se puede apreciar en al siguiente imagen:


nueva opción view en las vistas.

Después de haber visto esto vamos a continuación a ver como podemos generar facturas dinámicas en PDF.


Generando facturas en PDF dinámicamente


Ahora que tienes un sistema completo de pago y compra, puedes generar una factura en PDF para cada pedido. Existen varias bibliotecas de Python para generar archivos PDF. Una biblioteca popular para generar PDFs con código Python es ReportLab. Puedes encontrar información sobre cómo generar archivos PDF con ReportLab en https://docs.djangoproject.com/en/5.0/howto/outputting-pdf/.

En la mayoría de los casos, deberás agregar estilos y formato personalizados a tus archivos PDF. Encontrarás más sencillo renderizar una plantilla HTML y convertirla en un archivo PDF, manteniendo a Python alejado de la capa de presentación. Vas a seguir este enfoque y utilizar un módulo para generar archivos PDF con Django. Utilizaremos WeasyPrint, que es una biblioteca de Python que puede generar archivos PDF a partir de plantillas HTML.


Instalando WeasyPrint

Primero, instala las dependencias de WeasyPrint para tu sistema operativo desde https://doc.courtbouillon.org/weasyprint/stable/first_steps.html. Luego, instala WeasyPrint a través de pip utilizando el siguiente comando:

pip install weasyprint


Creando la plantilla para el PDF.


Tenemos que crear una plantilla HTML que servirá de base para el PDF. Crearemos la plantilla, la renderizaremos usando Django y la pasaremos a WeasyPrint para generar el PDF. Crearemos una nueva plantilla dentro de la aplicación de pedidos en templates/Orders/order/ y la llamaremos pdf.html. Añádele el siguiente código:

PracticaDjango/Orders/templates/Orders/order/pdf.html

<html>

<body>
    <h1>Unikgame</h1>
    <p>
        Factura no. {{ order.id }}<br>
        <span class="secondary">
            {{ order.created|date:"M d, Y" }}
        </span>
    </p>
    <h3>Facturar a:</h3>
    <p>
        {{ order.first_name }} {{ order.last_name }}<br>
        {{ order.email }}<br>
        {{ order.address }}<br>
        {{ order.postal_code }}, {{ order.city }}
    </p>
    <h3>Elementos Comprados</h3>
    <table>
        <thead>
            <tr>
                <th>Producto</th>
                <th>Precio</th>
                <th>Cantidad</th>
                <th>Coste</th>
            </tr>
        </thead>
        <tbody>
            {% for item in order.items.all %}
            <tr class="row{% cycle '1' '2' %}">
                <td>{{ item.product.name }}</td>
                <td class="num">${{ item.price }}</td>
                <td class="num">{{ item.quantity }}</td>
                <td class="num">${{ item.get_cost }}</td>
            </tr>
            {% endfor %}
            <tr class="total">
                <td colspan="3">Total</td>
                <td class="num">{{ order.get_total_cost }} €</td>
            </tr>
        </tbody>
    </table>
    <span class="{% if order.paid %}pagado{% else %}pending{% endif %}">
        {% if order.paid %}Paid{% else %}Pendiente de Pago{% endif %}
    </span>
</body>

</html>    


Esta es la plantilla de la factura que usaremos de base para crear el PDF. En ella mostraremos todos los detalles de la factura, así como un mensaje de si está pagada o no.


Renderizando archivos PDF.


Vamos a crear la vista para renderizar los archivos PDF para los pedidos existentes y usaremos para ello el panel de administración de Django. Edita el archivo views.py de la aplicación de pedidos y añade el siguiente código.

PracticaBlog/PracticaDjango/Orders/views.py

#...
# Para renderizar el archivo PDF de las facturas.
from django.conf import settings
from django.http import HttpResponse
from django.template.loader import render_to_string
import weasyprint

# Create your views here.


@staff_member_required
def admin_order_pdf(request, order_id):
    order = get_object_or_404(Order, id=order_id)
    html = render_to_string('Orders/order/pdf.html', {'order': order})
    response = HttpResponse(content_type='application/pdf')
    response['Content-Disposition'] = f'filename=order_{order.id}.pdf'
    weasyprint.HTML(string=html).write_pdf(response, stylesheets=[
        weasyprint.CSS(settings.STATIC_ROOT / 'Proyecto_web_app/css/pdf.css')])
    return response


Esta es la vista para generar una factura en formato PDF para un pedido. Se utiliza el decorador staff_member_required para asegurarse de que solo los usuarios del personal tengan acceso a esta vista. Se obtiene el objeto Order con el ID proporcionado y se utiliza la función render_to_string() proporcionada por Django para renderizar Orders/order/pdf.html. El HTML resultante se guarda en la variable html. Luego, se genera un nuevo objeto HttpResponse especificando el tipo de contenido application/pdf e incluyendo la cabecera Content-Disposition para especificar el nombre de archivo. Se utiliza WeasyPrint para generar un archivo PDF a partir del código HTML renderizado y se escribe el archivo en el objeto HttpResponse.

Se utiliza el archivo de estilo CSS pdf.css ubicado en archivos estáticos para agregar estilos CSS al archivo PDF generado. Luego, se carga desde la ruta local utilizando la configuración STATIC_ROOT. Finalmente, se devuelve la respuesta generada.

Dado que necesitas usar la configuración de STATIC_ROOT, debes agregarla a tu proyecto. Esta es la ruta del proyecto donde residen los archivos estáticos. Edita el archivo settings.py del proyecto PracticaDjango y agrega la siguiente configuración:

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/

STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'static'

Después ejecuta el siguiente comando:

python manage.py collectstatic

La salida del comando debería ser parecida a esto:

 142 static files copied to '/home/chema/PycharmProjects/PracticaBlog/PracticaDjango/static'.


El comando collectstatic copia todos los archivos estáticos de tus aplicaciones al directorio definido en la configuración STATIC_ROOT. Esto permite que cada aplicación proporcione sus propios archivos estáticos utilizando un directorio static/ que los contenga. También puedes proporcionar fuentes adicionales de archivos estáticos en la configuración STATICFILES_DIRS. Todos los directorios especificados en la lista STATICFILES_DIRS también se copiarán al directorio STATIC_ROOT cuando se ejecute collectstatic. Cada vez que ejecutas collectstatic nuevamente, se te preguntará si deseas sobrescribir los archivos estáticos existentes.

Edita el archivo urls.py dentro del directorio de la aplicación Orders y agrega el siguiente patrón de URL resaltado en azul:

PracticaBlog/PracticaDjango/Orders/urls.py

from django.urls import path
from . import views

app_name = 'orders'

urlpatterns = [
    path('create/', views.order_create, name='order_create'),
    path('admin/Order/<int:order_id>/', views.admin_order_detail,
         name='admin_order_detail'),
    path('admin/Order/<int:order_id>/pdf/',
         views.admin_order_pdf, name='admin_order_pdf'),
]

Ahora puedes editar la página de visualización de la lista de administración para el modelo Order para agregar un enlace al archivo PDF para cada resultado. Edita el archivo admin.py dentro de la aplicación Orders y agrega el siguiente código encima de la clase OrderAdmin:


PracticaBlog/PracticaDjango/Orders/admin.py

def order_pdf(obj):
    url = reverse('orders:admin_order_pdf', args=[obj.id])
    return mark_safe(f'<a href="{url}">PDF</a>')
order_pdf.short_description = 'Factura'

Si especificas un atributo short_description para tu función, Django lo utilizará como nombre de la columna.

Añade order_pdf al atributo list_display de la clase OrderAdmin, de la siguiente manera:

PracticaBlog/PracticaDjango/Orders/admin.py

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['id', 'first_name', 'last_name', 'email',
                    'address', 'postal_code', 'city', 'paid', order_payment,
                    'created', 'updated', order_detail, order_pdf]
    list_filter = ['paid', 'created', 'updated']
    inlines = [OrderItemInline]
    actions = [export_to_csv]

Asegúrate de que el servidor de desarrollo esté en ejecución. Abre http://127.0.0.1:8000/admin/Orders/order/ en tu navegador. Ahora, cada fila debería incluir un enlace PDF, similar a esto:enlace a pdfs



Haz clic en el enlace PDF para cualquier pedido. Deberías ver un archivo PDF generado, similar al siguiente:

fra generada en pdf



Enviando el archivo PDF de la factura por email.


Lo que vamos a hacer ahora es preparar el programa para que cuando se realice un pago exitosamente se envíe un email al cliente con la factura que hemos generado en PDF. Lo haremos a través de una tarea asincrónica. 

Crea un nuevo archivo en la aplicación de pago y llámalo task.py. Añade el siguiente código:

PracticaBlog/PracticaDjango/Payment/tasks.py

from io import BytesIO
from celery import shared_task
import weasyprint
from django.template.loader import render_to_string
from django.core.mail import EmailMessage
from django.conf import settings
from Orders.models import Order


@shared_task
def payment_completed(order_id):
    """
    Tarea que envia una notificación por email cuando el 
    pedido ha sido pagado con exito.
    """
    order = Order.objects.get(id=order_id)
    # crea la factura por email
    subject = f'Unikgame - Factura no. {order.id}'
    message = 'Te enviamos la factura de tu compra en el archivo adjunto.'
    email = EmailMessage(subject,
                         message,
                         'admin@unikgame.com',
                         [order.email])
    # genera el PDF
    html = render_to_string('Orders/order/pdf.html', {'order': order})
    out = BytesIO()
    stylesheets = [weasyprint.CSS(
        settings.STATIC_ROOT / 'Proyecto_web_app/css/pdf.css')]
    weasyprint.HTML(string=html).write_pdf(out,
                                           stylesheets=stylesheets)
    # adjunta el archivo PDF a la notificación
    email.attach(f'order_{order.id}.pdf',
                 out.getvalue(),
                 'application/pdf')
    # send e-mail
    email.send()
Definimos la tarea payment_completed utilizando el decorador @shared_task. En esta tarea, utilizas la clase EmailMessage proporcionada por Django para crear un objeto de correo electrónico. Luego, renderizas la plantilla en la variable html. Generas el archivo PDF a partir de la plantilla renderizada y lo envías a una instancia de BytesIO, que es un búfer de bytes en memoria. Después, adjuntas el archivo PDF generado al objeto EmailMessage utilizando el método attach(), incluyendo el contenido del búfer de salida. Finalmente, envías el correo electrónico. 

Recuerda configurar tus ajustes del Protocolo Simple de Transferencia de Correo (SMTP) en el archivo settings.py del proyecto para enviar correos electrónicos. Si no deseas configurar los ajustes de correo electrónico, puedes indicarle a Django que escriba los correos electrónicos en la consola agregando la siguiente configuración al archivo settings.py

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'


Añadamos la tarea `payment_completed` al punto final del webhook que maneja los eventos de finalización de pagos. Edita el archivo `webhooks.py` de la aplicación de pagos y modifícalo para que se vea de la siguiente manera:

PracticaBlog/PracticaDjango/Payment/tasks.py

import stripe
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from Orders.models import Order
from .tasks import payment_completed

#...
            order.paid = True
            # store Stripe payment ID
            order.stripe_id = session.payment_intent
            order.save()
            # lanza la tarea de forma asincrona para enviar un correo al usuario
            payment_completed.delay(order.id)
            
    return HttpResponse(status=200)


La tarea `payment_completed` se manda a la cola al llamar a su método 'delay()'. La tarea se agregará a la cola y se ejecutará de forma asíncrona por un trabajador de Celery tan pronto como sea posible.

Ahora puedes completar un nuevo proceso de pago para recibir la factura en formato PDF en tu correo electrónico. Si estás utilizando 'console.EmailBackend' como tu servicio de correo electrónico, en la consola donde estás ejecutando Celery, podrás ver la siguiente salida:

Message-ID: <170525972355.5337.6997908321665427625@machine>

--===============2428656861460200554==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit

Te enviamos la factura de tu compra en el archivo adjunto.
--===============2428656861460200554==
Content-Type: application/pdf
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="order_34.pdf"

JVBERi0xLjcKJfCflqQKNSAwIG9iago8PC9GaWx0ZXIgL0ZsYXRlRGVjb2RlL0xlbmd0aCA5Nzc+

Y con esto finalizamos este capítulo. 

Puedes encontrar el código del mismo en este enlace de GITHUB.