Diseño y renderizado de páginas HTML en Python con Flask: guía completa

0
320
Foto de Pixabay: https://www.pexels.com/es-es/foto/fondo-negro-con-captura-de-pantalla-de-superposicion-de-texto-270404/

Siempre que pienso en Flask pienso en una API rest, olvidando constantemente la capacidad de crear sitios completos de una manera extremadamente simple. El hecho de que algo sea simple no significa que sea fácil. He visto auténticas maravillas hechas con este framework que, de tener que diseñarlas yo mismo, habría dicho que es imposible sin el típico framework de Typescript (sí, evito javascript en la medida de lo posible 🫣)

Este artículo es un compendio bastante completo en el que vas a entender todo lo que puedes hacer con esta herramienta.

No solo nos centraremos en cómo utilizar flask para este propósito. También te introducirás en el mundo del testing con unnittest y podrás ver de primera mano como es un proceso de TDD y refactoring, tal y como hacemos en la vida real los programadores.

Antes de comenzar, puedes descargarte todo el código del proyecto que verás en esta guía desde el repositorio de github que he preparado para ello.

¿Qué vamos a hacer?

Renderización básica

Cualquier navegador sabe interpretar HTML. Podemos hacer algo tan sencillo como esto:

@app.route('/examplePage')
def example_page():
    return "<h1>Example Page!</h1>"

Nuestro navegador lo interpretará adecuadamente y veremos nuestro título correctamente.

Pero claro, ya puedes imaginar que devolver una página entera de esta forma resulta un tanto tedioso. (A mí tampoco me apasiona JSX/TSX a lo react, pero igualmente no se trata de esto ni por asomo)

El uso de templates no es más que tener nuestro HTML con sus estilos y cosas de presentación aparte de la lógica, permitiendo comunicar datos entre ambos.

Esto tiene numerosas ventajas:

  • Separación de preocupaciones: La separación de la lógica de la aplicación y la presentación mejora la mantenibilidad del código y facilita la colaboración entre equipos de desarrollo.
  • Reutilización de código: Las plantillas permiten reutilizar partes de HTML en diferentes páginas, lo que reduce la duplicación de código y simplifica la gestión de la interfaz de usuario.
  • Seguridad: Renderizar plantillas con un motor como Jinja2 proporciona funciones de escape automáticas que ayudan a prevenir ataques de inyección de código, como XSS (Cross-Site Scripting).
  • Facilidad de desarrollo: Trabajar con plantillas facilita la gestión del HTML y mejora la legibilidad del código, lo que hace que el desarrollo y el mantenimiento de la aplicación sean más eficientes.

Lo primero, es crear la plantilla HTML. Si has usado un generador de proyectos flask, como el que viene con tu IDE, habrás visto 2 carpetas creadas automáticamente: static y templates. En la carpeta templates es donde debemos crear nuestro HTML.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>PAGE 1</title>
</head>
<body>
    <h1>HI. YOU ARE VISITING PAGE 1</h1>
</body>
</html>

Y este código rederiza dicha plantilla:

@app.route('/page1')
def show_template():
    return render_template('page1.html')

Es posible que, aunque la aplicación funciona bien, el IDE te marque el archivo “page1.html” como no encontrado. En mi caso, con pycharm, tienes que configurar en el IDE qué carpeta es la de templates para que lo reconozca bien (probablemente, te lo haga solo).

Si arrancamos y hacemos una petición al endpoint ‘/page1’, ya vemos nuestro html renderizado.

La función render_template es de jinja2, una librería que se importa automáticamente al hacer uso de Flask. Saber esto será últil cuando necesites buscar ayuda para la sintaxis.

Variables

Evidentemente, escupir un html estático sin ninguna diferencia respecto a hacer un return tiene utilidad nula.

Es posible enviar variables desde el código de python a la plantilla. Vamos a mejorar un poco nuestro ejemplo. Ahora, el HTML recibe una variable:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>PAGE 1</title>
</head>
<body>
    <h1>HI {{name}} YOU ARE VISITING PAGE 1</h1>
</body>
</html></pre

Y, desde python, se la enviamos así:

@app.route('/page1')
def show_template():
    return render_template('page1.html', name="FLASK_FRAMEWORK")

Haciendo uso de variables en rutas, podemos dejar que el usuario escriba su nombre:

@app.route('/page1/<username>')
def show_template(username):
    return render_template('page1.html', name=username.upper())

Mecanismos de control

Para poder hacer que la plantilla tenga vida, es necesario controlar con lógica muchos aspectos que necesitamos. Para ello tenemos diferentes alternativas.

