Sobre este documento

Este guión de prácticas ha sido elaborado a partir de los tutoriales de introducción a Django que se pueden encontrar en la página de Django. Han sido adaptados y modificados específicamente para las clases de SAT y SARO de los Grados que se imparten en la Escuela de Ingeniería de Telecomunicación de la Universidad Rey Juan Carlos.

Jugando con la API de la base de datos

Partimos del entorno de la última clase, de la aplicación cms. De esta manera, tendrás que entrar en el entorno virtual "cursosweb" e ir al directorio donde te creaste el proyecto Django.

Hoy vamos a utilizar al shell interactivo de Python para jugar con la API que Django nos proporciona. Para conocer la API, en vez de utilizarla directamente en vistas (que requiere que modifiquemos las URLs y creemos las respuestas), vamos a utilizar la shell de Python. Para llamar el shell de Python, utiliza este comando:

$ python3 manage.py shell
...\> py manage.py shell

En vez de ejecutar Python3 directamente y llamar al archivo manage.py, lo que hacemos es establecer como variable de entorno DJANGO_SETTINGS_MODULE que le suministra a Django la ruta de importación de Python en proyecto/settings.py. En otras palabras, estaríamos en un entorno igual al que tendríamos en una vista de una aplicación Django.

Una vez en la shell, podemos explorar la API de base de datos. Hagámoslo:

>>> from cms.models import Comentario, Contenido  # Importamos los modelos de nuestra aplicación.

# Miremos qué contenidos tiene nuestra base de datos.
>>> Contenido.objects.all()
<QuerySet [<Contenido: Contenido object (1)>, <Contenido: Contenido object (2)>, <Contenido: Contenido object (3)>, <Contenido: Contenido object (4)>, <Contenido: Contenido object (5)>]>

# En mi caso, ya hay cinco contenidos.
# Son los contenidos que introduje la semana pasada vía el interfaz de admin o con PUT.
# Nótese que nos devuelve una lista de objetos Contenido.
# Creemos un nuevo Contenido.
>>> c = Contenido(clave="papageno", valor="¡Pa, Pa, Pa, Pa, Pa, Pa, Papageno!")

# Guardemos el objeto en la base de datos. Tendrás que llamar a save() de manera explícita.
>>> c.save()

# Una vez guardado, el contenido tendrá un id.
>>> c.id
6
# El id es secuencial, así que dependerá de cuántos contenidos había antes.
# En mi caso, devuelve 6, porque ya tenía 5 contenidos.
# Puede que en tu caso el id sea diferente por ello.

# Podemos acceder a los campos del modelo como si fueran atributos de Python.
>>> c.clave
"papageno"
>>> c.valor
"¡Pa, Pa, Pa, Pa, Pa, Pa, Papageno!"

# Podemos cambiar el valor mediante los mismos atributos, y después guardando los cambios en la base de datos con save().
>>> c.valor = "Wenn die Götter uns bedenken"
>>> c.save()
>>> c.valor
"Wenn die Götter uns bedenken"


# Salimos de la shell con Ctrl+D o con exit().

Una cuestión importante a tener en cuenta. Te habrás fijado que los modelos tienen un atributo id, que no hemos especificado nosotros. Esto quiere decir que Django añade a cada modelo una columna más, que tendrá como nombre id. El valor de este campo será un número entero que irá incrementando secuencialmente. Así, el primer contenido tiene valor id igual a 1. Y el segundo, será 2. Y así al crear un nuevo contenido (el del "papageno"), el campo id tendrá un valor de 6, porque había cinco contenidos ya.

De esta manera, nos aseguramos que siempre vamos a tener un campo que sea unívoco, algo así como el DNI de las personas, que nos identifican de manera de manera individual (no hay, o no debería haber dos personas diferentes con el mismo número de DNI -- al igual que no habrá dos filas con el mismo valor de id). La característica de ser unívoco se conoce en términos tecnológicos como ser llave primaria (primary key en inglés). En resumen, id es la clave por defecto, si no se especifica que quieres que otro campo sea la clave primaria. Si especificas un campo clave, Django ya no añade id.

Por otro lado, <Contenido: Contenido object (1)> no es una representación útil de este objeto. Y menos cuando tenemos muchos; no podemos ver de qué objeto se trata. Arreglemos esto modificando el modelo Contenido (en el archivo cms/models.py) agregando un metodo __str__() a los dos modelos, Contenido y Comentario:

cms/models.py
from django.db import models

class Contenido(models.Model):
    [...]
    def __str__(self):
        return self.clave

class Comentario(models.Model):
    [...]
    def __str__(self):
        return self.titulo

Nota que donde pone [...] viene el código con los campos de los modelos (que ya tenemos de la clase anterior), que no se han puesto aquí para simplificar.

Es importante añadir los métodos __str__() a tus modelos, no solo para tu conveniencia al lidiar con la línea de comandos interactiva, sino también porque las representaciones de objetos se usan en todo el sitio administrativo generado automáticamente de Django.

Ya que estamos, añadamos un método "custom" a este modelo. Estos métodos están "empotrados" al modelo y permite ofrecer funcionalidad adicional que va ligada a nuestros datos. Por ejemplo, en nuestro caso, podríamos estar interesados si el valor del contenido contiene la letra 'a'. Tendríamos algo así:

cms/models.py

from django.db import models

class Contenido(models.Model):
    # ...
    def tiene_as(self):
        return ('a' in self.valor)

Guarda estos cambios e inicia de nuevo una shell interactiva Python ejecutando python manage.py shell:

>>> from cms.models import Comentario, Contenido

# Veamos si funciona __str__().
>>> Contenido.objects.all()
<QuerySet [<Contenido: nabucco>, <Contenido: rigoletto>, <Contenido: burana>, <Contenido: barbieri>, <Contenido: carmen>, <Contenido: papageno>]>
# ¡Mucho mejor!.

# Django ofrece un interfaz (API) muy rica para ver los contenidos de la base de datos
# basada en pasarle argumentos a las llamadas.
>>> Contenido.objects.filter(id=6)
<QuerySet [<Contenido: papageno>]>
>>> Contenido.objects.filter(valor__startswith='Wenn')
<QuerySet [<Contenido: papageno>]>


# Si pido un id que no existe todavía, saltará una excepción.
>>> Contenido.objects.get(id=7)
Traceback (most recent call last):
    ...
DoesNotExist: Contenido matching query does not exist.

# Buscar por llave primera es el caso más común, así que
# Django ofrece un atajo para búsquedas exactas de llave primaria.
# Lo siguiente es idéntico a Contenido.objects.get(id=6).
>>> Contenido.objects.get(pk=6)
<Contenido: papageno>

# Probemos si de verdad funciona la función "custom" que nos dice si el valor tiene al menos una "a".
>>> c = Contenido.objects.get(pk=6)
>>> c.tiene_as()
False
# Y no, el valor del Contenido con pk=6 no tiene ninguna "a".
>>> c.valor
"Wenn die Götter uns bedenken"


# Veamos cuántos comentarios tiene un contenido.
# Primero seleccionamos un contenidos específico (p.ej., aquél que tiene pk=1).
>>> c = Contenido.objects.get(pk=1)

# Y después veamos cuántos comentarios tiene -- por ahora, ninguno.
>>> c.comentario_set.all()
<QuerySet []>

# Añadamos unos cuantos comentarios al contenido.
# El soporte para zonas horarias está habilitado en el fichero de settings
# así que Django espera información horaria con tzinfo para el campo fecha. Usa timezone.now().
>>> from django.utils import timezone
>>> c.comentario_set.create(titulo='carmen', cuerpo='L\'amour est un oiseau rebelle',  fecha=timezone.now())
<Comentario: carmen>
>>> q = c.comentario_set.create(titulo='Otro comentario', cuerpo='Y otro cuerpo',  fecha=timezone.now())

# Tenemos acceso a los objetos Comentario de la siguiente manera:
>>> q
<Comentario: Otro comentario>
>>> q.titulo
'Otro comentario'

# Y viceversa, los objetos Contenido tienen acceso a los objetos Comentario.
>>> c.comentario_set.all()
<QuerySet [<Comentario: carmen>, <Comentario: Otro comentario>]>
>>> c.comentario_set.count()
2

# Obtengamos los comentarios que han sido publicados este año.
>>> current_year = timezone.now().year
>>> Comentario.objects.filter(fecha__year=current_year)
<QuerySet [<Comentario: carmen>, <Comentario: Otro comentario>]>

