├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app ├── __init__.py ├── admin │ ├── __init__.py │ ├── forms.py │ ├── routes.py │ └── templates │ │ └── admin │ │ ├── index.html │ │ ├── post_form.html │ │ ├── posts.html │ │ ├── user_form.html │ │ └── users.html ├── auth │ ├── __init__.py │ ├── decorators.py │ ├── forms.py │ ├── models.py │ ├── routes.py │ └── templates │ │ └── auth │ │ ├── login_form.html │ │ └── signup_form.html ├── common │ ├── __init__.py │ ├── filters.py │ └── mail.py ├── models.py ├── public │ ├── __init__.py │ ├── forms.py │ ├── routes.py │ └── templates │ │ └── public │ │ ├── index.html │ │ └── post_view.html ├── static │ └── base.css ├── templates │ ├── 401.html │ ├── 404.html │ ├── 500.html │ └── base_template.html └── tests │ ├── __init__.py │ ├── test_blog_client.py │ └── test_post_model.py ├── config ├── __init__.py ├── default.py ├── dev.py ├── local.py ├── prod.py ├── staging.py └── testing.py ├── entrypoint.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 262155567f30_añade_campo_created_a_post.py │ ├── 2bf8ce8c32c9_añade_modelo_comment.py │ ├── cb86527f8105_initial_database.py │ └── f6d18db05281_añade_imagen_al_modelo_post.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat.pid 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | 118 | # IDEs project files 119 | .idea 120 | .vscode 121 | rest-client.env.json 122 | 123 | # Folder view configuration files 124 | *.DS_Store 125 | Desktop.ini 126 | 127 | # Thumbnails 128 | ._* 129 | Thumbs.db 130 | 131 | /media -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | - leccion1 - Lección 1: La primera aplicación Flask (03/12/2018) 4 | - leccion2 - Lección 2: Uso de plantillas para las páginas HTML (22/12/2018) 5 | - leccion3 - Lección 3: Uso de formularios en Flask (25/01/2019) 6 | - leccion4 - Lección 4: Login de usuarios en Flask (17/02/2019) 7 | - leccion5 - Lección 5: Añadiendo una base de datos: SQLAlchemy (27/03/2019) 8 | - leccion6 - Lección 6: Estructura de un proyecto con Flask. Blueprints (25/05/2019) 9 | - leccion7 - Lección 7: Parámetros de configuración de un proyecto (21/07/2019) 10 | - leccion8 - Lección 8: Gestión de errores (23/11/2019) 11 | - leccion9 - Lección 9: Logs en Flask (13/01/2020) 12 | - leccion10 - Lección 10: Añadiendo seguridad en las vistas (15/01/2020) 13 | - leccion11 - Lección 11: Actualizar la base de datos SQLAlchemy (20/01/2020) 14 | - leccion12 - Lección 12: Test con Flask (22/01/2020) 15 | - leccion13 - Lección 13: Paginar las consultas de base de datos (24/01/2020) 16 | - leccion14 - Lección 14: Enviar emails con Flask (27/01/2020) 17 | - leccion15 - Lección 15: Trabajar con Fechas en Flask (28/01/2020) 18 | - leccion16 - Lección 16: Procesar ficheros en Flask (29/01/2020) 19 | - leccion17 - Lección 17: Desplegar una aplicación Flask en un entorno de producción 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tutorial de Flask 2 | 3 | Este tutorial tiene como objetivo desarrollar un mini blog usando el framework web Flask, el cuál está basado en Python. 4 | 5 | Durante las diferentes lecciones se verá todo aquello que, personalmente, considero que hay que tener en cuenta a la 6 | hora de desarrollar una aplicación web (cualquier aplicación web, no solo en Flask). Por tanto, se repasarán aspectos 7 | esenciales como gestión de usuarios, control de errores, trazas de log, seguridad, test o arquitectura. 8 | 9 | Puedes seguir el tutorial en https://j2logo.com/tutorial-flask-espanol/ 10 | 11 | ## Funcionalidades del miniblog 12 | 13 | El miniblog a desarrollar tendrá las siguientes características: 14 | 15 | * Existirán dos tipos de usuario: administradores e invitados. 16 | * Un usuario administrador puede añadir, modificar y eliminar entradas del blog. 17 | * Los usuarios invitados pueden registrarse en el blog para comentar las diferentes entradas. 18 | * Un usuario administrador puede listar y eliminar usuarios, además de poder asignarles el rol de administrador. 19 | 20 | ## Lecciones del tutorial 21 | 22 | * Lección 1: La primera aplicación Flask 23 | * Lección 2: Uso de plantillas para las páginas HTML 24 | * Lección 3: Uso de formularios en Flask 25 | * Lección 4: Gestión de usuarios: Registro y Login 26 | * Lección 5: Añadiendo una base de datos: SQLAlchemy 27 | * Lección 6: Estructura de un proyecto con Flask: blueprints 28 | * Lección 7: Parámetros de configuración de un proyecto Flask 29 | * Lección 8: Gestión de errores 30 | * Lección 9: Logs en Flask 31 | * Lección 10: Añadiendo seguridad en las vistas 32 | * Lección 11: Actualizar la base de datos SQLAlchemy 33 | * Lección 12: Test con Flask 34 | * Lección 13: Paginar las consultas de base de datos 35 | * Lección 14: Enviar emails con Flask 36 | * Lección 15: Trabajar con Fechas en Flask 37 | * Lección 16: Procesar ficheros en Flask 38 | * Lección 17: Desplegar una aplicación Flask en un entorno de producción 39 | 40 | 41 | ## Descarga e instalación del proyecto 42 | 43 | Para descargar el proyecto puedes clonar el repositorio: 44 | 45 | git clone https://github.com/j2logo/tutorial-flask.git 46 | 47 | > Cada una de las lecciones se corresponde con una hoja del repositorio. 48 | > El nombre de las hojas es "leccionXX". 49 | 50 | Si quieres descargar una lección en concreto, ejecuta el siguiente comando git: 51 | 52 | git checkout tags/ -b 53 | 54 | Por ejemplo: 55 | 56 | git checkout tags/leccion1 -b leccion1 57 | 58 | ### Variables de entorno 59 | 60 | Para que el miniblog funcione debes crear las siguientes variables de entorno: 61 | 62 | #### Linux/Mac 63 | 64 | export FLASK_APP="entrypoint" 65 | export FLASK_ENV="development" 66 | export APP_SETTINGS_MODULE="config.local" 67 | 68 | #### Windows 69 | 70 | set "FLASK_APP=entrypoint" 71 | set "FLASK_ENV=development" 72 | set "APP_SETTINGS_MODULE=config.local" 73 | 74 | > Mi recomendación para las pruebas es que añadas esas variables en el fichero "activate" o "activate.bat" 75 | > si estás usando virtualenv 76 | 77 | ### Instalación de dependencias 78 | 79 | En el proyecto se distribuye un fichero (requirements.txt) con todas las dependencias. Para instalarlas 80 | basta con ejectuar: 81 | 82 | pip install -r requirements.txt 83 | 84 | ## Ejecución con el servidor que trae Flask 85 | 86 | Una vez que hayas descargado el proyecto, creado las variables de entorno e instalado las dependencias, 87 | puedes arrancar el proyecto ejecutando: 88 | 89 | flask run 90 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/05/2019 6 | 7 | """ 8 | 9 | import logging 10 | from logging.handlers import SMTPHandler 11 | 12 | from flask import Flask, render_template 13 | from flask_login import LoginManager 14 | from flask_mail import Mail 15 | from flask_migrate import Migrate 16 | from flask_sqlalchemy import SQLAlchemy 17 | 18 | from app.common.filters import format_datetime 19 | 20 | login_manager = LoginManager() 21 | db = SQLAlchemy() 22 | migrate = Migrate() 23 | mail = Mail() 24 | 25 | 26 | def create_app(settings_module): 27 | app = Flask(__name__, instance_relative_config=True) 28 | # Load the config file specified by the APP environment variable 29 | app.config.from_object(settings_module) 30 | # Load the configuration from the instance folder 31 | if app.config.get('TESTING', False): 32 | app.config.from_pyfile('config-testing.py', silent=True) 33 | else: 34 | app.config.from_pyfile('config.py', silent=True) 35 | 36 | configure_logging(app) 37 | 38 | login_manager.init_app(app) 39 | login_manager.login_view = "auth.login" 40 | 41 | db.init_app(app) 42 | migrate.init_app(app, db) 43 | mail.init_app(app) 44 | 45 | # Registro de los filtros 46 | register_filters(app) 47 | 48 | # Registro de los Blueprints 49 | from .auth import auth_bp 50 | app.register_blueprint(auth_bp) 51 | 52 | from .admin import admin_bp 53 | app.register_blueprint(admin_bp) 54 | 55 | from .public import public_bp 56 | app.register_blueprint(public_bp) 57 | 58 | # Custom error handlers 59 | register_error_handlers(app) 60 | 61 | return app 62 | 63 | 64 | def register_filters(app): 65 | app.jinja_env.filters['datetime'] = format_datetime 66 | 67 | 68 | def register_error_handlers(app): 69 | 70 | @app.errorhandler(500) 71 | def base_error_handler(e): 72 | return render_template('500.html'), 500 73 | 74 | @app.errorhandler(404) 75 | def error_404_handler(e): 76 | return render_template('404.html'), 404 77 | 78 | @app.errorhandler(401) 79 | def error_404_handler(e): 80 | return render_template('401.html'), 401 81 | 82 | 83 | def configure_logging(app): 84 | """ 85 | Configura el módulo de logs. Establece los manejadores para cada logger. 86 | 87 | :param app: Instancia de la aplicación Flask 88 | 89 | """ 90 | 91 | # Elimina los manejadores por defecto de la app 92 | del app.logger.handlers[:] 93 | 94 | loggers = [app.logger, ] 95 | handlers = [] 96 | 97 | console_handler = logging.StreamHandler() 98 | console_handler.setFormatter(verbose_formatter()) 99 | 100 | if (app.config['APP_ENV'] == app.config['APP_ENV_LOCAL']) or ( 101 | app.config['APP_ENV'] == app.config['APP_ENV_TESTING']) or ( 102 | app.config['APP_ENV'] == app.config['APP_ENV_DEVELOPMENT']): 103 | console_handler.setLevel(logging.DEBUG) 104 | handlers.append(console_handler) 105 | elif app.config['APP_ENV'] == app.config['APP_ENV_PRODUCTION']: 106 | console_handler.setLevel(logging.INFO) 107 | handlers.append(console_handler) 108 | 109 | mail_handler = SMTPHandler((app.config['MAIL_SERVER'], app.config['MAIL_PORT']), 110 | app.config['DONT_REPLY_FROM_EMAIL'], 111 | app.config['ADMINS'], 112 | '[Error][{}] La aplicación falló'.format(app.config['APP_ENV']), 113 | (app.config['MAIL_USERNAME'], 114 | app.config['MAIL_PASSWORD']), 115 | ()) 116 | mail_handler.setLevel(logging.ERROR) 117 | mail_handler.setFormatter(mail_handler_formatter()) 118 | handlers.append(mail_handler) 119 | 120 | for l in loggers: 121 | for handler in handlers: 122 | l.addHandler(handler) 123 | l.propagate = False 124 | l.setLevel(logging.DEBUG) 125 | 126 | 127 | def mail_handler_formatter(): 128 | return logging.Formatter( 129 | ''' 130 | Message type: %(levelname)s 131 | Location: %(pathname)s:%(lineno)d 132 | Module: %(module)s 133 | Function: %(funcName)s 134 | Time: %(asctime)s.%(msecs)d 135 | 136 | Message: 137 | 138 | %(message)s 139 | ''', 140 | datefmt='%d/%m/%Y %H:%M:%S' 141 | ) 142 | 143 | 144 | def verbose_formatter(): 145 | return logging.Formatter( 146 | '[%(asctime)s.%(msecs)d]\t %(levelname)s \t[%(name)s.%(funcName)s:%(lineno)d]\t %(message)s', 147 | datefmt='%d/%m/%Y %H:%M:%S' 148 | ) 149 | -------------------------------------------------------------------------------- /app/admin/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/05/2019 6 | 7 | """ 8 | 9 | from flask import Blueprint 10 | 11 | admin_bp = Blueprint('admin', __name__, template_folder='templates') 12 | 13 | from . import routes 14 | -------------------------------------------------------------------------------- /app/admin/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/01/2019 6 | 7 | """ 8 | 9 | from flask_wtf import FlaskForm 10 | from flask_wtf.file import FileField, FileAllowed 11 | from wtforms import (StringField, SubmitField, TextAreaField, BooleanField) 12 | from wtforms.validators import DataRequired, Length 13 | 14 | 15 | class PostForm(FlaskForm): 16 | title = StringField('Título', validators=[DataRequired(), Length(max=128)]) 17 | content = TextAreaField('Contenido') 18 | post_image = FileField('Imagen de cabecera', validators=[ 19 | FileAllowed(['jpg', 'png'], 'Solo se permiten imágenes') 20 | ]) 21 | submit = SubmitField('Guardar') 22 | 23 | 24 | class UserAdminForm(FlaskForm): 25 | is_admin = BooleanField('Administrador') 26 | submit = SubmitField('Guardar') 27 | -------------------------------------------------------------------------------- /app/admin/routes.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/05/2019 6 | 7 | """ 8 | 9 | import logging 10 | import os 11 | 12 | from flask import render_template, redirect, url_for, abort, current_app 13 | from flask_login import login_required, current_user 14 | from werkzeug.utils import secure_filename 15 | 16 | from app.auth.decorators import admin_required 17 | from app.auth.models import User 18 | from app.models import Post 19 | from . import admin_bp 20 | from .forms import PostForm, UserAdminForm 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | @admin_bp.route("/admin/") 26 | @login_required 27 | @admin_required 28 | def index(): 29 | return render_template("admin/index.html") 30 | 31 | 32 | @admin_bp.route("/admin/posts/") 33 | @login_required 34 | @admin_required 35 | def list_posts(): 36 | posts = Post.get_all() 37 | return render_template("admin/posts.html", posts=posts) 38 | 39 | 40 | @admin_bp.route("/admin/post/", methods=['GET', 'POST']) 41 | @login_required 42 | @admin_required 43 | def post_form(): 44 | """Crea un nuevo post""" 45 | form = PostForm() 46 | if form.validate_on_submit(): 47 | title = form.title.data 48 | content = form.content.data 49 | file = form.post_image.data 50 | image_name = None 51 | # Comprueba si se ha subido un fichero 52 | if file: 53 | image_name = secure_filename(file.filename) 54 | images_dir = current_app.config['POSTS_IMAGES_DIR'] 55 | os.makedirs(images_dir, exist_ok=True) 56 | file_path = os.path.join(images_dir, image_name) 57 | file.save(file_path) 58 | post = Post(user_id=current_user.id, title=title, content=content) 59 | post.image_name = image_name 60 | post.save() 61 | logger.info(f'Guardando nuevo post {title}') 62 | return redirect(url_for('admin.list_posts')) 63 | return render_template("admin/post_form.html", form=form) 64 | 65 | 66 | @admin_bp.route("/admin/post//", methods=['GET', 'POST']) 67 | @login_required 68 | @admin_required 69 | def update_post_form(post_id): 70 | """Actualiza un post existente""" 71 | post = Post.get_by_id(post_id) 72 | if post is None: 73 | logger.info(f'El post {post_id} no existe') 74 | abort(404) 75 | # Crea un formulario inicializando los campos con 76 | # los valores del post. 77 | form = PostForm(obj=post) 78 | if form.validate_on_submit(): 79 | # Actualiza los campos del post existente 80 | post.title = form.title.data 81 | post.content = form.content.data 82 | file = form.post_image.data 83 | # Comprueba si se ha subido un fichero 84 | if file: 85 | image_name = secure_filename(file.filename) 86 | images_dir = current_app.config['POSTS_IMAGES_DIR'] 87 | os.makedirs(images_dir, exist_ok=True) 88 | file_path = os.path.join(images_dir, image_name) 89 | file.save(file_path) 90 | post.image_name = image_name 91 | post.save() 92 | logger.info(f'Guardando el post {post_id}') 93 | return redirect(url_for('admin.list_posts')) 94 | return render_template("admin/post_form.html", form=form, post=post) 95 | 96 | 97 | @admin_bp.route("/admin/post/delete//", methods=['POST', ]) 98 | @login_required 99 | @admin_required 100 | def delete_post(post_id): 101 | logger.info(f'Se va a eliminar el post {post_id}') 102 | post = Post.get_by_id(post_id) 103 | if post is None: 104 | logger.info(f'El post {post_id} no existe') 105 | abort(404) 106 | post.delete() 107 | logger.info(f'El post {post_id} ha sido eliminado') 108 | return redirect(url_for('admin.list_posts')) 109 | 110 | 111 | @admin_bp.route("/admin/users/") 112 | @login_required 113 | @admin_required 114 | def list_users(): 115 | users = User.get_all() 116 | return render_template("admin/users.html", users=users) 117 | 118 | 119 | @admin_bp.route("/admin/user//", methods=['GET', 'POST']) 120 | @login_required 121 | @admin_required 122 | def update_user_form(user_id): 123 | # Aquí entra para actualizar un usuario existente 124 | user = User.get_by_id(user_id) 125 | if user is None: 126 | logger.info(f'El usuario {user_id} no existe') 127 | abort(404) 128 | # Crea un formulario inicializando los campos con 129 | # los valores del usuario. 130 | form = UserAdminForm(obj=user) 131 | if form.validate_on_submit(): 132 | # Actualiza los campos del usuario existente 133 | user.is_admin = form.is_admin.data 134 | user.save() 135 | logger.info(f'Guardando el usuario {user_id}') 136 | return redirect(url_for('admin.list_users')) 137 | return render_template("admin/user_form.html", form=form, user=user) 138 | 139 | 140 | @admin_bp.route("/admin/user/delete//", methods=['POST', ]) 141 | @login_required 142 | @admin_required 143 | def delete_user(user_id): 144 | logger.info(f'Se va a eliminar al usuario {user_id}') 145 | user = User.get_by_id(user_id) 146 | if user is None: 147 | logger.info(f'El usuario {user_id} no existe') 148 | abort(404) 149 | user.delete() 150 | logger.info(f'El usuario {user_id} ha sido eliminado') 151 | return redirect(url_for('admin.list_users')) 152 | -------------------------------------------------------------------------------- /app/admin/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %} 4 | Miniblog | Admin 5 | {% endblock %} 6 | 7 | {% block content %} 8 | 12 | {% endblock %} -------------------------------------------------------------------------------- /app/admin/templates/admin/post_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %} 4 | {% if form.title.data %} 5 | {{ form.title.data }} 6 | {% else %} 7 | Nueva entrada 8 | {% endif %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 |
13 | {{ form.hidden_tag() }} 14 |
15 | {{ form.title.label }} 16 | {{ form.title(size=128) }}
17 | {% for error in form.title.errors %} 18 | {{ error }} 19 | {% endfor %} 20 |
21 |
22 | {{ form.content.label }} 23 | {{ form.content }}
24 | {% for error in form.content.errors %} 25 | {{ error }} 26 | {% endfor %} 27 |
28 |
29 | {{ form.post_image.label }} 30 | {{ form.post_image }}
31 | {% for error in form.post_image.errors %} 32 | {{ error }} 33 | {% endfor %} 34 |
35 |
36 | {{ form.submit() }} 37 |
38 |
39 | {% if post %} 40 |
41 | 42 |
43 | {% endif %} 44 | {% endblock %} -------------------------------------------------------------------------------- /app/admin/templates/admin/posts.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %} 4 | Listado de posts 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | Añadir entrada 10 |
11 |

