Una pequeña introducción a una gran microframework

por Javier Luna Molina

Aprenderemos a...

  • Flask 101: Programar un pequeño servidor
  • Hacer uso de templates con Jinja 2
  • Hacer uso de SQLAlchemy como ORM para almacenar nuestros datos
  • Gestionar sesiones de usuario de una forma segura
  • Estructurar bien nuestra aplicación. ¿Es Flask escalable?
  • Diseñar e implementar una API REST

Aprenderemos a...

Hacer un blog!

Situación actual

Situación actual

Situación actual (real)

¿Por qué Flask?

¿Por qué Flask?

  • Rápido, ligero y directo
  • Se adapta mejor a tu manera de pensar
  • Funciona muy bien con módulos de terceros
  • Módulos muy fáciles de programar (y comunidad muy activa)

Flask 101

Hola mundo

Flask 101

Hola mundo!

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return "Hello world!"

if __name__ == '__main__':
    app.run()

Hola mundo!

...

@app.route('/')
def hello_world():
    return "Hello world!"

...

Rutas

Flask 101

Rutas

...

@app.route('/')  #http://localhost:5000/ -> Hello world!
def hello_world():
    return "Hello world!"

...

Rutas


@app.route('/')  #http://localhost:5000/ -> Hello world!
@app.route('/index') #http://localhost:5000/index -> Hello world!
@app.route('/hello/world') #http://localhost:5000/hello/world -> Hello world!
def hello_world():
    return "Hello world!"


print(app.url_map)

Map([<Rule '/hello/world' (OPTIONS, GET, HEAD) -> hello_world>,
 <Rule '/index' (OPTIONS, GET, HEAD) -> hello_world>,
 <Rule '/' (OPTIONS, GET, HEAD) -> hello_world>,
 <Rule '/static/<filename>' (OPTIONS, GET, HEAD) -> static>])

Rutas: Wildcards

Flask 101

Rutas: Wildcards

Estructura:

@app.route('/<variable>/<variable2>')
def vista(variable, variable2):
    return ""

Wildcards "tipadas":

@app.route('/<int:variable>/<variable2>')
def vista(variable, variable2):
    return ""

# /1/hola -> 200 OK
# /hola/paco -> 404 Not found

Rutas: Wildcards


@app.route('/')  #http://localhost:5000/ -> Hello world!
def hello_world():
    return "Hello world!"

@app.route('/<name>') #http://localhost:5000/paco -> Hello paco 
def hello_name(name):
    return "Hello " + name