Condifional (if)

Muy obvio: decidir cuándo queremos que se muestre algo o no.

<body>
    <h1>HI {{name}}! YOU ARE VISITING PAGE 1</h1>
    {% if name == 'PYTHON' %}
        <h2>Nice to meet you, {{ name }}</h2>
    {% else %}
        <h2>You are not Python. Get out {{ name }}!</h2>
    {% endif %}
</body>

Bucles for

Especialmente pensado para recorrer múltiples valores (listas ordenadas/ no ordenadas, tablas…)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>People (secret)</title>
</head>
<body>
<h1>HI {{ name }}. SECRET NAMES OF YOUR TEAM</h1>
{% if name == 'PYTHON' %}
    <p>You are the boss. You should know everyone's name. Keep them safe, and let no one else know.</p>
    <ol>
        {% for name in  names_list %}
            <li>{{ name.upper() }}</li>
        {% endfor %}
    </ol>
{% elif name == 'CONDA' %}
    <p>You're not in charge here. Access is restricted to first name only.</p>
    <ol>
        <li>{{ names_list[0].upper() }}</li>
    </ol>
{% else %}
    <p>YOU HAVE NO TEAM</p>
{% endif %}
</body>
</html>
@app.route('/people/<username>')
def people_page(username):
    people = ['Isabel', 'Maria', 'Ana', 'Javier', 'Pablo']
    return render_template('people.html', names_list=people, name=username.upper())

Crear macros

Las macros también vienen de jinja2. Nos permite convertir trozos de HTML a funciones que podemos llamar desde cualquier lado, inyectando el correspondiente código python.

Basándonos en el ejemplo anterior, vamos a convertir la lista ordenada de usuarios en un componente que podremos reutilizar en otro sitio:

<body>
<h1>HI {{ name }}. SECRET NAMES OF YOUR TEAM</h1>

{% macro team_names() %}

    {% if name == 'PYTHON' %}
        <p>You are the boss. You should know everyone's name. Keep them safe, and let no one else know.</p>
        <ol>
            {% for name in  names_list %}
                <li>{{ name.upper() }}</li>
            {% endfor %}
        </ol>
    {% elif name == 'CONDA' %}
        <p>You're not in charge here. Access is restricted to first name only.</p>
        <ol>
            <li>{{ names_list[0].upper() }}</li>
        </ol>
    {% else %}
        <p>YOU HAVE NO TEAM</p>
    {% endif %}
    
{% endmacro %}
</body>

Como podemos ver, es tan sencillo como utilizar las instrucciones macro y endmacro. El nombre se lo damos tal y como lo haríamos con una función. Y, efectivamente, ya estarás pensando: ¿puede recibir parámetros? Eso te lo explico en breves

De hecho, si ejecutamos el código tal cual lo he dejado, no funcionará. Ya que, como podemos ver, no se muestra la lista de usuarios:

Con eso que hemos hecho, hemos creado una función que renderiza el html de su interior. Lo que hay que hacer, obviamente, es llamar a la función. Donde queramos que se renderice nuestro componente, podemos llamarlo de la siguiente manera:

{{ team_names() }}

Los macros están pensados para reutilizar código. Vamos a utilizar el componente en otra página distinta. Creamos un nuevo template llamado new_people.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
{{ team_names() }}
</body>
</html>

Y su correspondiente endpoint para acceder a él:

@app.route('/new_people')
def new_people_page():
    return render_template('new_people.html')

Si ejecutamos esto obtendremos un server error.

Pensémoslo bien: el trozo de código que hemos convertido en una función necesita dos parámetros: name y names_list.

En el ejemplo inicial, tenía acceso a esos parámetros porque los obtenía de la página en la que estaba siendo renderizado. Es decir: dentro de la página en la que estaba ya existían dichos parámetros. Pero ahora, que está aislado como un componente aparte, ya no está dentro de ninguna página padre. Por tanto, es imposible que pueda acceder a los parámetros. Aunque utilices macros para repetir código dentro de una misma plantilla HTML, es mala práctica hacer que todo lo que recibe sean variables globales del archivo

Por lo tanto, ya tenemos una nueva lección: al crear macros, si necesitamos parámetros, debemos pasárselos a través de la función.

Hora de… ¡Testing + refactoring!

(¿Se nota que me gusta hacer tests y refactorizar?)

