diff --git a/.gitignore b/.gitignore index 7bbc71c..61c3304 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,6 @@ ENV/ # mypy .mypy_cache/ + +migrations/ +database.sqlite \ No newline at end of file diff --git a/README.md b/README.md index b49554c..83e8d32 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ pip3 install virtualenv virtualenv --python=python3 .venv source .venv/bin/activate pip3 install -r requirements.txt +python3 models.py db init +python3 models.py db migrate +python3 models.py db upgrade python3 app.py ```` @@ -31,6 +34,7 @@ python3 app.py ![Castellano](https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/static/es.png) Después abrir en tu navegador favorito, que posiblemente será el fantástico Firefox, una pestaña nueva con [http://127.0.0.1:5000](http://127.0.0.1:5000) +--- ## Workshop (Taller) ### Minimum requirements (Requisitos mínimos) @@ -50,6 +54,7 @@ sqlite3 --help ping -c 5 google.com ``` +--- ### Part 1 - Flask Core y Search (Parte 1 - Nucleo de Flask y Buscador) 50 min #### 1.1 Ready? @@ -68,6 +73,7 @@ wget https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/requ pip3 install -r requirements.txt ``` +--- #### 1.1 Hello PyConES17 ![English](https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/static/en.png) Template Flask. We created a new file called **app.py**. @@ -104,6 +110,7 @@ python3 app.py http://127.0.0.1:5000 ``` +--- #### 1.2 Templates ![English](https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/static/en.png) We created a folder called **templates**. Inside we make two more folders: **layouts** and **items**. In **layouts** we will make a new one with the name **master.html**. @@ -231,6 +238,7 @@ if __name__ == '__main__': ``` +--- #### 1.3 Forms ![English](https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/static/en.png) We make the new file **forms.py**. @@ -261,7 +269,7 @@ app = Flask(__name__) app.config['DEBUG'] = True app.config['SECRET_KEY'] = 'mi secreto' -@app.route('/', methods=['GET', 'POST']) +@app.route('/', methods=('GET', 'POST')) def buscador(): form = SearchForm() if form.validate_on_submit(): @@ -319,6 +327,7 @@ if __name__ == '__main__': {% endblock %} ``` +--- #### 1.4 Search ![English](https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/static/en.png) It's time for fun. First we update our **app.py** to get the form data if you pass the validations. Then, with that information, we will make a call to the Wallapop API. We will only need the URL that they use in your APP. With **urllib3** we will have all the results in a simple dictionary. Which is great, since it is easy to iterate within our template. @@ -338,7 +347,7 @@ app = Flask(__name__) app.config['DEBUG'] = True app.config['SECRET_KEY'] = 'mi secreto' -@app.route('/', methods=['GET', 'POST']) +@app.route('/', methods=('GET', 'POST')) def buscador(): form = SearchForm() results = None @@ -436,12 +445,628 @@ if __name__ == '__main__': ![Castellano](https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/static/es.png) Depuramos bugs y nos preparamos para el siguiente punto. +--- ### Part 2 - Databases and CRUD with Flask (Bases de datos y CRUD elementos con Flask) +#### 2.1 Models + +[ES] Con **Flask-alquemy** vamos a definir la estructura de nuestra base de datos. En este caso tendremos una única tabla llamada *Programado* con los campos: *id*, *item_id*, *title*, *picture_URL* y *price*. Para ello crearemos un nuevo archivo con el nombre **models.py**. + +```python3 +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.sqlite' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + +class Programado(db.Model): + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer) + title = db.Column(db.String(128)) + picture_URL = db.Column(db.String(300)) + price = db.Column(db.String(10)) + +``` + +[ES] Esta forma de trabajar tan limpia carece de varias funcionalidades básicas, como migraciones o la posibilidad de ejecutar ordenes por medio del terminal. Para ello le sumaremos **Flask-Migrate** para las migraciones automáticas y **Flask-Script** para su gestión. + + +```python3 +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_script import Manager +from flask_migrate import Migrate, MigrateCommand + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.sqlite' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + +migrate = Migrate(app, db) +manager = Manager(app) +manager.add_command('db', MigrateCommand) + +class Programado(db.Model): + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer) + title = db.Column(db.String(128)) + picture_URL = db.Column(db.String(300)) + price = db.Column(db.String(10)) + + +if __name__ == "__main__": + manager.run() +``` + +[ES] Abrimos nuestro terminal e iniciamos la base de datos, creamos la primera migración y actualizamos la base de datos. + +```bash +python3 models.py db init +python3 models.py db migrate +python3 models.py db upgrade +``` + +[ES] Comprobamos que todo ha ido bien. + +```bash +sqlite3 database.sqlite +.schema Programado +.exit +``` + +--- +#### 2.2 Save item + +[ES] Para guarda un elemento necesitamos modificar nuestra plantilla **buscador.html** para enviar la información que queremos guardar usando *POST*. Qué sencillamente será un **
** con las variables ocultas. + +```jinja2 +{% extends 'layouts/master.html' %} +{% set active_page = "buscador" %} +{% block title %}Buscador{% endblock %} +{% block body %} +

Buscador

+
+
+ + {{ form.csrf_token }} + {% for input in form %} + {% if input.type != 'CSRFTokenField' %} +
+ {# Label #} + {{ input.label }} + {# Input #} + {{ input(class="form-control") }} + {# Errors #} + {% if input.errors %} +
+ {% for error in input.errors %} + + {% endfor %} +
+ {% endif %} +
+ {% endif %} + {% endfor %} + + +
+
+{% if results %} + + {% for item in results %} + + + + + + + {% endfor %} +
{{ item.title }}{{ item.title }}{{ item.price }} +
+ + + + + +
+
+{% endif %} +{% endblock %} +``` + +[ES] Ahora, tendremos que crear la función para **programadas_nuevo** en **app.py**. Lo primero que le decimos es que solo puede aceptar peticiones *POST*. A continuación creamos variables para guardar la información del formulario. Después creamos el registro en la base de datos. Por último redireccionamos a la anterior página para ver el nuevo elemento. + +[ES] Por partes. Importamos **db** que será nuestro *ORM*, y **Programado** que será la tabla a manipular. + +```python3 +from models import db, Programado +``` + +[ES] Creamos el nuevo registro. + +```python3 +my_program = Programado( + item_id=itemId, + title=title, + picture_URL=pictureURL, + price=price + ) +``` + +[ES] Lo añadimos a la cola. + +```python3 +db.session.add(my_program) +``` + +[ES] Y ejecutamos las modificaciones. En caso que fallara, dejaría los datos como estaban. + +```python3 +try: + db.session.commit() +except: + db.session.rollback() +``` + +[ES] Todo junto quedará así. + +```python3 +from flask import Flask, render_template, request, redirect, url_for +from forms import SearchForm +# Get data Wallapop +import json +from urllib3 import PoolManager +import urllib.parse +# Database +from models import db, Programado + +# Flask +app = Flask(__name__) +app.config['DEBUG'] = True +app.config['SECRET_KEY'] = 'mi secreto' + +@app.route('/', methods=('GET', 'POST')) +def buscador(): + form = SearchForm() + results = None + if form.validate_on_submit(): + name = form.name.data + price_max = form.price_max.data or '' + # Search in Wallapop + results = get_resultados(name, price_max) + return render_template('items/buscador.html', form=form, results=results) + + +def get_resultados(name='', price_max=''): + http = PoolManager() + url_api = 'http://es.wallapop.com/rest/items?minPrice=&maxPrice={price_max}&dist=&order=creationDate-des&lat=41.398077&lng=2.170432&kws={kws}'.format( + kws=urllib.parse.quote(name, safe=''), + price_max=price_max + ) + results = http.request('GET', url_api) + results = json.loads( + results.data.decode('utf-8') + ) + return results['items'] + + +@app.route('/programadas/nuevo', methods=('POST',)) +def programadas_nuevo(): + itemId = int(request.form['itemId']) + title = request.form['title'] + pictureURL = request.form['pictureURL'] + price = request.form['price'] + # We saved in the database + my_program = Programado( + item_id=itemId, + title=title, + picture_URL=pictureURL, + price=price + ) + db.session.add(my_program) + try: + db.session.commit() + except: + db.session.rollback() + + return redirect(url_for('programadas')) + + +if __name__ == '__main__': + app.run() +``` +--- +#### 2.3 View items + +[ES] Lamentablemente veremos la página vacía. Por ahora. Haremos una consulta a la base de datos para que nos de todos los registros de la tabla **Programado**, y se lo pasaremos a la plantilla. Para ello modificaremos la función que muestra la plantilla **programadas.html**, que en nuestro caso se llama **programadas** y esta en **app.py**. + +```python3 +from flask import Flask, render_template, request, redirect, url_for +from forms import SearchForm +# Get data Wallapop +import json +from urllib3 import PoolManager +import urllib.parse +# Database +from models import db, Programado + +# Flask +app = Flask(__name__) +app.config['DEBUG'] = True +app.config['SECRET_KEY'] = 'mi secreto' + +@app.route('/', methods=('GET', 'POST')) +def buscador(): + form = SearchForm() + results = None + if form.validate_on_submit(): + name = form.name.data + price_max = form.price_max.data or '' + # Search in Wallapop + results = get_resultados(name, price_max) + return render_template('items/buscador.html', form=form, results=results) + + +@app.route('/programadas') +def programadas(): + programado_all = Programado.query.all() + return render_template('items/programadas.html', programado_all=programado_all) + + +def get_resultados(name='', price_max=''): + http = PoolManager() + url_api = 'http://es.wallapop.com/rest/items?minPrice=&maxPrice={price_max}&dist=&order=creationDate-des&lat=41.398077&lng=2.170432&kws={kws}'.format( + kws=urllib.parse.quote(name, safe=''), + price_max=price_max + ) + results = http.request('GET', url_api) + results = json.loads( + results.data.decode('utf-8') + ) + return results['items'] + + +@app.route('/programadas/nuevo', methods=('POST',)) +def programadas_nuevo(): + itemId = int(request.form['itemId']) + title = request.form['title'] + pictureURL = request.form['pictureURL'] + price = request.form['price'] + # We saved in the database + my_program = Programado( + item_id=itemId, + title=title, + picture_URL=pictureURL, + price=price + ) + db.session.add(my_program) + try: + db.session.commit() + except: + db.session.rollback() + + return redirect(url_for('programadas')) + + +if __name__ == '__main__': + app.run() +``` + +[ES] Actualizamos la plantilla **programadas.html** con un *bucle* que muestre todos los resultados en una tabla. + +```jinja2 +{% extends 'layouts/master.html' %} +{% set active_page = "programadas" %} +{% block title %}Programadas{% endblock %} +{% block body %} +

