Flask SQLAlchemy Aplicación CRUD con WTForms QuerySelectField y QuerySelectMultipleField
WTForms QuerySelectField y QuerySelectMultipleField facilitan la gestión de los datos de la relación SQLAlchemy .
Para una nueva aplicación Flask que utiliza WTForms y SQLAlchemy, tenía muchas relaciones entre tablas y buscaba la forma más sencilla de gestionar estas tablas. La opción más obvia es utilizar los QuerySelectField y QuerySelectMultipleField presentes en el paquete wtforms-sqlalchemy. Como no los he utilizado antes, he creado una pequeña aplicación para jugar.
A continuación os muestro el código (desarrollo sobre Ubuntu 20.04). Si quieres verlo en acción, puedes descargar el Docker image al final de este post.
Resumen de la aplicación
Esta es una aplicación CRUD que demuestra el QuerySelectField y el QuerySelectMultipleField. Para reducir el código he añadido Bootstrap-Flask. La base de datos es SQLite. No estoy utilizando Flask-SQLAlchemy sino una implementación propia.
Hay tres tablas:
- Amigo
- Ciudad
- Afición
Amigo-Ciudad es una relación de muchos a uno:
Un amigo puede vivir en una sola ciudad, y una ciudad puede tener muchos amigos.
Amigo-Afición es una relación many-to-many :
Un amigo puede tener muchas aficiones, y una afición puede ser practicada por muchos amigos.
En el formulario Amigo
- el QuerySelectField se utiliza para seleccionar una sola ciudad
- el QuerySelectMultipleField se utiliza para seleccionar un cero o más aficiones
Más o menos he duplicado el código para las operaciones de Crear, Editar y Eliminar. Esto deja algunos espacios para la experimentación. No utilicé el campo query_factory en el formulario con QuerySelectField y QuerySelectMultipleField. En su lugar, añadí esto a la función de la vista, como
form.city.query = app_db.session.query(City).order_by(City.name)
Crear virtual environment
Ve a tu directorio de desarrollo, crea un virtual environment para un nuevo directorio, por ejemplo flaskquery, actívalo y entra en el directorio:
cd <your-development-directory>
python3 -m venv flaskquery
source flaskquery/bin/activate
cd flaskquery
mkdir project
cd project
mkdir app
El directorio del proyecto es 'project' y nuestra aplicación está en el directorio app.
Instalar los paquetes
Para minimizar el código voy a utilizar Bootstrap-Flask. Lo mejor es el renderizado del formulario con una sola sentencia. Además utilizamos SQLAlchemy y para la base de datos SQLite. No utilizo Flask-SQLAlchemy, lo expliqué en un post anterior. Para las migraciones utilizamos Alembic, no puedo vivir sin él.
pip install flask
pip install flask-wtf
pip install bootstrap-flask
pip install sqlalchemy
pip install wtforms-sqlalchemy
pip install alembic
Directorio del proyecto
Para su referencia, aquí está el volcado del árbol del directorio del proyecto para el proyecto completado.
.
├── alembic
│ ├── env.py
│ ├── README
│ ├── script.py.mako
│ └── versions
│ ├── 1c20e6a53339_create_db.py
│ └── d821ac509404_1e_revision.py
├── alembic.ini
├── app
│ ├── blueprints
│ │ └── manage_data
│ │ ├── forms.py
│ │ └── views.py
│ ├── factory.py
│ ├── factory.py_first_version
│ ├── model.py
│ ├── service_app_db.py
│ ├── service_app_logging.py
│ ├── services.py
│ └── templates
│ ├── base.html
│ ├── home.html
│ ├── item_delete.html
│ ├── item_new_edit.html
│ └── items_list.html
├── app.db
├── app.log
├── config.py
├── requirements.txt
└── run.py
Empezar con una aplicación mínima
En el directorio del proyecto creamos un archivo run.py con el siguiente contenido:
# run.py
from app import factory
app = factory.create_app()
if __name__ == '__main__':
app.run(host= '0.0.0.0')
Observa que utilizamos un archivo factory.py en lugar de un archivo __init__.py. Evite el archivo __init__.py. Cuando tu aplicación crece puedes encontrarte con importaciones circulares.
Ponemos un config.py en el directorio del proyecto para almacenar nuestras variables de configuración:
# config.py
import os
project_dir = os.path.abspath(os.path.dirname(__file__))
class Config(object):
DEBUG = False
TESTING = False
class ConfigDevelopment(Config):
DEBUG = True
SECRET_KEY = 'NO8py79NIOU7694rgLKJHIGo87tKUGT97g'
SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(project_dir, 'app.db')
SQLALCHEMY_ENGINE_OPTIONS = {
'echo': True,
}
class ConfigTesting(Config):
TESTING = True
class ConfigProduction(Config):
pass
Dos servicios, logging y base de datos
Podemos poner todo en el archivo factory.py pero eso será un lío. Así que vamos a crear archivos separados con clases para el registro y la base de datos.
# service_app_logging.py
import logging
class AppLogging:
def __init__(self, app=None):
if app is not None:
self.init_app(app)
def init_app(self, app):
FORMAT = '%(asctime)s [%(levelname)-5.5s] [%(funcName)30s()] %(message)s'
logFormatter = logging.Formatter(FORMAT)
app.logger = logging.getLogger()
app.logger.setLevel(logging.DEBUG)
fileHandler = logging.FileHandler('app.log')
fileHandler.setFormatter(logFormatter)
app.logger.addHandler(fileHandler)
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(logFormatter)
app.logger.addHandler(consoleHandler)
return app.logger
Para acceder a la base de datos creamos un scoped_session SQLAlchemy .
# service_app_db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
class AppDb:
def __init__(self, app=None):
if app is not None:
self.init_app(app)
def init_app(self, app):
sqlalchemy_db_uri = app.config.get('SQLALCHEMY_DB_URI')
sqlalchemy_engine_options = app.config.get('SQLALCHEMY_ENGINE_OPTIONS')
engine = create_engine(
sqlalchemy_db_uri,
**sqlalchemy_engine_options
)
sqlalchemy_scoped_session = scoped_session(
sessionmaker(
bind=engine,
expire_on_commit=False
)
)
setattr(self, 'session', sqlalchemy_scoped_session)
Utilizamos un fichero intermedio services.py donde instanciamos los servicios.
# services.py
from .service_app_logging import AppLogging
from .service_app_db import AppDb
app_logging = AppLogging()
app_db = AppDb()
La fábrica de aplicaciones, primera versión
Ahora podemos crear la primera versión de nuestro fichero factory.py:
# factory.py
from flask import Flask, request, g, url_for, current_app, render_template
from flask_wtf.csrf import CSRFProtect
from flask_bootstrap import Bootstrap
from .services import app_logging, app_db
def create_app():
app = Flask(__name__)
app.config.from_object('config.ConfigDevelopment')
# services
app.logger = app_logging.init_app(app)
app_db.init_app(app)
app.logger.debug('test debug message')
Bootstrap(app)
csrf = CSRFProtect()
csrf.init_app(app)
@app.teardown_appcontext
def teardown_db(response_or_exception):
if hasattr(app_db, 'session'):
app_db.session.remove()
@app.route('/')
def index():
return render_template(
'home.html',
welcome_message='Hello world',
)
return app
Fíjate que aquí he puesto un '@app.route' para la página de inicio.
En el directorio templates ponemos dos archivos, aquí está la plantilla base, ver también el ejemplo en el paquete Bootstrap-Flask .
{% from 'bootstrap/nav.html' import render_nav_item %}
{% from 'bootstrap/utils.html' import render_messages %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>QuerySelectField and QuerySelectMultipleField</title>
{{ bootstrap.load_css() }}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
{{ render_nav_item('index', 'Home', use_li=True) }}
</ul>
</div>
</nav>
<main class="container">
{{ render_messages(container=False, dismissible=True) }}
{% block content %}{% endblock %}
</main>
{{ bootstrap.load_js() }}
</body>
</html>
Y la plantilla de la página de inicio.
{# home.html #}
{% extends "base.html" %}
{% block content %}
{{ welcome_message }}
{% endblock %}
Primera ejecución
Vaya al directorio del proyecto y escriba:
python3 run.py
Dirija su navegador a 127.0.0.1:5000 y debería ver el mensaje 'Hello world'. También deberías ver el menú Bootstrap en la parte superior de la página. Visualiza el código fuente de la página y comprueba los archivos bootstrap. En el directorio del proyecto también debería estar nuestro archivo de registro app.log.
Añadir el modelo
Ahora tenemos una aplicación en funcionamiento. En el directorio de la aplicación creamos un archivo model.py. Tenemos Amigos, Ciudades y Aficiones.
# model.py
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, ForeignKey, Integer, String, Table
from sqlalchemy.orm import relationship
Base = declarative_base()
# many-to-many link table
friend_mtm_hobby_table = Table(
'friend_mtm_hobby',
Base.metadata,
Column('friend_id', Integer, ForeignKey('friend.id')),
Column('hobby_id', Integer, ForeignKey('hobby.id'))
)
class Friend(Base):
__tablename__ = 'friend'
id = Column(Integer, primary_key=True)
name = Column(String(100), server_default='')
city_id = Column(Integer, ForeignKey('city.id'))
city = relationship(
'City',
back_populates='friends',
)
hobbies = relationship(
'Hobby',
secondary=friend_mtm_hobby_table,
back_populates='friends',
order_by=lambda: Hobby.name,
)
class City(Base):
__tablename__ = 'city'
id = Column(Integer, primary_key=True)
name = Column(String(100), server_default='')
friends = relationship(
'Friend',
back_populates='city',
order_by=Friend.name,
)
class Hobby(Base):
__tablename__ = 'hobby'
id = Column(Integer, primary_key=True)
name = Column(String(100), server_default='')
friends = relationship(
'Friend',
secondary=friend_mtm_hobby_table,
back_populates='hobbies',
order_by=Friend.name,
)
Problema de ordenación
Estaría bien que SQLAlchemy devolviera resultados ordenados por nombre. Podemos usar esto en las listas.
- Lista de amigos: Mostrar el nombre del amigo, el nombre de la ciudad y el nombre de las aficiones
- Lista de ciudades: muestra el nombre de la ciudad y los nombres de todos los amigos que viven en una ciudad
- Lista de pasatiempos: muestra el nombre del pasatiempo y los nombres de todos los amigos que tienen ese pasatiempo
Por ejemplo, con un hobby accedemos a los amigos como hobby.friends. La ordenación parece fácil, simplemente añadimos una cláusula 'order_by' en la relación. Sin embargo, debido a que nos referimos a una clase, Friend, sólo podemos utilizar esto con las clases que se cargan antes.
En nuestro modelo anterior no podemos ordenar los hobbies en la clase Friend, porque la clase Hobby no fue cargada antes que la clase Friend. Pero podemos ordenar los amigos en la clase Hobby porque la clase Friend fue cargada antes que la clase Hobby.
Para evitar esto podemos hacer una de estas dos cosas
hobbies = relationship(
'Hobby',
secondary=friend_mtm_hobby_table,
back_populates='friends',
order_by='Hobby.name',
)
o:
hobbies = relationship(
'Hobby',
secondary=friend_mtm_hobby_table,
back_populates='friends',
order_by=lambda: Hobby.name,
)
En ambos casos la resolución de nombres se pospone hasta el primer uso.
Usar Alembic para crear la base de datos
Alembic es una gran herramienta para las migraciones de bases de datos. Ya la hemos instalado, pero debemos inicializarla antes de poder utilizarla. Ve al directorio del proyecto y escribe:
alembic init alembic
Esto creará un archivo alembic.ini y un directorio alembic en el directorio del proyecto. En alembic.ini, cambia la línea
sqlalchemy.url = driver://user:pass@localhost/dbname
por:
sqlalchemy.url = sqlite:///app.db
Y en alembic/env.py cambia la línea
target_metadata = None
a:
from app.model import Base
target_metadata = [Base.metadata]
Crear la primera revisión:
alembic revision -m "1e revision"
Ejecuta la migración:
alembic upgrade head
Y el archivo de base de datos app.db fue creado en el directorio del proyecto.
Usa el navegador SQLite para ver la base de datos
Instale el navegador SQLite :
sudo apt install sqlitebrowser
Puede iniciar el navegador SQLite haciendo clic con el botón derecho del ratón en la base de datos. Sólo se ha creado una tabla: alembic_version.
Para crear las tablas de nuestra base de datos utilizamos el autogenerador:
alembic revision --autogenerate -m "create db"
Ejecuta la migración:
alembic upgrade head
Cerramos el navegador SQLite y lo volvemos a abrir y observamos que se han creado las tablas:
- amigo
- ciudad
- hobby
- amigo_mtm_hobby
Ahora añade un amigo utilizando 'Ejecutar SQL':
INSERT INTO friend (name) VALUES ('John');
No olvide hacer clic en "Escribir cambios" después de esto. A continuación, haga clic en "Examinar datos" y compruebe que el registro insertado está ahí.
Cambiar el mensaje de la página de inicio
Quiero mostrar un mensaje en la página de inicio que muestre a todos nuestros amigos. Para ello cambiamos factory.py para obtener los amigos y pasarlos a la plantilla:
# factory.py
from flask import Flask, request, g, url_for, current_app, render_template
from flask_wtf.csrf import CSRFProtect
from flask_bootstrap import Bootstrap
from .services import app_logging, app_db
from .model import Friends
def create_app():
app = Flask(__name__)
app.config.from_object('config.ConfigDevelopment')
# services
app.logger = app_logging.init_app(app)
app_db.init_app(app)
app.logger.debug('test debug message')
Bootstrap(app)
csrf = CSRFProtect()
csrf.init_app(app)
@app.teardown_appcontext
def teardown_db(response_or_exception):
if hasattr(app_db, 'session'):
app_db.session.remove()
@app.route('/')
def index():
friends = app_db.session.query(Friend).order_by(Friend.name).all()
return render_template(
'home.html',
welcome_message='Hello world',
friends=friends,
)
return app
Y iteramos nuestros amigos en la plantilla de la página de inicio:
{# home.html #}
{% extends "base.html" %}
{% block content %}
{{ welcome_message }}
<ul>
{% for friend in friends %}
<li>
{{ friend.name }}
</li>
{% endfor %}
</ul>
{% endblock %}
Refrescamos la página en el brower. Ahora el nombre de nuestro amigo Juan debería aparecer en la página de inicio.
Añadir un Blueprint para gestionar los datos
Para manipular las tablas de la base de datos creamos un Blueprint, manage_data. En este Blueprint añadimos los siguientes métodos para cada tabla (objeto):
- lista
- nuevo
- editar
- borrar
Creamos un directorio blueprints y en este directorio un directorio 'manage_data'. En este directorio creamos dos ficheros, views.py y forms.py. No utilizamos el parámetro QuerySelectField / QuerySelectMultipleField query_factory en las clases de los formularios sino que los añadimos en los métodos de las vistas.
# forms.py
from flask_wtf import FlaskForm
from wtforms import IntegerField, StringField, SubmitField
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms.validators import InputRequired, Length
from app.services import app_db
from app.model import Friend, City, Hobby
# friend
class FriendNewEditFormMixin():
name = StringField('Name',
validators=[ InputRequired(), Length(min=2) ])
city = QuerySelectField('City',
get_label='name',
allow_blank=False,
blank_text='Select a city',
render_kw={'size': 1},
)
hobbies = QuerySelectMultipleField('Hobbies',
get_label='name',
allow_blank=False,
blank_text='Select one or more hobbies',
render_kw={'size': 10},
)
class FriendNewForm(FlaskForm, FriendNewEditFormMixin):
submit = SubmitField('Add')
class FriendEditForm(FlaskForm, FriendNewEditFormMixin):
submit = SubmitField('Update')
class FriendDeleteForm(FlaskForm):
submit = SubmitField('Confirm delete')
# city
class CityNewEditFormMixin():
name = StringField('Name',
validators=[ InputRequired(), Length(min=2) ])
class CityNewForm(FlaskForm, CityNewEditFormMixin):
submit = SubmitField('Add')
class CityEditForm(FlaskForm, CityNewEditFormMixin):
submit = SubmitField('Update')
class CityDeleteForm(FlaskForm):
submit = SubmitField('Confirm delete')
# hobby
class HobbyNewEditFormMixin():
name = StringField('Name',
validators=[ InputRequired(), Length(min=2) ])
class HobbyNewForm(FlaskForm, HobbyNewEditFormMixin):
submit = SubmitField('Add')
class HobbyEditForm(FlaskForm, HobbyNewEditFormMixin):
submit = SubmitField('Update')
class HobbyDeleteForm(FlaskForm):
submit = SubmitField('Confirm delete')
Como se ha dicho antes, hay mucha repetición en views.py pero eso hace que sea fácil cambiar las cosas. Observa que compartimos las plantillas entre Friend, City y Hobby.
En el amigo views queremos seleccionar una ciudad y seleccionar una o más aficiones. Aquí inicializamos las consultas para el QuerySelectField y QuerySelectMultipleField. Según la documentación, si alguno de los elementos del formulario enviado no se encuentra en la consulta, se producirá un error de validación. Y esto es exactamente lo que queremos.
# views.py
from flask import Flask, Blueprint, current_app, g, session, request, url_for, redirect, \
render_template, flash, abort
from app.services import app_db
from app.model import Friend, City, Hobby
from .forms import (
FriendNewForm, FriendEditForm, FriendDeleteForm,
CityNewForm, CityEditForm, CityDeleteForm,
HobbyNewForm, HobbyEditForm, HobbyDeleteForm,
)
manage_data_blueprint = Blueprint('manage_data', __name__)
@manage_data_blueprint.route('/friends/list', methods=['GET', 'POST'])
def friends_list():
friends = app_db.session.query(Friend).order_by(Friend.name).all()
thead_th_items = [
{
'col_title': '#',
},
{
'col_title': 'Name',
},
{
'col_title': 'City',
},
{
'col_title': 'Hobbies',
},
{
'col_title': 'Delete',
},
]
tbody_tr_items = []
for friend in friends:
city_name = '-'
if friend.city:
city_name = friend.city.name
hobby_names = '-'
if friend.hobbies:
hobby_names = ', '.join([x.name for x in friend.hobbies])
tbody_tr_items.append([
{
'col_value': friend.id,
},
{
'col_value': friend.name,
'url': url_for('manage_data.friend_edit', friend_id=friend.id),
},
{
'col_value': city_name,
},
{
'col_value': hobby_names,
},
{
'col_value': 'delete',
'url': url_for('manage_data.friend_delete', friend_id=friend.id),
}
])
return render_template(
'items_list.html',
title='Friends',
thead_th_items=thead_th_items,
tbody_tr_items=tbody_tr_items,
item_new_url=url_for('manage_data.friend_new'),
item_new_text='New friend',
)
@manage_data_blueprint.route('/cities/list', methods=['GET', 'POST'])
def cities_list():
cities = app_db.session.query(City).order_by(City.name).all()
thead_th_items = [
{
'col_title': '#',
},
{
'col_title': 'City',
},
{
'col_title': 'Friends',
},
{
'col_title': 'Delete',
},
]
tbody_tr_items = []
for city in cities:
friend_names = ''
if city.friends:
friend_names = ', '.join([x.name for x in city.friends])
tbody_tr_items.append([
{
'col_value': city.id,
},
{
'col_value': city.name,
'url': url_for('manage_data.city_edit', city_id=city.id),
},
{
'col_value': friend_names,
},
{
'col_value': 'delete',
'url': url_for('manage_data.city_delete', city_id=city.id),
}
])
return render_template(
'items_list.html',
title='Cities',
thead_th_items=thead_th_items,
tbody_tr_items=tbody_tr_items,
item_new_url=url_for('manage_data.city_new'),
item_new_text='New city',
)
@manage_data_blueprint.route('/hobbies/list', methods=['GET', 'POST'])
def hobbies_list():
hobbies = app_db.session.query(Hobby).order_by(Hobby.name).all()
thead_th_items = [
{
'col_title': '#',
},
{
'col_title': 'Name',
},
{
'col_title': 'Friends',
},
{
'col_title': 'Delete',
},
]
tbody_tr_items = []
for hobby in hobbies:
friend_names = ''
if hobby.friends:
friend_names = ', '.join([x.name for x in hobby.friends])
tbody_tr_items.append([
{
'col_value': hobby.id,
},
{
'col_value': hobby.name,
'url': url_for('manage_data.hobby_edit', hobby_id=hobby.id),
},
{
'col_value': friend_names,
},
{
'col_value': 'delete',
'url': url_for('manage_data.hobby_delete', hobby_id=hobby.id),
}
])
return render_template(
'items_list.html',
title='Hobbies',
thead_th_items=thead_th_items,
tbody_tr_items=tbody_tr_items,
item_new_url=url_for('manage_data.hobby_new'),
item_new_text='New hobby',
)
@manage_data_blueprint.route('/friend/new', methods=['GET', 'POST'])
def friend_new():
item = Friend()
form = FriendNewForm()
form.city.query = app_db.session.query(City).order_by(City.name)
form.hobbies.query = app_db.session.query(Hobby).order_by(Hobby.name)
if form.validate_on_submit():
form.populate_obj(item)
app_db.session.add(item)
app_db.session.commit()
flash('Friend added: ' + item.name, 'info')
return redirect(url_for('manage_data.friends_list'))
return render_template('item_new_edit.html', title='New friend', form=form)
@manage_data_blueprint.route('/friend/edit/<int:friend_id>', methods=['GET', 'POST'])
def friend_edit(friend_id):
item = app_db.session.query(Friend).filter(Friend.id == friend_id).first()
if item is None:
abort(403)
form = FriendEditForm(obj=item)
form.city.query = app_db.session.query(City).order_by(City.name)
form.hobbies.query = app_db.session.query(Hobby).order_by(Hobby.name)
if form.validate_on_submit():
form.populate_obj(item)
app_db.session.commit()
flash('Friend updated: ' + item.name, 'info')
return redirect(url_for('manage_data.friends_list'))
return render_template('item_new_edit.html', title='Edit friend', form=form)
@manage_data_blueprint.route('/friend/delete/<int:friend_id>', methods=['GET', 'POST'])
def friend_delete(friend_id):
item = app_db.session.query(Friend).filter(Friend.id == friend_id).first()
if item is None:
abort(403)
form = FriendDeleteForm(obj=item)
item_name = item.name
if form.validate_on_submit():
app_db.session.delete(item)
app_db.session.commit()
flash('Deleted friend: ' + item_name, 'info')
return redirect(url_for('manage_data.friends_list'))
return render_template('item_delete.html', title='Delete friend', item_name=item_name, form=form)
@manage_data_blueprint.route('/city/new', methods=['GET', 'POST'])
def city_new():
item = City()
form = CityNewForm()
if form.validate_on_submit():
form.populate_obj(item)
app_db.session.add(item)
app_db.session.commit()
flash('City added: ' + item.name, 'info')
return redirect(url_for('manage_data.cities_list'))
return render_template('item_new_edit.html', title='New city', form=form)
@manage_data_blueprint.route('/city/edit/<int:city_id>', methods=['GET', 'POST'])
def city_edit(city_id):
item = app_db.session.query(City).filter(City.id == city_id).first()
if item is None:
abort(403)
form = CityEditForm(obj=item)
if form.validate_on_submit():
form.populate_obj(item)
app_db.session.commit()
flash('City updated: ' + item.name, 'info')
return redirect(url_for('manage_data.cities_list'))
return render_template('item_new_edit.html', title='Edit city', form=form)
@manage_data_blueprint.route('/city/delete/<int:city_id>', methods=['GET', 'POST'])
def city_delete(city_id):
item = app_db.session.query(City).filter(City.id == city_id).first()
if item is None:
abort(403)
form = CityDeleteForm(obj=item)
item_name = item.name
if form.validate_on_submit():
app_db.session.delete(item)
app_db.session.commit()
flash('Deleted city: ' + item_name, 'info')
return redirect(url_for('manage_data.cities_list'))
return render_template('item_delete.html', title='Delete city', item_name=item_name, form=form)
@manage_data_blueprint.route('/hobby/new', methods=['GET', 'POST'])
def hobby_new():
item = Hobby()
form = HobbyNewForm()
if form.validate_on_submit():
form.populate_obj(item)
app_db.session.add(item)
app_db.session.commit()
flash('Hobby added: ' + item.name, 'info')
return redirect(url_for('manage_data.hobbies_list'))
return render_template('item_new_edit.html', title='New hobby', form=form)
@manage_data_blueprint.route('/hobby/edit/<int:hobby_id>', methods=['GET', 'POST'])
def hobby_edit(hobby_id):
item = app_db.session.query(Hobby).filter(Hobby.id == hobby_id).first()
if item is None:
abort(403)
form = HobbyEditForm(obj=item)
if form.validate_on_submit():
form.populate_obj(item)
app_db.session.commit()
flash('Hobby updated: ' + item.name, 'info')
return redirect(url_for('manage_data.hobbies_list'))
return render_template('item_new_edit.html', title='Edit hobby', form=form)
@manage_data_blueprint.route('/hobby/delete/<int:hobby_id>', methods=['GET', 'POST'])
def hobby_delete(hobby_id):
item = app_db.session.query(Hobby).filter(Hobby.id == hobby_id).first()
if item is None:
abort(403)
form = HobbyDeleteForm(obj=item)
item_name = item.name
if form.validate_on_submit():
app_db.session.delete(item)
app_db.session.commit()
flash('Deleted hobby: ' + item_name, 'info')
return redirect(url_for('manage_data.hobbies_list'))
return render_template('item_delete.html', title='Delete hobby', item_name=item_name, form=form)
Añadir plantillas para el Blueprint
Tenemos tres plantillas compartidas para lista, nuevo y editar, eliminar. La función render_form() de Bootstrap-Flask pone el formulario en la página.
{# items_list.html #}
{% extends "base.html" %}
{% block content %}
<h1>
{{ title }}
</h1>
{% if tbody_tr_items %}
<table class="table">
<thead>
<tr>
{% for thead_th_item in thead_th_items %}
<th scope="col">
{{ thead_th_item.col_title }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for tbody_tr_item in tbody_tr_items %}
<tr>
{% for tbody_td_item in tbody_tr_item %}
<td>
{% if tbody_td_item.url %}
<a href="{{ tbody_td_item.url }}">
{{ tbody_td_item.col_value }}
</a>
{% else %}
{% if tbody_td_item.col_value %}
{{ tbody_td_item.col_value }}
{% else %}
-
{% endif %}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>
No items found
</p>
{% endif %}
<a class="btn btn-outline-dark" href="{{ item_new_url }}" role="button">
{{ item_new_text }}
</a>
{% endblock %}
La operación de nuevo y de edición sólo requiere una plantilla.
{# item_new_edit.html #}
{% from 'bootstrap/form.html' import render_form %}
{% extends "base.html" %}
{% block content %}
<h1>
{{ title }}
</h1>
{{ render_form(form) }}
{% endblock %}
Y por último la plantilla de borrado.
{# item_delete.html #}
{% from 'bootstrap/form.html' import render_form %}
{% extends "base.html" %}
{% block content %}
<h1>
{{ title }}
</h1>
<p>
Confirm you want to delete: {{ item_name }}
</p>
{{ render_form(form) }}
{% endblock %}
Actualizar el menú en la plantilla base
En la plantilla base añadimos elementos de navegación para Amigos, Ciudades y Aficiones:
{# home.html #}
{% from 'bootstrap/nav.html' import render_nav_item %}
{% from 'bootstrap/utils.html' import render_messages %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>QuerySelectField and QuerySelectMultipleField</title>
{{ bootstrap.load_css() }}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
{{ render_nav_item('index', 'Home', use_li=True) }}
{{ render_nav_item('manage_data.friends_list', 'Friends', use_li=True) }}
{{ render_nav_item('manage_data.cities_list', 'Cities', use_li=True) }}
{{ render_nav_item('manage_data.hobbies_list', 'Hobbies', use_li=True) }}
</ul>
</div>
</nav>
<main class="container">
{{ render_messages(container=False, dismissible=True) }}
{% block content %}{% endblock %}
</main>
{{ bootstrap.load_js() }}
</body>
</html>
Añadimos el Blueprint a factory.py
Añadimos unas líneas al factory.py para añadir el Blueprint. La versión final queda así:
# factory.py
from flask import Flask, request, g, url_for, current_app, render_template
from flask_wtf.csrf import CSRFProtect
from flask_bootstrap import Bootstrap
from .services import app_logging, app_db
from .model import Friend
def create_app():
app = Flask(__name__)
app.config.from_object('config.ConfigDevelopment')
# services
app.logger = app_logging.init_app(app)
app_db.init_app(app)
app.logger.debug('test debug message')
Bootstrap(app)
csrf = CSRFProtect()
csrf.init_app(app)
# blueprints
from .blueprints.manage_data.views import manage_data_blueprint
app.register_blueprint(manage_data_blueprint, url_prefix='/manage-data')
@app.teardown_appcontext
def teardown_db(response_or_exception):
if hasattr(app_db, 'session'):
app_db.session.remove()
@app.route('/')
def index():
friends = app_db.session.query(Friend).order_by(Friend.name).all()
return render_template(
'home.html',
welcome_message='Hello world',
friends=friends,
)
return app
Ejecutar la aplicación terminada
De nuevo, ve al directorio del proyecto y escribe:
python3 run.py
Dirija su navegador a 127.0.0.1:5000. Ahora debería ver la aplicación terminada. Hay elementos de menú para Amigos, Ciudades y Aficiones. Al hacer clic en Amigos, se accede a la lista de amigos. Aquí puedes añadir, editar y eliminar amigos. Lo mismo ocurre con las ciudades y los pasatiempos.
Descargar Docker image y ejecutar
Si quieres ejecutar esta aplicación puedes descargar el archivo Docker image (tgz, 64 MB):
La suma md5 es:
b4f8116e6b8f30c4980a7ff96f0428a5
Para cargar la imagen:
docker load < queryselectfield_100.tgz
Para ejecutar:
docker run --name queryselectfield -p 5000:5000 queryselectfield:1.00
Y luego apunte su navegador a 127.0.0.1:5000.
Resumen
Este es un ejemplo con muchas limitaciones pero muestra la potencia del WTForms QuerySelectField y QuerySelectMultipleField. Y al incluir Bootstrap-Flask podemos crear un menú sin ningún esfuerzo y no tenemos que renderizar los formularios nosotros mismos.
Por supuesto, esto no está listo para la producción, pero se puede refinar, añadir controles, más campos, etc. El QuerySelectField es genial para las relaciones uno a muchos y el QuerySelectMultipleField es genial para las relaciones many-to-many . Ofrecen suficiente flexibilidad para construir su aplicación.
Enlaces / créditos
Alembic 1.5.5 documentation » Tutorial
https://alembic.sqlalchemy.org/en/latest/tutorial.html
Bootstrap-Flask
https://bootstrap-flask.readthedocs.io/en/stable/
Larger Applications (inluding the Circular Imports warning)
https://flask.palletsprojects.com/en/1.1.x/patterns/packages/
WTForms-SQLAlchemy
https://wtforms-sqlalchemy.readthedocs.io/en/stable/
Leer más
Alembic Bootstrap Flask SQLAlchemy WTForms
Recientes
- Cómo ocultar las claves primarias de la base de datos UUID de su aplicación web
- Don't Repeat Yourself (DRY) con Jinja2
- SQLAlchemy, PostgreSQL, número máximo de filas por user
- Mostrar los valores en filtros dinámicos SQLAlchemy
- Transferencia de datos segura con cifrado de Public Key y pyNaCl
- rqlite: una alternativa de alta disponibilidad y dist distribuida SQLite
Más vistos
- Usando Python's pyOpenSSL para verificar los certificados SSL descargados de un host
- Usando UUIDs en lugar de Integer Autoincrement Primary Keys con SQLAlchemy y MariaDb
- Conectarse a un servicio en un host Docker desde un contenedor Docker
- Usando PyInstaller y Cython para crear un ejecutable de Python
- SQLAlchemy: Uso de Cascade Deletes para eliminar objetos relacionados
- Flask RESTful API validación de parámetros de solicitud con esquemas Marshmallow