# La API nos deja seguir de manera automática relaciones según necesitemos.
# Usa guiones bajos dobles para separar las relaciones.
# Esto funciona con tantos niveles de profundidad como necesites; no hay límite.
# Encuentra los Contenidos que tengan Comentarios cuya fecha sea de este año
# (reutilizando la variable 'current_year' usada anteriormente).
>>> Contenido.objects.filter(comentario__fecha__year=current_year)
<QuerySet [<Comentario: carmen>, <Comentario: Otro comentario>]>

# Borremos uno de los comentarios. Para ello, usaremos delete():
>>> q = c.comentario_set.filter(titulo__startswith='Otro')
>>> q.delete()

Para obtener más información sobre las relaciones de modelos, puedes consultar Accediendo a objetos relacionados. Para más información sobre cómo utilizar guiones bajos para realizar búsquedas de campo a través de la API, consulta :ref: Búsquedas de campos <field-lookups-intro>. Para más detalles sobre la API de base de datos, consulta la Referencia de API de base de datos.

Si has llegado hasta aquí, pon en el chat de Teams el siguiente mensaje: "Acabo de terminar la parte de los modelos"

Levantando un error 404

Hasta ahora, si no encontrábamos una clave, saltaba una excepción DoesNotExist. Pero al responder con un objeto HttpResponse, la respueta HTTP que enviábamos de vuelta era un 200 OK. Vamos a cambiarlo para que sea un 404:

cms/views.py
from django.http import HttpResponse, Http404

from .models import Contenido

def get_content(request, llave):
    try:
        respuesta = Contenido.objects.get(clave=llave).valor  
    except Contenido.DoesNotExist:
        raise Http404("No existe la clave " + llave)
    return HttpResponse(respuesta)

El nuevo concepto aquí: La vista levanta la excepción Http404 si no existe el contenido con la clave solicitada.

Es una práctica muy común utilizar get() y levantar la excepción Http404 si no existe el objeto. Por eso, Django proporciona un atajo. Aquí está la vista get_content(), reescrita:

cms/views.py

from django.shortcuts import get_object_or_404
from django.http import HttpResponse

from .models import Contenido

def get_content(request, llave):
    contenido = get_object_or_404(Contenido, clave=llave)
    return HttpResponse(contenido.valor)

La función get_object_or_404() toma un modelo Django como su primer argumento y un número arbitrario de argumentos de palabra clave que pasa a la función get() del administrador del modelo. Levanta la excepción Http404 si no existe el objeto.

También hay una función get_list_or_404() , que funciona igual que get_object_or_404() - excepto usando filter() en lugar de get(). La misma levanta la excepción Http404 si la lista está vacía.

Añadiendo contenidos con POST

La semana pasada vimos cómo hacer para enviar nuevos contenidos a cms con el método PUT. Si lo queremos hacer con POST, tendremos que crear un formulario con HTML. Lo que vamos a hacer es que dado un recurso (exista éste o no), vamos a mostrar el formulario para introducir el valor. Nota que si ya existe ese recurso, no creará una nueva entrada en la base de datos, sino que alterará la ya existente.

La vista en el fichero cms/views.py quedaría de la siguiente manera:

cms/views.py

from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt

from .models import Contenido, Comentario

formulario_contenido = """
<br>
<form action="" method="POST">
  Introduce el (nuevo) contenido para esta página: 
  <input type="text" name="valor">
  <input type="submit" value="Enviar">
</form>
"""

@csrf_exempt
def get_content(request, llave):
    if request.method == "PUT":
        valor = request.body.decode('utf-8')           
    elif request.method == "POST":
        valor = request.POST['valor']             
    if request.method in ["PUT", "POST"]:
        try:
            c = Contenido.objects.get(clave=llave)
            c.valor = valor
        except Contenido.DoesNotExist:
            c = Contenido(clave=llave, valor=valor)
        c.save()

    contenido = get_object_or_404(Contenido, clave=llave)
    return HttpResponse(contenido.valor + formulario_contenido)

En el caso de recibir un POST, el valor se encontrará tambień en el cuerpo de la petición. Sin embargo, el cuerpo de la petición tendrá esta pinta: campo1=valor_introducido1&campo2=valor_introducido2. Por eso, llamamos a request.POST, que nos devuelve los valores de POST como si fuera un diccionario.