Programadas

+ + + + + + + + + + + {% for item in programado_all %} + + + + + + + {% endfor %} + +
ImagenTituloPrecio
{{ item.title }}{{ item.title }}{{ item.price }}
+{% endblock %} +``` + +--- +#### 2.4 Delete item + +[ES] Repetiremos la estrategia anterior. En el *bucle* que muestra todos los resultados en **programadas.html**, añadimos un formulario que nos envíe un *id* a una futura función que definiremos en **app.py**. + +```jinja2 +{% extends 'layouts/master.html' %} +{% set active_page = "programadas" %} +{% block title %}Programadas{% endblock %} +{% block body %} +

Programadas

+ + + + + + + + + + + {% for item in programado_all %} + + + + + + {% endfor %} + +
ImagenTituloPrecio
{{ item.title }}{{ item.title }}{{ item.price }} +
+ + +
+
+{% endblock %} +``` + +[ES] La manera de eliminar un registro consiste en realizar una busqueda de los elementos que quieres eliminar, y luego ponerlo en la cola para eliminarlos. Por último ejecutas la orden como antes. + + +```python3 +db.session.delete(my_program) +try: + db.session.commit() +except: + db.session.rollback() +``` + +[ES] Quedaría así. + +```python3 +from flask import Flask, render_template, request, redirect, url_for +from forms import SearchForm +# Get data Wallapop +import json +from urllib3 import PoolManager +import urllib.parse +# Database +from models import db, Programado + +# Flask +app = Flask(__name__) +app.config['DEBUG'] = True +app.config['SECRET_KEY'] = 'mi secreto' + +@app.route('/', methods=('GET', 'POST')) +def buscador(): + form = SearchForm() + results = None + if form.validate_on_submit(): + name = form.name.data + price_max = form.price_max.data or '' + # Search in Wallapop + results = get_resultados(name, price_max) + return render_template('items/buscador.html', form=form, results=results) + + +@app.route('/programadas') +def programadas(): + programado_all = Programado.query.all() + return render_template('items/programadas.html', programado_all=programado_all) + + +def get_resultados(name='', price_max=''): + http = PoolManager() + url_api = 'http://es.wallapop.com/rest/items?minPrice=&maxPrice={price_max}&dist=&order=creationDate-des&lat=41.398077&lng=2.170432&kws={kws}'.format( + kws=urllib.parse.quote(name, safe=''), + price_max=price_max + ) + results = http.request('GET', url_api) + results = json.loads( + results.data.decode('utf-8') + ) + return results['items'] + + +@app.route('/programadas/nuevo', methods=('POST',)) +def programadas_nuevo(): + itemId = int(request.form['itemId']) + title = request.form['title'] + pictureURL = request.form['pictureURL'] + price = request.form['price'] + # We saved in the database + my_program = Programado( + item_id=itemId, + title=title, + picture_URL=pictureURL, + price=price + ) + db.session.add(my_program) + try: + db.session.commit() + except: + db.session.rollback() + + return redirect(url_for('programadas')) + + +@app.route('/programadas/borrar', methods=('POST',)) +def programadas_borrar(): + my_program = Programado.query.get(request.form['id']) + db.session.delete(my_program) + try: + db.session.commit() + except: + db.session.rollback() + + return redirect(url_for('programadas')) + + +if __name__ == '__main__': + app.run() +``` + +--- +#### 2.5 Flash messages + +[ES] Tenemos un problema de usabilidad: ¡El usuario esta a ciegas cuando añade o borra! Tenemos que informarle de que ha ocurrido. Para ello nos haremos uso de los **Fash messages**. Como queremos que se vean en todas nuestras páginas, modificamos **master.html**. + +```jinja2 + + + + {% block title %}{% endblock %} | Vigilador de Wallapop + + + + + +
+ + {# Flashed messages #} + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {# End Flashed messages #} + {% block body %}{% endblock %} +
+ + +``` + +[ES] Ahora ya será visible todos los mensajes en cajas elegantes de *Bootstrap*. Ahora importamos **flash**. + +```python3 +from flask import Flask, render_template, request, redirect, url_for, flash +``` + +[ES] Y añadimos los mensajes que deseamos ver. Por ejemplo. + +```python3 +try: + db.session.commit() + flash('Añadida con éxito.') +except: + db.session.rollback() +``` + +[ES] Nuestro **app.py** se nos quedaría así. + +```python3 +from flask import Flask, render_template, request, redirect, url_for, flash +from forms import SearchForm +# Get data Wallapop +import json +from urllib3 import PoolManager +import urllib.parse +# Database +from models import db, Programado + +# Flask +app = Flask(__name__) +app.config['DEBUG'] = True +app.config['SECRET_KEY'] = 'mi secreto' + +@app.route('/', methods=('GET', 'POST')) +def buscador(): + form = SearchForm() + results = None + if form.validate_on_submit(): + name = form.name.data + price_max = form.price_max.data or '' + # Search in Wallapop + results = get_resultados(name, price_max) + return render_template('items/buscador.html', form=form, results=results) + + +@app.route('/programadas') +def programadas(): + programado_all = Programado.query.all() + return render_template('items/programadas.html', programado_all=programado_all) + + +def get_resultados(name='', price_max=''): + http = PoolManager() + url_api = 'http://es.wallapop.com/rest/items?minPrice=&maxPrice={price_max}&dist=&order=creationDate-des&lat=41.398077&lng=2.170432&kws={kws}'.format( + kws=urllib.parse.quote(name, safe=''), + price_max=price_max + ) + results = http.request('GET', url_api) + results = json.loads( + results.data.decode('utf-8') + ) + return results['items'] + + +@app.route('/programadas/nuevo', methods=('POST',)) +def programadas_nuevo(): + itemId = int(request.form['itemId']) + title = request.form['title'] + pictureURL = request.form['pictureURL'] + price = request.form['price'] + # We saved in the database + my_program = Programado( + item_id=itemId, + title=title, + picture_URL=pictureURL, + price=price + ) + db.session.add(my_program) + try: + db.session.commit() + flash('Añadida con éxito.') + except: + db.session.rollback() + + return redirect(url_for('programadas')) + + +@app.route('/programadas/borrar', methods=('POST',)) +def programadas_borrar(): + my_program = Programado.query.get(request.form['id']) + db.session.delete(my_program) + try: + db.session.commit() + flash('Borrada "{title}".'.format(title=my_program.title)) + except: + db.session.rollback() + + return redirect(url_for('programadas')) + + +if __name__ == '__main__': + app.run() +``` + +--- ### Break (Descanso) - 10 min ![English](https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/static/en.png) We take air for the last part. Otherwise, we make as we go to the bathroom and do not come back. ![Castellano](https://raw.githubusercontent.com/tanrax/flask-wallapop-watcher/master/static/es.png) Cogemos aire para la última parte. En caso contrario, hacemos como que vamos al baño y nos piramos. +--- ### Part 3 - Sending emails with new items (Envío de emails con nuevos elementos) \ No newline at end of file diff --git a/app.py b/app.py index 8570fac..bd76fe1 100644 --- a/app.py +++ b/app.py @@ -1,16 +1,18 @@ -from flask import Flask, render_template, request +from flask import Flask, render_template, request, redirect, url_for, flash from forms import SearchForm # Get data Wallapop import json from urllib3 import PoolManager import urllib.parse +# Database +from models import db, Programado # Flask app = Flask(__name__) app.config['DEBUG'] = True app.config['SECRET_KEY'] = 'mi secreto' -@app.route('/', methods=['GET', 'POST']) +@app.route('/', methods=('GET', 'POST')) def buscador(): form = SearchForm() results = None @@ -24,7 +26,8 @@ def buscador(): @app.route('/programadas') def programadas(): - return render_template('items/programadas.html') + programado_all = Programado.query.all() + return render_template('items/programadas.html', programado_all=programado_all) def get_resultados(name='', price_max=''): @@ -40,5 +43,41 @@ def get_resultados(name='', price_max=''): return results['items'] +@app.route('/programadas/nuevo', methods=('POST',)) +def programadas_nuevo(): + itemId = int(request.form['itemId']) + title = request.form['title'] + pictureURL = request.form['pictureURL'] + price = request.form['price'] + # We saved in the database + my_program = Programado( + item_id=itemId, + title=title, + picture_URL=pictureURL, + price=price + ) + db.session.add(my_program) + try: + db.session.commit() + flash('Añadida con éxito.') + except: + db.session.rollback() + + return redirect(url_for('programadas')) + + +@app.route('/programadas/borrar', methods=('POST',)) +def programadas_borrar(): + my_program = Programado.query.get(request.form['id']) + db.session.delete(my_program) + try: + db.session.commit() + flash('Borrada "{title}".'.format(title=my_program.title)) + except: + db.session.rollback() + + return redirect(url_for('programadas')) + + if __name__ == '__main__': app.run() \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..f59533c --- /dev/null +++ b/models.py @@ -0,0 +1,24 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_script import Manager +from flask_migrate import Migrate, MigrateCommand + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.sqlite' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + +migrate = Migrate(app, db) +manager = Manager(app) +manager.add_command('db', MigrateCommand) + +class Programado(db.Model): + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer) + title = db.Column(db.String(128)) + picture_URL = db.Column(db.String(300)) + price = db.Column(db.String(10)) + + +if __name__ == "__main__": + manager.run() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2437309..4ab0f64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ Flask==0.12.2 +Flask-Migrate==2.1.0 +Flask-Script==2.0.5 +Flask-SQLAlchemy==2.1 Flask-WTF==0.14.2 urllib3==1.22 \ No newline at end of file diff --git a/templates/items/buscador.html b/templates/items/buscador.html index e68e320..3977a59 100644 --- a/templates/items/buscador.html +++ b/templates/items/buscador.html @@ -38,7 +38,15 @@ {{ item.title }} {{ item.title }} {{ item.price }} - + + +
+ + + + + +
+ {% endfor %} diff --git a/templates/items/programadas.html b/templates/items/programadas.html index b7c4625..ad4beb6 100644 --- a/templates/items/programadas.html +++ b/templates/items/programadas.html @@ -3,4 +3,28 @@ {% block title %}Programadas{% endblock %} {% block body %}

Programadas

+ + + + + + + + + + + {% for item in programado_all %} + + + + + + {% endfor %} + +
ImagenTituloPrecio
{{ item.title }}{{ item.title }}{{ item.price }} +
+ + +
+
{% endblock %} \ No newline at end of file diff --git a/templates/layouts/master.html b/templates/layouts/master.html index 673b440..48acc81 100644 --- a/templates/layouts/master.html +++ b/templates/layouts/master.html @@ -12,6 +12,15 @@
  • Buscador
  • Programadas
  • + {# Flashed messages #} + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {# End Flashed messages #} {% block body %}{% endblock %}