domingo, 29 de octubre de 2023

16.1 - Mejorando la aplicación BLOG con características avanzadas (URL canónicas, SEO, Paginación)

En el capítulo anterior, aprendimos los componentes principales de Django desarrollando un blog dentro de la aplicación principal. Creamos una aplicación de blog sencilla utilizando vistas, plantillas y URLs. En este capítulo,  ampliaremos las funcionalidades de la aplicación del blog con características nuevas.

Usando URLs canónicas para los modelos.


Un sitio web puede tener diferentes páginas que muestran el mismo contenido. Tal como nos dice Google "Si tienes una página a la que se puede acceder mediante varias URLs, o bien páginas diferentes con contenido similar (por ejemplo, una página para móviles y otra para ordenadores), Google las considerará versiones duplicadas de la misma página. En este caso, elegirá una URL como canónica, que es la que rastreará, y considerará que las otras URLs son duplicados, por lo que las rastreará con menos frecuencia". 

Django te permite implementar el método get_absolute_url() en los modelos para devolver la URL canónica del objeto.

Usaremos la URL detalle_post definida en los patrones de URL de la aplicación para construir la URL canónica para los post de la publicación. 

Django proporciona diferentes funciones de resolución de URL que le permiten construir
URL dinámicamente usando su nombre y cualquier parámetro requerido. Usaremos la utilidad reverse() del módulo django.urls.

Edita el archivo models.py de la aplicación de blog para importar la función reverse() y añade el método get_absolute_url() al modelo Post de la siguiente manera. El nuevo código está resaltado en negrita:

PracticaDjango/Blog/models.py: 

from django.db import models

from django.contrib.auth.models import User

# Para importar el usuario que esta creando el post

from django.utils import timezone

# Para importar el timezone y establecer la fecha de creación.

from django.urls import reverse

# Para crear la Url canónica


# Create your models here.
class Categoria(models.Model):
    """Diferentes tags para asignar a los post"""

    nombre = models.CharField(max_length=50)
    created = models.DateTimeField(default=timezone.now)
    updated = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.nombre