A continuación, miramos si ya existe un contenido con esa llave. Si existe, tomamos ese objeto y cambiamos su valor. Si no existe, saltará una excepción Contenido.DoesNotExist, por lo que procederemos a crear el objeto. En ambos casos, al final guardamos el cambio en la base de datos con c.save().

Otro cambio con respecto a la versión anterior es que hemos creado una nueva variable formulario, que es un string multilínea que contiene un formulario HTML. Este formulario lo añadimos a toda repuesta que enviamos con HttpResponse (es por ello por lo que añadimos una marca de salto de línea, <br&gr;, para que no salga todo apelotonado en la página web).

Añadiendo comentarios

Hemos añadido comentarios a algún contenido, pero todavía no mostramos ningún comentario en las vistas, ni podemos añadirlos vía web. El siguiente extracto de código muestra cómo habría que cambiar la vista para hacerlo.

cms/views.py
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone

from .models import Contenido, Comentario

formulario_contenido = """
<br>
<form action="" method="POST">
  Introduce el (nuevo) contenido para esta página: 
  <input type="text" name="valor">
  <input type="submit" name="action" value="Enviar Contenido">
</form>
"""

formulario_comentario = """
<br>
<form action="" method="POST">
  Introduce un nuevo comentario para esta página: 
  <br>Título: <input type="text" name="titulo">
  <br>Cuerpo: <input type="text" name="cuerpo">
  <input type="submit" name="action" value="Enviar Comentario">
</form>
"""

@csrf_exempt
def get_content(request, llave):
    if request.method == "PUT":
        valor = request.body.decode('utf-8')           
    elif request.method == "POST":
        action = request.POST['action']
        if action == "Enviar Contenido":
            valor = request.POST['valor']
    if request.method == "PUT" or (request.method == "POST" and action == "Enviar Contenido"):
        try:
            c = Contenido.objects.get(clave=llave)
            c.valor = valor
        except Contenido.DoesNotExist:
            c = Contenido(clave=llave, valor=valor)
        c.save()
    if request.method == "POST" and action == "Enviar Comentario":
            c = Contenido.objects.get(clave=llave)    
            titulo = request.POST['titulo']
            cuerpo = request.POST['cuerpo']
            q = Comentario(contenido=c, titulo=titulo, cuerpo=cuerpo, fecha=timezone.now())
            q.save()

    contenido = get_object_or_404(Contenido, clave=llave)
    comentarios = contenido.comentario_set.all()
    respuesta = contenido.valor
    for comentario in comentarios:
        respuesta += "<p><b>Título</b>: " + comentario.titulo + "<br><b>Cuerpo</b>: " + comentario.cuerpo + "<br><b>Enviado</b>: " + comentario.fecha.strftime('%Y-%m-%d %H:%M:%S') + "</p>"
    respuesta += formulario_comentario
    return HttpResponse(respuesta + formulario_contenido)

En primer lugar, notemos que hay un import del módulo timezone. Este módulo nos proporciona funcionalidad de fechas -- y vamos a necesitarlo al introducir la fecha en la que se ha enviado un comentario.

Además, hemos añadido un nuevo formulario para introducir comentarios. Como ahora la página web tendrá dos formularios (el que teníamos antes para contenidos y el nuevo de comentarios), tenemos que añadir un atributo name al botón de submit. Este atributo nos permitirá tomar el valor del botón que se ha pulsado (que puede ser "Enviar contenido" o "Enviar comentario").

En get_content(), tomamos primero los valores enviados por el formulario. Para eso debemos tener en cuenta si la petición ha sido PUT (sólo se envía el valor en el cuerpo) o POST (tenemos que tener en cuenta el action y el cuerpo).

Si se trata de un contenido, tendremos que comprobar si ya existe en la base de datos para evitar duplicados. Si se trata de un comentario, tendremos que crear un objeto Comentario nuevo. Necesitamos pasar como parámetros el objeto Contenido al que se refiere el comentario, el título y el cuerpo del comentario a partir del formulario HTML y la fecha utilizando el método now() del módulo timezone.