@app.route('/<name>/<int:times>') #http://localhost/paco/2 -> Hello paco paco 
def hello_name2(name, times): #times es de tipo int
    return "Hello ".join([name+" " for i in range(times)].strip() 

Respuestas

Flask 101

Respuestas


@app.route('/')
def hello_world():
    return "Hello world!"

<html>
    <head></head>
    <body>
        Hello world!
    </body>
<html>

Respuestas

@app.route('/')
def hello_world():
	return """
        <html>
	    <head>
		<title>HELLO WORLD</title>
	    </head>
	    <body>
		<h1>HELLO WORLD!!!!</h1>
		<a href="http://www.disneylandparis.es/">Go to disneyland now</a>
	    </body>
	</html>
	"""


Respuestas: JSON

from flask import Flask, jsonify

...

@app.route('/json')
def hello_json():
	return jsonify({'hello':'json!'})

#http://localhost:5000/json -> {'hello':'json!'} 200 OK

Respuestas: 301(redirect)

from flask import Flask, redirect

...

@app.route('/disney')
def goto_disney():
	return redirect('http://www.disneylandparis.es/')

#http://localhost:5000/disney -> 301 Moved permanently 
# -> http://www.disneylandparis.es/

Respuestas: 301(redirect)

from flask import Flask, redirect

...

@app.route('/')
def index():
	return "<h1> I'm a cool ass index </h1>"

@app.route('/index')
def redirect_to_index():
        return redirect('/')

@app.route('/indice')
def redirect_to_index2():
        return redirect('http://localhost:5000/')

@app.route('/illo')
def redirect_to_index3():
        return redirect('/')

Métodos HTTP

Flask 101

Métodos HTTP

  • GET: Leer
  • POST: Crear
  • PUT: Actualizar
  • PATCH: Actualizar parcialmente
  • DELETE: Borrar

Métodos HTTP

from flask import Flask, request, redirect
...
@app.route('/login', methods=['GET', 'POST'])
def login():
	if request.method == 'POST':
		username = request.form['username']
		password = request.form['password']
		if username == 'admin' and password == 'hunter02':
			return redirect('/admin')
		else:
			return """<p>Ha habido un error con tus datos de login</p>
					  <a href='/login'> Vuelve a intentarlo</a>"""
	return """
		<form action="/login" method='POST'>
            Username:<br>
            <input type="text" name="username"><br>
            Password:<br>
            <input type="password" name="password">
            <input type="submit" value="Login">
		</form>
	"""

Métodos HTTP

GET /login

200 OK

POST /login

username: admin

password: hunter02

¿Datos correctos?

301 Redirect a /admin

No

GET /admin

200 OK

Error handling

Flask 101

Error handling: 404, 500...

http://localhost:5000/hola-mundo-mio

Not Found

The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.

Error handling: 404, 500...

@app.errorhandler(404)
def page_not_found(e):
	return "<h1>La página no está, sorry :(</h1>", 404

La página no está, sorry :(

Error handling: 404, 500...

@app.errorhandler(500)
def page_not_found(e):
	return """
		<html>
		<head><title>Illo :(</title></head>
		<body>
			<h1>El servidor ha petado cosa mala</h1>
			<p>Gracias</p>
		</body>
		</html>
	""", 500

Error handling: Excepciones

@app.route('/<item>')
def yield_item(item):
	valor = {'hola': 'mundo'}
	return valor[item]

@app.errorhandler(KeyError)
def key_error(e):
	return "<h1>Key error</h1>", 500

# /hola -> "mundo" 200 OK
# /mundo -> <h1>Key error </h1> 500 Internal Server Error

Generar URLs

Flask 101

from flask import Flask, redirect

...

@app.route('/')
def index():
	return "<h1> I'm a cool ass index </h1>"

@app.route('/index')
def redirect_to_index():
        return redirect('/')

@app.route('/indice')
def redirect_to_index2():
        return redirect('http://localhost:5000/')

@app.route('/illo')
def redirect_to_index3():
        return redirect('/')

Ejemplo de antes

from flask import Flask, redirect

...

@app.route('/coolassindex')
def index():
	return "<h1> I'm a cool ass index </h1>"

@app.route('/index')
def redirect_to_index():
        return redirect('/')

@app.route('/indice')
def redirect_to_index2():
        return redirect('http://localhost:5000/')

@app.route('/illo')
def redirect_to_index3():
        return redirect('/')

Si cambiamos la ruta...

Solución: Generar las URLs

url_for(endpoint)

from flask import Flask, redirect, url_for

...

@app.route('/coolassindex')
def index():
	return "<h1> I'm a cool ass index </h1>"

@app.route('/holaa')
def redirect_to_index():
        return redirect(url_for('index'))

# url_for('index') -> /coolassindex

url_for() con wildcards

from flask import Flask, redirect, url_for

...

@app.route('/hello/<name>')
def hola_illo(name):
    return "Hello "+name

@app.route('/')
    return redirect(url_for('hola_illo', name="World!"))

#url_for('hola_illo', name="Paco") -> /hello/paco
#url_for('hola_illo', name="Paco", _external=True) 
#-> http://localhost:5000/hello/paco

¡Felicidades!

Ya sabes un 80% de Flask

¿

?

Ejercicio

Lista de comentarios

Programar un servidor que te muestre una serie de comentarios y te permita añadir un comentario usando un formulario

Ejercicio

Lista de comentarios

<html>
<head><title>Comentarios cool</title></head>
<body>
	<form action="/comment" method="POST">
		<h3>Comenta:</h3>
		<input type="text" name="comment">
		<input type="submit" name="submit">
	</form>
	<br>
	<h3>Comentarios</h3>
	<ul>
<!-- Aquí es donde tenéis que poner vuestros comentarios -->
		<li>Comentario1</li> 
		<li>Comentario2</li>
	</ul>
</body>
</html>

Solución

Lista de comentarios

from flask import Flask, request, redirect, url_for

app = Flask(__name__)

comments = []

plantilla = """
<html>
<head><title>Comentarios cool</title></head>
<body>
	<form action="RUTA" method="POST">
		<h3>Comenta:</h3>
		<input type="text" name="comment">
		<input type="submit" name="submit">
	</form>
	<br>
	<h3>Comentarios</h3>
	<ul>
		COMENTARIOS
	</ul>
</body>
</html>
"""

...

Solución (parte I)

...


@app.route('/')
def index():
	html_comments = "".join(["<li>"+comment+"</li>\n" for comment in comments])
	return plantilla.replace("COMENTARIOS", html_comments).replace('RUTA', url_for('create_comment'))

@app.route('/comment', methods=['POST'])
def create_comment():
	comment = request.form['comment']
	global comments
	comments.append(comment)
	return redirect(url_for('index'))

app.run(debug=True)

Solución (parte II)

Jinja2 Templates

@app.route('/')
def hello_world():
    return """
        <html>
	    <head>
		<title>HELLO WORLD</title>
	    </head>
	    <body>
		<h1>HELLO WORLD!!!!</h1>
		<a href="http://www.disneylandparis.es/">Go to disneyland now</a>
	    </body>
	</html>
	"""


La chapuza del siglo

@app.route('/')
def hello_world():
    content = ""
    with open('helloworld.html') as file:
        content = file.read()
    return content


Una posible solución...

Jinja2 saves the day!

Qué nos ofrece Jinja...

  • Una forma rápida, flexible y segura de generar páginas mediante plantillas
  • Cierta lógica
  • Herencia e inclusión
  • Macros
  • Filtros

Cargar plantillas desde Flask

from flask import Flask, render_template
...

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


Sintáxis

Jinja2

Sintáxis

  • {{ expresión }} - Representar el resultado en el contenido final
  • {% código %} - Ejecución de código
  • {# comentario #} - Comentarios no incluidos en el contenido final

{{ expresión }}

<html>
    <head>
        <title>Hello {{ name }}</title>
    </head>
    <body>
        Hello {{ name }}!!
    </body>
</html>

hello_world.html

{{ expresión }}

app.py

from flask import Flask, render_template

...

@app.route('/<someones_name>')
def hello_name(someones_name):
    return render_template('hello_world.html',
                             name=someones_name)

{% código %}

{% if <condición> %}

<html>
    <head>
        <title> Hello {{ name }} </title>
    </head>
    <body>
        <p>Hello {{ name }}!</p>
        {% if yearsold > 90 %}
           <p> Damn, you're old! </p>
        {% elif yearsold < 4 %}
           <p> You're too young!!</p>
        {% else %}
           <p> Glad you're here </p>
        {% endif %}
    </body>
</html>

{% for element in collection %}

<html>
    <head>
        <title> Hello {{ name }} </title>
    </head>
    <body>
        <p>Hello {{ name }}!</p>
        <p>These are my friends:</p>
        <ul>
        {% for friend in friends %}
            <li>{{ friend }}</li>
        {% endfor %}
        </ul>
    </body>
</html>

hello_friends.html

{% for element in collection %}

app.py

from flask import Flask, render_template

...

@app.route('/')
def hello_name(someones_name):
    return render_template('hello_friends.html',
                             name="Pablo", 
                             friends=["Gustavo", "Poison", "Blackie"])

{% for trick in cool_tricks %}

<html>
    <head>
        <title> Hello {{ name }} </title>
    </head>
    <body>
        <p>Hello {{ name }}!</p>
        <p>My friends are:</p>
        {% for friend in friends %}
            {{ friend }}
            {% if not loop.last %}
                , 
            {% else %}
                and 
            {% endif %}
        {% endfor %}
    </body>
</html>

Última iteración

{% for trick in cool_tricks %}

Par o impar

<html>
    <head>
        <title> Hello {{ name }} </title>
    </head>
    <body>
        <p>Hello {{ name }}!</p>
        <p>These are my friends:</p>
        <ul>
        {% for friend in friends %}
            <li class="{{ loop.cycle('odd', 'even') }}">{{ friend }}</li>
        {% endfor %}
        </ul>
    </body>
</html>

{% for trick in cool_tricks %}

Colección vacía

<html>
    <head>
        <title> Hello {{ name }} </title>
    </head>
    <body>
        <p>Hello {{ name }}!</p>
        <p>These are my friends:</p>
        <ul>
        {% for friend in friends %}
            <li>{{ friend }}</li>
        {% else %} 
            <li> Ol' Darkness! </li> {# Hello darkness my old friend... #}
        {% endfor %}
        </ul>
    </body>
</html>

{% for trick in cool_tricks %}

Número de iteración (empezando en 0)

<html>
    <head>
        <title> Hello {{ name }} </title>
    </head>
    <body>
        <p>Hello {{ name }}!</p>
        <p>These are my friends:</p>
        <ul>
        {% for friend in friends %}
            <li>{{ friend }} is my number {{ loop.index0 }} friend</li>
        {% endfor %}
        </ul>
    </body>
</html>

{% for trick in cool_tricks %}

Número de iteración (empezando en 1)

<html>
    <head>
        <title> Hello {{ name }} </title>
    </head>
    <body>
        <p>Hello {{ name }}!</p>
        <p>These are my friends:</p>
        <ul>
        {% for friend in friends %}
            <li>{{ friend }} is my number {{ loop.index }} friend</li>
        {% endfor %}
        </ul>
    </body>
</html>

{% macro funcion() %}

{% macro input(name, value='', type='text', size=20) %}
    <input type="{{ type }}" name="{{ name }}" value="{{
        value}}" size="{{ size }}">
{% endmacro %}

<html>
    <head>
        <title> Hello {{ name }} </title>
    </head>
    <body>
        <p>Hello {{ name }}!</p>
        <p>Wanna hang out later?</p>
        {{ input("yes", type="button", value="yes") }}
        {{ input("no", type="button", value="no") }}
    </body>
</html>

{% Herencia %}

{% block nombrebloque %}

<html>
    <head>
        <title> {%block title%}{% endblock %} </title>
        {% block styles %}
        {% endblock styles %}
        {% block scripts %}
        {% endblock %}
    </head>
    <body>
        {% block body %}
        {% endblock body %}
    </body>
</html>

base.html

{% extends plantilla_padre %}

{% extends "base.html %}

{% block title %} Bootstrap {% endblock title %}
{% block styles %}
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
{% endblock styles %}
{% block scripts %}
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
{% endblock scripts %}

{% block body %}
    <div class="page-header">
          <h1>Bootstrap is<small>neat</small></h1>
    </div>

{% endblock body %}

bootstrap.html

{% include plantilla %} y {{ super() }}

<footer> This is my template you guys ©</footer>

footer.html

b_with_footer.html

{% extends "bootstrap.html" %}

{% block body %}
    {{ super() }} {# carga el bloque body del padre #}
    {% include "footer.html" %}
{% endblock body %}

Ejercicio

Lista de comentarios II

Programar un servidor que te muestre una serie de comentarios y te permita añadir un comentario usando un formulario. 

Ejercicio

Lista de comentarios II

El plus:

  • Hacer uso de Jinja2
  • Hacer uso de url_for
  • Poder borrar comentarios

Solución

Lista de comentarios

Solución (parte I)

comments_template.html

<html>
<head><title>Comentarios cool</title></head>
<body>
<form action="{{ url_for('create_comment') }}" method="POST">
    <h3>Comenta:</h3>
    <input type="text" name="comment">
    <input type="submit" name="submit">
</form>
<br>
<h3>Comentarios</h3>
<ul>
    {% for comment in comments %}
        <li>{{ comment }} 
            <a href="{{url_for('delete_comment', comment_id=loop.index0)}}">Delete</a>
        </li>
    {% endfor %}
</ul>
</body>
</html>

Solución (parte II)

app.py

from flask import Flask, request, redirect, url_for, render_template

app = Flask(__name__)

comments = []


@app.route('/')
def index():
	return render_template('comments_template.html', comments=comments)


@app.route('/comment', methods=['POST'])
def create_comment():
	comment = request.form['comment']
	global comments
	comments.append(comment)
	return redirect(url_for('index'))

@app.route('/delete/<int:comment_id>')
def delete_comment(comment_id):
	global comments
	comments.pop(comment_id)
	return redirect(url_for('index'))

app.run()

Hagamos un blog!

Hagamos un blog!

Extensiones que usaremos

Extensiones

Flask-SQLAlchemy

Extensiones

Flask-Bootstrap

Extensiones

Flask-WTF

Extensiones

Flask-Login

Extensiones

Flask-Migrate

Extensiones

Flask-Script

Extensiones

python-slugify

Extensiones

ForgeryPy

1. Estructura básica del proyecto

01

Cómo lo estábamos haciendo...

templates

hello_world.html

app.py

proyecto

Una posible estructura...

app

env

config.py

manage.py

proyecto

tests

Directorio: app

main

static

models.py

__init__.py

app

templates

Directorio: app/main

__init__.py

errors.py

app/main

forms.py

views.py

Demo: Pycharm

2. Configuraciones

02

Configuraciones

Development:

  • Base de datos de desarrollo
  • Debug activado

Testing:

  • Base de datos de testeo
  • Modo testing de Flask activado

Production:

  • Base de datos de producción

Configuración base

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY') \
	             or 'hunter02'
    SQLALCHEMY_COMMIT_ON_TEARDOWN = True
    SQLALCHEMY_TRACK_MODIFICATIONS = True

    @staticmethod
    def init_app(app):
	pass

Desarrollo

class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = 
os.environ.get('DEV_DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, 
                        'data-dev.sqlite')

Testing

class TestingConfig(Config):
	TESTING = True
	SQLALCHEMY_DATABASE_URI = 
os.environ.get('TEST_DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, 'data-test.sqlite')

Producción

class ProductionConfig(Config):
	SQLALCHEMY_DATABASE_URI = 
os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, 'data.sqlite')

Formatos de URI SQLAlchemy

MySQL mysql://username:password@hostname/database

Postgres postgresql://username:password@hostname/database

SQLite

sqlite:///absolute/path/to/db

sqlite:///c:/absolute/path/to/db

config.py

import os

basedir = os.path.abspath(os.path.dirname(__file__))


class Config:
	...


class DevelopmentConfig(Config):
	...


class TestingConfig(Config):
	...


class ProductionConfig(Config):
	...


config = {
	'development': DevelopmentConfig,
	'testing': TestingConfig,
	'production': ProductionConfig,
	'default': DevelopmentConfig
}

3. App Factory

03

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return "Hello world!"

if __name__ == '__main__':
    app.run()

Cómo creamos apps ahora

Problemas

  • Fallos y comportamiento extraño al usar servidores con varios workers
  • Los test no se pueden evaluar en paralelo
  • No podemos cambiar la configuración de la app dinámicamente (entra en el global scope y ya es demasiado tarde)

Solución! App Factory

from flask import Flask
from config import config

#Declarar plugins aquí

def create_app(config_name='default'):
	app = Flask(__name__, static_folder='./static')
	app.config.from_object(config[config_name])
	config[config_name].init_app(app)

	# Inicializar plugins aquí

	# Registrar rutas aquí

	return app

app/__init__.py

...¿registrar rutas aquí?

def create_app(config_name='default'):
	app = Flask(__name__, static_folder='./static')
	app.config.from_object(config[config_name])
	config[config_name].init_app(app)

	# Inicializar plugins aquí

	# Registrar rutas aquí
        
        @app.route('index')
        def index():
            return 'Index'

	return app

¿

?

...¿registrar rutas aquí?

¿

?

Blueprints al rescate!

Ejemplo de uso

from flask import Blueprint

main = Blueprint('main', __name__)

from . import views, errors, forms

app/main/__init__.py

Ejemplo de uso

from . import main

@main.route('/')
def index():
    return 'Index'

app/main/views.py

Ejemplo de uso

app/__init__.py

def create_app(config_name='default'):
	app = Flask(__name__, static_folder='./static')
	app.config.from_object(config[config_name])
	config[config_name].init_app(app)

	# Inicializar plugins aquí

	# Registrar blueprints aquí
        from .main import main as main_blueprint
	app.register_blueprint(main_blueprint)

	return app

4. Flask-SQLAlchemy

04

Inicializamos el plugin

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from config import config

#Declarar plugins aquí
db = SQLAlchemy()

def create_app(config_name='default'):
	app = Flask(__name__, static_folder='./static')
	app.config.from_object(config[config_name])
	config[config_name].init_app(app)

	# Inicializar plugins aquí
        db.init_app(app)

	# Registrar rutas aquí

	return app

app/__init__.py

Definimos el Modelo Entidad-Relación

Definimos modelos en nuestra app

app/models.py

from . import db

post_has_tags = db.Table('post_has_tags',
                     db.Column('post_id', db.Integer, db.ForeignKey('posts.id')),
                     db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'))
                         )

class User(db.Model):
    __tablename__ = 'users'

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), unique=True, nullable=False)
    photo = db.Column(db.String(200))
    email = db.Column(db.String(255), unique=True, nullable=False)
    password_hash = db.Column(db.Text(), nullable=False)

    # Relaciones
    posts = db.relationship('Post', backref='user', lazy='dynamic')
    comments = db.relationship('Comment', backref='user', lazy='dynamic')

    def __repr__(self):
	return "<User " + self.username + " >"

Definimos modelos en nuestra app

app/models.py

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

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100), nullable=False, unique=True)
    body = db.Column(db.Text(), nullable=False)
    slug = db.Column(db.String(200))

    created_on = db.Column(db.DateTime)
    edited_on = db.Column(db.DateTime)

    # Relaciones

    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))

    comments = db.relationship('Comment', backref='post', lazy='dynamic')
    tags = db.relationship('Tag', secondary=post_has_tags,
	                       backref=db.backref('posts', lazy='dynamic'), 
                                                        lazy='dynamic')


    def __repr__(self):
	return "<Post " + self.title + " >"