class Post(models.Model):
    titulo = models.CharField(max_length=250)
    slug = models.CharField(max_length=250)
    contenido = models.TextField()
    autor = models.ForeignKey(User, on_delete=models.CASCADE, related_name="blog_posts")
    categorias = models.ManyToManyField(Categoria, null=True, blank=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    # Model meta Options - https://docs.djangoproject.com/en/4.1/topics/db/models/#meta-options
    class Meta:
        verbose_name = "post"
        verbose_name_plural = "posts"
        ordering = ['-updated']
        indexes = [
            models.Index(fields=['updated']),
        ]

    def __str__(self):
        return self.titulo
    
    def get_absolute_url(self):
        return reverse("Blog:detalle_post", args=[self.id])
La función reverse() construirá la URL de forma dinámica, usando el nombre URL definido en los patrones URL de la aplicación. Hemos usado el namespace 'Blog' seguido de dos puntos y el nombre de URL 'detalle_post'. Recuerda que el namespace 'Blog' esta definido en el archivo urls.py principal de la aplicación ya que hemos incluido el patrón de la URL en Blog.urls. La URL de 'detalle_post' está definido en el archivo urls.py de la aplicación Blog. El resultado 'Blog:detalle_post' puede usarse de forma global en todo el proyecto para referirnos a la URL de detalle_post. Esta Url tiene un parámetro requerido, que es el id del post. Lo hemos incluido como un argumento posicional a través de args=[self.id].

Puedes encontrar más información sobre utilidades para URLs de Django en el siguiente en https://docs.djangoproject.com/en/4.2/ref/urlresolvers/.

Reemplacemos la URLs de 'detalle_post' en la plantilla con el nuevo método get_absolute_url().

Edita el archivo de plantilla Blog/lista.html y remplaza la siguiente línea:

<a href="{% url 'Blog:detalle_post' post.id %}">{{post.titulo}}</a>
Con la línea:

<a href="{{ post.get_absolute_url }}">{{post.titulo}}</a>
La plantilla /Blog/lista.html debería tener un aspecto similar a este:

PracticaDjango/Blog/templates/Blog/lista.html

<!--Cargamos la plantilla base-->
{% extends "Proyecto_web_app/base.html" %}

<!-- Establecemos el titulo de la página -->
{% block title %}Blog{% endblock %}

{% block content %}
<div class="container-fluid bg-white" style="margin-bottom: 50px;">
    <h1 class="text-center">Listado de Post publicados</h1>
    {% for post in posts %}
        <h2>
            <a href="{{ post.get_absolute_url }}">{{post.titulo}}</a>
        </h2>
        <p class="date">Publicado {{ post.autor }} por {{ post.updated }} </p>
        {{ post.contenido|truncatewords:30|linebreaks }}
    {% endfor %}   
</div>
...

También para que funcionen los enlaces de los post que pertenecen a cada una de las categorías tenemos que modificar en el mismo sentido el archivo Blog/categoria.html

PracticaDjango/Blog/templates/Blog/categoria.html

{% block content %}
<div class="container-fluid bg-white" style="margin-bottom: 50px;">
    <h1 class="text-center">Listado de Post publicados por "{{categoria}}"</h1>
    {% for post in posts %}
    <h2>
        <a href="{{ post.get_absolute_url }}">{{post.titulo}}</a>
    </h2>
    <p class="date">Publicado {{ post.autor }} por {{ post.updated }} </p>
    {{ post.contenido|truncatewords:30|linebreaks }}
    {% endfor %}
</div>
<!-- Contenedor para centrar el botón de regresar a la lista de posts -->

Ahora abre el shell y ejecuta el servidor para ver que todo sigue funcionando correctamente.


Creando URL amigables para los SEO de las publicaciones.


La URL canónica para la vista detallada de una entrada de blog actualmente se ve como /blog/1/.
Cambiaremos el patrón de la URL para crear URLs que sean amigables para los SEO de los buscadores. Utilizaremos tanto la fecha de creación como los valores de la "slug" (una especie de resumen del título de la entrada) para construir las URL de las entradas individuales. Al combinar las fechas, haremos que la URL de una entrada en detalle se parezca a esto: /blog/2023/10/25/este-primer-post-prueba/. Proporcionaremos a los motores de búsqueda, URLs amigables para que las indexen y que contengan tanto el título como la fecha de la entrada.

Para recuperar post individuales mediante la combinación de la fecha de creación y la "slug" (una especie de resumen del título de la entrada), debemos asegurarnos de que ningún post se almacene en la base de datos con la misma "slug" y fecha de creación que uno existente. Para prevenir la duplicación de entradas en el modelo "Post", definiremos las "slugs" como únicas para la fecha de creación de la entrada.

Para lograrlo, debes editar el archivo "models.py" y agregar el parámetro "unique_for_date" al campo "slug" del modelo "Post".

PracticaDjango/Blog/models.py: 

...
class Post(models.Model):
   ...
    slug = models.CharField(max_length=250, unique_for_date='created')
   ...
Al utilizar "unique_for_date", se requiere que el campo "slug" sea único para la fecha de creación. Esto evita duplicados en las entradas para una misma fecha. Los cambios en el modelo no requieren una migración de la base de datos, pero crearemos una migración para mantener el seguimiento de cambios en el modelo. 

El comando a ejecutar en la consola es el habitual al realizar migraciones en el modelo.

$ python manage.py makemigrations Blog
$ python manage.py migrate

Modificando los patrones de las URLs.


Vamos a modificar los patrones de las URLs para que usen la fecha de creación y el slug para crear la dirección URL de la vista detalle_post.

Edita el archivo urls.py de la aplicación Blog y reemplaza la siguiente línea:

PracticaDjango/Blog/urls.py: 

path('<int:id>/', views.detalle_post, name='detalle_post'),
Con las siguientes líneas:

PracticaDjango/Blog/urls.py: 

path('<int:year>/<int:month>/<int:day>/<slug:post>/',
views.detalle_post,
name='detalle_post'),
Con esto el patrón de la vista para 'detalle_post' tendrá los parámetros 'year', 'month', 'date' que tendrán que ser números enteros y el parámetro 'post' que tendrá que ser de tipo slug, es decir una cadena de texto que solo tendrá caracteres en minúsculas y guiones.


Modificando las vistas.


Ahora tenemos que modificar los parámetros de la vista 'detalle_post' para que coincidan con los parámetros de la nueva URL y usarlos para recuperar el correspondiente objeto Post.

Edita el archivo views.py y modifica la vista detalle_post de la siguiente forma:

PracticaDjango/Blog/views.py

...
def detalle_post(request, year, month, day, post):
    """Muestra todos los post"""
    post = get_object_or_404(Post, 
                             slug=post,
                             created__year=year,
                             created__month=month,
                             created__day=day)
    return render(request, 'Blog/detalle.html', {'post': post})
...
Hemos modificado la vista detalle_post para tomar los argumentos de año, mes, día y created y recuperar un post con el slug y la fecha de creación indicados. Al agregar unique_for_date='created' a el campo slug del modelo de publicación anteriormente, nos aseguramos de que solo habrá un post con un slug para un fecha dada. Por lo tanto, podemos recuperar publicaciones individuales utilizando la fecha y el slug.


Modificar la URL canónica para las post.


Tenemos también que modificar los parámetros de la URL canónica para los post del blog para que coincidan con la nueva URL.

Editemos el archivo models,py de la aplicación Blog y editaremos el método get_absolute_url() de la siguiente forma:

PracticaDjango/Blog/models.py

class Post(models.Model):
...
def get_absolute_url(self):
        return reverse("Blog:detalle_post", 
                       args=[self.created.year,
                             self.created.month,
                             self.created.day,
                             self.slug])

Añadiendo Paginación para la lista de Post.


A medida que se vayan añadiendo post al contenido del blog, estos fácilmente pueden llegar a ser varios cientos. En vez de mostrarlos en una única página sería mejor dividirlos en varios post a lo largo de diversas páginas y diseñar una barra de navegación para poder ir entre ellas. A esto es lo que se llama paginación y puedes encontrarlo en casi cualquier aplicación que muestre una larga lista de elementos. 

Un ejemplo de esto es la paginación que muestra Google cuando muestra los resultados de una búsqueda.


Django ya tiene incorporada una clase que hace posible esto de forma sencilla. Solamente tenemos que definir el número de objetos que queremos que se muestren en cada página

Edita el archivo de vistas de la aplicación blog "views.py" y modifica la vista "detalle_post" de la siguiente manera:

PracticaDjango/Blog/views.py

from django.shortcuts import render, get_object_or_404, get_list_or_404

from .models import Post, Categoria

from django.core.paginator import Paginator

# Create your views here.


def lista_post(request):
    posts = get_list_or_404(Post)
    categorias = get_list_or_404(Categoria)
    # Paginación con 3 post por página.
    paginador = Paginator(posts, 3)
    numero_pagina = request.GET.get('page', 1)
    posts_por_pagina = paginador.page(numero_pagina)
    return render(
        request, "Blog/lista.html", {"posts": posts_por_pagina, "categorias": categorias}
    )

Vamos a echarle un vistazo al nuevo código que hemos añadido a la vista.

  1. Hemos instanciado la clase 'Paginator' pasándole como argumentos los post y el número de objetos a devolver por página.
  2. Hemos recuperado el número de la página a mostrar del GET del request. Como funciona como un diccionario le pedimos que recupere el número de página y si no existiera que nos devuelva 1, que será el valor por defecto que cargará la primera página de los resultados.
  3. Obtenemos los objetos para cada página llamando al método 'page' de paginacion. Este método devuelve un objeto que se guardara en la variable posts_por_pagina.
  4. Pasamos a la plantilla este objeto que contendrá el número de página y los post que han de mostrarse en ella.

Creando la plantilla para la paginación.


Necesitamos crear un sistema de navegación para que los usuarios puedan navegar a través de las diferentes páginas. Crearemos una plantilla para mostrar los enlaces a las páginas. Lo haremos genérico para que lo podamos usar siempre que lo necesitemos en nuestra web.

En el archivo de las plantillas generales de Proyecto_web_app, crea un nuevo archivo y llámalo paginacion.html. Añade el siguiente código HTML al archivo:

PracticaDjango/Proyecto_web_app/templates/Proyecto_web_app/paginacion.html

<div>
    <ul class="pagination">

        {% if page.has_previous %}
            <li class="page-item">
                <a class="page-link" href="?page={{ page.previous_page_number }}">
                    Anterior
                </a>
            </li>
        {% endif %}

        <li class="page-item">
            <a class="page-link" href="#">
               Página {{ page.number }} de {{ page.paginator.num_pages }}
            </a>
        </li>

        {% if page.has_next %}
            <li class="page-item">
                <a class="page-link" href="?page={{ page.next_page_number }}">
                    Siguiente
                </a>
            </li>
        {% endif %}
    </ul>
</div>

La plantilla espera tener el objeto Page en el contexto para renderizar el link posterior o anterior y mostrar la página actual y el número de páginas total.

Ahora volvamos a la plantilla Blog/lista.html y añade al final del bloque { % content % } lo siguiente:

PracticaDjango/Blog/templates/Blog/lista.html

<!--Cargamos la plantilla base-->
{% extends "Proyecto_web_app/base.html" %}

<!-- Establecemos el titulo de la página -->
{% block title %}Blog{% endblock %}

{% block content %}
<div class="container-fluid bg-white" style="margin-bottom: 50px;">
    <h1 class="text-center">Listado de Post publicados</h1>
    {% for post in posts %}
        <h2>
            <a href="{{ post.get_absolute_url }}">{{post.titulo}}</a>
        </h2>
        <p class="date">Publicado {{ post.autor }} por {{ post.updated }} </p>
        {{ post.contenido|truncatewords:30|linebreaks }}
    {% endfor %}   
</div>

{% include "Proyecto_web_app/paginacion.html" with page=posts %}

<section>
    <div style="width: 75%; margin: auto; color: white; text-align: center;">
        Categorías:
        {% for categoria in categorias %}
        <a href="{% url 'Blog:categoria' categoria.id %}" class="link-info">
            <span style="color:rgb(2, 7, 59)">{{ categoria.nombre }}</span>
        </a>&nbsp;&nbsp;&nbsp;
        {% endfor %}
    </div>
</section> 

{% endblock %}

La etiqueta {% include  %} carga la plantilla que hemos creado anteriormente y la renderiza pero usando el contexto de la etiqueta actual. Usamos "with" para pasarle variables adicionales de contexto a la plantilla. La plantilla de paginación utiliza la variable 'page' para renderizar, mientras que el objeto que pasamos desde nuestra vista a la plantilla se llama "posts". Utilizamos "with page=posts" para pasar la variable esperada por la plantilla de paginación. Puedes seguir este método para utilizar la plantilla de paginación con cualquier tipo de objeto.

Si ejecutas el servidor y vas a la página del blog, verás algo semejante a esto (procura tener más de 7 post):

paginacion


Manejando errores de paginación.


Ahora que la paginación funciona, podemos añadir algo para tratar con los errores que pueden surgir en la paginación de las vistas. El parámetro 'page' que hemos usado en la vista para recuperar la página solicitada podría potencialmente usarse con valores erróneos, como intentar ir a una página que no existe o pasar un string de texto en vez de un número de página. Vamos a implementar una captura de errores adecuada para evitar esto.

Abre el navegador e introduce como valor de página un número de página que no exista. En mi caso como tengo 7 post introducidos, solamente hay 3 páginas, por lo que si introduzco cualquier otro número dará un error. Abre el navegador e introduce la dirección http://127.0.0.1:8000/blog/?page=7. Deberías ver el siguiente mensaje de error:


Error de página EmptyPage




El objetor paginador lanza un mensaje de error "EmptyPage" porque estamos intentando acceder a una página que está fuera de rango. No hay resultados que mostrar. 

Edita el archivo views.py de la aplicación Blog importa el módulo 'EmptyPage' y modifica el código de la siguiente manera. 

PracticaDjango/Blog/views.py

from django.shortcuts import render, get_object_or_404, get_list_or_404

from .models import Post, Categoria

from django.core.paginator import Paginator, EmptyPage

# Create your views here.


def lista_post(request):
    posts = get_list_or_404(Post)
    categorias = get_list_or_404(Categoria)
    # Paginación con 3 post por página.
    paginador = Paginator(posts, 3)
    numero_pagina = request.GET.get('page', 1)
    try:
        posts_por_pagina = paginador.page(numero_pagina)
    except EmptyPage:
        # Si la página está fuera del rango de las paginas de resultados
        posts_por_pagina = paginador.page(paginador.num_pages)
    return render(
        request, "Blog/lista.html", {"posts": posts_por_pagina, "categorias": categorias}
    )
...

Con este código si la pagina solicitada esta fuera del rango, se mostrará la última página de los resultados. Conseguimos saber el numero total de paginas que tiene el paginador usando "paginador.num_pages". El numero total de páginas es igual que el último numero de página.

Pero a nuestra vista tambien le podemos pasar algo que sea diferente a un entero como parámetro de página. Por ejemplo abre el navegador y ve a http://127.0.0.1:8000/blog/?page=alapollo

Error de página PageNotAnInteger




En este caso el paginador nos lanza el error "PageNotAnInteger", de que el párametro introducido no es un número entero. Veamos como tratar este error. 

Edita el archivo views.py de la aplicación Blog importa el módulo 'PageNotAnInteger' y modifica el código de la siguiente manera:

PracticaDjango/Blog/views.py

from django.shortcuts import render, get_object_or_404, get_list_or_404

from .models import Post, Categoria

from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger

# Create your views here.


def lista_post(request):
    posts = get_list_or_404(Post)
    categorias = get_list_or_404(Categoria)
    # Paginación con 3 post por página.
    paginador = Paginator(posts, 3)
    numero_pagina = request.GET.get('page', 1)
    try:
        posts_por_pagina = paginador.page(numero_pagina)
    except EmptyPage:
        # Si la página está fuera del rango de las paginas de resultados
        posts_por_pagina = paginador.page(paginador.num_pages)
    except PageNotAnInteger:
        # Si el párametro introducido no es un número entero
        posts_por_pagina = paginador.page(1)
    return render(
        request, "Blog/lista.html", {"posts": posts_por_pagina, "categorias": categorias}
    )
...

Con esto, si la página solicitada no es un número entero, se devolverá la primera página de los resultados.

Después de ver esto ya tenemos la paginación del blog plenamente implementada. Puedes encontrar más información en https://docs.djangoproject.com/en/4.2/ref/paginator/


Construir las vistas usando clases.


Hasta el momento hemos usado funciones para crear las vistas de la aplicación. Las vistas basadas en funciones son efectivas y muy sencillas de implementar. Sin embargo Django también permite usar clases para construir las vistas.

Como una vista es una función que toma una petición web y devuelve una respuesta web, puedes definir las vistas como métodos de una clase. Las vistas basadas en clases tienen algunas ventajas sobre las que se basan en funciones ya que son muy útiles en determinados casos:
  • Organizar el código relacionado con los métodos HTTP, como GET, POST o PUT, en métodos separados en lugar de utilizar ramificaciones condicionales.
  • Utilizar la herencia múltiple para crear clases de vista reutilizables (también conocidas como mezclas).


Usar una vista basada en clases para listar los Post.


Para entender como funcionan las vistas basadas en clases, vamos a crear una que es la equivalente a la que hasta ahora era la función que listaba los post del blog "lista_post". Crearemos una clase que heredará de una clase generica llamada ListView que nos facilita Django. ListView nos permite listar cualquier clase de objetos.

Edita el archivo views.py de la aplicación blog, comenta la funcion lista_post y añade el siguiente código:

PracticaDjango/Blog/views.py

from django.shortcuts import render, get_object_or_404, get_list_or_404

from .models import Post, Categoria

# from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger

from django.views.generic import ListView

# Create your views here.

class lista_post(ListView):
    """
    Una alternativa a la función lista_post
    """
    model = Post
    context_object_name = 'posts'
    paginate_by = 3
    template_name = "Blog/lista.html"
...

La clase lista_post es análoga a la función que teníamos anteriormente pero con mucho menos código. Hemos implementado una vista basada en una clase que hereda de la clase ListView. La vista esta definida con los siguientes atributos:

  • model = 'Post' especifica que la vista trabajará con el modelo 'Post'. Podríamos haber utilizado una expresión análoga como queryset = Post.objects.all(), pero esta expresión es mucho más sencilla.
  • Usamos la variables de contexto 'posts' que es la que están utilizando las plantillas para mostar los resultados de la consulta. La variable que se usa por defecto es object_list si tu no especificas ningún context_object_name.
  • Especificamos la paginación con la propiedad paginate_by para decirle a Django que muestre tres objetos por página.
  • Usaremos la plantilla personalizada para renderizar la página usando template_name. Si no especificas ninguna plantilla por defecto Django buscará la plantilla en /Blog/lista_post.html, es decir dentro de template en /nombre_aplicacion/nombre_clase.html

Ahora, edita el archivo urls.py de la aplicación Blog, comenta el parámetro de la función lista_post que usábamos anteriormente y agrega el siguiente patrón URL para la clase lista_post que acabamos de crear:

PracticaDjango/Blog/urls.py

from django.urls import path
from . import views

app_name = 'Blog'

urlpatterns = [
# post views
# path('', views.lista_post, name='lista_post'),
path('', views.lista_post.as_view(), name='lista_post'),
path('<int:year>/<int:month>/<int:day>/<slug:post>/', views.detalle_post, name='detalle_post'),
path('categoria/<int:categoria_id>/', views.categoria, name='categoria'),
]
Como ves la única diferencia es añadir .as_view() cuando se trata de una vista definida mediante una clase.

Para mantener funcionando la paginación, debemos utilizar el objeto de página correcto que se pasa a la plantilla. La vista genérica ListView de Django pasa la página solicitada en una variable llamada 'page_obj'. Debemos editar la plantilla 'Blog/lista.html' en consecuencia, para incluir el paginador utilizando la variable correcta, de la siguiente manera:

PracticaDjango/Blog/templates/Blog/lista.html

    ...  
     <p class="date">Publicado {{ post.autor }} por {{ post.updated }} </p>
        {{ post.contenido|truncatewords:30|linebreaks }}
    {% endfor %}   
</div>

<!-- {% include "Proyecto_web_app/paginacion.html" with page=posts %} -->
{% include "Proyecto_web_app/paginacion.html" with page=page_obj %}

<section>
    <div style="width: 75%; margin: auto; color: white; text-align: center;">
        Categorías:
        {% for categoria in categorias %}
...

Para finalizar solo nos queda agregar las categorías de los post al contexto. Lamentablemente, no se pueden definir multiples modelos en la clase "ListView", ya que esta esta diseñada para trabajar con un solo modelo a la vez. 

Sin embargo podemos añadir más de un objeto al contexto utilizando el método "get_context_data". Edita el archivo views.py y añade el siguiente código:

PracticaDjango/Blog/views.py

from django.shortcuts import render, get_object_or_404, get_list_or_404

from .models import Post, Categoria

# from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger

from django.views.generic import ListView

# Create your views here.

class lista_post(ListView):
    """
    Una alternativa a la función lista_post
    """
    model = Post
    context_object_name = 'posts'
    paginate_by = 3
    template_name = "Blog/lista.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['categorias'] = Categoria.objects.all()
        return context
...

Lo primero que hacemos dentro de la clase es sobrescribir el método "get_context_data" para personalizar el contexto que se pasa a la plantilla. Este método se llama para obtener el contexto de la vista.

Luego llamamos al método "super().get_context_data(**Kwargs)" para obtener el contexto predeterminado y lo almacenamos en la variable "context".

Terminamos añadiendo context['categorias'] al contexto y lo llenamos con todos los objetos de la clase "Categoria" utilizando Categoria.objects.all(). Esto hace que la lista de categorías este disponible en la plantilla con el nombre de 'categorias'.