DevTools

Cheatsheet Flask

Micro-framework Python leve e flexível para aplicações web

Volver a los lenguajes
Flask
72 tarjetas encontradas
Categorías:
Versiones:

Setup


9 cards
Instalar Flask
# Crear entorno virtual:
python -m venv venv
source venv/bin/activate   # Linux/Mac
venv\Scripts\activate      # Windows

# Instalar:
pip install flask

# Verificar:
flask --version
# Python 3.12, Flask 3.1, Werkzeug 3.1

venv aísla las dependencias del proyecto. pip install flask instala el framework y dependencias (Werkzeug, Jinja2). Activa siempre el entorno antes de trabajar.

Configuración
# config.py
import os

class Config:
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev")
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        "DATABASE_URL", "sqlite:///app.db"
    )
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevConfig(Config):
    DEBUG = True

class ProdConfig(Config):
    DEBUG = False

config = {
    "development": DevConfig,
    "production": ProdConfig,
    "default": DevConfig,
}

SECRET_KEY es obligatorio para sesiones y CSRF. os.environ.get() lee variables de entorno. Clases de config por entorno (Dev, Prod). SQLALCHEMY_TRACK_MODIFICATIONS = False evita overhead.

Flask CLI
# Comandos útiles:
flask routes              # listar todas las rutas
flask shell               # shell interactivo
flask db upgrade          # aplicar migrations
flask db migrate -m "msg" # generar migration

# Comandos custom:
import click

@app.cli.command("seed")
def seed():
    """Poblar la base de datos."""
    db.session.add(User(nombre="Admin"))
    db.session.commit()
    click.echo("¡Datos creados!")

# Ejecutar:
flask seed

flask routes lista los endpoints registrados. flask shell abre un REPL con la app cargada. @app.cli.command() crea comandos custom. click.echo() imprime en la terminal. Útil para seeds, limpieza y tareas admin.

App mínima (Hello World)
# app.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def inicio():
    return "¡Hola, Flask!"

if __name__ == "__main__":
    app.run(debug=True)

Flask(__name__) crea la aplicación. @app.route() define el endpoint. app.run(debug=True) inicia el servidor con auto-reload. __name__ ayuda a Flask a localizar templates y static.

Variables de entorno
# .env (usar python-dotenv)
FLASK_APP=run.py
FLASK_DEBUG=1
SECRET_KEY=super-secreto-123
DATABASE_URL=postgresql://user:pass@localhost/db

# Cargar automáticamente:
pip install python-dotenv

# ¡Flask carga .env automáticamente!
# O manual:
from dotenv import load_dotenv
load_dotenv()

# Acceder:
import os
key = os.environ["SECRET_KEY"]

FLASK_APP indica el módulo de la app. FLASK_DEBUG=1 activa el debug. Flask 2.x+ carga .env automáticamente con python-dotenv. Nunca commitees .env (añádelo al .gitignore).

Estructura del proyecto
proyecto/
├── app/
│   ├── __init__.py      # app factory
│   ├── models.py        # modelos SQLAlchemy
│   ├── routes.py        # rutas principales
│   ├── templates/       # HTML Jinja2
│   │   ├── base.html
│   │   └── index.html
│   └── static/          # CSS, JS, imágenes
├── config.py            # configuraciones
├── requirements.txt     # dependencias
└── run.py               # punto de entrada

templates/ y static/ son localizados automáticamente por Flask. __init__.py contiene el factory. config.py separa configuraciones. requirements.txt lista dependencias para reproducción.

Ejecutar el servidor
# Modo recomendado (Flask CLI):
flask run
flask run --debug          # con debug
flask run --port 8080      # puerto custom
flask run --host 0.0.0.0   # accesible en la red

# Alternativa (script):
python run.py

# Con auto-reload y debug:
export FLASK_DEBUG=1
flask run

# Servidor de producción (NUNCA usar flask run):
gunicorn -w 4 "app:create_app()"

flask run es el comando estándar. --debug activa el debugger y el reload. --host 0.0.0.0 expone en la red local. En producción usa gunicorn o uwsgi — el servidor de Flask es solo para desarrollo.

Application Factory
# app/__init__.py
from flask import Flask

def create_app(config_name="default"):
    app = Flask(__name__)
    app.config.from_object(config[config_name])

    # Inicializar extensiones
    db.init_app(app)
    migrate.init_app(app, db)
    login.init_app(app)

    # Registrar blueprints
    from .routes import bp
    app.register_blueprint(bp)

    return app

create_app() es el patrón factory — permite múltiples instancias (tests, producción). Las extensiones se inicializan con init_app(). Los blueprints se registran dentro del factory. Esencial para proyectos medianos/grandes.

Extensiones esenciales
# Instalar extensiones comunes:
pip install flask-sqlalchemy    # ORM
pip install flask-migrate       # migrations
pip install flask-login         # autenticación
pip install flask-wtf           # formularios
pip install flask-cors          # CORS
pip install flask-caching       # cache
pip install flask-mail          # emails
pip install flask-restful       # API REST

# requirements.txt:
flask>=3.0
flask-sqlalchemy>=3.1
flask-migrate>=4.0
flask-login>=0.6

Flask es minimalista — las extensiones añaden funcionalidad. flask-sqlalchemy = ORM. flask-migrate = migrations (Alembic). flask-login = sesiones de usuario. flask-wtf = forms con CSRF. Fija siempre las versiones en requirements.txt.

Rotas e Views


9 cards
Rutas básicas
@app.route("/")
def inicio():
    return "Página inicial"

@app.route("/acerca")
def acerca():
    return render_template("acerca.html")

# Múltiples rutas para la misma view:
@app.route("/hola")
@app.route("/hola/<nombre>")
def hola(nombre="Mundo"):
    return f"¡Hola, {nombre}!"

@app.route() mapea una URL a una función. El nombre de la función es el endpoint (usado en url_for). Múltiples decoradores = múltiples URLs. Un valor default en el parámetro lo hace opcional.

url_for y redirect
from flask import url_for, redirect

# Generar URLs (¡nunca hardcode!):
url_for("inicio")                    # "/"
url_for("perfil", username="ana")    # "/user/ana"
url_for("post", id=5)                # "/post/5"

# Con query string:
url_for("search", q="flask", page=2)
# "/search?q=flask&page=2"

# Redireccionar:
return redirect(url_for("login"))
return redirect(url_for("perfil", username="ana"))

# Redirect con código:
return redirect(url_for("inicio"), code=301)

url_for() genera URLs a partir del nombre de la función — si la ruta cambia, el código sigue válido. redirect() devuelve 302 por default. code=301 para redirect permanente. Usa siempre url_for en vez de strings hardcodeadas.

Subdominios y reglas
# Activar subdominios:
app.config["SERVER_NAME"] = "misitio.com"

@app.route("/", subdomain="<user>")
def perfil_sub(user):
    return f"Perfil de {user}"