Finalmente, mostramos el resultado. Para ello, obtenemos primero el contenido (con get_object_or_404(Contenido, clave=llave)) y luego sus comentarios. El contenido puede no tener comentarios, tener uno o tener varios. Es por eso que haremos contenido.comentario_set.all() sobre el objeto contenido al que corresponden. Esto nos devolverá una lista, por la que iteraremos, utilizando el string respuesta para ir añadiendo los comentarios.

Especialmente en la línea correspondiente a la concatenación de respuestas dentro del bucle for es fea. Esto es así, porque estamos mezclando código Python y lenguaje HTML. Nótese que se trata de dos concepctos diferentes: el código Python es la lógica del negocio, mientras que el HTML es como se muestra. Si quisiéramos modificar cómo se ve la página en el navegador, tendríamos que tocar el código. En este caso, todavía es poco (así de feas son nuestras páginas por ahora), pero tendremos más y más. Es por eso que vamos a ver un mecanismo para separar el código de la presentación. Vamos a ver las plantillas.

Si has llegado hasta aquí, pon en el chat de Teams el siguiente mensaje: "Ahora voy a por las plantillas"

Introduciendo las plantillas

Como decíamos, vamos a usar el sistema de plantillas de Django para separar el diseño de Python mediante la creación de una plantilla que la vista pueda utilizar.

Primero, crea un directorio llamado templates en tu directorio cms. Django buscará las plantillas allí.

La configuración de TEMPLATES de tu proyecto describe cómo Django cargará y creará las plantillas. El archivo de configuración predeterminada configura un backend DjangoTemplates cuya opción APP_DIRS está configurada como True. Convencionalmente, DjangoTemplates busca un subdirectorio «templates» en cada una de las INSTALLED_APPS.

Dentro del directorio templates que acabmos de crear, crea otro directorio llamado cms, y dentro de este último crea un fichero llamado index.html; en otras palabras, tu plantilla debería estar en cms/templates/cms/index.html. Por cómo funciona el cargador de plantillas (conocido como app_directories), esta plantilla será referida dentro de Django como cms/index.html.

Espacio de nombres de plantillas

La razón por la que no ponemos las plantillas directamente en el directorio cms/templates (y tengamos que crear un subdirectorio cms) es porque Django escogerá la primera plantilla que encuentra cuyo nombre se ajuste al nombre que busca. Si tenemos una plantilla que se llama igual en una aplicación diferente, Django será incapaz de distinguir entre las dos (y tomará una de las dos, quizás la que no queremos). Tenemos, por tanto, que apuntar a Django a la correcta, y la mejor manera de hacerlo es utilizar un espacio de nombres (namespace), esto es, poner las plantillas en otro directorio llamado como la propia aplicación.

Introduce el siguiente código en esa plantilla:

cms/templates/cms/index.html
{% if content_list %}
    <ul>
    {% for content in content_list %}
        <li><a href="/cms/{{ content.clave }}">{{ content.clave }}</a></li>
    {% endfor %}
    </ul>
{% else %}
    <p>No contents are available.</p>
{% endif %}

Nota

En este guión, todas los ejemplos de plantillas usan HTML incompleto. En realidad, deberíamos utilizar siempre documentos HTML completos.

Ahora vamos a crear una nueva vista index en cms/views.py para usar la plantilla. Esta vista lo que hará es mostrar cinco contenidos:

cms/views.py
from django.http import HttpResponse
[...]
from django.template import loader

from .models import Contenido

[...]

def index(request):
    content_list = Contenido.objects.all()[:5]
    template = loader.get_template('cms/index.html')
    context = {
        'content_list': content_list,
    }
    return HttpResponse(template.render(context, request))

Ese código carga la plantilla llamada cms/index.html y le pasa un contexto. El contexto es un diccionario que relaciona los nombres de variables de plantillas con objetos Python.

Finalmente, añadiremos en cms/urls.py la siguiente regla:

cms/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.index),
    path('<str:llave>', views.get_content),
]

Lanza el servidor y carga la página señalando tu navegador en «/cms/», y usted debería ver una lista con los contenidos que hemos almacenado en la base de datos. Cada contenido es un enlace que apunta a la página que nos mostrará el valor.

Un atajo: render()

Es una práctica muy común cargar una plantilla, llenar un contexto y retornar un objeto HttpResponse con el resultado de la plantilla creada. Django proporciona un atajo. A continuación la vista index() completa, reescrita:

cms/views.py
from django.shortcuts import render

from .models import Contenido

def index(request):
    content_list = Contenido.objects.all()[:5]
    context = {'content_list': content_list}
    return render(request, 'cms/index.html', context)

Ten en cuenta que ya no necesitamos importar loader y HttpResponse (ojo, seguiremos queriendo importar HttpResponse si todavía tenemos otras vistas que lo utilizan, p.ej., get_content).

La función render() toma el objeto solicitado como su primer argumento, un nombre de plantilla como su segundo argumento y un diccionario como su tercer argumento opcional. La función retorna un objeto HttpResponse de la plantilla determinada creada con el contexto dado.

El sistema de plantillas

Vuelve a la vista get_content() para nuestra aplicación cms. Teniendo en cuenta la variable de contexto contenido, así es como la plantilla cms/content.html podría verse:

cms/templates/cms/content.html
<h1>{{ contenido.valor }}</h1>
{% for comentario in contenido.comentario_set.all %}
    <hr>
    <h1>{{ comentario.titulo }}</h1><br>
        {{ comentario.cuerpo }}<br>
        Enviado el {{ comentario.fecha }}<br>
{% endfor %}

<hr>
<br>
<form action="" method="POST">
  Introduce el (nuevo) contenido para esta página: 
  <input type="text" name="valor">
  <input type="submit" name="action" value="Enviar Contenido">
</form>

<br>
<form action="" method="POST">
  Introduce un nuevo comentario para esta página: 
  <br>Título: <input type="text" name="titulo">
  <br>Cuerpo: <input type="text" name="cuerpo">
  <input type="submit" name="action" value="Enviar Comentario">
</form>

El sistema de plantillas utiliza la sintaxis de búsqueda con puntos para acceder a los atributos de variables. En el ejemplo de {{contenido.valor}}, Django primero realiza una consulta en el diccionario sobre el objeto contenido. Si esto falla, intenta una búsqueda de atributos que en este caso funciona. Si la búsqueda de atributos hubiera fallado, hubiera intentado una búsqueda de índice de lista.

El llamado del método ocurre en el bucle {% for%}: contenido.comentario_set.all se interpreta como el código Python contenido.comentario_set.all() que retorna un iterable de objetos Comentario y es adecuado para usarse en la etiqueta {% for%}.

Consulta la guía de plantillas para más información sobre las plantillas.

Finalmente, tendríamos que modificar la vista para que quede de la siguiente manera:

cms/views.py
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone
from django.template import loader

from .models import Contenido, Comentario


@csrf_exempt
def get_content(request, llave):
    if request.method == "PUT":
        valor = request.body.decode('utf-8')           
    elif request.method == "POST":
        action = request.POST['action']
        if action == "Enviar Contenido":
            valor = request.POST['valor']                
    if request.method == "PUT" or (request.method == "POST" and action == "Enviar Contenido"):
        try:
            c = Contenido.objects.get(clave=llave) 
            c.valor = valor
        except Contenido.DoesNotExist:
            c = Contenido(clave=llave, valor=valor)
        c.save()
    if request.method == "POST" and action == "Enviar Comentario":
            c = get_object_or_404(Contenido, clave=llave) 
            titulo = request.POST['titulo']
            cuerpo = request.POST['cuerpo']
            q = Comentario(contenido=c, titulo=titulo, cuerpo=cuerpo, fecha=timezone.now())
            q.save()

    contenido = get_object_or_404(Contenido, clave=llave)
    context = {'contenido': contenido}
    return render(request, 'cms/content.html', context)

Hemos realizado dos modificaciones: Por un lado, han desaparecido las strings multilínea de los formularios, ya que ahora están en la plantilla. Y por otro, las líneas finales de la vista. Una vez obtenido el contenido, lo incluimos como contexto. El método render se encarga de unir la plantilla con el contexto y devolver una respuesta HTTP con el resultado.

Si observas la nueva vista, verás que ahora sólo contiene código Python, mucho más legible y sencillo. La presentación, con todo su HTML, está en la plantilla.

Y hasta aquí la práctica de hoy. Por favor, pon en el chat de Teams: "He terminado con el guión" para que los profes lo sepamos. Esto nos ayuda a dimensionar las prácticas. Muchas gracias.