Para ir convirtiendo nuestro código en algo mejor, voy a hacer algunos cambios en los nombres de las variables. Además, tendremos que cambiar cómo funciona nuestra palntilla para crear el macro con parámetros. Por tanto, antes de nada, vamos a hacer unos tests unitarios que nos permitan saber que, cuando cambiemos ciertas cosas, todo siga funcionando igual. Este es el archivo TestPeople:

import unittest
import app


class TestPeople(unittest.TestCase):

    def setUp(self):
        self.app = app.app.test_client()

    def test_when_boss_is_python_should_show_all_employees(self):
        response = self.app.get('/people/python')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'HI PYTHON. SECRET NAMES OF YOUR TEAM', response.data)
        self.assertIn(b'ISABEL', response.data)
        self.assertIn(b'MARIA', response.data)
        self.assertIn(b'ANA', response.data)
        self.assertIn(b'JAVIER', response.data)
        self.assertIn(b'PABLO', response.data)

    def test_boss_CONDA(self):
        response = self.app.get('/people/conda')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'HI CONDA. SECRET NAMES OF YOUR TEAM', response.data)
        self.assertIn(b'ISABEL', response.data)
        self.assertNotIn(b'MARIA', response.data)
        self.assertNotIn(b'ANA', response.data)
        self.assertNotIn(b'JAVIER', response.data)
        self.assertNotIn(b'PABLO', response.data)

    def test_invalid_boss(self):
        response = self.app.get('/people/unknown')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'YOU HAVE NO TEAM', response.data)

Los tests funcionan.

Ahora cambiamos ciertas cosas:

– Naming: vamos a mejorar el nombre de las variables. “name” y “names_list” son malos nombres. Mejor “boss_name” y “employees_names”. Además, llamar “…list” a algo para indicar que es una lista es un bad smell. Si le pones un nombre en plural, ya queda claro que son varios. Jefe es solo uno, pero empleados es una lista.
– Extracción a variable: vamos a extraer la lista de nombres para poder reutilizarla
– Macro con parámetros: por supuesto, no olvido el objetivo inicial: crear el macro con parámetros para que pueda ser reutilizado en otros contextos.

<body>
<h1>HI {{ boss_name }}. SECRET NAMES OF YOUR TEAM</h1>

{% macro team_names(boss_name, employees_names) %}

    {% if boss_name == 'PYTHON' %}
        <p>You are the boss. You should know everyone's name. Keep them safe, and let no one else know.</p>
        <ol>
            {% for name in  employees_names %}
                <li>{{ name.upper() }}</li>
            {% endfor %}
        </ol>
    {% elif boss_name == 'CONDA' %}
        <p>You're not in charge here. Access is restricted to first name only.</p>
        <ol>
            <li>{{ employees_names[0].upper() }}</li>
        </ol>
    {% else %}
        <p>YOU HAVE NO TEAM</p>
    {% endif %}

{% endmacro %}

{{ team_names(boss_name, employees_names) }}
from flask import Flask, abort, render_template

app = Flask(__name__)
people = ['Isabel', 'Maria', 'Ana', 'Javier', 'Pablo']


@app.route('/people/<username>')
def people_page(username):
    return render_template('people.html', employees_names=people, boss_name=username.upper())

Ahora que ya tenemos una lista de personas que es accesible, podemos refactorizar los tests. Y ya que estoy, voy a mejorar también el naming de los tests (¡que también hay que mantenerlos!):

import unittest
import app


class TestPeople(unittest.TestCase):

    def setUp(self):
        self.app = app.app.test_client()

    def test_when_boss_is_python_should_show_all_employees(self):
        response = self.app.get('/people/python')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'HI PYTHON. SECRET NAMES OF YOUR TEAM', response.data)
        self.assertIn(app.people[0].upper().encode(), response.data)
        self.assertIn(app.people[1].upper().encode(), response.data)
        self.assertIn(app.people[2].upper().encode(), response.data)
        self.assertIn(app.people[3].upper().encode(), response.data)
        self.assertIn(app.people[4].upper().encode(), response.data)

    def test_when_boss_is_conda_should_show_first_employee_only(self):
        response = self.app.get('/people/conda')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'HI CONDA. SECRET NAMES OF YOUR TEAM', response.data)
        self.assertIn(app.people[0].upper().encode(), response.data)
        self.assertNotIn(app.people[1].upper().encode(), response.data)
        self.assertNotIn(app.people[2].upper().encode(), response.data)
        self.assertNotIn(app.people[3].upper().encode(), response.data)
        self.assertNotIn(app.people[4].upper().encode(), response.data)

    def test_whe_unknown_boss_should_not_show_employees(self):
        response = self.app.get('/people/unknown')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'YOU HAVE NO TEAM', response.data)
        self.assertNotIn(app.people[0].upper().encode(), response.data)
        self.assertNotIn(app.people[1].upper().encode(), response.data)
        self.assertNotIn(app.people[2].upper().encode(), response.data)
        self.assertNotIn(app.people[3].upper().encode(), response.data)
        self.assertNotIn(app.people[4].upper().encode(), response.data)