Listado de entradas

12 |
    13 | {% for post in posts %} 14 |
  • 15 | {{ post.title }} 16 |
  • 17 | {% endfor %} 18 |
19 | {% endblock %} -------------------------------------------------------------------------------- /app/admin/templates/admin/user_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %} 4 | {{ user.name }} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

Detalle del usuario

9 |
10 | Nombre: {{ user.name }} 11 |
12 |
13 | Email: {{ user.email }} 14 |
15 |
16 | {{ form.hidden_tag() }} 17 |
18 | {{ form.is_admin.label }} 19 | {{ form.is_admin }}
20 | {% for error in form.is_admin.errors %} 21 | {{ error }} 22 | {% endfor %} 23 |
24 |
25 | {{ form.submit() }} 26 |
27 |
28 |
29 | 30 |
31 | {% endblock %} -------------------------------------------------------------------------------- /app/admin/templates/admin/users.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %} 4 | Listado de usuarios 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
    9 | {% for user in users %} 10 |
  • {{ user.name }} (Admin: {{ user.is_admin}})
  • 11 | {% endfor %} 12 |
13 | {% endblock %} -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/05/2019 6 | 7 | """ 8 | 9 | from flask import Blueprint 10 | 11 | auth_bp = Blueprint('auth', __name__, template_folder='templates') 12 | 13 | from . import routes 14 | -------------------------------------------------------------------------------- /app/auth/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 13/01/2020 6 | 7 | """ 8 | 9 | from functools import wraps 10 | 11 | from flask import abort 12 | 13 | from flask_login import current_user 14 | 15 | 16 | def admin_required(f): 17 | @wraps(f) 18 | def decorated_function(*args, **kws): 19 | is_admin = getattr(current_user, 'is_admin', False) 20 | if not is_admin: 21 | abort(401) 22 | return f(*args, **kws) 23 | return decorated_function 24 | -------------------------------------------------------------------------------- /app/auth/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/01/2019 6 | 7 | """ 8 | 9 | from flask_wtf import FlaskForm 10 | from wtforms import StringField, SubmitField, PasswordField, BooleanField 11 | from wtforms.validators import DataRequired, Email, Length 12 | 13 | 14 | class SignupForm(FlaskForm): 15 | name = StringField('Nombre', validators=[DataRequired(), Length(max=64)]) 16 | password = PasswordField('Password', validators=[DataRequired()]) 17 | email = StringField('Email', validators=[DataRequired(), Email()]) 18 | submit = SubmitField('Registrar') 19 | 20 | 21 | class LoginForm(FlaskForm): 22 | email = StringField('Email', validators=[DataRequired()]) 23 | password = PasswordField('Password', validators=[DataRequired()]) 24 | remember_me = BooleanField('Recuérdame') 25 | submit = SubmitField('Login') 26 | -------------------------------------------------------------------------------- /app/auth/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/05/2019 6 | 7 | """ 8 | 9 | from flask_login import UserMixin 10 | from werkzeug.security import generate_password_hash, check_password_hash 11 | 12 | from app import db 13 | 14 | 15 | class User(db.Model, UserMixin): 16 | 17 | __tablename__ = 'blog_user' 18 | 19 | id = db.Column(db.Integer, primary_key=True) 20 | name = db.Column(db.String(80), nullable=False) 21 | email = db.Column(db.String(256), unique=True, nullable=False) 22 | password = db.Column(db.String(128), nullable=False) 23 | is_admin = db.Column(db.Boolean, default=False) 24 | 25 | def __init__(self, name, email): 26 | self.name = name 27 | self.email = email 28 | 29 | def __repr__(self): 30 | return f'' 31 | 32 | def set_password(self, password): 33 | self.password = generate_password_hash(password) 34 | 35 | def check_password(self, password): 36 | return check_password_hash(self.password, password) 37 | 38 | def save(self): 39 | if not self.id: 40 | db.session.add(self) 41 | db.session.commit() 42 | 43 | def delete(self): 44 | db.session.delete(self) 45 | db.session.commit() 46 | 47 | @staticmethod 48 | def get_by_id(id): 49 | return User.query.get(id) 50 | 51 | @staticmethod 52 | def get_by_email(email): 53 | return User.query.filter_by(email=email).first() 54 | 55 | @staticmethod 56 | def get_all(): 57 | return User.query.all() 58 | -------------------------------------------------------------------------------- /app/auth/routes.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/05/2019 6 | 7 | """ 8 | 9 | from flask import (render_template, redirect, url_for, 10 | request, current_app) 11 | from flask_login import current_user, login_user, logout_user 12 | from werkzeug.urls import url_parse 13 | 14 | from app import login_manager 15 | from app.common.mail import send_email 16 | from . import auth_bp 17 | from .forms import SignupForm, LoginForm 18 | from .models import User 19 | 20 | 21 | @auth_bp.route("/signup/", methods=["GET", "POST"]) 22 | def show_signup_form(): 23 | if current_user.is_authenticated: 24 | return redirect(url_for('public.index')) 25 | form = SignupForm() 26 | error = None 27 | if form.validate_on_submit(): 28 | name = form.name.data 29 | email = form.email.data 30 | password = form.password.data 31 | # Comprobamos que no hay ya un usuario con ese email 32 | user = User.get_by_email(email) 33 | if user is not None: 34 | error = f'El email {email} ya está siendo utilizado por otro usuario' 35 | else: 36 | # Creamos el usuario y lo guardamos 37 | user = User(name=name, email=email) 38 | user.set_password(password) 39 | user.save() 40 | # Enviamos un email de bienvenida 41 | send_email(subject='Bienvenid@ al miniblog', 42 | sender=current_app.config['DONT_REPLY_FROM_EMAIL'], 43 | recipients=[email, ], 44 | text_body=f'Hola {name}, bienvenid@ al miniblog de Flask', 45 | html_body=f'

Hola {name}, bienvenid@ al miniblog de Flask

') 46 | # Dejamos al usuario logueado 47 | login_user(user, remember=True) 48 | next_page = request.args.get('next', None) 49 | if not next_page or url_parse(next_page).netloc != '': 50 | next_page = url_for('public.index') 51 | return redirect(next_page) 52 | return render_template("auth/signup_form.html", form=form, error=error) 53 | 54 | 55 | @auth_bp.route('/login', methods=['GET', 'POST']) 56 | def login(): 57 | if current_user.is_authenticated: 58 | return redirect(url_for('public.index')) 59 | form = LoginForm() 60 | if form.validate_on_submit(): 61 | user = User.get_by_email(form.email.data) 62 | if user is not None and user.check_password(form.password.data): 63 | login_user(user, remember=form.remember_me.data) 64 | next_page = request.args.get('next') 65 | if not next_page or url_parse(next_page).netloc != '': 66 | next_page = url_for('public.index') 67 | return redirect(next_page) 68 | return render_template('auth/login_form.html', form=form) 69 | 70 | 71 | @auth_bp.route('/logout') 72 | def logout(): 73 | logout_user() 74 | return redirect(url_for('public.index')) 75 | 76 | 77 | @login_manager.user_loader 78 | def load_user(user_id): 79 | return User.get_by_id(int(user_id)) 80 | -------------------------------------------------------------------------------- /app/auth/templates/auth/login_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %}Login{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 | {{ form.hidden_tag() }} 9 |
10 | {{ form.email.label }} 11 | {{ form.email }}
12 | {% for error in form.email.errors %} 13 | {{ error }} 14 | {% endfor %} 15 |
16 |
17 | {{ form.password.label }} 18 | {{ form.password }}
19 | {% for error in form.password.errors %} 20 | {{ error }} 21 | {% endfor %} 22 |
23 |
{{ form.remember_me() }} {{ form.remember_me.label }}
24 |
25 | {{ form.submit() }} 26 |
27 |
28 |
29 |
¿No tienes cuenta? Regístrate
30 | {% endblock %} -------------------------------------------------------------------------------- /app/auth/templates/auth/signup_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %}Registro de usuarios{% endblock %} 4 | 5 | {% block content %} 6 | {% if error %} 7 |

Error: {{ error }} 8 | {% endif %} 9 |

10 | {{ form.hidden_tag() }} 11 |
12 | {{ form.name.label }} 13 | {{ form.name(size=64) }}
14 | {% for error in form.name.errors %} 15 | {{ error }} 16 | {% endfor %} 17 |
18 |
19 | {{ form.email.label }} 20 | {{ form.email }}
21 | {% for error in form.email.errors %} 22 | {{ error }} 23 | {% endfor %} 24 |
25 |
26 | {{ form.password.label }} 27 | {{ form.password }}
28 | {% for error in form.password.errors %} 29 | {{ error }} 30 | {% endfor %} 31 |
32 |
33 | {{ form.submit() }} 34 |
35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /app/common/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 17/01/2020 6 | 7 | """ -------------------------------------------------------------------------------- /app/common/filters.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 20/01/2020 6 | 7 | """ 8 | 9 | 10 | def format_datetime(value, format='short'): 11 | """Filtro que transforma un datetime en str con formato. 12 | 13 | El filtro es para ser usado en plantillas JINJA2. 14 | Los formatos posibles son los siguientes: 15 | * short: dd/mm/aaaa 16 | * full: dd de mm de aaaa 17 | 18 | :param datetime value: Fecha a ser transformada. 19 | :param format: Formato con el que mostrar la fecha. Valores posibles: short y full. 20 | :return: Un string con formato de la fecha. 21 | """ 22 | 23 | value_str = None 24 | if not value: 25 | value_str = '' 26 | if format == 'short': 27 | value_str = value.strftime('%d/%m/%Y') 28 | elif format == 'full': 29 | value_str = value.strftime('%d de %m de %Y') 30 | else: 31 | value_str = '' 32 | return value_str 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/common/mail.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 17/01/2020 6 | 7 | """ 8 | 9 | import logging 10 | 11 | from smtplib import SMTPException 12 | from threading import Thread 13 | 14 | from flask import current_app 15 | from flask_mail import Message 16 | 17 | from app import mail 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def _send_async_email(app, msg): 23 | with app.app_context(): 24 | try: 25 | mail.send(msg) 26 | except SMTPException: 27 | logger.exception("Ocurrió un error al enviar el email") 28 | 29 | 30 | def send_email(subject, sender, recipients, text_body, 31 | cc=None, bcc=None, html_body=None): 32 | msg = Message(subject, sender=sender, recipients=recipients, cc=cc, bcc=bcc) 33 | msg.body = text_body 34 | if html_body: 35 | msg.html = html_body 36 | Thread(target=_send_async_email, args=(current_app._get_current_object(), msg)).start() 37 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 15/02/2019 6 | 7 | """ 8 | 9 | import datetime 10 | 11 | from slugify import slugify 12 | from sqlalchemy.exc import IntegrityError 13 | 14 | from app import db 15 | 16 | 17 | class Post(db.Model): 18 | id = db.Column(db.Integer, primary_key=True) 19 | user_id = db.Column(db.Integer, db.ForeignKey('blog_user.id', ondelete='CASCADE'), nullable=False) 20 | title = db.Column(db.String(256), nullable=False) 21 | title_slug = db.Column(db.String(256), unique=True, nullable=False) 22 | content = db.Column(db.Text) 23 | created = db.Column(db.DateTime, default=datetime.datetime.utcnow) 24 | image_name = db.Column(db.String) 25 | comments = db.relationship('Comment', backref='post', lazy=True, cascade='all, delete-orphan', 26 | order_by='asc(Comment.created)') 27 | 28 | def __repr__(self): 29 | return f'' 30 | 31 | def save(self): 32 | if not self.id: 33 | db.session.add(self) 34 | if not self.title_slug: 35 | self.title_slug = slugify(self.title) 36 | 37 | saved = False 38 | count = 0 39 | while not saved: 40 | try: 41 | db.session.commit() 42 | saved = True 43 | except IntegrityError: 44 | db.session.rollback() 45 | db.session.add(self) 46 | count += 1 47 | self.title_slug = f'{slugify(self.title)}-{count}' 48 | 49 | def delete(self): 50 | db.session.delete(self) 51 | db.session.commit() 52 | 53 | @staticmethod 54 | def get_by_slug(slug): 55 | return Post.query.filter_by(title_slug=slug).first() 56 | 57 | @staticmethod 58 | def get_by_id(id): 59 | return Post.query.get(id) 60 | 61 | @staticmethod 62 | def get_all(): 63 | return Post.query.all() 64 | 65 | @staticmethod 66 | def all_paginated(page=1, per_page=20): 67 | return Post.query.order_by(Post.created.asc()). \ 68 | paginate(page=page, per_page=per_page, error_out=False) 69 | 70 | 71 | class Comment(db.Model): 72 | id = db.Column(db.Integer, primary_key=True) 73 | user_id = db.Column(db.Integer, db.ForeignKey('blog_user.id', ondelete='SET NULL')) 74 | user_name = db.Column(db.String) 75 | post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False) 76 | content = db.Column(db.Text) 77 | created = db.Column(db.DateTime, default=datetime.datetime.utcnow) 78 | 79 | def __init__(self, content, user_id=None, user_name=user_name, post_id=None): 80 | self.content = content 81 | self.user_id = user_id 82 | self.user_name = user_name 83 | self.post_id = post_id 84 | 85 | def __repr__(self): 86 | return f'' 87 | 88 | def save(self): 89 | if not self.id: 90 | db.session.add(self) 91 | db.session.commit() 92 | 93 | def delete(self): 94 | db.session.delete(self) 95 | db.session.commit() 96 | 97 | @staticmethod 98 | def get_by_post_id(post_id): 99 | return Comment.query.filter_by(post_id=post_id).all() 100 | -------------------------------------------------------------------------------- /app/public/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/05/2019 6 | 7 | """ 8 | 9 | from flask import Blueprint 10 | 11 | public_bp = Blueprint('public', __name__, template_folder='templates') 12 | 13 | from . import routes 14 | -------------------------------------------------------------------------------- /app/public/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/01/2019 6 | 7 | """ 8 | 9 | from flask_wtf import FlaskForm 10 | from wtforms import SubmitField, TextAreaField 11 | from wtforms.validators import DataRequired 12 | 13 | 14 | class CommentForm(FlaskForm): 15 | content = TextAreaField('Contenido', validators=[DataRequired(), ]) 16 | submit = SubmitField('Comentar') 17 | -------------------------------------------------------------------------------- /app/public/routes.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 24/05/2019 6 | 7 | """ 8 | 9 | import logging 10 | 11 | from flask import abort, render_template, redirect, url_for, request, current_app 12 | from flask_login import current_user 13 | 14 | from app.models import Post, Comment 15 | from . import public_bp 16 | from .forms import CommentForm 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @public_bp.route("/") 22 | def index(): 23 | logger.info('Mostrando los posts del blog') 24 | page = int(request.args.get('page', 1)) 25 | per_page = current_app.config['ITEMS_PER_PAGE'] 26 | post_pagination = Post.all_paginated(page, per_page) 27 | return render_template("public/index.html", post_pagination=post_pagination) 28 | 29 | 30 | @public_bp.route("/p//", methods=['GET', 'POST']) 31 | def show_post(slug): 32 | logger.info('Mostrando un post') 33 | logger.debug(f'Slug: {slug}') 34 | post = Post.get_by_slug(slug) 35 | if not post: 36 | logger.info(f'El post {slug} no existe') 37 | abort(404) 38 | form = CommentForm() 39 | if current_user.is_authenticated and form.validate_on_submit(): 40 | content = form.content.data 41 | comment = Comment(content=content, user_id=current_user.id, 42 | user_name=current_user.name, post_id=post.id) 43 | comment.save() 44 | return redirect(url_for('public.show_post', slug=post.title_slug)) 45 | return render_template("public/post_view.html", post=post, form=form) 46 | 47 | 48 | @public_bp.route("/error") 49 | def show_error(): 50 | res = 1 / 0 51 | posts = Post.get_all() 52 | return render_template("public/index.html", posts=posts) 53 | -------------------------------------------------------------------------------- /app/public/templates/public/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %}Tutorial Flask: Miniblog{% endblock %} 4 | 5 | {% block content %} 6 | 17 | 18 | 31 | {% endblock %} -------------------------------------------------------------------------------- /app/public/templates/public/post_view.html: -------------------------------------------------------------------------------- 1 | {% extends "base_template.html" %} 2 | 3 | {% block title %}{{ post.title }}{% endblock %} 4 | 5 | {% block content %} 6 |