Definimos modelos en nuestra app

app/models.py

class Comment(db.Model):
    __tablename__ = 'comments'

    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text(), nullable=False)
    posted_on = db.Column(db.DateTime)
    banned = db.Column(db.Boolean, default=False)

    # Relaciones
    post_id = db.Column(db.Integer, db.ForeignKey('posts.id'))
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))

    def __repr__(self):
	return "<Comment " + self.body + " >"

Definimos modelos en nuestra app

app/models.py

class Tag(db.Model):
    __tablename__ = 'tags'

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False, unique=True)
    slug = db.Column(db.String(200))

    def __repr__(self):
	return "<Tag " + self.name + ">"

Hashing

¿Qué es hashing?

contraseñaxd

8e1310a0e786aa93c7953c5a5901953f

Función hash

Comprobar si una contraseña es correcta

Contraseña correcta: serresietemakina

Hash: 13c71b459d68adbe6e5ef5b1526e6f49

cr7makina   ->   7317c893ba679d06ca0d189c615dc195

¿Son los hashes iguales?

No

Comprobar si una contraseña es correcta

Contraseña correcta: serresietemakina

Hash: 13c71b459d68adbe6e5ef5b1526e6f49

serresietemakina   ->   13c71b459d68adbe6e5ef5b1526e6f49