# ana.misitio.com → "Perfil de ana"

# Reglas avanzadas:
from werkzeug.routing import Rule

app.url_map.add(Rule(
    "/api/<version>/users",
    endpoint="users",
    defaults={"version": "v1"}
))

# Convertidor custom:
from werkzeug.routing import BaseConverter

class RegexConverter(BaseConverter):
    def __init__(self, map, *items):
        super().__init__(map)
        self.regex = items[0]

subdomain="<user>" captura subdominios (requiere SERVER_NAME). Rule permite reglas avanzadas. Convertidores custom con BaseConverter y regex. Útil para multi-tenancy y APIs versionadas.

Métodos HTTP
@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        email = request.form["email"]
        # autenticar...
        return redirect(url_for("dashboard"))
    return render_template("login.html")

# API REST:
@app.route("/api/users", methods=["GET"])
def listar(): ...

@app.route("/api/users", methods=["POST"])
def crear(): ...

@app.route("/api/users/<int:id>", methods=["PUT", "DELETE"])
def actualizar(id): ...

methods=["GET", "POST"] define los métodos aceptados. El default es solo GET. request.method indica el método usado. Para APIs: GET=leer, POST=crear, PUT=actualizar, DELETE=eliminar.

Error handlers
@app.errorhandler(404)
def no_encontrado(e):
    return render_template("404.html"), 404

@app.errorhandler(500)
def error_interno(e):
    return render_template("500.html"), 500

@app.errorhandler(403)
def acceso_denegado(e):
    return "¡Acceso denegado!", 403

# Error custom con abort:
from flask import abort

@app.route("/admin")
def admin():
    if not current_user.is_admin:
        abort(403)
    return "Panel admin"

@app.errorhandler(código) personaliza páginas de error. abort(código) lanza un error HTTP manualmente. Devolver una tupla (template, código) define el status. Útil para 404, 403, 500 personalizados con el layout de la app.

Parámetros de URL
# String (default):
@app.route("/user/<username>")
def perfil(username):
    return f"Perfil de {username}"

# Convertidores de tipo:
@app.route("/post/<int:id>")
def post(id):          # id es int
    ...

@app.route("/price/<float:valor>")
def precio(valor):     # valor es float
    ...

@app.route("/path/<path:subpath>")
def archivo(subpath):  # acepta /
    ...

# Múltiples parámetros:
@app.route("/blog/<int:anio>/<slug>")
def articulo(anio, slug): ...

Convertidores: int, float, path (acepta barras), string (default). Si el tipo no corresponde → 404 automático. Múltiples parámetros separados por /. El nombre debe corresponder al argumento de la función.

Before/After request
@app.before_request
def antes():
    # Se ejecuta ANTES de cada request
    g.inicio = time.time()
    if request.endpoint != "static":
        # log, auth, etc.
        pass

@app.after_request
def despues(response):
    # Se ejecuta DESPUÉS (modifica la response)
    duracion = time.time() - g.inicio
    response.headers["X-Duracion"] = f"{duracion:.3f}s"
    return response

@app.teardown_request
def finalizar(exc):
    # Siempre se ejecuta (incluso con error)
    pass

@app.before_request se ejecuta antes de cada request (auth, logging). @app.after_request modifica la response (headers, cache). @app.teardown_request se ejecuta siempre (cleanup). g es un objeto global por-request.

Query parameters
from flask import request

# URL: /search?q=flask&page=2&lang=es
@app.route("/search")
def search():
    q = request.args.get("q", "")
    page = request.args.get("page", 1, type=int)
    lang = request.args.get("lang", "es")

    # Todos los params:
    todos = request.args.to_dict()
    # {"q": "flask", "page": "2", "lang": "es"}

    # Múltiples valores (lista):
    # /tags?a=1&a=2
    tags = request.args.getlist("a")
    # ["1", "2"]

    return f"Búsqueda: {q}, página {page}"

request.args es un diccionario inmutable. .get(clave, default, type=) con conversión automática. .getlist() para parámetros repetidos. .to_dict() convierte todo. Los valores son siempre strings sin type=.

Class-based views
from flask.views import MethodView

class UserAPI(MethodView):
    def get(self, user_id=None):
        if user_id is None:
            return {"users": ["ana", "juan"]}
        return {"id": user_id}

    def post(self):
        data = request.json
        return {"creado": data}, 201

    def delete(self, user_id):
        return "", 204

# Registrar:
app.add_url_rule(
    "/api/users",
    view_func=UserAPI.as_view("users"),
    defaults={"user_id": None}
)
app.add_url_rule(
    "/api/users/<int:user_id>",
    view_func=UserAPI.as_view("user_detail")
)

MethodView separa los métodos HTTP en métodos de la clase. Cada método (get, post, delete) maneja el verbo correspondiente. add_url_rule() registra. Ideal para APIs REST con lógica compleja por método.

Templates Jinja2


9 cards
Renderizar templates
from flask import render_template, render_template_string

# Archivo HTML:
@app.route("/posts")
def posts():
    lista = [{"título": "Post 1"}, {"título": "Post 2"}]
    return render_template(
        "posts.html",
        título="Mis Posts",
        posts=lista,
        total=len(lista)
    )

# String inline (¡cuidado con XSS!):
return render_template_string(
    "¡Hola {{ nombre }}!", nombre="Ana"
)

render_template() búsqueda en templates/. Pasa variables como keyword arguments. render_template_string() para strings (evitar con input del usuario — riesgo XSS). Los datos quedan disponibles como variables en el template.

Loops
{% for post in posts %}
    <li>{{ loop.index }}. {{ post.título }}</li>
{% endfor %}

<!-- Variables del loop: -->
{{ loop.index }}     <!-- 1, 2, 3... -->
{{ loop.index0 }}    <!-- 0, 1, 2... -->
{{ loop.first }}     <!-- True en el 1º -->
{{ loop.last }}      <!-- True en el último -->
{{ loop.length }}    <!-- total -->

<!-- Else (lista vacía): -->
{% for item in elementos %}
    <p>{{ item }}</p>
{% else %}
    <p>Sin elementos.</p>
{% endfor %}

<!-- Dict: -->
{% for clave, valor in dict.items() %}
    {{ clave }}: {{ valor }}
{% endfor %}

{% for %} itera listas/dicts. loop.index (1-based), loop.first, loop.last son variables especiales. {% else %} se ejecuta si la lista está vacía. .items() para diccionarios.

Seguridad en templates
<!-- Jinja2 escapa HTML automáticamente: -->
{{ user_input }}
<!-- <script> → &lt;script&gt; (¡seguro!) -->

<!-- DESACTIVAR escape (¡cuidado!): -->
{{ html_content | safe }}
<!-- Renderiza HTML real — solo para contenido
     de confianza (ej: rich text admin) -->