Los tests antes decían: “tiene que contener (o no) Isabel, María, etc”. Ahora dicen: “tiene que mostrar (o no) los elementos de esta lista”. Si la lista cambia, los tests siguen pasando.

Ahora bien, si la lista cambia, también puede cambiar el tamaño, ¿verdad?

Eso se arregla usando bucles. Y, además, reducimos líneas de código:

import unittest
import app


class TestPeople(unittest.TestCase):

    def setUp(self):
        self.app = app.app.test_client()

    def test_when_boss_is_python_should_show_all_employees(self):
        response = self.app.get('/people/python')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'HI PYTHON. SECRET NAMES OF YOUR TEAM', response.data)

        for name in app.people:
            self.assertIn(name.upper().encode(), response.data)

    def test_when_boss_is_conda_should_show_first_employee_only(self):
        response = self.app.get('/people/conda')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'HI CONDA. SECRET NAMES OF YOUR TEAM', response.data)
        self.assertIn(app.people[0].upper().encode(), response.data)

        for name in app.people[1:]:
            self.assertNotIn(name.upper().encode(), response.data)

    def test_whe_unknown_boss_should_not_show_employees(self):
        response = self.app.get('/people/unknown')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'YOU HAVE NO TEAM', response.data)

        for name in app.people:
            self.assertNotIn(name.upper().encode(), response.data)

Utilizar macros

Ahora ya tenemos nuestro macro totalmente reutilizable, recibiendo parámetros con un naming claro.

La plantilla new people queda así:

{% from "people.html" import team_names %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
{{ team_names(boss_name, employees_names) }}
</body>
</html>

Y su endpoint:

@app.route('/new_people/<username>')
def new_people_page(username):
    return render_template('new_people.html', employees_names=new_people, boss_name=username.upper())

Tal y como lo hemos hecho, el macro está definido dentro del archivo people.html. Esto se puede hacer cuando, en una misma plantilla, vas a reutilizar código. Pero en nuestro ejemplo, que se trata de reutilizar el macro en otra plantilla, lo mejor sería definirlo en un archivo aparte.

Creamos un archivo llamado team_names.jinja2. (Sí, esta es una extensión específica de la librería que hemos hablado) Con este contenido:

{% macro team_names(boss_name, employees_names) %}

    {% if boss_name == 'PYTHON' %}
        <p>You are the boss. You should know everyone's name. Keep them safe, and let no one else know.</p>
        <ol>
            {% for name in  employees_names %}
                <li>{{ name.upper() }}</li>
            {% endfor %}
        </ol>
    {% elif boss_name == 'CONDA' %}
        <p>You're not in charge here. Access is restricted to first name only.</p>
        <ol>
            <li>{{ employees_names[0].upper() }}</li>
        </ol>
    {% else %}
        <p>YOU HAVE NO TEAM</p>
    {% endif %}

{% endmacro %}

Y borramos la creación del componente del people.html.

Ahora, tanto en people.html como en new_people.html, debemos importar el macro desde team_names.jinja2:

{% from "team_names.jinja2" import team_names %}

Pasamos todos los tests y funcionan (lo que significa que nos ahorramos tener que ir probando todo lo que hemos implementado a mano).

Ya no solo estamos aprendiendo plantillas, también estamos practicando testing y refactoring.

Herencia

Las plantillas también pueden heredar, de forma muy parecida a los macros.
Partimos de base de esta plantilla, a la que llamaremos base.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Default title</title>
</head>
<body>
    <h1>Default body</h1>
</body>
<footer>
    <p>© 2024 Urbano Villanueva. All rights reserved.</p>
    <a href="{{ url_for('home_page') }}">GO HOME</a>
</footer>
</html>

Ahora vamos a crear una plantilla que hereda esta base. La llamaremos extended_page.html y, por ahora, solo tendrá una línea:

{% extends 'base.html' %}

No podemos olvidar el endpoint que la renderiza:

@app.route('/extended_page')
def extended_page():
    return render_template('extended_page.html')

No hace falta que pregunte qué va a mostrar, ¿verdad?:

¿Qué sentido tiene heredar si muestran exactamente lo mismo?

Vamos a transformar nuestra página base en esto:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}Default title{% endblock %}</title>
</head>
<body>
{% block body %}
    <h1>Default body</h1>
{% endblock %}
</body>
<footer>
    <p>© 2024 Urbano Villanueva. All rights reserved.</p>
    <a href="{{ url_for('home_page') }}">GO HOME</a>