¿Son los hashes iguales?

Si!

Funciones hash NO SEGURAS

  • Las programadas por ti (La has cagao seguro)
  • MD5
  • SHA-1
  • SHA-1(MD5)
  • MD5+SHA-1(MD5(SHA-1))

Contraseñas de usuario

app/models.py

from werkzeug.security import generate_password_hash, 
                                check_password_hash

class User(db.Model):
    ...
    @property
    def password(self):
	raise AttributeError('password is not 
                            a readable attribute')

    @password.setter
    def password(self, password):
	self.password_hash = generate_password_hash(password)

    def verify_password(self, password):
	return check_password_hash(self.password_hash, password)

Generando el slug

app/models.py

from slugify import slugify

# slugify("talentum flask tutorial") -> talentum-flask-tutorial

class Post(db.Model):
    ...
    def generate_slug(self):
	self.slug = slugify(self.title)
    ...

class Tag(db.Model):
    ...
    def generate_slug(self):
	self.slug = slugify(self.name)
    ...

Auto-timestamps

app/models.py

import datetime

class Post(db.Model):
    ...
    created_on = db.Column(db.DateTime, default=datetime.datetime.utcnow)
    edited_on = db.Column(db.DateTime, default=datetime.datetime.utcnow, 
                                        onupdate=datetime.datetime.utcnow)
    ...

class Comment(db.Model):
    ...
    posted_on = db.Column(db.DateTime, default=datetime.datetime.utcnow)
    ...

Gravatar como foto de perfil

app/models.py

import hashlib

class User(db.Model):

    ...
    def generate_gravatar(self):
	self.photo = "https://www.gravatar.com/avatar/" + hashlib.md5(
		self.email.lower().encode('utf-8')).hexdigest() + "?size=60"
    ...

5. Falseando datos

05

Usuarios falsos

class User(db.Model):
    ...
    @staticmethod
    def generate_fake(count=100):
	from sqlalchemy.exc import IntegrityError
	from random import seed
	import forgery_py

	seed()
	for _ in range(count):
	    u = User(username=forgery_py.internet.user_name(with_num=True),
			         email=forgery_py.internet.email_address(),
			         password=forgery_py.lorem_ipsum.word())
            u.generate_gravatar()

	    db.session.add(u)
	    try:
		db.session.commit()
	    except IntegrityError:
		db.session.rollback()
    ...

Posts falsos

class Post(db.Model):
    ...
    @staticmethod
    def generate_fake(count=100):
	from random import seed, randint
	from sqlalchemy.exc import IntegrityError
	import forgery_py

	seed()
	user_count = User.query.count()
	for _ in range(count):
		u = User.query.offset(randint(0, user_count - 1)).first()
		p = Post(title=forgery_py.lorem_ipsum.words(3),
			         body=forgery_py.lorem_ipsum.sentences(randint(1, 3)),
			         user=u)

		p.generate_slug()

		db.session.add(p)
		try:
		    db.session.commit()
		except IntegrityError:
		    db.session.rollback()
    ...

Tags falsas

class Tag(db.Model):
    ...
    @staticmethod
    def generate_fake(count=6):
	from random import seed, randint
	from sqlalchemy.exc import IntegrityError
	import forgery_py

	seed()
	post_count = Post.query.count()
	for _ in range(count):
	    p = Post.query.offset(randint(0, post_count - 1)).first()
	    t = Tag(name=forgery_py.lorem_ipsum.word())

	    t.generate_slug()
	    t.posts.append(p)

	    db.session.add(t)
	    try:
		db.session.commit()
	    except IntegrityError:
		db.session.rollback()
    ...

Comments falsos

class Comment(db.Model):
    ...
    @staticmethod
    def generate_fake(count=100):
	from random import seed, randint
	from sqlalchemy.exc import IntegrityError
	import forgery_py

	seed()
	post_count = Post.query.count()
	user_count = User.query.count()
	for _ in range(count):
	    p = Post.query.offset(randint(0, post_count - 1)).first()
	    u = User.query.offset(randint(0, user_count - 1)).first()
	    c = Comment(body=forgery_py.lorem_ipsum.words(), user=u, post=p)
	    db.session.add(c)
	    try:
		db.session.commit()
	    except IntegrityError:
		db.session.rollback()
    ...

6. manage.py

06

manage.py

Funciones interesantes:

  • Migraciones de la base de datos
  • Aplicar las migraciones
  • Shell interactivo del proyecto
  • Correr el servidor

manage.py

import os

from flask_script import Manager

app = create_app(os.getenv('FLASK_CONFIG') or 'default')
manager = Manager(app)


if __name__ == '__main__':
    manager.run()

# -----------Comandos------------
# Uso python manage.py <comando>
# runserver - Corre el servidor

manage.py: Base de datos

import os
from flask_script import Manager
from flask_migrate import Migrate, MigrateCommand

app = create_app(os.getenv('FLASK_CONFIG') or 'default')
manager = Manager(app)
migrate = Migrate(app, db)

manager.add_command('db', MigrateCommand)

if __name__ == '__main__':
    manager.run()

# -----------Comandos------------
# Uso python manage.py <comando>
# runserver - Corre el servidor
# db init - Inicia el repositorio de migraciones
# db migrate -m "mensaje" - Crea una migración
# db upgrade - Aplica la migración antes creada

manage.py: Shell

import os
from flask_script import Manager, Shell
from flask_migrate import Migrate, MigrateCommand

app = create_app(os.getenv('FLASK_CONFIG') or 'default')
manager = Manager(app)
migrate = Migrate(app, db)

manager.add_command('db', MigrateCommand)
manager.add_command('shell', Shell())

if __name__ == '__main__':
    manager.run()

# -----------Comandos------------
# Uso python manage.py <comando>
# ...
# shell - Lanza un shell interactivo

manage.py: Shell con contexto

import os
from flask_script import Manager, Shell
from flask_migrate import Migrate, MigrateCommand

from app import create_app, db
from app.models import User, Post, Comment, Tag

app = create_app(os.getenv('FLASK_CONFIG') or 'default')
manager = Manager(app)
migrate = Migrate(app, db)

def make_shell_context():
    return dict(app=app, db=db, User=User, Post=Post, 
                Comment=Comment, Tag=Tag)

manager.add_command('db', MigrateCommand)
manager.add_command('shell', Shell(make_context=make_shell_context))

if __name__ == '__main__':
    manager.run()

# -----------Comandos------------
# Uso python manage.py <comando>
# ...
# shell - Lanza un shell interactivo con todo precargado!!

manage.py: Funciones de vagos

def make_shell_context():
    def clear():
	[print() for _ in range(20)]

    def db_add(o):
	db.session.add(o)
	db.session.commit()

    def generate_fake():
	User.generate_fake()
	Post.generate_fake()
	Comment.generate_fake()
	Tag.generate_fake()

    return dict(app=app, db=db, User=User, Post=Post, Comment=Comment, 
    Tag=Tag, clear=clear, db_add=db_add, generate_fake=generate_fake)

7. Vistas

07

Flask-Bootstrap

app/__init__.py

...
from flask_bootstrap import Bootstrap
...

#Declarar plugins aquí
db = SQLAlchemy()
bootstrap = Bootstrap()

def create_app(config_name='default'):
	...

	# Inicializar plugins aquí
        db.init_app(app)
        bootstrap.init_app(app)

	...

En nuestras plantillas...

app/templates/base.html

{% extends "bootstrap/base.html" %}

Declara los siguientes bloques (entre otros):

  • title: Contenido de <title>
  • content: (Casi) Contenido de <body>
  • navbar: Bloque vacío encima de content
  • scripts: Contiene todas las etiquetas <script> al final del doc

Estructura de templates

main

widgets

templates

_macros.html

base.html

La base de nuestro blog

app/templates/base.html

{% extends "bootstrap/base.html" %}

{% block title %} Talentum's blog {% endblock title %}

{% block styles %}
    {{ super() }}
    <link href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" 
                rel="stylesheet">
    <style type="text/css">
        body {
            padding-top: 70px; 
        }

        footer {
            margin: 50px 0;
        }
    </style>

{% endblock styles %}

La base de nuestro blog

app/templates/base.html

{% block navbar %}
    <nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse"
                        data-target="#bs-example-navbar-collapse-1">
                    <span class="sr-only">Toggle navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a class="navbar-brand" href="{{ url_for('main.frontpage') }}">Talentum's blog</a>
            </div>
            <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
                <ul class="nav navbar-nav">
                    <li>
                        <a href="{{ url_for('main.frontpage') }}">Home</a>
                    </li>
                </ul>
                <ul class="nav navbar-nav navbar-right">
                        <li><a href="#">Login</a></li>
                        <li><a href="#">Register</a></li>
                </ul>
            </div>
        </div>
    </nav>
{% endblock navbar %}

La base de nuestro blog

app/templates/base.html

{% block content %}
    <div class="container">
        <h1>{% if blog_title is defined %} {{ blog_title }} {% else %} Talentum's blog {% endif %}</h1>
        <p class="lead">
            {% if blog_subtitle is defined %} {{ blog_subtitle }} {% else %}written entirely in
                <a href="#">Flask</a>{% endif %}
        </p>
        <hr>
        <div class="row">
            <div class="col-lg-8">

                <!-- GENERAR LOS POSTS AQUI -->
                {% block main_content %}{% endblock main_content %}

            </div>
            <div class="col-md-4">

                {% block widgets %}
                    {% include "widgets/description.html" %}
                    {% include "widgets/tags.html" %}
                {% endblock widgets %}

            </div>
        </div>
        <hr>

        {% include "widgets/footer.html" %}

    </div>
{% endblock content %}

Camarero! Una de widgets

app/templates/widgets/description.html

<div class="well">
    <h4>Welcome!</h4>
    <p>
Welcome to the best blog ever. 
Made by Talentums for Talentums and written entirely in Flask. 
Isn't that awesome??
</p>
</div>

Camarero! Una de widgets

app/templates/widgets/tags.html

<div class="well">
    <h4>Tags</h4>
    <div class="row">
        {% if tags %}
            <ul class="list-group">
                {% for tag in tags %}
                    <li class="list-group-item">
                        <span class="badge">{{ tag.posts.count() }}</span>
                        <a href="#">{{ tag.name }}</a>
                    </li>
                {% endfor %}
            </ul>
        {% else %}
            <p>No tags yet :(</p>
        {% endif %}

    </div>
</div>

Camarero! Una de widgets

app/templates/widgets/footer.html

<footer>
    <div class="row">
        <div class="col-lg-12">
            <p>Plantilla gitaneada de 
<a href="https://startbootstrap.com/template-categories/all/">aquí</a>
</p>
        </div>
    </div>
</footer>

Resultado final

Portada

Portada

app/templates/main/frontpage.html

{% extends "base.html" %} {# la base que definimos antes #}
{% import "_macros.html" as macros %}

{% block main_content %}
    {% for post in posts %}
        {{ macros.generate_post_summary(post) }}
    {% endfor %}
{% endblock %}

Macros

Macros: Resumen Post I

app/templates/_macros.html

{% macro generate_post_summary(post) %}
    <div class="panel panel-default">
       <div class="panel-body">
           <div class="page-header">
<h1><a href="{{ url_for('main.post_detail', post_slug=post.slug) }}">
    {{ post.title }}</a></h1>
<p><span class="glyphicon glyphicon-time"></span> Posted
     on {{ post.created_on.strftime("%B %d, %Y at %-I:%M%p") }} by <a
        href="#">{{ post.user.username }}</a></p>
            </div>
            <p>{{ post.body | striptags }}</p>
        </div>

        ...

Macros: Resumen Post II

app/templates/_macros.html

<div class="panel-footer">
            <i class="fa fa-tags" aria-hidden="true"></i>
            {% if post.tags.count() %}
                {% for tag in post.tags.all() %}
 <a href="{{ url_for('main.tagged_posts', tag_slug=tag.slug) }}">
            {{ tag.name }}</a>
        {% if not loop.last %}, {% endif %}
                {% endfor %}
            {% else %}
                No tags
            {% endif %}
             
            <i class="fa fa-comments" aria-hidden="true"></i>
            {% if post.comments.count() %}
                {{ post.comments.count() }}
            {% else %}
                No comments
            {% endif %}
        </div>
    </div>
{% endmacro %}

Vista detallada de Post

Detalle de un Post

app/templates/main/postdetail.html

{% extends "base.html" %}
{% import "_macros.html" as macros %}
{% block main_content %}
    <div class="col-lg-8">
        <h1>{{ post.title }}</h1>
        <p class="lead">
            by <a href="#">{{ post.user.username }}</a>
        </p>
        <hr>
        <p><span class="glyphicon glyphicon-time"></span> 
Posted on {{ post.created_on.strftime("%B %d, %Y at %-I:%M%p")}}</p>
        <hr>
        {{ post.body | safe }}
        <hr>
        <div class="well">
            <h4> WIP</h4>
        </div>
        <hr>
        {% for comment in post.comments.all() %}
            {{ macros.generate_comment(comment) }}
        {% endfor %}
    </div>
{% endblock main_content %}

Blueprint principal

Blueprint principal

app/main/views.py

...
from ..models import Post, Tag, Comment, User
...

@main.route('/')
def frontpage():
    return render_template("main/frontpage.html", posts=Post.query.all(), tags=Tag.query.all())


@main.route('/post/<post_slug>')
def post_detail(post_slug):
    post = Post.query.filter_by(slug=post_slug).first_or_404()
    return render_template('main/postdetail.html', post=post, tags=Tag.query.all())


@main.route('/tag/<tag_slug>')
def tagged_posts(tag_slug):
    tag = Tag.query.filter_by(slug=tag_slug).first_or_404()
    return render_template("main/frontpage.html", posts=tag.posts.all(), tags=Tag.query.all(),
	                       blog_title="Tagged as " + tag.name, blog_subtitle="", 
                        title="Tagged as " + tag.name)

Para probarlo...

python manage.py db init
python manage.py db migrate -m "primera migración"
python manage.py db upgrade
python manage.py shell

>>> generate_fake()
>>> exit()

python manage.py runserver

8. Creando posts

08

Cómo lo hacíamos antes...

<form action="{{ url_for('create_comment') }}" method="POST">
    <h3>Comenta:</h3>
    <input type="text" name="comment">
    <input type="submit" name="submit">
</form>

Problema: CSRF

Ejemplo CSRF

dominio1.com

dominio1.com/usuario/borrar/21

Inicia sesión un admin

dominio2.com

dominio2.com/index

<img 
   src="dominio1.com/usuario/borrar/21"/>

admin navega a

admin borra sin querer usuario 21

Problema: Validación

Solución! Flask-WTF

app/main/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField, SelectMultipleField
from wtforms.validators import DataRequired, Length



class PostCreationForm(FlaskForm):
	title = StringField('Title', validators=[DataRequired(), Length(1, 200)])
	body = TextAreaField('Body', validators=[DataRequired(), Length(1, 999)])
	tags = SelectMultipleField('Tags')
	submit = SubmitField('Post')

Formulario para crear Posts

app/main/forms.py

...

class TagCreationForm(FlaskForm):
	name = StringField('Name', validators=[DataRequired(), Length(1, 100)])
	submit = SubmitField('Create tag')

class CommentCreationForm(FlaskForm):
	content = TextAreaField('', validators=[DataRequired(), Length(1, 900)])
	submit = SubmitField('Submit')

Formulario para crear Tags y Comentarios

Modificamos las vistas

app/templates/postcreate.html

Formulario para crear Posts

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %} Create! {% endblock title %}

{% block main_content %}
    <div class="panel panel-default">
        <div class="panel-body">
            {{ wtf.quick_form(post_form) }}
        </div>
    </div>
{% endblock %}

{% block widgets %}
<div class="well">
    <h4>Create a tag</h4>
    <div class="row">
        {{ wtf.quick_form(tag_form) }}
    </div>
</div>
{% endblock widgets %}

Y los controladores...

app/main/views.py

Crear post: Controlador

from .forms import PostCreationForm
...
@main.route('/create/post', methods=['GET', 'POST'])
def create_post():
    post_form = PostCreationForm()

    if post_form.validate_on_submit():
        post = Post.query.filter_by(title=post_form.title.data).first()
	if post is None:
	    p = Post(title=post_form.title.data, body=post_form.body.data)
	    p.generate_slug()
	    for tag_name in post_form.tags.data:
		tag = Tag.query.filter_by(name=tag_name).first()
		if tag is not None:
		    p.tags.append(tag)
		    db.session.add(p)
		    try:
		        db.session.commit()
		    except IntegrityError:
			db.session.rollback() #ERROR EL POST YA EXISTÍA
		else:
                    #ERROR POR ALGUNA RAZÓN
		    return redirect(url_for('main.frontpage'))
            ...

    return render_template("main/postcreate.html", post_form=post_form, 
        blog_title="Post something!",blog_subtitle="Hope you're inspired")

¿Cómo notificamos errores al usuario?

flash("mensaje")

Configuramos nuestra base

app/templates/base.html

...

{% for message in get_flashed_messages() %}
    <div class="alert alert-warning">
        <button type="button" class="close" data-dismiss="alert">×</button>
        {{ message }}
    </div>
{% endfor %}

<!-- GENERAR LOS POSTS AQUI -->
{% block main_content %}{% endblock main_content %}

...

Ahora podemos avisarles!!

app/main/views.py

from flask import flash

...

except IntegrityError:
    flash("Error: Ya existía un post con el mismo título")
    db.session.rollback()

...

else:
    flash("Error: No se pudo guardar el post")
    return redirect(url_for('main.frontpage'))

...

9. Flask-Login

09

Solo queremos que creen posts los usuarios registrados

Configuremos Flask-Login

app/__init__.py

from flask_login import LoginManager
...

login_manager = LoginManager()
login_manager.session_protection = 'strong'

def create_app(config_name='default'):
	
        ...

	login_manager.init_app(app)
        
        ...

Configuremos Flask-Login

app/models.py

from flask_login import UserMixin
from . import db, login_manager
...


@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

class User(UserMixin, db.Model):
    ...

¿Donde ponemos las rutas de autenticación?

Otro blueprint!

Creamos el blueprint "auth"

auth

app

__init__.py

views.py

forms.py

errors.py

...

Creamos el blueprint "auth"

app/auth/__init__.py

from flask import Blueprint

auth = Blueprint('auth', __name__)

from . import views, errors, forms

Lo registramos

app/__init__.py

def create_app():
    
    ...

    from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/auth')
    
    ...

Formulario para login

app/auth/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo

class LoginForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Length(1, 64), Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    remember_me = BooleanField('Keep me logged in')
    submit = SubmitField('Log in')

Formulario para registro

app/auth/forms.py

class RegistrationForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired(), Length(1, 54), 
Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,'Usernames must contain only letters, 
                                        numbers, dots or underscores')])


    email = StringField('Email', validators=[DataRequired(), Length(1, 64), Email()])
    password = PasswordField('Password', validators=[DataRequired(), EqualTo('password2', 
                                                            message="Passwords must match")])
    password2 = PasswordField('Confirm password', validators=[DataRequired()])
    submit = SubmitField('Register')

Formulario para registro II 

app/auth/forms.py

class RegistrationForm(FlaskForm):
    ...

    def validate_email(self, field):
	if User.query.filter_by(email=field.data).first():
	    raise ValidationError('Email already registered')

    def validate_username(self, field):
	if User.query.filter_by(username=field.data).first():
	    raise ValidationError('Username already in use')

Vistas /auth

app/auth/views.py

@auth.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
	user = User.query.filter_by(email=form.email.data).first()
	if user is not None and user.verify_password(form.password.data):
	    login_user(user, form.remember_me.data)
	    return redirect(request.args.get('next') or url_for('main.frontpage'))
	flash('Invalid username or password')
    return render_template('auth/login.html', form=form, blog_title="Login", blog_subtitle="")


@auth.route('/logout')
@login_required
def logout():
    logout_user()
    flash('You have been logged out')
    return redirect(url_for('main.frontpage'))


@auth.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
	user = User(email=form.email.data, username=form.username.data, password=form.password.data)
	user.generate_gravatar()
	db.session.add(user)
	flash("You can now login")
	return redirect(url_for('auth.login'))
    return render_template('auth/registration.html', form=form, blog_title="Register")

Protegemos crear post

app/main/views.py

...

@main.route('/create/post', methods=['GET', 'POST'])
@login_required
def create_post():
    ...

...

Último toque

app/__init__.py

...

login_manager = LoginManager()
login_manager.session_protection = 'strong'
login_manager.login_view = 'auth.login'

...

10. Actualizamos vistas

10

Actualizamos navbar

app/templates/base.html

<ul class="nav navbar-nav navbar-right">
    {% if not current_user.is_authenticated %}
        <li><a href="{{ url_for('auth.login') }}">Login</a></li>
        <li><a href="{{ url_for('auth.register') }}">Register</a></li>
    {% else %}
        <li><a href="{{ url_for('main.my_user') }}">{{ current_user.email }}</a></li>
        <li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
    {% endif %}
</ul>

Actualizamos navbar

app/templates/base.html

...
    
<ul class="nav navbar-nav">
    <li>
        <a href="{{ url_for('main.frontpage') }}">Home</a>
    </li>
    {% if current_user.is_authenticated %}
        <li>
            <a href="{{ url_for('main.create_post') }}">Post something!</a>
        </li>
    {% endif %}
</ul>
                
<ul class="nav navbar-nav navbar-right">
    {% if not current_user.is_authenticated %}
        <li><a href="{{ url_for('auth.login') }}">Login</a></li>
        <li><a href="{{ url_for('auth.register') }}">Register</a></li>
    {% else %}
        <li><a href="{{ url_for('main.my_user') }}">{{ current_user.email }}</a></li>
        <li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
    {% endif %}
</ul>

...

Habilitamos comentarios

app/templates/postdetail.html

...
    
<div class="well">
    {% if current_user.is_authenticated %}
        <h4>Leave a Comment:</h4>
        {{ wtf.quick_form(form) }}
    {% else %}
        <h4>You need to be logged in to comment</h4>
    {% endif %}
</div>

...

11. Paginación

11

¡Muchos posts!

Solución: Paginar

Añadimos un parámetro

config.py

class Config:
    ...
    POSTS_PER_PAGE = 4
    ...

Paginar con Flask-SQLAlchemy

app/main/views.py

from flask import current_app

...

@main.route('/')
def frontpage():
    page = request.args.get('page', 1, type=int)
    pagination = Post.query.order_by(Post.created_on.desc()).paginate(
	page, per_page=current_app.config['POSTS_PER_PAGE'], error_out=False
    )
    return render_template("main/frontpage.html", posts=pagination.items, tags=Tag.query.all(), 
                pagination=pagination,
	        prevpage=url_for('main.frontpage', page=pagination.prev_num),
	        nextpage=url_for('main.frontpage', page=pagination.next_num))

...

Widget al canto!

app/templates/widgets/pagination.html

<ul class="pager">
    <li class="previous">
        {% if pagination.has_prev %}
            <a href="{{ url_for(endpoint, page=pagination.prev_num) }}">← Newer</a>
        {% endif %}
    </li>
    <li class="next">
        {% if pagination.has_next %}
            <a href="{{ url_for(endpoint, page=pagination.next_num) }}">Older →</a>
        {% endif %}
    </li>
</ul>

Incluimos el widget...

app/templates/main/frontpage.html

{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block main_content %}
    {% for post in posts %}
        {{ macros.generate_post_summary(post) }}
    {% endfor %}
    {% include "widgets/pagination.html" %}
{% endblock %}

12. REST API

12

¿Qué es una API REST?

Una API que cumple los siguientes principios:

  • Interfaz uniforme entre cliente y servidor
  • Sin estado
  •  Respuestas cacheables
  • Cliente-Servidor bien marcado
  • Arquitectura por capas
  • (Opcional) On-demand

Acciones API REST

Verbo Ruta Código respuesta
Get (List) /users 200 y JSON
Get (Detail) /users/{id} 200 y JSON
Post (Create) /users 201 y Location
Put (Update) /users/{id} 204 No response
Delete (Duh) /users/{id} 204 No response

¿Cómo podríamos implementarlo en Flask?

Con una blueprint, claro

Creamos la blueprint "api"

api

app

__init__.py

...

v1.0

__init__.py

posts.py

Creamos la blueprint "api"

app/api/__init__.py

from .v1_0 import *

Creamos la blueprint "api"

app/api/v1.0/__init__.py

from flask import Blueprint

api1_0 = Blueprint('api1_0', __name__)

from . import posts

Registramos la "api"

app/__init__.py

...

def create_app():
    ...
    
    from .api import api1_0 as api1_0_blueprint
    app.register_blueprint(api1_0_blueprint, url_prefix='/api/1.0v')
    
    ...

Nuestro "boilerplate"

app/api/v1.0/posts.py

from flask import jsonify

from . import api1_0

@api1_0.route("/posts", methods=['GET']) #List
def list_posts():
    return jsonify({})

@api1_0.route('/posts/<int:postid>', methods=['GET']) #Detail
def detail_post(postid):
    return jsonify({})

@api1_0.route("/posts", methods=['POST']) #Create
def create_post():
    return jsonify({})


@api1_0.route('/posts/<int:postid>', methods=['PUT']) #Update
def update_post(postid):
    return jsonify({})

@api1_0.route('/posts/<int:postid>', methods=['DELETE']) #Tu qué crees
def delete_post(postid):
    return jsonify({})

Modificamos nuestro modelo

app/models.py

class Post(db.Model):

    ...
    
    def to_json(self):
        return {
            'id': self.id,
            'title': self.title,
            'body': self.body,
            'slug': self.slug,
            'created_on': str(self.created_on),
            'edited_on': str(self.edited_on),
            'user': self.user.username,
            'tags': [tag.to_json() for tag in self.tags.all()],
            'comments': [comment.to_json() for comment in self.comments.all()]
                }
    ...

GET (Detail)

app/api/v1_0

...

@api1_0.route('/posts/<int:postid>', methods=['GET'])
def detail_post(postid):
	post = Post.query.get_or_404(postid)
	return jsonify(post.to_json())
...

GET (List)

app/api/v1_0

...

@api1_0.route("/posts", methods=['GET'])
def list_posts():
    return jsonify({'items':[post.to_json() for post in Post.query.all()])
...

GET (List) con paginación

app/api/v1_0

...

@api1_0.route("/posts", methods=['GET'])
def list_posts():
    page = request.args.get('page', 1, type=int)
    pagination = Post.query.order_by(Post.created_on.desc()).paginate(
	    page, per_page=current_app.config['RESULTS_PER_API_CALL'], error_out=False
    )
    response = {
    'next' : url_for('api1_0.list_posts', page=pagination.next_num, _external=True)
                                         if pagination.has_next else "",
    'prev' : url_for('api1_0.list_posts', page=pagination.prev_num, _external=True) 
                                         if pagination.has_prev else "",
    'items': [post.to_json() for post in pagination.items]
	}
    return jsonify(response)
...

RESULTS_PER_API_CALL

config.py

class Config:
    ...

    RESULTS_PER_API_CALL = 25
    
    ...

POST (Create)

app/api/v1_0/posts.py

@api1_0.route("/posts", methods=['POST'])
def create_post():
    post = Post(title=request.form['title'], body=request.form['body'], user=None) #User?
    post.generate_slug()
    try:
        db.session.add(post)
	db.session.commit()
    except IntegrityError:
	db.session.rollback()
	abort(409)
    response = jsonify({'id': post.id})
    response.status_code = 201
    response.headers['Location'] = url_for('api1_0.detail_post', postid=post.id)
    return response

PUT (Update)

app/api/v1_0/posts.py

@api1_0.route('/posts/<int:postid>', methods=['PUT'])
def update_post(postid):
    post = Post.query.get_or_404(postid)
    post.title = request.form['title']
    post.body = request.form['body']
    post.generate_slug()
    try:
	db.session.add(post)
	db.session.commit()
    except IntegrityError:
	db.session.rollback()
	abort(409)
    return jsonify(post.to_json())

DELETE (xd)

app/api/v1_0/posts.py

@api1_0.route('/posts/<int:postid>', methods=['DELETE'])
def delete_post(postid):
    post = Post.query.get_or_404(postid)
    db.session.delete(post)
    db.session.commit()
    return '', 204

Problema: Escribimos mucho jsonify :(

Solución: Un decorador!

@json

app/api/v1_0/__init__.py

def json(f):
    @wraps(f)
    def wrapped(*args, **kwargs):
	rv = f(*args, **kwargs)
	if not isinstance(rv, dict):
	    rv = rv.to_json()
	return jsonify(rv)
    return wrapped

GET (Detail)

app/api/v1_0/posts.py

@api1_0.route('/posts/<int:postid>', methods=['GET'])
@json
def detail_post(postid):
	post = Post.query.get_or_404(postid)
	return post

Problema: Los errores están en HTML :(

Solución: Error handlers!

Errors handlers

app/api/v1_0/__init__.py

@api1_0.errorhandler(409)
def error_409(e):
    return jsonify({'error': 
            'Aborted, a conflict may had happened'}), 409

@api1_0.errorhandler(400)
def error_keyerror(e):
    return jsonify({'error': 
        'Expected one or more parameters to the call'}), 400

@api1_0.errorhandler(401)
def error_keyerror(e):
	return jsonify({'error': 'Unauthorized access'}), 401

Problema: Quiero que algunas rutas necesiten autenticación

Solución: Flask-HTTPAuth

Configuramos Flask-HTTPAuth

app/api/__init__.py

from flask_httpauth import HTTPBasicAuth

from ..models import User

api_auth = HTTPBasicAuth()

@api_auth.verify_password
def verify_pw(username, password):
    user = User.query.filter_by(username=username).first()
    if user is not None:
	return user.verify_password(password)
    return False

Protegemos las vistas

app/api/v1_0/posts.py


...

@api1_0.route('/posts/<int:postid>', methods=['PUT'])
@json
@api_auth.login_required
def update_post(postid):
    ...

...

¡Ahora ya sabemos el user!

app/api/v1_0/posts.py


...

@api1_0.route("/posts", methods=['POST'])
@api_auth.login_required
def create_post():
    post = Post(title=request.form['title'], body=request.form['body'],
	user=User.query.filter_by(username=api_auth.username()).first())

...

Probamos el API REST con requests

pip install requests

python

>> import requests
>>url = "http://localhost:5000/api/1.0v"
>> respuesta = requests.get(url+"/posts")
>> respuesta

 <Response [200]>

>> respuesta.json()

{ 'next': 'http://localhost:5000/api/1.0v/posts?page=2',
  'prev': '',
  'items': [
            {'title': 'blabla',
             'body': 'ajoaisdnfoisndf',
                ...
            ]
}

Probamos rutas protegidas

>> post = {'title': 'Juan', 'body': 'Es un buen tio'}
>> respuesta = requests.post(url+"/posts")
>> respuesta

 <Response [401]>

>> respuesta.json()

{'error': 'Unauthorized access'}

>> respuesta = requests.post(url+"/post", data=post, 
                        auth=('user', 'password'))

>> respuesta

<Response [201]>

>> respuesta.headers['Location']

'http://localhost:5000/api/1.0v/posts/103'

¡Terminado!

¿Qué hacer ahora?

Ejercicio:

Haced lo que os plazca (con Flask). Divertíos

Muchas gracias :)

talentum-flask-tutorial

By Javier Luna Molina

talentum-flask-tutorial

Tutorial de flask para el curso Talentum de Python Avanzado. Además, iremos construyendo un blog desde cero mientras aprendemos con esta grandiosa microframework

  • 871