<!-- Autoescape por extensión: -->
<!-- .html → auto-escape ON -->
<!-- .txt → auto-escape OFF -->

<!-- Configurar globalmente: -->
app.jinja_env.autoescape = True

<!-- Nunca hacer: -->
<!-- {{ request.args.get("nombre") | safe }} -->
<!-- ↑ ¡XSS garantizado! -->

Jinja2 hace auto-escape por default en .html — previene XSS. | safe desactiva el escape (solo para contenido confiable). Nunca apliques safe a input del usuario. Markup() marca strings como seguras en Python.

Variables y expresiones
<!-- {{ }} para output -->
<h1>{{ título }}</h1>
<p>{{ user.nombre }}</p>
<p>{{ lista[0] }}</p>
<p>{{ dict["clave"] }}</p>

<!-- Expresiones -->
<p>{{ 2 + 3 }}</p>
<p>{{ "Hola " ~ nombre }}</p>
<p>{{ lista | length }} ítems</p>

<!-- Comentarios -->
{# Esto no aparece en el HTML #}

<!-- Whitespace control -->
{%- if activo -%}
  Activo
{%- endif -%}

{{ }} imprime variables. Punto para atributos/keys: user.nombre. ~ concatena strings. {# #} son comentarios. {%- -%} elimina whitespace extra. Se soportan expresiones Python básicas.

Herencia de templates
<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
    <title>{% block título %}App{% endblock %}</title>
</head>
<body>
    {% include "navbar.html" %}
    <main>
        {% block contenido %}{% endblock %}
    </main>
    {% block scripts %}{% endblock %}
</body>
</html>

<!-- templates/index.html -->
{% extends "base.html" %}

{% block título %}Inicio{% endblock %}

{% block contenido %}
    <h1>Página inicial</h1>
{% endblock %}

{% extends %} hereda de un template base. {% block %} define secciones sobrescribibles. El hijo solo redefine los blocks que necesita. {{ super() }} incluye el contenido del block padre. Patrón esencial para layouts consistentes.

Filtros
<!-- Filtros con pipe | -->
{{ nombre | upper }}          <!-- ANA -->
{{ nombre | lower }}          <!-- ana -->
{{ nombre | capitalize }}     <!-- Ana -->
{{ nombre | title }}          <!-- Ana Silva -->
{{ texto | truncate(50) }}    <!-- corta a 50 chars -->
{{ lista | length }}          <!-- tamaño -->
{{ lista | join(", ") }}      <!-- "a, b, c" -->
{{ valor | round(2) }}        <!-- 3.14 -->
{{ fecha | datetimeformat }}  <!-- custom -->
{{ html | safe }}             <!-- sin escape -->
{{ precio | default("N/A") }} <!-- si None -->

<!-- Encadenar: -->
{{ nombre | trim | upper }}

Los filtros transforman el output con |. safe desactiva el escape HTML (¡cuidado!). default() para valores nulos. truncate() corta texto. Encadena con múltiples |. Filtros custom registrados con @app.template_filter().

Include y macros
<!-- Include (insertar parcial): -->
{% include "partials/navbar.html" %}
{% include "partials/footer.html" %}

<!-- Con variables: -->
{% include "card.html" with context %}

<!-- Macros (funciones reutilizables): -->
{% macro input(name, label, type="text") %}
<div class="field">
    <label for="{{ name }}">{{ label }}</label>
    <input type="{{ type }}" name="{{ name }}"
           id="{{ name }}">
</div>
{% endmacro %}

<!-- Usar la macro: -->
{{ input("email", "Email", type="email") }}
{{ input("clave", "Contraseña", type="password") }}

{% include %} inserta parciales (navbar, footer). {% macro %} crea componentes reutilizables con parámetros. Las macros aceptan defaults. Importar de otro archivo: {% from "forms.html" import input %}.

Condicionales
{% if user %}
    <p>Hola, {{ user.nombre }}</p>
{% elif user_invitado %}
    <p>¡Bienvenido, invitado!</p>
{% else %}
    <p>Haz <a href="/login">login</a></p>
{% endif %}

<!-- Operadores: -->
{% if edad >= 18 and activo %}
{% if role in ["admin", "mod"] %}
{% if lista is defined %}
{% if valor is none %}
{% if nombre is not none %}

<!-- Expresión inline: -->
<p>{{ "Admin" if user.is_admin else "User" }}</p>

{% if %} / {% elif %} / {% else %} / {% endif %}. Operadores: and, or, not, in. Tests: is defined, is none. Expresión ternaria: {{ X if cond else Y }}.

Filtros y tests custom
# Filtro custom:
@app.template_filter("moneda")
def filtro_moneda(valor):
    return f"€{valor:,.2f}"

# En el template: {{ precio | moneda }} → €1.234,56

# Filtro con parámetro:
@app.template_filter("truncate_words")
def truncate_words(texto, n=20):
    words = texto.split()[:n]
    return " ".join(words) + "..."

# Test custom:
@app.template_test("par")
def test_par(n):
    return n % 2 == 0

# En el template: {% if n is par %}

@app.template_filter("nombre") registra un filtro custom. Úsalo con {{ valor | nombre }}. @app.template_test() crea tests para {% if x is test %}. Los filtros reciben el valor como 1º argumento + parámetros extra.

Request e Response


9 cards
Datos del request
from flask import request

@app.route("/datos", methods=["POST"])
def datos():
    # Form data (application/x-www-form-urlencoded):
    email = request.form["email"]
    nombre = request.form.get("nombre", "")

    # JSON body:
    data = request.json          # dict
    data = request.get_json()    # alternativo
    data = request.get_json(silent=True)  # None si inválido

    # Archivos:
    foto = request.files["foto"]

    # Info del request:
    request.method       # "POST"
    request.url          # URL completa
    request.remote_addr  # IP del cliente
    request.content_type # mime type

request.form para datos de formulario. request.json / get_json() para el body JSON. request.files para uploads. request.method indica el verbo. Usa .get() con default para evitar KeyError.

Sesiones
from flask import session

# ¡Requiere SECRET_KEY configurado!
app.config["SECRET_KEY"] = "super-secreto"

@app.route("/login", methods=["POST"])
def login():
    if autenticar(request.form):
        session["user_id"] = user.id
        session["nombre"] = user.nombre
        session.permanent = True  # usa PERMANENT_SESSION_LIFETIME
        return redirect(url_for("dashboard"))
    return "Credenciales inválidas", 401

@app.route("/logout")
def logout():
    session.clear()       # limpiar todo
    session.pop("user_id", None)  # o solo una clave
    return redirect(url_for("inicio"))

# Verificar:
if "user_id" in session: ...

session almacena datos en la cookie firmada (cliente). Requiere SECRET_KEY. session.permanent = True usa el tiempo de vida configurado. session.clear() limpia todo. Los datos se serializan — solo tipos JSON-safe. No guardes objetos complejos.

CORS y caching
# CORS (Cross-Origin Resource Sharing):
pip install flask-cors

from flask_cors import CORS

CORS(app)                    # todas las rutas
CORS(app, resources={
    r"/api/*": {"origins": "https://misitio.com"}
})

# Cache con flask-caching:
pip install flask-caching

from flask_caching import Cache
cache = Cache(app, config={"CACHE_TYPE": "simple"})

@app.route("/datos")
@cache.cached(timeout=300)   # 5 minutos
def datos():
    return jsonify(datos_pesados())

# Invalidar:
cache.delete("datos")

flask-cors permite requests de otros dominios. Configura origins para restringir. flask-caching con @cache.cached(timeout=N) evita recálculos. Tipos: simple (memoria), redis, memcached. Esencial para APIs públicas.

Respuestas JSON (API)
from flask import jsonify

@app.route("/api/users")
def users():
    return jsonify({
        "users": [
            {"id": 1, "nombre": "Ana"},
            {"id": 2, "nombre": "Juan"},
        ],
        "total": 2
    })

# Con status code:
return jsonify({"error": "No encontrado"}), 404

# Lista directa (Flask 2.2+):
return [{"id": 1}, {"id": 2}]

# Dict directo (Flask 2.2+):
return {"status": "ok"}

# Headers custom:
resp = jsonify({"ok": True})
resp.headers["X-Total"] = "42"
return resp

jsonify() serializa dict/lista a JSON con Content-Type: application/json. Flask 2.2+ permite devolver dict/lista directamente. La tupla (json, código) define el status. Ideal para APIs REST.

Upload de archivos
from werkzeug.utils import secure_filename
import os

UPLOAD_DIR = "uploads"
EXTENSIONES = {"png", "jpg", "pdf"}

@app.route("/upload", methods=["POST"])
def upload():
    f = request.files["archivo"]

    if not f or f.filename == "":
        return "Sin archivo", 400

    ext = f.filename.rsplit(".", 1)[1].lower()
    if ext not in EXTENSIONES:
        return "Tipo no permitido", 400

    nombre = secure_filename(f.filename)
    f.save(os.path.join(UPLOAD_DIR, nombre))
    return f"Enviado: {nombre}", 201

# HTML: <form enctype="multipart/form-data">

request.files["campo"] accede al archivo. secure_filename() elimina caracteres peligrosos. Valida extensión y tamaño. f.save() guarda en disco. El formulario necesita enctype="multipart/form-data". Configura MAX_CONTENT_LENGTH como límite.

Headers y status code
from flask import make_response

# Status code simple:
return "¡Creado!", 201
return "Sin contenido", 204
return "Error", 400

# Response completo:
resp = make_response("OK")
resp.status_code = 200
resp.headers["X-Custom"] = "valor"
resp.headers["Cache-Control"] = "no-cache"
resp.content_type = "text/plain"
return resp

# Tuple con headers:
return "Error", 400, {"X-Error": "validacion"}

# Redirect:
return "", 302, {"Location": "/login"}

Devuelve una tupla: (body, status) o (body, status, headers). make_response() crea un objeto editable. resp.headers[] añade headers. Códigos: 200=OK, 201=Creado, 204=Sin contenido, 400=Bad request, 404=Not found.

Download y streaming
from flask import send_file, send_from_directory, Response

# Enviar archivo:
@app.route("/download/<nombre>")
def download(nombre):
    return send_from_directory("uploads", nombre,
                               as_attachment=True)

# Generar y enviar:
@app.route("/export")
def export():
    return send_file(
        "informe.pdf",
        mimetype="application/pdf",
        as_attachment=True,
        download_name="informe.pdf"
    )

# Streaming (archivos grandes):
@app.route("/stream")
def stream():
    def generar():
        for i in range(1000):
            yield f"línea {i}\n"
    return Response(generar(), mimetype="text/plain")

send_from_directory() envía un archivo de forma segura (previene path traversal). as_attachment=True fuerza la descarga. Response(generator()) para streaming. mimetype define el tipo. Ideal para exports y archivos grandes.

Cookies
from flask import request, make_response

# Definir cookie:
resp = make_response("Cookie definida")
resp.set_cookie(
    "tema", "oscuro",
    max_age=3600,          # 1 hora
    httponly=True,         # sin acceso JS
    secure=True,           # solo HTTPS
    samesite="Lax"         # protección CSRF
)
return resp

# Leer cookie:
tema = request.cookies.get("tema", "claro")

# Eliminar cookie:
resp = make_response("Eliminada")
resp.delete_cookie("tema")
return resp

set_cookie() define con opciones de seguridad. httponly=True impide el acceso vía JavaScript. secure=True solo envía por HTTPS. samesite protege contra CSRF. request.cookies.get() lee. Prefiere sesiones para datos sensibles.

Flash messages
from flask import flash, get_flashed_messages

@app.route("/guardar", methods=["POST"])
def guardar():
    flash("¡Datos guardados con éxito!", "exito")
    flash("Email ya registrado.", "error")
    return redirect(url_for("perfil"))

# En el template (base.html):
# {% with messages = get_flashed_messages(
#     with_categories=true) %}
#   {% for categoria, msg in messages %}
#     <div class="alert-{{ categoria }}">
#       {{ msg }}
#     </div>
#   {% endfor %}
# {% endwith %}

# Categorías: success, error, warning, info

flash(msg, categoria) guarda un mensaje para el siguiente request. get_flashed_messages(with_categories=true) lo recupera en el template. Los mensajes se consumen (aparecen una vez). Requiere SECRET_KEY. Estándar para feedback post-redirect.

Base de Dados


9 cards
Setup Flask-SQLAlchemy
pip install flask-sqlalchemy

# app/__init__.py
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

def create_app():
    app = Flask(__name__)
    app.config["SQLALCHEMY_DATABASE_URI"] = \
        "sqlite:///app.db"
    db.init_app(app)
    return app

# Crear tablas (¡solo dev!):
with app.app_context():
    db.create_all()

SQLAlchemy() crea la instancia. db.init_app(app) la vincula a la aplicación (factory pattern). SQLALCHEMY_DATABASE_URI define la BD. db.create_all() crea las tablas (usa migrations en producción). Requiere app_context().

Relaciones
class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    comentarios = db.relationship(
        "Comentario", backref="post",
        lazy="dynamic", cascade="all, delete-orphan"
    )

class Comentario(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    texto = db.Column(db.Text)
    post_id = db.Column(
        db.Integer, db.ForeignKey("post.id"),
        nullable=False
    )

# Usar:
post.comentarios.all()
post.comentarios.filter_by(aprobado=True)
comentario.post.título  # backref

db.relationship() define la relación. backref="post" crea acceso inverso. db.ForeignKey() en la columna de la tabla hija. lazy="dynamic" devuelve query (filtros). cascade="all, delete-orphan" borra los hijos al eliminar el padre.

Raw SQL y transacciones
from sqlalchemy import text

# Query SQL directa:
result = db.session.execute(
    text("SELECT * FROM posts WHERE views > :min"),
    {"min": 100}
)
for row in result:
    print(row.título)

# Transacción manual:
try:
    db.session.begin_nested()  # SAVEPOINT
    db.session.add(Post(título="A"))
    db.session.add(Post(título="B"))
    db.session.commit()
except Exception:
    db.session.rollback()
    raise

# Context manager (Flask 2.x):
with db.session.begin():
    db.session.add(post)
    # commit automático (o rollback)

db.session.execute(text(...)) para SQL raw con parámetros seguros. begin_nested() crea un SAVEPOINT. rollback() revierte en caso de error. with db.session.begin() hace commit/rollback automático. Evita el SQL raw — prefiere el ORM.

Definir modelos
from datetime import datetime

class Post(db.Model):
    __tablename__ = "posts"

    id = db.Column(db.Integer, primary_key=True)
    título = db.Column(db.String(200), nullable=False)
    cuerpo = db.Column(db.Text, default="")
    views = db.Column(db.Integer, default=0)
    publicado = db.Column(db.Boolean, default=False)
    creado = db.Column(db.DateTime, default=datetime.utcnow)
    precio = db.Column(db.Float)

    def __repr__(self):
        return f"<Post {self.título}>"

# Tipos: Integer, String, Text, Boolean,
#         DateTime, Float, JSON, LargeBinary

db.Model es la clase base. db.Column(tipo, opciones) define campos. nullable=False = obligatorio. default= valor por defecto. __tablename__ personaliza el nombre de la tabla. Tipos comunes: String, Integer, Text, Boolean, DateTime.

Migrations (Flask-Migrate)
pip install flask-migrate

# Setup:
from flask_migrate import Migrate
migrate = Migrate(app, db)

# Comandos CLI:
flask db init          # crear carpeta migrations/
flask db migrate -m "add tabla posts"
flask db upgrade       # aplicar
flask db downgrade     # revertir
flask db history       # historial
flask db current       # versión actual

# Workflow:
# 1. Modificar el modelo
# 2. flask db migrate -m "descripción"
# 3. Revisar el archivo generado
# 4. flask db upgrade

flask-migrate usa Alembic por debajo. db init solo una vez. db migrate genera el script de migración. db upgrade aplica. Revisa siempre el archivo generado antes de aplicar. Esencial para la evolución del esquema en producción.

CRUD básico
# CREATE:
post = Post(título="Hola", cuerpo="Texto")
db.session.add(post)
db.session.commit()

# READ:
post = Post.query.get(1)           # por PK
post = db.session.get(Post, 1)     # alternativo
todos = Post.query.all()
primero = Post.query.first()

# UPDATE:
post.título = "Nuevo título"
db.session.commit()

# DELETE:
db.session.delete(post)
db.session.commit()

# Bulk:
db.session.add_all([post1, post2, post3])
db.session.commit()

db.session.add() + commit() para crear. query.get(id) búsqueda por PK. Modifica atributos + commit() para actualizar. db.session.delete() elimina. Siempre commit() para persistir. rollback() para cancelar.

Paginación
@app.route("/posts")
def posts():
    page = request.args.get("page", 1, type=int)
    per_page = 20

    paginacion = Post.query.order_by(
        Post.creado.desc()
    ).paginate(
        page=page, per_page=per_page,
        error_out=False
    )

    posts = paginacion.items      # lista de la página
    total = paginacion.total      # total de registros
    tiene_siguiente = paginacion.has_next
    tiene_anterior = paginacion.has_prev

    return render_template("posts.html",
        posts=posts, paginacion=paginacion)

# En el template:
# {% for p in paginacion.iter_pages() %}

.paginate(page=, per_page=) divide resultados. .items = registros de la página. .has_next / .has_prev para navegación. .iter_pages() genera números de página. error_out=False devuelve vacío en vez de 404.

Queries y filtros
# Filtros:
Post.query.filter_by(publicado=True).all()
Post.query.filter(Post.views > 100).all()
Post.query.filter(
    Post.título.like("%flask%")
).all()

# Múltiples condiciones:
from sqlalchemy import and_, or_
Post.query.filter(
    and_(Post.views > 50, Post.publicado == True)
).all()

# Ordenación y límite:
Post.query.order_by(Post.creado.desc()).limit(10).all()

# Conteo y agregación:
Post.query.count()
Post.query.filter_by(publicado=True).count()

# Primero o 404:
post = Post.query.get_or_404(id)

filter_by() para igualdad simple. filter() para expresiones complejas (>, like, and_, or_). order_by() + desc() para ordenar. limit() restringe resultados. get_or_404() lanza 404 si no existe.

Eventos y hooks
from sqlalchemy import event

# Antes de insertar:
@event.listens_for(Post, "before_insert")
def antes_insertar(mapper, connection, target):
    target.slug = generar_slug(target.título)

# Después de actualizar:
@event.listens_for(Post, "after_update")
def despues_update(mapper, connection, target):
    target.actualizado = datetime.utcnow()

# Hybrid properties:
from sqlalchemy.ext.hybrid import hybrid_property

class User(db.Model):
    nombre = db.Column(db.String(50))
    apellido = db.Column(db.String(50))

    @hybrid_property
    def nombre_completo(self):
        return f"{self.nombre} {self.apellido}"

@event.listens_for(Model, "evento") registra hooks. Eventos: before_insert, after_update, before_delete. hybrid_property funciona en Python y en queries SQL. Útil para slugs, timestamps y campos calculados.

Forms e Validação


9 cards
Setup Flask-WTF
pip install flask-wtf email-validator

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Email

class LoginForm(FlaskForm):
    email = StringField("Email", validators=[
        DataRequired(message="Email obligatorio"),
        Email(message="Email inválido")
    ])
    clave = PasswordField("Contraseña", validators=[
        DataRequired()
    ])
    recordar = BooleanField("Recuérdame")

# Campos: StringField, TextAreaField,
# SelectField, IntegerField, DateField,
# FileField, BooleanField, RadioField

FlaskForm es la clase base (incluye CSRF). Campos de wtforms con validators. DataRequired() = obligatorio. Email() valida el formato (requiere email-validator). Cada campo tiene .data (valor) y .errors (lista de errores).

Protección CSRF
# ¡Flask-WTF activa CSRF automáticamente!
# Requiere SECRET_KEY en la config.

# En el template (obligatorio en todo form):
{{ form.hidden_tag() }}
<!-- o manualmente: -->
<input type="hidden" name="csrf_token"
       value="{{ csrf_token() }}">

# Para AJAX:
# <meta name="csrf-token" content="{{ csrf_token() }}">
# fetch(url, {
#   headers: {"X-CSRFToken": token}
# })

# Excluir una view específica:
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)

@app.route("/webhook", methods=["POST"])
@csrf.exempt
def webhook(): ...

CSRFProtect protege todos los POST. {{ form.hidden_tag() }} incluye el token. Para AJAX: enviar X-CSRFToken en el header. @csrf.exempt excluye rutas (webhooks, APIs). Sin token → error 400. Nunca desactivar globalmente.

AJAX con forms
# View que devuelve JSON:
@app.route("/api/validar", methods=["POST"])
def validar():
    form = RegistroForm()
    if form.validate():
        return {"válido": True}
    return {"válido": False, "errores": form.errors}, 400

# JavaScript:
# const token = document.querySelector(
#     "[name=csrf-token]").content;
#
# fetch("/api/validar", {
#     method: "POST",
#     headers: {
#         "Content-Type": "application/json",
#         "X-CSRFToken": token
#     },
#     body: JSON.stringify({
#         email: "ana@mail.com",
#         csrf_token: token
#     })
# })

form.errors es un dict con errores por campo — serializable a JSON. Enviar X-CSRFToken en el header para AJAX. form.validate() sin on_submit para APIs. Devolver 400 con los errores para que el frontend los trate.

Validadores
from wtforms.validators import (
    DataRequired, Length, Email,
    EqualTo, NumberRange, Regexp,
    Optional, URL
)

class RegistroForm(FlaskForm):
    nombre = StringField(validators=[
        Length(min=2, max=50,
               message="2 a 50 caracteres")
    ])
    email = StringField(validators=[
        DataRequired(), Email()
    ])
    clave = PasswordField(validators=[
        Length(min=8, message="Mínimo 8 caracteres"),
        Regexp(r"\d", message="Requiere un número")
    ])
    confirmar = PasswordField(validators=[
        EqualTo("clave", message="Las contraseñas no coinciden")
    ])
    edad = IntegerField(validators=[
        Optional(), NumberRange(min=18, max=120)
    ])

Length(min, max) limita el tamaño. EqualTo("campo") compara (confirmación de contraseña). Regexp() valida con regex. NumberRange() para números. Optional() permite vacío. message= personaliza el error. Los validadores se ejecutan en orden.

Validador custom
from wtforms.validators import ValidationError

# Función validadora:
def email_unico(form, field):
    user = User.query.filter_by(
        email=field.data.lower()
    ).first()
    if user:
        raise ValidationError("Email ya registrado.")

# Usar en el form:
class RegistroForm(FlaskForm):
    email = StringField(validators=[
        DataRequired(), Email(), email_unico
    ])

# Método validate_<campo> (automático):
class RegistroForm(FlaskForm):
    username = StringField()

    def validate_username(self, field):
        if len(field.data) < 3:
            raise ValidationError("Mínimo 3 caracteres.")
        if " " in field.data:
            raise ValidationError("Sin espacios.")

Validador custom: función que recibe (form, field) y lanza ValidationError. El método validate_<campo> se llama automáticamente. Ambos añaden a field.errors. Ideal para validaciones que dependen de la BD (unicidad).

Procesar en la view
@app.route("/registro", methods=["GET", "POST"])
def registro():
    form = RegistroForm()

    if form.validate_on_submit():
        # Datos válidos:
        user = User(
            nombre=form.nombre.data,
            email=form.email.data
        )
        user.set_password(form.clave.data)
        db.session.add(user)
        db.session.commit()

        flash("¡Cuenta creada!", "exito")
        return redirect(url_for("login"))

    # GET o validación fallida:
    return render_template("registro.html", form=form)

# validate_on_submit() = POST + válido

form.validate_on_submit() verifica si es POST Y datos válidos. form.campo.data accede al valor limpio. Si es inválido, re-renderiza con errores. Patrón: GET muestra el form, POST procesa. flash() + redirect() tras el éxito (patrón PRG).

File upload con form
from flask_wtf.file import FileField, FileAllowed, FileRequired

class UploadForm(FlaskForm):
    foto = FileField("Foto de perfil", validators=[
        FileRequired(message="Seleccione un archivo"),
        FileAllowed(["jpg", "png", "webp"],
                    message="¡Solo imágenes!")
    ])

# En la view:
@app.route("/upload", methods=["POST"])
def upload():
    form = UploadForm()
    if form.validate_on_submit():
        f = form.foto.data
        nombre = secure_filename(f.filename)
        f.save(f"static/uploads/{nombre}")
        flash("¡Upload hecho!")
        return redirect(url_for("perfil"))
    return render_template("upload.html", form=form)

# HTML: <form enctype="multipart/form-data">

FileField para uploads. FileAllowed(["ext"]) valida la extensión. FileRequired() lo hace obligatorio. form.campo.data es el objeto archivo. El formulario necesita enctype="multipart/form-data". Combinar con secure_filename().

Renderizar en el template
<!-- registro.html -->
<form method="POST" novalidate>
    {{ form.hidden_tag() }}

    <div class="field">
        {{ form.nombre.label }}
        {{ form.nombre(class="input", placeholder="Nombre") }}
        {% for error in form.nombre.errors %}
            <span class="error">{{ error }}</span>
        {% endfor %}
    </div>

    <div class="field">
        {{ form.email.label }}
        {{ form.email(class="input") }}
        {% for error in form.email.errors %}
            <span class="error">{{ error }}</span>
        {% endfor %}
    </div>

    {{ form.submit(class="btn") }}
</form>

{{ form.hidden_tag() }} renderiza el token CSRF (¡obligatorio!). {{ form.campo() }} genera el input HTML. class= añade clases CSS. form.campo.errors lista los errores de validación. form.campo.label genera el <label>.

Form con datos iniciales
# Editar un registro existente:
@app.route("/post/<int:id>/editar", methods=["GET", "POST"])
def editar(id):
    post = Post.query.get_or_404(id)
    form = PostForm(obj=post)  # ¡rellenar!

    if form.validate_on_submit():
        form.populate_obj(post)  # ¡actualizar!
        db.session.commit()
        flash("¡Post actualizado!")
        return redirect(url_for("post", id=id))

    return render_template("editar.html",
                           form=form, post=post)

# populate_obj copia form → objeto
# obj=post copia objeto → form (GET)

# O manualmente:
form = PostForm(título=post.título,
                cuerpo=post.cuerpo)

PostForm(obj=post) rellena el form con los datos del objeto. form.populate_obj(post) hace lo inverso (form → objeto). Ideal para edición: GET muestra los datos actuales, POST actualiza. Alternativa: pasar los campos manualmente en el constructor.

Blueprints


9 cards
Crear blueprint
# app/auth/routes.py
from flask import Blueprint, render_template

bp = Blueprint(
    "auth",              # nombre único
    __name__,            # módulo
    url_prefix="/auth",  # prefijo URL
    template_folder="templates",
    static_folder="static"
)

@bp.route("/login")
def login():
    return render_template("auth/login.html")

@bp.route("/registro")
def registro():
    return render_template("auth/registro.html")

# URLs finales: /auth/login, /auth/registro

Blueprint(nombre, __name__) crea un módulo independiente. url_prefix prefija todas las rutas. template_folder y static_folder son opcionales. Cada blueprint es una mini-app. Ideal para separar auth, blog, admin, API.

Error handlers por BP
# Error específico del blueprint:
@bp.app_errorhandler(404)
def bp_404(e):
    return render_template("auth/404.html"), 404

# Before request solo en este BP:
@bp.before_app_request
def verificar_auth():
    if request.endpoint and \
       request.endpoint.startswith("admin."):
        if not current_user.is_admin:
            abort(403)

# Context processor del BP:
@bp.context_processor
def inject_auth():
    return {"auth_version": "2.0"}

@bp.app_errorhandler() registra handlers globales. @bp.before_app_request se ejecuta antes de todos los requests (no solo del BP). @bp.context_processor inyecta variables en los templates. Útil para middleware específico de un módulo.

Testear blueprints
import pytest
from app import create_app

@pytest.fixture
def app():
    app = create_app("testing")
    app.config["TESTING"] = True
    return app

@pytest.fixture
def client(app):
    return app.test_client()

# Testear el BP auth:
def test_login_page(client):
    r = client.get("/auth/login")
    assert r.status_code == 200
    assert b"Login" in r.data

def test_login_post(client):
    r = client.post("/auth/login", data={
        "email": "ana@mail.com",
        "clave": "123456"
    }, follow_redirects=True)
    assert b"Dashboard" in r.data

Testear BPs como rutas normales con test_client(). create_app("testing") usa config de test. follow_redirects=True sigue los redirects. r.data es bytes — usar b"texto". Fixtures de pytest para el setup. Un archivo de test por BP.

Registrar blueprints
# app/__init__.py
from flask import Flask

def create_app():
    app = Flask(__name__)

    from .auth.routes import bp as auth_bp
    from .blog.routes import bp as blog_bp
    from .api.routes import bp as api_bp

    app.register_blueprint(auth_bp)
    app.register_blueprint(blog_bp)
    app.register_blueprint(api_bp, url_prefix="/api/v1")

    return app

# url_prefix en el registro sobrescribe
# el prefijo definido en el blueprint

app.register_blueprint(bp) lo añade a la app. Importar dentro del factory evita imports circulares. url_prefix en el registro sobrescribe el del blueprint. El orden de registro no importa. Cada BP puede tener su propio prefijo.

API versioning
# api/v1/routes.py
bp_v1 = Blueprint("api_v1", __name__)

@bp_v1.route("/users")
def users():
    return jsonify({"version": 1, "users": []})

# api/v2/routes.py
bp_v2 = Blueprint("api_v2", __name__)

@bp_v2.route("/users")
def users():
    return jsonify({"version": 2, "data": []})

# Registrar:
app.register_blueprint(bp_v1, url_prefix="/api/v1")
app.register_blueprint(bp_v2, url_prefix="/api/v2")

# /api/v1/users → versión 1
# /api/v2/users → versión 2

Versionar con blueprints: un BP por versión. url_prefix="/api/v1" los separa. Los clientes antiguos siguen funcionando. Nueva versión = nuevo BP sin romper el existente. Alternativa: header Accept-Version. Estándar para APIs públicas.

Estructura modular
app/
├── __init__.py          # create_app()
├── extensions.py        # db, migrate, login
├── auth/
│   ├── __init__.py
│   ├── routes.py        # bp auth
│   ├── forms.py         # LoginForm
│   └── templates/auth/
├── blog/
│   ├── __init__.py
│   ├── routes.py        # bp blog
│   ├── models.py        # Post, Comment
│   └── templates/blog/
├── api/
│   ├── __init__.py
│   └── routes.py        # bp api
└── templates/           # base.html global

Cada funcionalidad en su propio paquete. extensions.py centraliza db, migrate, login (evita imports circulares). Templates del BP en templates/nombre_bp/. models.py por módulo. Escalable para equipos grandes.

Templates por blueprint
# Blueprint con templates propios:
bp = Blueprint("blog", __name__,
    template_folder="templates",
    static_folder="static",
    static_url_path="/blog/static"
)

# Estructura:
# app/blog/
#   templates/blog/
#     index.html
#     post.html
#   static/
#     blog.css

# En el route del BP:
@bp.route("/")
def index():
    # Búsqueda en blog/templates/blog/index.html
    return render_template("blog/index.html")

# Heredar del base global:
# {% extends "base.html" %}

template_folder define la carpeta de templates del BP. Usar una subcarpeta con el nombre del BP evita conflictos. static_folder + static_url_path para assets propios. Los templates del BP pueden heredar del base.html global. Prefijar nombres para claridad.

url_for con blueprints
# Formato: "nombre_bp.funcion"
url_for("auth.login")           # /auth/login
url_for("blog.post", id=5)      # /blog/post/5
url_for("api.users")            # /api/v1/users

# En el template:
# <a href="{{ url_for('auth.login') }}">Login</a>
# <a href="{{ url_for('blog.post', id=p.id) }}">

# Redirigir entre BPs:
return redirect(url_for("auth.login"))

# Verificar el endpoint actual:
request.endpoint  # "auth.login"
request.blueprint # "auth"

url_for("bp.funcion") referencia rutas de blueprints. El prefijo es el nombre del BP (1er arg del Blueprint). request.endpoint muestra el endpoint actual. request.blueprint muestra el BP. Usar siempre url_for — nunca hardcodear URLs.

Nested blueprints
# Flask 2.0+ soporta BPs anidados:
parent = Blueprint("admin", __name__,
                   url_prefix="/admin")
child = Blueprint("users", __name__,
                  url_prefix="/users")

@child.route("/")
def listar():
    return "Lista de users"

# Anidar:
parent.register_blueprint(child)
app.register_blueprint(parent)

# URL final: /admin/users/

# Útil para:
# /admin/users/
# /admin/posts/
# /admin/settings/
# Cada submódulo es un BP hijo

Flask 2.0+ permite parent.register_blueprint(child). Los prefijos se acumulan: /admin + /users = /admin/users. Ideal para paneles admin con submódulos. Cada nivel es independiente y testeable. Evitar más de 2 niveles de nesting.

Extensões e Deploy


9 cards
Flask-Login (autenticación)
pip install flask-login

from flask_login import (
    LoginManager, UserMixin,
    login_user, logout_user,
    login_required, current_user
)

login = LoginManager(app)
login.login_view = "auth.login"  # redirect

class User(UserMixin, db.Model):
    # UserMixin añade: is_authenticated,
    # is_active, is_anonymous, get_id()
    pass

@login.user_loader
def load_user(id):
    return db.session.get(User, int(id))

# Proteger ruta:
@app.route("/dashboard")
@login_required
def dashboard():
    return f"Hola {current_user.nombre}"

flask-login gestiona sesiones de usuario. UserMixin añade los métodos obligatorios. @login_required protege rutas. current_user es el usuario actual. login_user() / logout_user() gestionan la sesión. login_view define el redirect si no está autenticado.

Deploy con Gunicorn
pip install gunicorn

# Ejecutar:
gunicorn -w 4 -b 0.0.0.0:8000 "app:create_app()"

# Opciones:
gunicorn \
    --workers 4 \
    --bind 0.0.0.0:8000 \
    --timeout 120 \
    --access-logfile - \
    --error-logfile - \
    "app:create_app()"

# Workers = (2 × CPU cores) + 1

# Nginx como reverse proxy:
# proxy_pass http://127.0.0.1:8000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;

# systemd para gestionar el proceso

gunicorn es el servidor WSGI de producción. -w 4 = 4 workers (procesos). "app:create_app()" llama al factory. Nunca usar flask run en producción. Nginx delante como reverse proxy. systemd para auto-restart.

Seguridad y buenas prácticas
# 1. Headers de seguridad:
from flask_talisman import Talisman
Talisman(app, force_https=True)

# 2. Rate limiting:
from flask_limiter import Limiter
limiter = Limiter(app, key_func=get_remote_address)

@app.route("/api/login")
@limiter.limit("5/minute")
def login(): ...

# 3. Nunca debug en producción:
app.config["DEBUG"] = False

# 4. Validar TODO el input:
# ¡request.json puede ser None!
data = request.get_json(silent=True) or {}

# 5. Usar parámetros en SQL:
db.session.execute(
    text("SELECT * FROM users WHERE id = :id"),
    {"id": user_id}
)

# 6. HTTPS obligatorio en producción

flask-talisman fuerza HTTPS y headers seguros. flask-limiter limita requests (anti brute-force). Nunca DEBUG=True en producción. Validar el input (get_json(silent=True)). Parámetros en SQL (anti injection). HTTPS obligatorio. SECRET_KEY fuerte y en env vars.

Flask-RESTful (API)
pip install flask-restful

from flask_restful import Resource, Api, reqparse

api = Api(app)

class UserResource(Resource):
    def get(self, user_id):
        user = User.query.get_or_404(user_id)
        return {"id": user.id, "nombre": user.nombre}

    def put(self, user_id):
        parser = reqparse.RequestParser()
        parser.add_argument("nombre", required=True)
        args = parser.parse_args()
        user.nombre = args["nombre"]
        db.session.commit()
        return {"ok": True}

    def delete(self, user_id):
        return "", 204

api.add_resource(UserResource, "/api/user/<int:user_id>")

Resource define endpoints con métodos HTTP. Api(app) lo registra. reqparse valida argumentos. add_resource() mapea la URL. Devolver dict = JSON automático. Alternativa moderna: flask-smorest o flask-restx con OpenAPI.

Docker
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", \
     "app:create_app()"]

# docker-compose.yml
# services:
#   web:
#     build: .
#     ports: ["8000:8000"]
#     environment:
#       - SECRET_KEY=super
#       - DATABASE_URL=postgresql://...
#   db:
#     image: postgres:16
#     volumes: [pgdata:/var/lib/postgresql/data]

# docker compose up --build

python:3.12-slim como base (ligera). Instalar dependencias antes del COPY (caché Docker). gunicorn como CMD. docker-compose orquesta web + BD. Variables de entorno para la config. --build reconstruye la imagen.

Context processors
# Variables globales en todos los templates:
@app.context_processor
def inject_globals():
    return {
        "app_name": "Mi App",
        "anio_actual": datetime.now().year,
        "menu_items": ["Inicio", "Blog", "Contacto"]
    }

# En cualquier template:
# {{ app_name }} - {{ anio_actual }}

# Con blueprint:
@bp.context_processor
def inject_bp():
    return {"bp_version": "1.0"}

# Utility functions:
@app.context_processor
def inject_utils():
    return {"formatear_fecha": lambda d: d.strftime("%d/%m/%Y")}

@app.context_processor inyecta variables en todos los templates. Devolver dict. Se ejecuta en cada request (mantener ligero). Ideal para: nombre de la app, año, menú, user actual. Alternativa: app.jinja_env.globals para funciones.

Logging
import logging
from logging.handlers import RotatingFileHandler

def setup_logging(app):
    handler = RotatingFileHandler(
        "logs/app.log",
        maxBytes=5_000_000,  # 5MB
        backupCount=5
    )
    handler.setFormatter(logging.Formatter(
        "%(asctime)s %(levelname)s: %(message)s"
    ))
    handler.setLevel(logging.INFO)

    app.logger.addHandler(handler)
    app.logger.setLevel(logging.INFO)

# Usar:
app.logger.info("Servidor iniciado")
app.logger.warning("Caché casi llena")
app.logger.error(f"Error: {e}")

# En producción: log a stdout (Docker)

app.logger es el logger de Flask. RotatingFileHandler limita el tamaño de los logs. logging.Formatter define el formato. Niveles: DEBUG, INFO, WARNING, ERROR. En Docker: log a stdout. Nunca loguear datos sensibles.

Testing con pytest
# conftest.py
import pytest
from app import create_app, db

@pytest.fixture
def app():
    app = create_app("testing")
    with app.app_context():
        db.create_all()
        yield app
        db.drop_all()

@pytest.fixture
def client(app):
    return app.test_client()

# test_routes.py
def test_home(client):
    r = client.get("/")
    assert r.status_code == 200

def test_create_post(client, auth_headers):
    r = client.post("/posts", json={
        "título": "Prueba"
    }, headers=auth_headers)
    assert r.status_code == 201

# Ejecutar: pytest -v

conftest.py define fixtures compartidas. create_app("testing") usa BD de test. db.create_all() / drop_all() por test. test_client() simula requests HTTP. pytest -v ejecuta. Config testing: SQLALCHEMY_DATABASE_URI = "sqlite://" (memoria).

Flask-Mail
pip install flask-mail

from flask_mail import Mail, Message

mail = Mail(app)

app.config["MAIL_SERVER"] = "smtp.gmail.com"
app.config["MAIL_PORT"] = 587
app.config["MAIL_USE_TLS"] = True
app.config["MAIL_USERNAME"] = "app@gmail.com"
app.config["MAIL_PASSWORD"] = "clave-app"

def enviar_email(destinatario, asunto, cuerpo):
    msg = Message(
        subject=asunto,
        recipients=[destinatario],
        body=cuerpo,
        sender="noreply@app.com"
    )
    mail.send(msg)

# HTML:
msg.html = "<h1>¡Hola!</h1>"

flask-mail envía emails vía SMTP. Configurar servidor, puerto, credenciales. Message() crea el email. mail.send() envía (síncrono). msg.html para contenido HTML. En producción: usar cola (Celery) para no bloquear. Gmail requiere contraseña de app.