</footer>
</html>

Hemos creado dos bloques: bloque título y bloque body.

Hemos dejado lo que ya había como valor por defecto.

Si recargamos la página hija, seguimos viendo lo mismo (porque carga los valores por defecto).

Podemos sustituir, en las páginas que heredan, el contenido de los bloques. Veamos una nueva extended_page que nos cuenta cosas acerca de Lorem Ipsum:

{% extends 'base.html' %}

{% block title %}Extended page{% endblock %}

{% block body%}
    <h1>Acerca de Lorem Ipsum</h1>

    <h2>¿Qué es Lorem Ipsum?</h2>
    <p>Lorem Ipsum es simplemente el texto de relleno de las imprentas y archivos de texto. Lorem Ipsum ha sido el texto
        de relleno estándar de las industrias desde el año 1500, cuando un impresor (N. del T. persona que se dedica a
        la imprenta) desconocido usó una galería de textos y los mezcló de tal manera que logró hacer un libro de textos
        especimen. No sólo sobrevivió 500 años, sino que tambien ingresó como texto de relleno en documentos electrónicos,
        quedando esencialmente igual al original. Fue popularizado en los 60s con la creación de las hojas "Letraset",
        las cuales contenian pasajes de Lorem Ipsum, y más recientemente con software de autoedición, como por ejemplo
        Aldus PageMaker, el cual incluye versiones de Lorem Ipsum.</p>

    <h2>¿Por qué lo usamos?</h2>
    <p>Es un hecho establecido hace demasiado tiempo que un lector se distraerá con el contenido del texto de un sitio
        mientras que mira su diseño. El punto de usar Lorem Ipsum es que tiene una distribución más o menos normal de
        las letras, al contrario de usar textos como por ejemplo "Contenido aquí, contenido aquí". Estos textos hacen
        parecerlo un español que se puede leer. Muchos paquetes de autoedición y editores de páginas web usan el Lorem
        Ipsum como su texto por defecto, y al hacer una búsqueda de "Lorem Ipsum" va a dar por resultado muchos sitios
        web que usan este texto si se encuentran en estado de desarrollo. Muchas versiones han evolucionado a través
        de los años, algunas veces por accidente, otras veces a propósito (por ejemplo insertándole humor y cosas por
        el estilo).</p>


    <h2>¿De dónde viene?</h2>
    <p>Al contrario del pensamiento popular, el texto de Lorem Ipsum no es simplemente texto aleatorio. Tiene sus raices
        en una pieza cl´sica de la literatura del Latin, que data del año 45 antes de Cristo, haciendo que este adquiera
        mas de 2000 años de antiguedad. Richard McClintock, un profesor de Latin de la Universidad de Hampden-Sydney en
        Virginia, encontró una de las palabras más oscuras de la lengua del latín, "consecteur", en un pasaje de Lorem
        Ipsum, y al seguir leyendo distintos textos del latín, descubrió la fuente indudable. Lorem Ipsum viene de las
        secciones 1.10.32 y 1.10.33 de "de Finnibus Bonorum et Malorum" (Los Extremos del Bien y El Mal) por Cicero,
        escrito en el año 45 antes de Cristo. Este libro es un tratado de teoría de éticas, muy popular durante el
        Renacimiento. La primera linea del Lorem Ipsum, "Lorem ipsum dolor sit amet..", viene de una linea en la sección
        1.10.32</p>

    <p>El trozo de texto estándar de Lorem Ipsum usado desde el año 1500 es reproducido debajo para aquellos interesados.
        Las secciones 1.10.32 y 1.10.33 de "de Finibus Bonorum et Malorum" por Cicero son también reproducidas en su
        forma original exacta, acompañadas por versiones en Inglés de la traducción realizada en 1914 por H. Rackham.</p>

    <h2>¿Dónde puedo conseguirlo?</h2>
    <p>Hay muchas variaciones de los pasajes de Lorem Ipsum disponibles, pero la mayoría sufrió alteraciones en alguna
        manera, ya sea porque se le agregó humor, o palabras aleatorias que no parecen ni un poco creíbles. Si vas a
        utilizar un pasaje de Lorem Ipsum, necesitás estar seguro de que no hay nada avergonzante escondido en el medio
        del texto. Todos los generadores de Lorem Ipsum que se encuentran en Internet tienden a repetir trozos
        predefinidos cuando sea necesario, haciendo a este el único generador verdadero (válido) en la Internet. Usa un
        diccionario de mas de 200 palabras provenientes del latín, combinadas con estructuras muy útiles de sentencias,
        para generar texto de Lorem Ipsum que parezca razonable. Este Lorem Ipsum generado siempre estará libre de
        repeticiones, humor agregado o palabras no características del lenguaje, etc.</p>
{% endblock %}