{{ post.title }}

7 |
8 | {{ post.created|datetime('full') }} 9 |
10 | {% if post.image_name %} 11 |
12 | 13 |
14 | {% endif %} 15 | {{ post.content }} 16 | 17 |

Comentarios

18 | {% if current_user.is_authenticated %} 19 |
20 |
21 | {{ form.hidden_tag() }} 22 |
23 | {{ form.content.label }} 24 | {{ form.content }}
25 | {% for error in form.content.errors %} 26 | {{ error }} 27 | {% endfor %} 28 |
29 |
30 | {{ form.submit() }} 31 |
32 |
33 |
34 | {% endif %} 35 |
36 | {% for comment in post.comments %} 37 |
38 |
39 | El usuario {{ comment.user_name }} comentó el 40 | {{ comment.created|datetime }}: 41 |
42 |
{{ comment.content }}
43 |
44 | {% endfor %} 45 |
46 | {% endblock %} -------------------------------------------------------------------------------- /app/static/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-size: 100%; 5 | line-height: 1.5 6 | } 7 | 8 | h1, h2, h3, h4 { 9 | margin: 1em 0 .5em; 10 | line-height: 1.25 11 | } 12 | 13 | h1 { 14 | font-size: 2em 15 | } 16 | 17 | h2 { 18 | font-size: 1.5em 19 | } 20 | 21 | h3 { 22 | font-size: 1.2em 23 | } 24 | 25 | ul, ol { 26 | margin: 1em 0; 27 | padding-left: 40px 28 | } 29 | 30 | p, figure { 31 | margin: 1em 0 32 | } 33 | 34 | a img { 35 | border: none 36 | } 37 | 38 | sup, sub { 39 | line-height: 0 40 | } 41 | 42 | .user-info { 43 | list-style: none; 44 | margin: 0; 45 | padding: 0; 46 | } 47 | 48 | .user-info li { 49 | display: inline-block; 50 | margin: 0; 51 | padding: 0 10px 0 0; 52 | } -------------------------------------------------------------------------------- /app/templates/401.html: -------------------------------------------------------------------------------- 1 | {% extends 'base_template.html' %} 2 | 3 | {% block content %} 4 | Ooops!! No tienes permisos de acceso 5 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'base_template.html' %} 2 | 3 | {% block content %} 4 | Ooops!! La página que buscas no existe xD 5 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends 'base_template.html' %} 2 | 3 | {% block content %} 4 | Ooops!! Parece que ha habido un error :( 5 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/base_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}{% endblock %} 6 | 7 | 8 | 9 |
10 | 26 |
27 | {% block content %}{% endblock %} 28 | 29 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 15/01/2020 6 | 7 | """ 8 | 9 | import unittest 10 | 11 | from app import create_app, db 12 | from app.auth.models import User 13 | 14 | 15 | class BaseTestClass(unittest.TestCase): 16 | 17 | def setUp(self): 18 | """Define test variables and initialize app.""" 19 | self.app = create_app(settings_module="config.testing") 20 | self.client = self.app.test_client() 21 | 22 | # Crea un contexto de aplicación 23 | with self.app.app_context(): 24 | # Crea las tablas de la base de datos 25 | db.create_all() 26 | # Creamos un usuario administrador 27 | BaseTestClass.create_user('admin', 'admin@xyz.com', '1111', True) 28 | # Creamos un usuario invitado 29 | BaseTestClass.create_user('guest', 'guest@xyz.com', '1111', False) 30 | 31 | def tearDown(self): 32 | """teardown all initialized variables.""" 33 | with self.app.app_context(): 34 | # Elimina todas las tablas de la base de datos 35 | db.session.remove() 36 | db.drop_all() 37 | 38 | @staticmethod 39 | def create_user(name, email, password, is_admin): 40 | user = User(name, email) 41 | user.set_password(password) 42 | user.is_admin = is_admin 43 | user.save() 44 | return user 45 | 46 | def login(self, email, password): 47 | return self.client.post('/login', data=dict( 48 | email=email, 49 | password=password 50 | ), follow_redirects=True) 51 | -------------------------------------------------------------------------------- /app/tests/test_blog_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 15/01/2020 6 | 7 | """ 8 | 9 | import unittest 10 | 11 | from app.auth.models import User 12 | from app.models import Post 13 | from . import BaseTestClass 14 | 15 | 16 | class BlogClientTestCase(BaseTestClass): 17 | 18 | def test_index_with_no_posts(self): 19 | res = self.client.get('/') 20 | self.assertEqual(200, res.status_code) 21 | self.assertIn(b'No hay entradas', res.data) 22 | 23 | def test_index_with_posts(self): 24 | with self.app.app_context(): 25 | admin = User.get_by_email('admin@xyz.com') 26 | post = Post(user_id=admin.id, title='Post de prueba', content='Lorem Ipsum') 27 | post.save() 28 | res = self.client.get('/') 29 | self.assertEqual(200, res.status_code) 30 | self.assertNotIn(b'No hay entradas', res.data) 31 | 32 | def test_redirect_to_login(self): 33 | res = self.client.get('/admin/') 34 | self.assertEqual(302, res.status_code) 35 | self.assertIn('login', res.location) 36 | 37 | def test_unauthorized_access_to_admin(self): 38 | self.login('guest@xyz.com', '1111') 39 | res = self.client.get('/admin/') 40 | self.assertEqual(401, res.status_code) 41 | self.assertIn(b'Ooops!! No tienes permisos de acceso', res.data) 42 | 43 | def test_authorized_access_to_admin(self): 44 | self.login('admin@xyz.com', '1111') 45 | res = self.client.get('/admin/') 46 | self.assertEqual(200, res.status_code) 47 | self.assertIn(b'Posts', res.data) 48 | self.assertIn(b'Usuarios', res.data) 49 | 50 | 51 | if __name__ == '__main__': 52 | unittest.main() 53 | -------------------------------------------------------------------------------- /app/tests/test_post_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 15/01/2020 6 | 7 | """ 8 | 9 | import unittest 10 | 11 | from app.auth.models import User 12 | from app.models import Post 13 | from . import BaseTestClass 14 | 15 | 16 | class PostModelTestCase(BaseTestClass): 17 | """Suite de tests del modelo Post""" 18 | 19 | def test_title_slug(self): 20 | with self.app.app_context(): 21 | admin = User.get_by_email('admin@xyz.com') 22 | post = Post(user_id=admin.id, title='Post de prueba', content='Lorem Ipsum') 23 | post.save() 24 | self.assertEqual('post-de-prueba', post.title_slug) 25 | 26 | def test_title_slug_duplicated(self): 27 | with self.app.app_context(): 28 | admin = User.get_by_email('admin@xyz.com') 29 | post = Post(user_id=admin.id, title='Prueba', content='Lorem Ipsum') 30 | post.save() 31 | post_2 = Post(user_id=admin.id, title='Prueba', content='Lorem Ipsum Lorem Ipsum') 32 | post_2.save() 33 | self.assertEqual('prueba-1', post_2.title_slug) 34 | post_3 = Post(user_id=admin.id, title='Prueba', content='Lorem Ipsum Lorem Ipsum') 35 | post_3.save() 36 | self.assertEqual('prueba-2', post_3.title_slug) 37 | posts = Post.get_all() 38 | self.assertEqual(3, len(posts)) 39 | 40 | 41 | if __name__ == '__main__': 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 08/07/2019 6 | 7 | """ -------------------------------------------------------------------------------- /config/default.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 08/07/2019 6 | 7 | """ 8 | 9 | from os.path import abspath, dirname, join 10 | 11 | 12 | # Define the application directory 13 | BASE_DIR = dirname(dirname(abspath(__file__))) 14 | 15 | # Media dir 16 | MEDIA_DIR = join(BASE_DIR, 'media') 17 | POSTS_IMAGES_DIR = join(MEDIA_DIR, 'posts') 18 | 19 | SECRET_KEY = '7110c8ae51a4b5af97be6534caef90e4bb9bdcb3380af008f90b23a5d1616bf319bc298105da20fe' 20 | 21 | # Database configuration 22 | SQLALCHEMY_TRACK_MODIFICATIONS = False 23 | 24 | # App environments 25 | APP_ENV_LOCAL = 'local' 26 | APP_ENV_TESTING = 'testing' 27 | APP_ENV_DEVELOPMENT = 'development' 28 | APP_ENV_STAGING = 'staging' 29 | APP_ENV_PRODUCTION = 'production' 30 | APP_ENV = '' 31 | 32 | # Configuración del email 33 | MAIL_SERVER = 'tu servidor smtp' 34 | MAIL_PORT = 587 35 | MAIL_USERNAME = 'tu correo' 36 | MAIL_PASSWORD = 'tu contraseña' 37 | DONT_REPLY_FROM_EMAIL = 'dirección from' 38 | ADMINS = ('juanjo@j2logo.com', ) 39 | MAIL_USE_TLS = True 40 | MAIL_DEBUG = False 41 | 42 | ITEMS_PER_PAGE = 3 43 | -------------------------------------------------------------------------------- /config/dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 08/07/2019 6 | 7 | """ 8 | 9 | from .default import * 10 | 11 | 12 | APP_ENV = APP_ENV_DEVELOPMENT 13 | 14 | SQLALCHEMY_DATABASE_URI = 'postgresql://db_user:db_pass@host:port/db_name' 15 | -------------------------------------------------------------------------------- /config/local.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 08/07/2019 6 | 7 | """ 8 | 9 | from .default import * 10 | 11 | 12 | APP_ENV = APP_ENV_LOCAL 13 | -------------------------------------------------------------------------------- /config/prod.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 08/07/2019 6 | 7 | """ 8 | from .default import * 9 | 10 | 11 | SECRET_KEY = '5e04a4955d8878191923e86fe6a0dfb24edb226c87d6c7787f35ba4698afc86e95cae409aebd47f7' 12 | 13 | APP_ENV = APP_ENV_PRODUCTION 14 | 15 | SQLALCHEMY_DATABASE_URI = 'postgresql://db_user:db_pass@host:port/db_name' 16 | -------------------------------------------------------------------------------- /config/staging.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 08/07/2019 6 | 7 | """ 8 | 9 | from .default import * 10 | 11 | 12 | APP_ENV = APP_ENV_STAGING 13 | 14 | SQLALCHEMY_DATABASE_URI = 'postgresql://db_user:db_pass@host:port/db_name' 15 | -------------------------------------------------------------------------------- /config/testing.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 08/07/2019 6 | 7 | """ 8 | 9 | from .default import * 10 | 11 | 12 | # Parámetros para activar el modo debug 13 | TESTING = True 14 | DEBUG = True 15 | 16 | APP_ENV = APP_ENV_TESTING 17 | 18 | WTF_CSRF_ENABLED = False 19 | -------------------------------------------------------------------------------- /entrypoint.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | AUTOR: Juanjo 4 | 5 | FECHA DE CREACIÓN: 2/12/18 6 | 7 | """ 8 | 9 | import os 10 | 11 | from flask import send_from_directory 12 | 13 | from app import create_app 14 | 15 | 16 | settings_module = os.getenv('APP_SETTINGS_MODULE') 17 | app = create_app(settings_module) 18 | 19 | 20 | @app.route('/media/posts/') 21 | def media_posts(filename): 22 | dir_path = os.path.join( 23 | app.config['MEDIA_DIR'], 24 | app.config['POSTS_IMAGES_DIR']) 25 | return send_from_directory(dir_path, filename) 26 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | config.set_main_option( 26 | 'sqlalchemy.url', current_app.config.get( 27 | 'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) 28 | target_metadata = current_app.extensions['migrate'].db.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, target_metadata=target_metadata, literal_binds=True 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | 65 | # this callback is used to prevent an auto-migration from being generated 66 | # when there are no changes to the schema 67 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 68 | def process_revision_directives(context, revision, directives): 69 | if getattr(config.cmd_opts, 'autogenerate', False): 70 | script = directives[0] 71 | if script.upgrade_ops.is_empty(): 72 | directives[:] = [] 73 | logger.info('No changes in schema detected.') 74 | 75 | connectable = engine_from_config( 76 | config.get_section(config.config_ini_section), 77 | prefix='sqlalchemy.', 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, 84 | target_metadata=target_metadata, 85 | process_revision_directives=process_revision_directives, 86 | **current_app.extensions['migrate'].configure_args 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/262155567f30_añade_campo_created_a_post.py: -------------------------------------------------------------------------------- 1 | """Añade campo created a Post 2 | 3 | Revision ID: 262155567f30 4 | Revises: cb86527f8105 5 | Create Date: 2020-01-15 11:53:21.518238 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '262155567f30' 14 | down_revision = 'cb86527f8105' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('post', sa.Column('created', sa.DateTime(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('post', 'created') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/2bf8ce8c32c9_añade_modelo_comment.py: -------------------------------------------------------------------------------- 1 | """Añade modelo Comment 2 | 3 | Revision ID: 2bf8ce8c32c9 4 | Revises: 262155567f30 5 | Create Date: 2020-01-15 12:50:31.329048 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2bf8ce8c32c9' 14 | down_revision = '262155567f30' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('comment', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('user_id', sa.Integer(), nullable=True), 24 | sa.Column('user_name', sa.String(), nullable=True), 25 | sa.Column('post_id', sa.Integer(), nullable=False), 26 | sa.Column('content', sa.Text(), nullable=True), 27 | sa.Column('created', sa.DateTime(), nullable=True), 28 | sa.ForeignKeyConstraint(['post_id'], ['post.id'], ), 29 | sa.ForeignKeyConstraint(['user_id'], ['blog_user.id'], ondelete='SET NULL'), 30 | sa.PrimaryKeyConstraint('id') 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('comment') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /migrations/versions/cb86527f8105_initial_database.py: -------------------------------------------------------------------------------- 1 | """Initial database 2 | 3 | Revision ID: cb86527f8105 4 | Revises: 5 | Create Date: 2020-01-15 09:26:34.256119 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'cb86527f8105' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('blog_user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(length=80), nullable=False), 24 | sa.Column('email', sa.String(length=256), nullable=False), 25 | sa.Column('password', sa.String(length=128), nullable=False), 26 | sa.Column('is_admin', sa.Boolean(), nullable=True), 27 | sa.PrimaryKeyConstraint('id'), 28 | sa.UniqueConstraint('email') 29 | ) 30 | op.create_table('post', 31 | sa.Column('id', sa.Integer(), nullable=False), 32 | sa.Column('user_id', sa.Integer(), nullable=False), 33 | sa.Column('title', sa.String(length=256), nullable=False), 34 | sa.Column('title_slug', sa.String(length=256), nullable=False), 35 | sa.Column('content', sa.Text(), nullable=True), 36 | sa.ForeignKeyConstraint(['user_id'], ['blog_user.id'], ondelete='CASCADE'), 37 | sa.PrimaryKeyConstraint('id'), 38 | sa.UniqueConstraint('title_slug') 39 | ) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade(): 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.drop_table('post') 46 | op.drop_table('blog_user') 47 | # ### end Alembic commands ### 48 | -------------------------------------------------------------------------------- /migrations/versions/f6d18db05281_añade_imagen_al_modelo_post.py: -------------------------------------------------------------------------------- 1 | """añade imagen al modelo post 2 | 3 | Revision ID: f6d18db05281 4 | Revises: 2bf8ce8c32c9 5 | Create Date: 2020-02-03 10:41:37.457599 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f6d18db05281' 14 | down_revision = '2bf8ce8c32c9' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('post', sa.Column('image_name', sa.String(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('post', 'image_name') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.3.2 2 | blinker==1.4 3 | Click==7.0 4 | Flask==1.1.1 5 | Flask-Login==0.4.1 6 | Flask-Mail==0.9.1 7 | Flask-Migrate==2.5.2 8 | Flask-SQLAlchemy==2.4.1 9 | Flask-WTF==0.14.2 10 | itsdangerous==1.1.0 11 | Jinja2==2.10.3 12 | Mako==1.1.0 13 | MarkupSafe==1.1.1 14 | psycopg2==2.8.4 15 | python-dateutil==2.8.1 16 | python-editor==1.0.4 17 | python-slugify==4.0.0 18 | six==1.13.0 19 | SQLAlchemy==1.3.12 20 | text-unidecode==1.3 21 | Werkzeug==0.16.0 22 | WTForms==2.2.1 23 | --------------------------------------------------------------------------------