Ahora nos muestra el título, que hemos sustituido por “Extended page” y el contenido, que muestra el mismo footer pero el body es totalmente diferente:


Con estas herramientas ya tienes la base para, con un poco de creatividad, crear prácticamente cualquier sitio.

Construyendo un sitio web completo con bootstrap

Esto tiene que lucir más profesional. Hay que aplicar CSS para tener un sitio decente.

Hasta ahora teníamos páginas independientes. Vamos a crear una estructura que haga que, independientemente de en qué página estemos, veamos que estamos navegando en el mismo sitio. Aplicaremos una barra superior con el título de cada página, añadiremos un menú lateral para poder navegar y un pie de página estándar que se mostrará en todas las páginas.

Vamos a transformar nuestra página base.html en esto:

{% from 'sidebar.jinja2' import sidebar %}
<!DOCTYPE html>
<html lang="en">
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
    <meta charset="UTF-8">
    <style>
        /* Asegura que el contenido ocupe completamente el alto de la pantalla */
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
            overflow-x: hidden; /* Evita el desplazamiento horizontal */
        }
    </style>
    <title>{% block title %}Default title{% endblock %}</title>
</head>
<body style="width: 100%">
<div class="bg-success text-light font-weight-bold text-center" style="height: 3pc">
    {% block page_header %}
        <h1>DEFAULT HEADER</h1>
    {% endblock %}
</div>
<div class="text-center bg-color-gray-200">
    <div class="row">
        <div class="col">
            {{ sidebar() }}
        </div>
        <div class="col-6">
            {% block page_content %}
                <h1>Default content</h1>
            {% endblock %}
        </div>
        <div class="col"></div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
            crossorigin="anonymous"></script>
</div>
</body>
<footer>
    <div class="mt-2 bg-success text-light font-weight-bold text-center">
        <div class="container">
            <p>© 2024 Urbano Villanueva. All rights reserved.</p>
            <a href="{{ url_for('home_page') }}" class="text-light">GO HOME</a>
        </div>
    </div>
</footer>
</html>

Como puedes ver, estamos importando la función sidebar() desde el archivo sidebar.jinja2.

Este archivo contiene lo siguiente:

{% macro sidebar() %}
    <div class="container-fluid">
        <div class="row flex-nowrap">
            <div class="col-auto">
                <div class="bg-dark rounded-3 ml-1 mt-1" style="height: 70pc">
                    <div class="d-flex flex-column align-items-center align-items-sm-start px-3 pt-2 text-white">
                        <a href="#"
                           class="d-flex align-items-center pb-3 mb-md-0 me-md-auto text-white text-decoration-none">
                            <span class="fs-5 d-none d-sm-inline">Menu</span>
                        </a>
                        <ul class="nav nav-pills flex-column mb-sm-auto mb-0 align-items-center align-items-sm-start"
                            id="menu">
                            <li class="nav-item">
                                <a href="{{ url_for('home_page') }}" class="nav-link align-middle px-0">
                                    <i class="fs-4 bi-house"></i> <span
                                        class="ms-1 d-none d-sm-inline">Home</span>
                                </a>
                            </li>
                            <li>
                                <a href="{{ url_for('form_page') }}" class="nav-link px-0 align-middle">
                                    <i class="fs-4 bi-speedometer2"></i> <span class="ms-1 d-none d-sm-inline">Formulario</span>
                                </a>
                            </li>
                            <li>
                                <a href="{{ url_for('extended_page') }}" class="nav-link px-0 align-middle">
                                    <i class="fs-4 bi-speedometer2"></i> <span class="ms-1 d-none d-sm-inline">Lorem ipsum</span>
                                </a>
                            </li>

                        </ul>
                    </div>
                </div>
            </div>
        </div>
    </div>
{% endmacro %}

Esto nos muestra un menú lateral de esta manera:

Ahora, transformamos nuestra página home (que antes devolvíamos un sencillo h1) en esto:

{% extends 'base.html' %}

{% block title %}Home{% endblock %}

{% block page_header %}
<h1>PÁGINA PRINCIPAL</h1>
{% endblock %}

{% block page_content %}
Bienvenido a la página principal de ejercicios con flask
{% endblock %}

Veamos qué estamos haciendo aquí:

  • Estamos heredando de base.html
  • Estamos editando el título de la página (el que aparece en la pestaña de la página) para mostrar Home
  • El bloque page_header es la barra superior de color verde, queremos ver aquí “PÁGINA PRINCIPAL”
  • Y, por último, el contenido, que en esta página es muy sencillo

Y todo esto, ¿cómo se ve?

Aquí tenemos el resultado:

No es la página más bonita del mundo pero ya vemos cómo podemos combinar python, flask, html y librerías de css como bootstrap para tener un sitio completamente funcional. Podrá quedar más bonito o feo en función de la creatividad de cada uno. Además, bootstrap es una herramienta que no es pequeña, hay que trastear mucho para dominarla. Seguro que tú mismo puedes hacer algo mucho más bonito dedicándole el tiempo suficiente.

Ya tenemos estandarizada nuestra página. Si navegamos a Lorem Ipsum veremos, en el contenido, la página que habíamos creado anteriormente. Pero todo wrapeado de lo que hemos creado, manteniendo el sidebar, el header y el footer (visualmente, ahora ya sentimos que estamos navegando dentro del mismo sitio o que estamos usando una plantilla base de diseño):

En el sidebar, verás una página que todavía no he explicado: FORMULARIO. Vamos a ello.

Formularios

Con estas herramientas que hemos visto hasta ahora, ya tienes la base para poder crear prácticamente cualquier sitio. Pero falta algo, ¿cómo introduce datos el usuario en nuestra página? Los formularios son la base de cualquier aplicación web.

Ya que hemos estandarizado el diseño de todo nuestro sitio web, hemos creado una nueva página que luce igual que todas las demás con contenido nuevo:

El código de esta página es así:

{% extends 'base.html' %}

{% block title %}Formulario{% endblock %}

{% block page_header %}
    <h1>DATOS DE USUARIO</h1>
{% endblock %}

{% block page_content %}
    <div class="p-3 text-primary-emphasis bg-primary-subtle border border-primary-subtle rounded-3 m-1">
        Por favor, completa el formulario con tus datos:
    </div>
    <form method="post">
        <div class="input-group mb-3">
            <span class="input-group-text" id="inputGroup-sizing-sm">Nombre de usuario: </span>
            <span class="input-group-text" id="basic-addon1">@</span>
            <input type="text" class="form-control" placeholder="Username" aria-label="Username"
                   aria-describedby="basic-addon1">
        </div>

        <div class="input-group mb-3">
            <span class="input-group-text" id="inputGroup-sizing-sm">Email:</span>
            <input type="text" class="form-control" placeholder="Username" aria-label="Username">
            <span class="input-group-text">@</span>
            <input type="text" class="form-control" placeholder="Server" aria-label="Server">
        </div>

        <div class="input-group">
            <span class="input-group-text">Descripción:</span>
            <textarea class="form-control" aria-label="With textarea"></textarea>
        </div>

        <button type="submit" class="btn btn-dark m-1">GUARDAR</button>
    </form>
{% endblock %}

No tiene mucha más lógica que antes: extiende de la base, edita título, header y, en el contenido, pone el formulario que vemos en pantalla para introducir datos. Todavía no tiene lógica. El endpoint de esta página luce, aún, así de sencillo:

@app.route('/form')
def form_page():
    return render_template('form_example.html')

Ahora solo hay que meterle chicha.

Para empezar, vamos a añadir nombre a cada input del formulario:

Además, si te fijas en la declaración del <form>, le estamos indicando que el método es POST. Es decir: cuando hagamos submit, se enviará un HTTP POST a la dirección indicada (en este caso, al no poner nada, es la misma en la que estamos). Es decir:

  • Cargamos la página “/formulario” con una petición GET
  • Introducimos los datos del formulario
  • Pulsamos el botón (submit) y esto produce una petición POST a la misma URL “/formulario”

Por tanto, tenemos que decirle a nuestro código qué debe hacer cuando se trata de POST, GET o ambos.

@app.route('/form', methods=['GET', 'POST'])
def form_page():
    if request.method == 'POST':
        print(request.form.get('username'))
        print(request.form.get('email_username'))
        print(request.form.get('email_server'))
        print(request.form.get('description'))
    return render_template('form_example.html')

Cuando se trata de una petición POST, significa que tenemos un cuerpo de petición con datos, en los que se encuentra nuestro formulario. Fíjate que los nombres de los campos cuyo valor estamos obteniendo coincide con el nombre que le hemos puesto a los inputs, pues son los que se usan para enviar el formulario.

Si cotilleamos las peticiones de red en nuestro navegador, vemos claramente cómo se hace la petición post y qué contiene el cuerpo o payload.

El código que hemos hecho va a hacer que, cuando reciba un post, se imprima en la consola los datos que le estamos pidiendo:

Esto significa que ya podemos acceder a estos datos desde nuestro “backend” en python.

Vamos a añadir un poquito más de código a nuestro HTML. Esto es lo que hay ahora justo debajo del formulario:

</form>

    {% if show_info_box == True %}
        <div class="p-3 text-primary-emphasis bg-primary-subtle border border-primary-subtle rounded-3 m-1">
            <p>Estos son los datos recopilados:</p>
            <p>Usuario: {{ username }}</p>
            <p>Email: {{ email }}</p>
            <p>Descripción: {{ description }}</p>
        </div>
    {% endif %}

{% endblock %}

Si la variable show_info_box es True, mostraremos un cuadro de diálogo mostrando los datos introducimos por el usuario en el formulario.

Y así luce, ahora, el código python:

@app.route('/form', methods=['GET', 'POST'])
def form_page():
    if request.method == 'POST':
        return render_template('form_example.html',
                               show_info_box=True, username=request.form.get('username'),
                               email=f"{request.form.get('email_username')}@{request.form.get('email_server')}",
                               description=request.form.get('description'))

    if request.method == 'GET':
        return render_template('form_example.html', show_info_box=False)

De esta forma, si recibimos un get no hacemos nada, ya que estamos cargando la web sin nada más. Si estamos accediendo con un método post, significa que hemos rellenado el formulario y queremos mostrar la información en pantalla. Construimos el email concatenando el username y el servidor y enviamos todas las variables necesarias a la plantilla HTML.

El resultado, tras rellenar los campos y hacer submit, es algo así:

Como puedes ver, se ha reseteado el formulario (porque hemos cargado la web de nuevo) y se están mostrando en el cuadro azul inferior los datos introducidos.

Testeando el formulario

Ahora los tests adquiren un grado más de complejidad porque tenemos que emular que se mandan datos a través del formulario.

Vamos a hacer tests que comprueben los dos comportamientos:

  • Cuando cargamos la página por primera vez, no se muestra el cuadro azul inferior con información.
  • Cuando rellenamos los datos del formulario y hacemos submit, se muestra el cuadro de información con los datos correctos que se han introducido en el formulario.
import unittest
from app import app


class TestFormPage(unittest.TestCase):

    def setUp(self):
        self.app = app.test_client()

    def test_when_load_page_should_not_show_info_box(self):
        response = self.app.get('/form')
        self.assertNotIn(b"Estos son los datos recopilados:", response.data)

    def test_form_submission(self):
        form_data = {
            'username': 'test_user',
            'email_username': 'test_email_username',
            'email_server': 'test_email_server',
            'description': 'test_description'
        }
        response = self.app.post('/form', data=form_data, follow_redirects=True)

        self.assertIn(b"Estos son los datos recopilados:", response.data)
        self.assertIn(b"Usuario: test_user", response.data)
        self.assertIn(b"Email: test_email_username@test_email_server", response.data)
        self.assertIn(b"Descripci\xc3\xb3n: test_description", response.data)

Mejora tus formularios con WTF

Los formularios que has visto aquí son nativos. Es decir, estamos implementando todo de cero. Existe un módulo de Flask conocido como WTF que nos ayuda a implementar por defecto cierta securizaión y nos trae formas más cómodas de implementar validaciones de datos que haciéndolo nosotros de cero. Yo mismo he escrito otra guía que complementa esta formación en la que puedes conocer todo lo necesario para trabajar con WTF y evitar tener que hacer todo tú mismo.

Conclusiones

Si has puesto todo esto en práctica, ya sabes cómo:

  • Renderizar una página dada una URL específica
  • Crear bloques de HTML que reutilizaremos en otras páginas
  • Heredar de otras páginas para estandarizar plantillas y que todas las hijas partan de la misma base.
  • Controlar lo que mostramos en la página con secuencias de control (if, for…)
  • Hemos practicado algo de testing y refactoring, conociendo la sintaxis de los tests

En conclusión: ya tienes todas las bases para poder crear, de cero, cualquier sitio utilizando python y flask, apoyándote en una librería de CSS como bootstrap.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad