diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/README.md b/docs/docs/05_flask_smorest/02_data_model_improvements/README.md index 0d3bc5be..126ef751 100644 --- a/docs/docs/05_flask_smorest/02_data_model_improvements/README.md +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/README.md @@ -108,14 +108,14 @@ Next, let's create the `.flaskenv` file: ```txt title=".flaskenv" FLASK_APP=app -FLASK_ENV=development +FLASK_DEBUG=True ``` If we have the `python-dotenv` library installed, when we run the `flask run` command, Flask will read the variables inside `.flaskenv` and use them to configure the Flask app. -The configuration that we'll do is to define the Flask app file (here, `app.py`). Then we'll also set the Flask environment to `development`, which does a couple things: +The configuration that we'll do is to define the Flask app file (here, `app.py`). Then we'll also set the `FLASK_DEBUG` flag to `True`, which does a couple things: -- Sets debug mode to true, which makes the app give us better error messages +- Makes the app give us better error messages and return a traceback when we make requests if there's an error. - Sets the app reloading to true, so the app restarts when we make code changes We don't want debug mode to be enabled in production (when we deploy our app), but while we're doing development it's definitely a time-saving tool! diff --git a/docs/docs/05_flask_smorest/02_data_model_improvements/end/.flaskenv b/docs/docs/05_flask_smorest/02_data_model_improvements/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/02_data_model_improvements/end/.flaskenv +++ b/docs/docs/05_flask_smorest/02_data_model_improvements/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/.flaskenv b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/.flaskenv +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/.flaskenv b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/.flaskenv +++ b/docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/.flaskenv b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/.flaskenv +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/.flaskenv b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/.flaskenv +++ b/docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/.flaskenv b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/.flaskenv +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/.flaskenv b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/.flaskenv +++ b/docs/docs/05_flask_smorest/05_reload_api_docker_container/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/end/.flaskenv b/docs/docs/05_flask_smorest/06_api_with_method_views/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/06_api_with_method_views/end/.flaskenv +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/06_api_with_method_views/start/.flaskenv b/docs/docs/05_flask_smorest/06_api_with_method_views/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/06_api_with_method_views/start/.flaskenv +++ b/docs/docs/05_flask_smorest/06_api_with_method_views/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/.flaskenv b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/.flaskenv +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/.flaskenv b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/.flaskenv +++ b/docs/docs/05_flask_smorest/07_marshmallow_schemas/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/.flaskenv b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/.flaskenv +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/.flaskenv b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/.flaskenv +++ b/docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/end/.flaskenv b/docs/docs/05_flask_smorest/09_decorating_responses/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/09_decorating_responses/end/.flaskenv +++ b/docs/docs/05_flask_smorest/09_decorating_responses/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/05_flask_smorest/09_decorating_responses/start/.flaskenv b/docs/docs/05_flask_smorest/09_decorating_responses/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/05_flask_smorest/09_decorating_responses/start/.flaskenv +++ b/docs/docs/05_flask_smorest/09_decorating_responses/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/README.md b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/README.md index d71c9c75..706cc10b 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/README.md +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/README.md @@ -89,8 +89,7 @@ def create_app(db_url=None): api = Api(app) # highlight-start - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() # highlight-end @@ -104,7 +103,7 @@ We've done three things: 1. Added the `db_url` parameter. This lets us create an app with a certain database URL, or alternatively try to fetch the database URL from the environment variables. The default value will be a local SQLite file, if we don't pass a value ourselves and it isn't in the environment. 2. Added two SQLAlchemy values to `app.config`. One is the database URL (or URI), the other is a [configuration option](https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/) which improves performance. -3. Registered a function to run before our Flask app handles its first request. The function will tell SQLAlchemy to use what it knows in order to create all the database tables we need. +3. When the app is created, tell SQLAlchemy to create all the database tables we need. :::tip How does SQLAlchemy know what tables to create? The line `import models` lets SQLAlchemy know what models exist in our application. Because they are `db.Model` instances, SQLAlchemy will look at their `__tablename__` and defined `db.Column` attributes to create the tables. diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/app.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/.flaskenv b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/.flaskenv +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/app.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/app.py index f3f475ca..539afd91 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/app.py +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md index 9ca03bb6..10e05c2d 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md @@ -83,7 +83,6 @@ class TagSchema(PlainTagSchema): Let's add the Tag endpoints that aren't related to Items: - | Method | Endpoint | Description | | ---------- | --------------------- | ------------------------------------------------------- | | ✅ `GET` | `/store/{id}/tag` | Get a list of tags in a store. | @@ -177,8 +176,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) @@ -188,4 +186,4 @@ def create_app(db_url=None): # highlight-end return app -``` \ No newline at end of file +``` diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py index 8d1cee05..00f1c1bf 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py index 0af1f37f..0a3cfb9b 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py @@ -24,8 +24,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md index 2b2dcab2..df2ffa1e 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md @@ -275,4 +275,15 @@ class Tag(MethodView): And with that, we're done! +## Making sure Store ID matches when linking tags + +If you wanted to, you can make sure that you can only link a tag that belongs to a certain store, with an item of that same store. + +Something like this would work: + +```py +if item.store.id != tag.store.id: + abort(400, message="Make sure item and tag belong to the same store before linking.") +``` + Now we're ready to look at securing API endpoints with user authentication. \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py index 8d1cee05..00f1c1bf 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py index 8d1cee05..00f1c1bf 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/README.md b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/README.md index 30bb9851..510ca1c4 100644 --- a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/README.md +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/README.md @@ -51,8 +51,7 @@ def create_app(db_url=None): jwt = JWTManager(app) # highlight-end - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() @@ -68,4 +67,4 @@ def create_app(db_url=None): The secret key set here, `"jose"`, is **not very safe**. Instead you should generate a long and random secret key using something like `secrets.SystemRandom().getrandbits(128)`. -::: \ No newline at end of file +::: diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flaskenv b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/app.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/app.py index c22daf8c..ca41f545 100644 --- a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/app.py +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/app.py @@ -28,8 +28,7 @@ def create_app(db_url=None): app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flaskenv b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/app.py b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/app.py index de81eb75..3d006860 100644 --- a/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/app.py +++ b/docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/app.py @@ -24,8 +24,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flaskenv b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/app.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/app.py index c22daf8c..ca41f545 100644 --- a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/app.py +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/app.py @@ -28,8 +28,7 @@ def create_app(db_url=None): app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flaskenv b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/app.py b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/app.py index c22daf8c..ca41f545 100644 --- a/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/app.py +++ b/docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/app.py @@ -28,8 +28,7 @@ def create_app(db_url=None): app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flaskenv b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/app.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/app.py index 20fa6086..c1d49150 100644 --- a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/app.py +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/app.py @@ -29,8 +29,7 @@ def create_app(db_url=None): app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flaskenv b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/app.py b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/app.py index c22daf8c..ca41f545 100644 --- a/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/app.py +++ b/docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/app.py @@ -28,8 +28,7 @@ def create_app(db_url=None): app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flaskenv b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/app.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/app.py index 20fa6086..c1d49150 100644 --- a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/app.py +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/app.py @@ -29,8 +29,7 @@ def create_app(db_url=None): app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flaskenv b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/app.py b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/app.py index 20fa6086..c1d49150 100644 --- a/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/app.py +++ b/docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/app.py @@ -29,8 +29,7 @@ def create_app(db_url=None): app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flaskenv b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/app.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/app.py index b486d312..11f9a31c 100644 --- a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/app.py +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/app.py @@ -68,8 +68,7 @@ def revoked_token_callback(jwt_header, jwt_payload): # JWT configuration ends - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flaskenv b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/app.py b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/app.py index 20fa6086..c1d49150 100644 --- a/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/app.py +++ b/docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/app.py @@ -29,8 +29,7 @@ def create_app(db_url=None): app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flaskenv b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/app.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/app.py index e0e6c6f3..2d164494 100644 --- a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/app.py +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/app.py @@ -75,8 +75,7 @@ def revoked_token_callback(jwt_header, jwt_payload): # JWT configuration ends - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flaskenv b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/app.py b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/app.py index b486d312..11f9a31c 100644 --- a/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/app.py +++ b/docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/app.py @@ -68,8 +68,7 @@ def revoked_token_callback(jwt_header, jwt_payload): # JWT configuration ends - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flaskenv b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/app.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/app.py index 3535e15d..deac9e23 100644 --- a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/app.py +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/app.py @@ -80,8 +80,7 @@ def revoked_token_callback(jwt_header, jwt_payload): # JWT configuration ends - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flaskenv b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/app.py b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/app.py index 589a9391..d6e784d9 100644 --- a/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/app.py +++ b/docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/app.py @@ -66,8 +66,7 @@ def missing_token_callback(error): # JWT configuration ends - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flaskenv b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/app.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/app.py index 3a527c74..5b20b22c 100644 --- a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/app.py +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/app.py @@ -92,8 +92,7 @@ def revoked_token_callback(jwt_header, jwt_payload): # JWT configuration ends - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flaskenv b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flaskenv index 75473901..ccb3106e 100644 --- a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flaskenv +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/app.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/app.py index 3535e15d..deac9e23 100644 --- a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/app.py +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/app.py @@ -80,8 +80,7 @@ def revoked_token_callback(jwt_header, jwt_payload): # JWT configuration ends - @app.before_first_request - def create_tables(): + with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/README.md b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/README.md index 2a157108..3010fe57 100644 --- a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/README.md +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/README.md @@ -9,7 +9,7 @@ Adding Flask-Migrate to our app is simple, just install it and add a couple line To install: -``` +```bash pip install flask-migrate ``` @@ -33,7 +33,15 @@ migrate = Migrate(app, db) # highlight-end api = Api(app) -@app.before_first_request -def create_tables(): +with app.app_context(): + db.create_all() +``` + +Since we will be using Flask-Migrate to create our database, we no longer need to tell Flask-SQLAlchemy to do it when we create the app. + +Delete these two lines: + +```py +with app.app_context(): db.create_all() -``` \ No newline at end of file +``` diff --git a/docs/docs/11_deploy_to_render/03_docker_with_gunicorn/README.md b/docs/docs/11_deploy_to_render/03_docker_with_gunicorn/README.md index 1047f353..e246657a 100644 --- a/docs/docs/11_deploy_to_render/03_docker_with_gunicorn/README.md +++ b/docs/docs/11_deploy_to_render/03_docker_with_gunicorn/README.md @@ -62,9 +62,9 @@ If you use this `Dockerfile`, it doesn't mean you can't run it locally using the To run the Docker container locally, you'll have to do this from now on: ```zsh -docker run -dp 5000:5000 -w /app -v "$(pwd):/app" teclado-site-flask sh -c "flask run" +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" teclado-site-flask sh -c "flask run --host 0.0.0.0" ``` -This is similar to how we've ran the Docker container with our local code as a volume (that's what `-w /app -v "$(pwd):/app"` does), but at the end of the command we're telling the container to run `flask run` instead of the `CMD` line of the `Dockerfile`. That's what `sh -c "flask run"` does! +This is similar to how we've ran the Docker container with our local code as a volume (that's what `-w /app -v "$(pwd):/app"` does), but at the end of the command we're telling the container to run `flask run --host 0.0.0.0` instead of the `CMD` line of the `Dockerfile`. That's what `sh -c "flask run --host 0.0.0.0"` does! Now you're ready to commit and push this to your repository and re-deploy to Render.com! diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/README.md b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/README.md new file mode 100644 index 00000000..66b32f46 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/README.md @@ -0,0 +1,85 @@ +# How to send emails with Python and Mailgun + +To send e-mails using Python, we are going to use Mailgun, a third party service which actually delivers the messages. + +You could use [your own personal account and the built-in `email` and `smtp` libraries](https://blog.teclado.com/learn-python-send-emails/), but most personal e-mail providers will limit how many e-mails you can send per day. Plus, you won't get analytics and a host of other features that you can get with an email service like Mailgun. + +There are two ways to use the Mailgun service: [via SMTP or via their API](https://www.mailgun.com/blog/email/difference-between-smtp-and-api/). I'll show you how to use the API since it's a bit easier and has the same functionality. + +Sending an e-mail with Mailgun is just a matter of sending a request to their API. To do this, we'll use the `requests` library: + +```bash +pip install requests +``` + +Remember to add it to your `requirements.txt` as well: + +```text title="requirements.txt" +requests +``` + +## Setting up for Mailgun + +Before we can send any emails, we need to set up our Mailgun account. First, register over at [https://mailgun.com](https://mailgun.com). + +Once you have registered, select your sandbox domain. It's in [your dashboard](https://app.mailgun.com/app/dashboard), at the bottom. It looks like this: `sandbox847487f8g78.mailgun.org`. + +Then at the top right, enter your personal email address under "Authorized recipients". + +You will get an email to confirm. Click the button that you see in that email to add your personal email to the list of authorized recipients. + +Next up, grab your API key. You can find it by clicking on this button (my domain and API key are blurred in this screenshot): + +![Click the 'Select' button to reveal your Mailgun API key](./assets/mailgun-api-key.png) + +## Sending emails with Mailgun + +To make the API request which sends an email, we'll use a function that looks very much like this one (taken from their documentation): + +```py +def send_simple_message(): + return requests.post( + "https://api.mailgun.net/v3/YOUR_DOMAIN_NAME/messages", + auth=("api", "YOUR_API_KEY"), + data={"from": "Excited User ", + "to": ["bar@example.com", "YOU@YOUR_DOMAIN_NAME"], + "subject": "Hello", + "text": "Testing some Mailgun awesomness!"}) +``` + +So let's go into our User resource and add a couple of imports and this function. Make sure to replace "Your Name" with your actual name or that of your application: + +```py title="resources/user.py" +import os +import requests + +... + +def send_simple_message(to, subject, body): + domain = os.getenv("MAILGUN_DOMAIN") + return requests.post( + f"https://api.mailgun.net/v3/{domain}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) +``` + +Then let's go to the `.env` file and add your Mailgun API key and domain: + +```text title=".env" +MAILGUN_API_KEY="" +MAILGUN_DOMAIN="" +``` + +:::info +The API Key should look something like this: `"1f1ahfjhf4878797887187j-5ac54n"`. + +The Domain should look something like this: `"sandbox723b05d9.mailgun.org"` +::: + +With this, we're ready to actually send emails! diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/assets/mailgun-api-key.png b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/assets/mailgun-api-key.png new file mode 100644 index 00000000..e854e08e Binary files /dev/null and b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/assets/mailgun-api-key.png differ diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.env.example b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.env.example new file mode 100644 index 00000000..312ae619 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.flaskenv b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.gitignore b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/Dockerfile b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/README.md b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/app.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/blocklist.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/db.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/README b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/alembic.ini b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/env.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/script.py.mako b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/__init__.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/item.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/item_tags.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/store.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/tag.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/user.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/user.py new file mode 100644 index 00000000..fefdf936 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/requirements.txt b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/requirements.txt new file mode 100644 index 00000000..215ae898 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/requirements.txt @@ -0,0 +1,11 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/item.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/store.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/tag.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/user.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/user.py new file mode 100644 index 00000000..ec8747f6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/user.py @@ -0,0 +1,99 @@ +import os +import requests +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +def send_simple_message(to, subject, body): + domain = os.getenv("MAILGUN_DOMAIN") + return requests.post( + f"https://api.mailgun.net/v3/{domain}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/schemas.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/schemas.py new file mode 100644 index 00000000..cb3f7a07 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/schemas.py @@ -0,0 +1,52 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.env.example b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.env.example new file mode 100644 index 00000000..4cc714a2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.env.example @@ -0,0 +1 @@ +DATABASE_URL= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.flaskenv b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.gitignore b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/Dockerfile b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/README.md b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/app.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/blocklist.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/db.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/README b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/alembic.ini b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/env.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/script.py.mako b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/__init__.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/item.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/item_tags.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/store.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/tag.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/user.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/user.py new file mode 100644 index 00000000..fefdf936 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/requirements.txt b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/requirements.txt new file mode 100644 index 00000000..31d5b60b --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/requirements.txt @@ -0,0 +1,10 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/item.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/store.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/tag.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/user.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/user.py new file mode 100644 index 00000000..a3edc00d --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/user.py @@ -0,0 +1,77 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]) + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/schemas.py b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/schemas.py new file mode 100644 index 00000000..cb3f7a07 --- /dev/null +++ b/docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/schemas.py @@ -0,0 +1,52 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/README.md b/docs/docs/12_task_queues_emails/02_send_email_user_registration/README.md new file mode 100644 index 00000000..99256545 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/README.md @@ -0,0 +1,150 @@ +# Sending emails when users register + +If we want to be able to send emails to users when they register, we'll need to: + +- Add an `email` column to the user model. +- Collect user email addresses when users register. + +Let's begin with the model. + +## Add an `email` column to the user model + +```diff title="models/user.py" ++ email = db.Column(db.String, unique=True, nullable=False) +``` + +Then run the migration as we've already learned, to generate the migration script and upgrade the database to include the new column: + +```bash +flask db migrate +``` + +Now let's check the migration script. It should include adding the `email` column, and making it unique. + +Make sure that the `UniqueConstraint` is given a name. Alembic won't do this for you. Instead, it gives it the name `None` by default: + +```py +op.create_unique_constraint(None, 'users', ['email']) +``` + +Change that to this: + +```py +op.create_unique_constraint("email", 'users', ['email']) +``` + +And also when dropping the constraint: + +```py +op.drop_constraint("email", 'users', type_='unique') +``` + +```bash +flask db upgrade # make sure this is using the local dev database +``` + +## Collect user email addresses when they register + +To do this, first let's add an `email` field to the incoming data. Remember that we use the `UserSchema` for this in our API, but at the moment we are using `UserSchema` for two things: registration and login. + +If we modify `UserSchema` to add an email field, users will need to give us their username, email, and password when they log in. + +So it's better to keep two schemas: one for registration, which asks for an email, and one for login, which only asks for the username. + +```py +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True, load_only=True) + + +# highlight-start +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) +# highlight-end +``` + +:::info +You could also get rid of usernames and only use emails. You can use email/password for login in that case! +::: + +Now that we've got that, we can actually use the email field to create our `UserModel` objects: + +```py title="resources/user.py" +from schemas import UserSchema, UserRegisterSchema + +... + +@blp.route("/register") +class UserRegister(MethodView): + # highlight-start + @blp.arguments(UserRegisterSchema) + # highlight-end + def post(self, user_data): +... + + user = UserModel( + username=user_data["username"], + # highlight-start + email=user_data["email"], + # highlight-end + password=pbkdf2_sha256.hash(user_data["password"]), + ) +``` + +Now we can use the `send_simple_message` function [we defined earlier](../01_send_emails_python_mailgun/README.md#sending-emails-with-mailgun) to actually send an email! + +```py title="resources/user.py" +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + # highlight-start + send_simple_message( + to=user.email, + subject="Successfully signed up", + body=f"Hi {user.username}! You have successfully signed up to the Stores REST API." + ) + # highlight-end + + return {"message": "User created successfully."}, 201 +``` + +## Error handling duplicate emails + +In our `UserRegister` resource we are checking for duplicate usernames, but we should also check for duplicate emails. Otherwise, if a user tries to sign up with an email that already exists in the database, they'll get an ugly error. + +```py title="resources/user.py" +from sqlalchemy import or_ + +... + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"] + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + # ... Method continues here ... +``` + +So voilà, we're now sending an email when a user signs up! + +But sending an email can take a non-trivial amount of time... Wouldn't it be nice if we could offload the task of sending emails to another process, so that it happens in the background without our API user having to wait? \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.env.example b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.env.example new file mode 100644 index 00000000..312ae619 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.flaskenv b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.gitignore b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.python-version b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.python-version new file mode 100644 index 00000000..ac957df8 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.python-version @@ -0,0 +1 @@ +3.10.6 diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/Dockerfile b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/README.md b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/app.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/blocklist.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/db.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/README b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/alembic.ini b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/env.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/script.py.mako b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..98ef4cdf --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,54 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "bb5da1e68550" +down_revision = "8ca023a4a4b0" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "items", + "price", + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False, + ) + op.alter_column( + "users", + "password", + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "users", + "password", + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False, + ) + op.alter_column( + "items", + "price", + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False, + ) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/d8e0f80631fb_.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/d8e0f80631fb_.py new file mode 100644 index 00000000..c5a7f793 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/d8e0f80631fb_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: d8e0f80631fb +Revises: bb5da1e68550 +Create Date: 2022-10-11 14:46:28.100282 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8e0f80631fb" +down_revision = "bb5da1e68550" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=False)) + op.create_unique_constraint("email", "users", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("email", "users", type_="unique") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/__init__.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/item.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/item_tags.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/store.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/tag.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/user.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/user.py new file mode 100644 index 00000000..5fb33bbb --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/user.py @@ -0,0 +1,10 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/requirements.txt b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/requirements.txt new file mode 100644 index 00000000..215ae898 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/requirements.txt @@ -0,0 +1,11 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/item.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/store.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/tag.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/user.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/user.py new file mode 100644 index 00000000..9fd3da6b --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/user.py @@ -0,0 +1,112 @@ +import os +import requests +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) +from sqlalchemy import or_ + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema, UserRegisterSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +def send_simple_message(to, subject, body): + domain = os.getenv("MAILGUN_DOMAIN") + return requests.post( + f"https://api.mailgun.net/v3/{domain}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"], + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + send_simple_message( + to=user.email, + subject="Successfully signed up", + body=f"Hi {user.username}! You have successfully signed up to the Stores REST API.", + ) + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/schemas.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/schemas.py new file mode 100644 index 00000000..8c145440 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/end/schemas.py @@ -0,0 +1,56 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) + + +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.env.example b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.env.example new file mode 100644 index 00000000..312ae619 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.flaskenv b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.gitignore b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/Dockerfile b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/README.md b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/app.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/blocklist.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/db.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/README b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/alembic.ini b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/env.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/script.py.mako b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/__init__.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/item.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/item_tags.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/store.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/tag.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/user.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/user.py new file mode 100644 index 00000000..fefdf936 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/user.py @@ -0,0 +1,9 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/requirements.txt b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/requirements.txt new file mode 100644 index 00000000..215ae898 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/requirements.txt @@ -0,0 +1,11 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/item.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/store.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/tag.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/user.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/user.py new file mode 100644 index 00000000..ec8747f6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/user.py @@ -0,0 +1,99 @@ +import os +import requests +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +def send_simple_message(to, subject, body): + domain = os.getenv("MAILGUN_DOMAIN") + return requests.post( + f"https://api.mailgun.net/v3/{domain}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + if UserModel.query.filter(UserModel.username == user_data["username"]).first(): + abort(409, message="A user with that username already exists.") + + user = UserModel( + username=user_data["username"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/schemas.py b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/schemas.py new file mode 100644 index 00000000..cb3f7a07 --- /dev/null +++ b/docs/docs/12_task_queues_emails/02_send_email_user_registration/start/schemas.py @@ -0,0 +1,52 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/03_what_is_task_queue/README.md b/docs/docs/12_task_queues_emails/03_what_is_task_queue/README.md new file mode 100644 index 00000000..97ba53a9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/03_what_is_task_queue/README.md @@ -0,0 +1,33 @@ +# What is a task queue? + +A queue is a data structure to which you can add and remove data, but a key aspect of it is that when you want to remove a piece of data from it, the piece of data removed is the first piece of data that was added. + +![New elements are added at the end, called pushing, and removed from the start, called popping, of a queue](./assets/queues.drawio.png) + +This is identical to how people queuing works. The first person to arrive at the queue (i.e. the first in line), is the first person removed from the queue when they reach the ticket counter. + +We need a queueing system for our email sending so that when we offload tasks, we put them in a queue. Then we will have a separate program (the **background worker**), taking items from the queue one at a time and processing them. + +Each item in the queue will be an email to be sent (or rather, information so that the background worker can send the email). + +## Setting up the Redis database for our queue + +We can use the Redis database to store our queue of tasks. There are alternative options, such as RabbitMQ, but we won't cover them in this course. + +You can install Redis in a few different ways: + +- Install it locally by following their guides. +- Install it using Docker (I recommend this for a local install). +- Use a Redis database in the cloud so you don't have to install anything (this is what we do in the video). + +Render.com can provide us with a free Redis database, so I recommend using that to get started. + +Navigate to your Render.com dashboard, and create a new free Redis database. The free Redis provided doesn't have persistence enabled, but that's okay. It means we will lose data if the service is turned off, but since we're using it as a task queue that's not as big a deal as it otherwise could be. + +Later on if we want, we can upgrade to one of the paid plans. + +To be able to add tasks to the queue from your dev environment, make sure to [allow external connections](https://render.com/docs/redis#connecting-to-your-redis-from-outside-render) in your Redis database configuration. + +![Screenshot showing 0.0.0.0/0 as an allowed IP address when connecting to our Render Redis database](./assets/render-redis-allowing-outside.png) + +You should get a Redis URL that looks like this: `rediss://red-ct8aen0hkl10:MnLs0mmrX7MBXWRkdrh49@frankfurt-redis.render.com:6379`. Save it, for we'll need it in the next lecture! diff --git a/docs/docs/12_task_queues_emails/03_what_is_task_queue/assets/queues.drawio.png b/docs/docs/12_task_queues_emails/03_what_is_task_queue/assets/queues.drawio.png new file mode 100644 index 00000000..2ee2b414 Binary files /dev/null and b/docs/docs/12_task_queues_emails/03_what_is_task_queue/assets/queues.drawio.png differ diff --git a/docs/docs/12_task_queues_emails/03_what_is_task_queue/assets/render-redis-allowing-outside.png b/docs/docs/12_task_queues_emails/03_what_is_task_queue/assets/render-redis-allowing-outside.png new file mode 100644 index 00000000..ab7bfb93 Binary files /dev/null and b/docs/docs/12_task_queues_emails/03_what_is_task_queue/assets/render-redis-allowing-outside.png differ diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/README.md b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/README.md new file mode 100644 index 00000000..f0204210 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/README.md @@ -0,0 +1,121 @@ +# Populating and consuming the task queue with rq + +We'll be using the [`rq` library](https://python-rq.org/) for our task queue implementation. Another popular option is `celery`, which is substantially more complex. For most workloads, `rq` is sufficient and it's much easier to work with. + +First install the library: + +```bash +pip install rq +``` + +And remember to add it to your `requirements.txt` + +```text title="requirements.txt" +rq +``` + +Then it's helpful if we move the task code out to a separate file. Let's take our `send_simple_message` function and move it to `tasks.py`: + +```py title="tasks.py" +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +DOMAIN = os.getenv("MAILGUN_DOMAIN") + +def send_simple_message(to, subject, body): + return requests.post( + f"https://api.mailgun.net/v3/{DOMAIN}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={"from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body} + ) +``` + +Here I moved the domain line outside the function so it only runs once, and I've made sure to run `load_dotenv()` before it is requested. + +The background worker will import `tasks.py` once at the start of its lifetime, so doing this will (very slightly) improve performance. + +We could leave it like this, but I think we can do better. Let's write another function underneath that one that specifically describes the task that we want to perform in the background: send a registration email to a specific user: + +```py title="tasks.py" +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +DOMAIN = os.getenv("MAILGUN_DOMAIN") + +def send_simple_message(to, subject, body): + return requests.post( + f"https://api.mailgun.net/v3/{DOMAIN}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={"from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body} + ) + + +# highlight-start +def send_user_registration_email(email, username): + return send_simple_message( + email, + "Successfully signed up", + f"Hi {username}! You have successfully signed up to the Stores REST API.", + ) +# highlight-end +``` + +:::tip +Remember to change "Your Name" in `from` to whatever name you want your emails to come from! +::: + +Next up, add the Redis connection string that we got in the [previous section](../what_is_task_queue) to the `.env` file: + +```text title=".env" +REDIS_URL="" +``` + +And then let's go to our User resource and add a couple of imports: + +```py title="resources/user.py" +import redis +from rq import Queue +from tasks import send_user_registration_email +``` + +Then let's connect to Redis and create our `rq` queue. Under the blueprint definition, I'll add these lines: + +```py title="resources/user.py" +connection = redis.from_url( + os.getenv("REDIS_URL") +) # Get this from Render.com or run in Docker +queue = Queue("emails", connection=connection) +``` + +Now we can use the `queue` to "enqueue" jobs, i.e. add to the queue. That will put some data into the Redis database, which then the background worker can consume. + +### How to enqueue a job using `rq` + +This is the easy part! + +We are going to remove the code that sends the email from `resources/user.py`, and instead enqueue it using the `queue` variable. This takes the name of the function we want the background worker to call, and then all the arguments we'd like to pass to that function when it runs. + +```diff title="resources/user.py" +-send_simple_message( +- to=user.email, +- subject="Successfully signed up", +- body=f"Hi {user.username}! You have successfully signed up to the Stores REST API." +-) ++queue.enqueue(send_user_registration_email, user.email, user.username) +``` + +:::info +Remember the `send_user_registration_email` function doesn't run when we call `.enqueue`. It runs when the background worker starts working on this task, which could take some time! +::: \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.env.example b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.env.example new file mode 100644 index 00000000..0437809b --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +REDIS_URL= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.flaskenv b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.gitignore b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/Dockerfile b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/README.md b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/app.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/blocklist.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/db.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/README b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/alembic.ini b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/env.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/script.py.mako b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/d8e0f80631fb_.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/d8e0f80631fb_.py new file mode 100644 index 00000000..c5a7f793 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/d8e0f80631fb_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: d8e0f80631fb +Revises: bb5da1e68550 +Create Date: 2022-10-11 14:46:28.100282 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8e0f80631fb" +down_revision = "bb5da1e68550" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=False)) + op.create_unique_constraint("email", "users", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("email", "users", type_="unique") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/__init__.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/item.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/item_tags.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/store.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/tag.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/user.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/user.py new file mode 100644 index 00000000..5fb33bbb --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/user.py @@ -0,0 +1,10 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/requirements.txt b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/requirements.txt new file mode 100644 index 00000000..98937dfa --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/requirements.txt @@ -0,0 +1,13 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests +redis +rq \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/item.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/store.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/tag.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/user.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/user.py new file mode 100644 index 00000000..15fc1e04 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/user.py @@ -0,0 +1,100 @@ +import os +import redis +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) +from rq import Queue +from sqlalchemy import or_ + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema, UserRegisterSchema +from tasks import send_user_registration_email + + +blp = Blueprint("Users", "users", description="Operations on users") +connection = redis.from_url( + os.getenv("REDIS_URL") +) # Get this from Render.com or run in Docker +queue = Queue("emails", connection=connection) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"], + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + queue.enqueue(send_user_registration_email, user.email, user.username) + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/schemas.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/schemas.py new file mode 100644 index 00000000..8c145440 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/schemas.py @@ -0,0 +1,56 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) + + +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/tasks.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/tasks.py new file mode 100644 index 00000000..9fccd544 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/tasks.py @@ -0,0 +1,28 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +DOMAIN = os.getenv("MAILGUN_DOMAIN") + + +def send_simple_message(to, subject, body): + return requests.post( + f"https://api.mailgun.net/v3/{DOMAIN}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) + + +def send_user_registration_email(email, username): + return send_simple_message( + email, + "Successfully signed up", + f"Hi {username}! You have successfully signed up to the Stores REST API.", + ) diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.env.example b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.env.example new file mode 100644 index 00000000..312ae619 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.flaskenv b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.gitignore b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/Dockerfile b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/README.md b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/app.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/blocklist.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/db.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/README b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/alembic.ini b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/env.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/script.py.mako b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/d8e0f80631fb_.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/d8e0f80631fb_.py new file mode 100644 index 00000000..c5a7f793 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/d8e0f80631fb_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: d8e0f80631fb +Revises: bb5da1e68550 +Create Date: 2022-10-11 14:46:28.100282 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8e0f80631fb" +down_revision = "bb5da1e68550" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=False)) + op.create_unique_constraint("email", "users", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("email", "users", type_="unique") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/__init__.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/item.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/item_tags.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/store.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/tag.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/user.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/user.py new file mode 100644 index 00000000..5fb33bbb --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/user.py @@ -0,0 +1,10 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/requirements.txt b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/requirements.txt new file mode 100644 index 00000000..215ae898 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/requirements.txt @@ -0,0 +1,11 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/item.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/store.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/tag.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/user.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/user.py new file mode 100644 index 00000000..9fd3da6b --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/user.py @@ -0,0 +1,112 @@ +import os +import requests +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) +from sqlalchemy import or_ + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema, UserRegisterSchema + + +blp = Blueprint("Users", "users", description="Operations on users") + + +def send_simple_message(to, subject, body): + domain = os.getenv("MAILGUN_DOMAIN") + return requests.post( + f"https://api.mailgun.net/v3/{domain}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"], + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + send_simple_message( + to=user.email, + subject="Successfully signed up", + body=f"Hi {user.username}! You have successfully signed up to the Stores REST API.", + ) + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/schemas.py b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/schemas.py new file mode 100644 index 00000000..8c145440 --- /dev/null +++ b/docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/schemas.py @@ -0,0 +1,56 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) + + +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/README.md b/docs/docs/12_task_queues_emails/05_rq_background_worker/README.md new file mode 100644 index 00000000..52ecb807 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/README.md @@ -0,0 +1,76 @@ +# Process background tasks with the rq worker + +We've got our queue and we've added tasks to it, but they won't run until we start consuming them and popping them off the queue. + +To do this, we'll run a background worker whose job it is to pop items off the queue one at a time, and run the associated Python function with the associated arguments. + +:::tip MacOS or Linux? +If you are using MacOS or Linux, you can run the background worker for testing using this command (make sure your virtual environment is active): + +```bash +rq worker -u emails +``` + +The `rq` executable is available after installing the `rq` library with `pip`. The `-u` flag gives it the Redis URL to connect to. The `emails` at the end is the name of the queue that it should consume from. Make sure it matches the name of the queue you defined in `resources/user.py`. +::: + +:::warning Running on MacOS +You may get an error when running `rq worker` directly using MacOS (without Docker): + +```text +objc[21400]: +[__NSCFConstantString initialize] may have been in progress in another thread when fork() was called. +``` + +If so, try running this command before starting your `rq worker`: + +```bash +export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES +``` + +::: + +The most reliable way to run the worker though, is using Docker. + +We are already used to running our API using Docker, so now we can use the same Docker image to run our worker. + +First, build the image: + +```bash +docker build -t rest-apis-flask-smorest-rq . +``` + +Then run a container, but instead of running the default entrypoint (defined by the `CMD` line in the `Dockerfile`), we'll tell it to run the `rq` program: + +```bash +docker run -w /app rest-apis-flask-smorest-rq sh -c "rq worker -u emails" +``` + +This ensures one of the [considerations](https://python-rq.org/docs/#considerations-for-jobs) that the `rq` documentation suggests: that the worker and the work generator (our API) share _exactly_ the same source code. + +Run another Docker container for your API, and try to register! + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +
+ + + +```bash +docker run -p 5000:5000 rest-apis-flask-smorest-rq sh -c "flask run --host 0.0.0.0" +``` + + + + +```bash +docker run -w /app rest-apis-flask-smorest-rq sh -c "rq worker -u emails" +``` + +:::info +Make sure to enter your own Redis connection string in that command! +::: + + + +
\ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.env.example b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.env.example new file mode 100644 index 00000000..0437809b --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +REDIS_URL= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.flaskenv b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.gitignore b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/Dockerfile b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/README.md b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/app.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/blocklist.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/db.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/README b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/alembic.ini b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/env.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/script.py.mako b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/d8e0f80631fb_.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/d8e0f80631fb_.py new file mode 100644 index 00000000..c5a7f793 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/d8e0f80631fb_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: d8e0f80631fb +Revises: bb5da1e68550 +Create Date: 2022-10-11 14:46:28.100282 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8e0f80631fb" +down_revision = "bb5da1e68550" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=False)) + op.create_unique_constraint("email", "users", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("email", "users", type_="unique") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/__init__.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/item.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/item_tags.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/store.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/tag.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/user.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/user.py new file mode 100644 index 00000000..5fb33bbb --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/user.py @@ -0,0 +1,10 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/requirements.txt b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/requirements.txt new file mode 100644 index 00000000..98937dfa --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/requirements.txt @@ -0,0 +1,13 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests +redis +rq \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/item.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/store.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/tag.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/user.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/user.py new file mode 100644 index 00000000..15fc1e04 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/user.py @@ -0,0 +1,100 @@ +import os +import redis +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) +from rq import Queue +from sqlalchemy import or_ + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema, UserRegisterSchema +from tasks import send_user_registration_email + + +blp = Blueprint("Users", "users", description="Operations on users") +connection = redis.from_url( + os.getenv("REDIS_URL") +) # Get this from Render.com or run in Docker +queue = Queue("emails", connection=connection) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"], + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + queue.enqueue(send_user_registration_email, user.email, user.username) + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/schemas.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/schemas.py new file mode 100644 index 00000000..8c145440 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/schemas.py @@ -0,0 +1,56 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) + + +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/end/tasks.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/tasks.py new file mode 100644 index 00000000..9fccd544 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/end/tasks.py @@ -0,0 +1,28 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +DOMAIN = os.getenv("MAILGUN_DOMAIN") + + +def send_simple_message(to, subject, body): + return requests.post( + f"https://api.mailgun.net/v3/{DOMAIN}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) + + +def send_user_registration_email(email, username): + return send_simple_message( + email, + "Successfully signed up", + f"Hi {username}! You have successfully signed up to the Stores REST API.", + ) diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.env.example b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.env.example new file mode 100644 index 00000000..0437809b --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +REDIS_URL= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.flaskenv b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.gitignore b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/Dockerfile b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/README.md b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/app.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/blocklist.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/db.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/README b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/alembic.ini b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/env.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/script.py.mako b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/d8e0f80631fb_.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/d8e0f80631fb_.py new file mode 100644 index 00000000..c5a7f793 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/d8e0f80631fb_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: d8e0f80631fb +Revises: bb5da1e68550 +Create Date: 2022-10-11 14:46:28.100282 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8e0f80631fb" +down_revision = "bb5da1e68550" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=False)) + op.create_unique_constraint("email", "users", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("email", "users", type_="unique") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/__init__.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/item.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/item_tags.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/store.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/tag.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/user.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/user.py new file mode 100644 index 00000000..5fb33bbb --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/user.py @@ -0,0 +1,10 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/requirements.txt b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/requirements.txt new file mode 100644 index 00000000..98937dfa --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/requirements.txt @@ -0,0 +1,13 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests +redis +rq \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/item.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/store.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/tag.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/user.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/user.py new file mode 100644 index 00000000..15fc1e04 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/user.py @@ -0,0 +1,100 @@ +import os +import redis +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) +from rq import Queue +from sqlalchemy import or_ + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema, UserRegisterSchema +from tasks import send_user_registration_email + + +blp = Blueprint("Users", "users", description="Operations on users") +connection = redis.from_url( + os.getenv("REDIS_URL") +) # Get this from Render.com or run in Docker +queue = Queue("emails", connection=connection) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"], + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + queue.enqueue(send_user_registration_email, user.email, user.username) + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/schemas.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/schemas.py new file mode 100644 index 00000000..8c145440 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/schemas.py @@ -0,0 +1,56 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) + + +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) diff --git a/docs/docs/12_task_queues_emails/05_rq_background_worker/start/tasks.py b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/tasks.py new file mode 100644 index 00000000..9fccd544 --- /dev/null +++ b/docs/docs/12_task_queues_emails/05_rq_background_worker/start/tasks.py @@ -0,0 +1,28 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +DOMAIN = os.getenv("MAILGUN_DOMAIN") + + +def send_simple_message(to, subject, body): + return requests.post( + f"https://api.mailgun.net/v3/{DOMAIN}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) + + +def send_user_registration_email(email, username): + return send_simple_message( + email, + "Successfully signed up", + f"Hi {username}! You have successfully signed up to the Stores REST API.", + ) diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/README.md b/docs/docs/12_task_queues_emails/06_sending_html_emails/README.md new file mode 100644 index 00000000..4bce036e --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/README.md @@ -0,0 +1,258 @@ +# Sending HTML emails with Mailgun + +Until now, we've been sending exclusively text emails. These have a clear advantage: text is simple! They'll look the same in every email client and device, and for many things, text is good enough. + +However, I'll be the first to say that it doesn't look amazing. You're at the mercy of the default font family and size of the recipient's email client, and you can't personalize the email with your business branding. + +This is where HTML emails come into play. + +HTML emails require that we write HTML instead of text, and also CSS for the styling. We should still keep the text version of the email, just in case the recipient's email client doesn't render HTML for whatever reason. + +## Writing HTML emails + +Crafting HTML emails is difficult! Every email client renders things slightly differently and supports different versions of the HTML and CSS specs. + +For example, it's discouraged to use CSS Flex when writing emails, because many email clients don't support it. + +That's why you'll see most HTML emails use HTML tables for their layout 🤮 + +Fortunately for us, Mailgun provides a few [HTML templates](https://www.mailgun.com/blog/email/transactional-html-email-templates/) that we can simply copy, paste, and modify. They test these HTML templates to make sure they render correctly in most email clients, and they come with CSS already written. + +## Getting the Mailgun HTML email templates + +This link has a writeup of how HTML templates work: [https://www.mailgun.com/blog/email/transactional-html-email-templates/](https://www.mailgun.com/blog/email/transactional-html-email-templates/). + +You can find their templates here: [https://github.com/mailgun/transactional-email-templates/tree/master/templates/inlined](https://github.com/mailgun/transactional-email-templates/tree/master/templates/inlined). + +There are three different transactional email templates, and we'll be using the [`action.html`](https://raw.githubusercontent.com/mailgun/transactional-email-templates/master/templates/inlined/action.html) template in this lecture for our "user registration" email. + +## Adding the template to our application + +Create a `templates/email/action.html` file in your project, and place the entire raw code of the `action.html` file from the Mailgun repository. + +:::tip +Make sure to grab the [**raw** code](https://raw.githubusercontent.com/mailgun/transactional-email-templates/master/templates/inlined/action.html) to make sure there are no GitHub artefacts in the code. +::: + +The copied [`action.html`](https://github.com/mailgun/transactional-email-templates/blob/master/templates/inlined/action.html) code from the [Mailgun repository](https://github.com/mailgun/transactional-email-templates) (below) is licensed with the MIT license. Please see the [repository license](https://github.com/mailgun/transactional-email-templates/blob/master/LICENSE) for more information. + +```html title="templates/email/action.html" + + + + + +Actionable emails e.g. reset password + + + + + + + + + + +
+
+ +
+ + + + +
+ Please confirm your email address by clicking the link below. +
+ We may need to send you critical information about our service and it is important that we have an accurate email address. +
+ +
+ — The Mailgunners +
+
+ +``` + +Now we can easily modify this file to suit our needs. Here are the changes I'll make: + +```diff title="templates/email/action.html" + + + +-Actionable emails e.g. reset password ++Welcome to Stores REST API + + + + + + + + + + +
+
+ +
+ + + + +
+ Welcome to the Stores REST API. +
+ Your account with username {{ username }} has been created successfully. +
+ +
+ — Stores REST API +
+
+ diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/end/templates/email/registration.original.html b/docs/docs/12_task_queues_emails/06_sending_html_emails/end/templates/email/registration.original.html new file mode 100644 index 00000000..6a2d39b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/end/templates/email/registration.original.html @@ -0,0 +1,85 @@ + + + + + +Actionable emails e.g. reset password + + + + + + + + + + +
+
+ +
+ + + + +
+ Please confirm your email address by clicking the link below. +
+ We may need to send you critical information about our service and it is important that we have an accurate email address. +
+ +
+ — The Mailgunners +
+
+ \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.env.example b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.env.example new file mode 100644 index 00000000..0437809b --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +REDIS_URL= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.flaskenv b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.gitignore b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/Dockerfile b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/README.md b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/app.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/blocklist.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/db.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/README b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/alembic.ini b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/env.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/script.py.mako b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/d8e0f80631fb_.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/d8e0f80631fb_.py new file mode 100644 index 00000000..c5a7f793 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/d8e0f80631fb_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: d8e0f80631fb +Revises: bb5da1e68550 +Create Date: 2022-10-11 14:46:28.100282 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8e0f80631fb" +down_revision = "bb5da1e68550" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=False)) + op.create_unique_constraint("email", "users", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("email", "users", type_="unique") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/__init__.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/item.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/item_tags.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/store.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/tag.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/user.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/user.py new file mode 100644 index 00000000..5fb33bbb --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/user.py @@ -0,0 +1,10 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/requirements.txt b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/requirements.txt new file mode 100644 index 00000000..98937dfa --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/requirements.txt @@ -0,0 +1,13 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests +redis +rq \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/item.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/store.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/tag.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/user.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/user.py new file mode 100644 index 00000000..15fc1e04 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/user.py @@ -0,0 +1,100 @@ +import os +import redis +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) +from rq import Queue +from sqlalchemy import or_ + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema, UserRegisterSchema +from tasks import send_user_registration_email + + +blp = Blueprint("Users", "users", description="Operations on users") +connection = redis.from_url( + os.getenv("REDIS_URL") +) # Get this from Render.com or run in Docker +queue = Queue("emails", connection=connection) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"], + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + queue.enqueue(send_user_registration_email, user.email, user.username) + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/schemas.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/schemas.py new file mode 100644 index 00000000..8c145440 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/schemas.py @@ -0,0 +1,56 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) + + +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/start/tasks.py b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/tasks.py new file mode 100644 index 00000000..9fccd544 --- /dev/null +++ b/docs/docs/12_task_queues_emails/06_sending_html_emails/start/tasks.py @@ -0,0 +1,28 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +DOMAIN = os.getenv("MAILGUN_DOMAIN") + + +def send_simple_message(to, subject, body): + return requests.post( + f"https://api.mailgun.net/v3/{DOMAIN}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Your Name ", + "to": [to], + "subject": subject, + "text": body, + }, + ) + + +def send_user_registration_email(email, username): + return send_simple_message( + email, + "Successfully signed up", + f"Hi {username}! You have successfully signed up to the Stores REST API.", + ) diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/README.md b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/README.md new file mode 100644 index 00000000..a9c5ae6e --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/README.md @@ -0,0 +1,47 @@ +# Deploy the rq background worker to Render.com + +When deploying to Render.com, it's much easier if we don't have to pass the `REDIS_URL` and the queue name directly to the command. + +So instead, let's create a `settings.py` file and put our `rq` worker configuration there: + +```python title="settings.py" +import os +from dotenv import load_dotenv + +load_dotenv() + +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") +QUEUES = ["emails", "default"] +``` + +The names of the variables are important, see [the documentation](https://python-rq.org/docs/workers/#using-a-config-file) for all the options that are currently supported. + +To run the `rq` worker using this settings file use `rq worker -c settings`. + +Let's add this to our repo, and then deploy the background worker to Render.com. + +First create a new background worker: + +![Create a new service of type background worker in Render.com](./assets/render-create-bg-worker.png) + +Then, give it a name and fill in its basic settings. The default works for the most part. Make sure it's in the same region as or close to your Postgres and Redis databases: + +![Filling in the Render basic worker information with its name set to 'rest-api-background-worker', environment set to 'docker', and region set to 'Frankfurt'](./assets/render-bg-worker-basic-settings.png) + +Add the environment variables it needs. Although in this case it doesn't need the `DATABASE_URL`, you can add it if you will be adding other tasks that do use the database in the near future. If not, leave it out. + +:::warning Internal URL +If your Redis database is with Render.com, you'd want to use the Redis database **Internal URL**, but I encountered some issues with it where the `redis` package didn't recognise the URL. Try it, but fall back to the external URL if it doesn't work. +::: + +![Environment variables added in Render.com including DATABASE_URL, REDIS_URL, MAILGUN_API_KEY, and MAILGUN_DOMAIN, with their respective values](./assets/render-bg-worker-env-vars.png) + +Finally, this "background worker" is just a Python program without networking capabilities. So if we leave it as is, it will actually just run our Dockerfile and the Dockerfile's `CMD` command (which starts our web application). Therefore we want to give it a custom Docker command that starts the background worker. + +In that command, I'll go into the `/app` directory of the Docker container, and run the `rq` worker passing in the `settings.py` file. + +The command is `/bin/bash -c cd /app && rq worker -c settings`. + +This is what it looks like in Render.com: + +![Screenshot showing the Docker command in Render.com](./assets/render-bg-worker-docker-command.png) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-basic-settings.png b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-basic-settings.png new file mode 100644 index 00000000..ce462bd7 Binary files /dev/null and b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-basic-settings.png differ diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-docker-command.png b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-docker-command.png new file mode 100644 index 00000000..baccf252 Binary files /dev/null and b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-docker-command.png differ diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-env-vars.png b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-env-vars.png new file mode 100644 index 00000000..e64a54b1 Binary files /dev/null and b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-bg-worker-env-vars.png differ diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-create-bg-worker.png b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-create-bg-worker.png new file mode 100644 index 00000000..45eb4db0 Binary files /dev/null and b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/assets/render-create-bg-worker.png differ diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.env.example b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.env.example new file mode 100644 index 00000000..0437809b --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +REDIS_URL= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.flaskenv b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.gitignore b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.python-version b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.python-version new file mode 100644 index 00000000..ac957df8 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.python-version @@ -0,0 +1 @@ +3.10.6 diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/Dockerfile b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/README.md b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/app.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/blocklist.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/db.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/README b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/alembic.ini b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/env.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/script.py.mako b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/d8e0f80631fb_.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/d8e0f80631fb_.py new file mode 100644 index 00000000..c5a7f793 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/d8e0f80631fb_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: d8e0f80631fb +Revises: bb5da1e68550 +Create Date: 2022-10-11 14:46:28.100282 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8e0f80631fb" +down_revision = "bb5da1e68550" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=False)) + op.create_unique_constraint("email", "users", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("email", "users", type_="unique") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/__init__.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/item.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/item_tags.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/store.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/tag.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/user.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/user.py new file mode 100644 index 00000000..5fb33bbb --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/user.py @@ -0,0 +1,10 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/requirements.txt b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/requirements.txt new file mode 100644 index 00000000..98937dfa --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/requirements.txt @@ -0,0 +1,13 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests +redis +rq \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/item.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/store.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/tag.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/user.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/user.py new file mode 100644 index 00000000..15fc1e04 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/user.py @@ -0,0 +1,100 @@ +import os +import redis +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) +from rq import Queue +from sqlalchemy import or_ + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema, UserRegisterSchema +from tasks import send_user_registration_email + + +blp = Blueprint("Users", "users", description="Operations on users") +connection = redis.from_url( + os.getenv("REDIS_URL") +) # Get this from Render.com or run in Docker +queue = Queue("emails", connection=connection) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"], + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + queue.enqueue(send_user_registration_email, user.email, user.username) + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/schemas.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/schemas.py new file mode 100644 index 00000000..8c145440 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/schemas.py @@ -0,0 +1,56 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) + + +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/settings.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/settings.py new file mode 100644 index 00000000..c3a22556 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/settings.py @@ -0,0 +1,7 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") +QUEUES = ["emails", "default"] diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/tasks.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/tasks.py new file mode 100644 index 00000000..f37853c0 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/tasks.py @@ -0,0 +1,37 @@ +import os +import requests +from dotenv import load_dotenv +import jinja2 + +load_dotenv() + +DOMAIN = os.getenv("MAILGUN_DOMAIN") +template_loader = jinja2.FileSystemLoader("templates") +template_env = jinja2.Environment(loader=template_loader) + + +def render_template(template_filename, **context): + return template_env.get_template(template_filename).render(**context) + + +def send_simple_message(to, subject, body, html): + return requests.post( + f"https://api.mailgun.net/v3/{DOMAIN}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Jose Salvatierra ", + "to": [to], + "subject": subject, + "text": body, + "html": html, + }, + ) + + +def send_user_registration_email(email, username): + return send_simple_message( + email, + "Successfully signed up", + f"Hi {username}! You have successfully signed up to the Stores REST API.", + render_template("email/registration.html", username=username), + ) diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/templates/email/diff.txt b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/templates/email/diff.txt new file mode 100644 index 00000000..40e840b4 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/templates/email/diff.txt @@ -0,0 +1,43 @@ +diff --git a/registration.original.html b/registration.html +index 6a2d39b..ca38bbd 100644 +--- a/registration.original.html ++++ b/registration.html +@@ -3,7 +3,7 @@ + + + +-Actionable emails e.g. reset password ++Welcome to Stores REST API + + + + + + + + + + +
+
+ +
+ + + + +
+ Welcome to the Stores REST API. +
+ Your account with username {{ username }} has been created successfully. +
+ +
+ — Stores REST API +
+
+ diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/templates/email/registration.original.html b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/templates/email/registration.original.html new file mode 100644 index 00000000..6a2d39b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/templates/email/registration.original.html @@ -0,0 +1,85 @@ + + + + + +Actionable emails e.g. reset password + + + + + + + + + + +
+
+ +
+ + + + +
+ Please confirm your email address by clicking the link below. +
+ We may need to send you critical information about our service and it is important that we have an accurate email address. +
+ +
+ — The Mailgunners +
+
+ \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.env.example b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.env.example new file mode 100644 index 00000000..0437809b --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +REDIS_URL= \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.flaskenv b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.gitignore b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.gitignore new file mode 100644 index 00000000..6104f428 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.gitignore @@ -0,0 +1,7 @@ +.env +.venv +.vscode +__pycache__ +data.db +*.pyc +.DS_Store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.python-version b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.python-version new file mode 100644 index 00000000..ac957df8 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.python-version @@ -0,0 +1 @@ +3.10.6 diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/CONTRIBUTING.md b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/CONTRIBUTING.md new file mode 100644 index 00000000..7e550e79 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# CONTRIBUTING + +## How to run the Dockerfile locally + +``` +docker run -dp 5000:5000 -w /app -v "$(pwd):/app" IMAGE_NAME sh -c "flask run" +``` diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/Dockerfile b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/Dockerfile new file mode 100644 index 00000000..121cf5b6 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["/bin/bash", "docker-entrypoint.sh"] \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/README.md b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/README.md new file mode 100644 index 00000000..ae704d28 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/README.md @@ -0,0 +1,3 @@ +# REST APIs Recording Project + +Nothing here yet! diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/app.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/app.py new file mode 100644 index 00000000..65d7d0ca --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/app.py @@ -0,0 +1,109 @@ +import os + +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from dotenv import load_dotenv + + +from db import db +from blocklist import BLOCKLIST +import models + +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint +from resources.user import blp as UserBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + load_dotenv() + + app.config["PROPAGATE_EXCEPTIONS"] = True + app.config["API_TITLE"] = "Stores REST API" + app.config["API_VERSION"] = "v1" + app.config["OPENAPI_VERSION"] = "3.0.3" + app.config["OPENAPI_URL_PREFIX"] = "/" + app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" + app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + @jwt.needs_fresh_token_loader + def token_not_fresh_callback(jwt_header, jwt_payload): + return ( + jsonify( + { + "description": "The token is not fresh.", + "error": "fresh_token_required", + } + ), + 401, + ) + + @jwt.additional_claims_loader + def add_claims_to_jwt(identity): + # Look in the database and see whether the user is an admin + if identity == 1: + return {"is_admin": True} + return {"is_admin": False} + + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + jsonify({"message": "The token has expired.", "error": "token_expired"}), + 401, + ) + + @jwt.invalid_token_loader + def invalid_token_callback(error): + return ( + jsonify( + {"message": "Signature verification failed.", "error": "invalid_token"} + ), + 401, + ) + + @jwt.unauthorized_loader + def missing_token_callback(error): + return ( + jsonify( + { + "description": "Request does not contain an access token.", + "error": "authorization_required", + } + ), + 401, + ) + + @app.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + api.register_blueprint(UserBlueprint) + + return app \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/blocklist.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/blocklist.py new file mode 100644 index 00000000..77751bef --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/blocklist.py @@ -0,0 +1,9 @@ +""" +blocklist.py + +This file just contains the blocklist of the JWT tokens. It will be imported by +app and the logout resource so that tokens can be added to the blocklist when the +user logs out. +""" + +BLOCKLIST = set() \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/db.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/docker-entrypoint.sh b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/docker-entrypoint.sh new file mode 100644 index 00000000..134c2988 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +flask db upgrade + +exec gunicorn --bind 0.0.0.0:80 "app:create_app()" \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/README b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/alembic.ini b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/env.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/env.py new file mode 100644 index 00000000..2ec83a7e --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + compare_type=True, + literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + compare_type=True, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/script.py.mako b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/07006e31e788_.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/07006e31e788_.py new file mode 100644 index 00000000..e58a46db --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/07006e31e788_.py @@ -0,0 +1,68 @@ +"""empty message + +Revision ID: 07006e31e788 +Revises: +Create Date: 2022-08-15 12:44:59.705694 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '07006e31e788' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password', sa.String(length=80), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('price', sa.Float(precision=2), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('store_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('items_tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('items_tags') + op.drop_table('tags') + op.drop_table('items') + op.drop_table('users') + op.drop_table('stores') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/8ca023a4a4b0_.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/8ca023a4a4b0_.py new file mode 100644 index 00000000..3c369e48 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/8ca023a4a4b0_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 8ca023a4a4b0 +Revises: 07006e31e788 +Create Date: 2022-08-15 12:52:41.303543 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ca023a4a4b0' +down_revision = '07006e31e788' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'description') + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/bb5da1e68550_.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/bb5da1e68550_.py new file mode 100644 index 00000000..e6e23e40 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/bb5da1e68550_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: bb5da1e68550 +Revises: 8ca023a4a4b0 +Create Date: 2022-08-29 13:06:57.697368 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bb5da1e68550' +down_revision = '8ca023a4a4b0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('items', 'price', + existing_type=sa.REAL(), + type_=sa.Float(precision=2), + existing_nullable=False) + op.alter_column('users', 'password', + existing_type=sa.VARCHAR(length=80), + type_=sa.String(length=256), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'password', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=80), + existing_nullable=False) + op.alter_column('items', 'price', + existing_type=sa.Float(precision=2), + type_=sa.REAL(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/d8e0f80631fb_.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/d8e0f80631fb_.py new file mode 100644 index 00000000..c5a7f793 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/d8e0f80631fb_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: d8e0f80631fb +Revises: bb5da1e68550 +Create Date: 2022-10-11 14:46:28.100282 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8e0f80631fb" +down_revision = "bb5da1e68550" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=False)) + op.create_unique_constraint("email", "users", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("email", "users", type_="unique") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/__init__.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/__init__.py new file mode 100644 index 00000000..04f2e012 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.store import StoreModel +from models.item import ItemModel +from models.tag import TagModel +from models.item_tags import ItemTags +from models.user import UserModel \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/item.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/item.py new file mode 100644 index 00000000..45006d57 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/item.py @@ -0,0 +1,13 @@ +from sqlalchemy import ForeignKey +from db import db + +class ItemModel(db.Model): + __tablename__ = "items" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=False, nullable=False) + description = db.Column(db.String) + price = db.Column(db.Float(precision=2), unique=False, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) + store = db.relationship("StoreModel", back_populates="items") + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/item_tags.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/item_tags.py new file mode 100644 index 00000000..5dfd5cf5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemTags(db.Model): + __tablename__ = "items_tags" + + id = db.Column(db.Integer, primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("items.id")) + tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/store.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/store.py new file mode 100644 index 00000000..90ad43d5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/store.py @@ -0,0 +1,10 @@ +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/tag.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/tag.py new file mode 100644 index 00000000..008e8d37 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/user.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/user.py new file mode 100644 index 00000000..5fb33bbb --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/user.py @@ -0,0 +1,10 @@ +from db import db + + +class UserModel(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/requirements.txt b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/requirements.txt new file mode 100644 index 00000000..98937dfa --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/requirements.txt @@ -0,0 +1,13 @@ +flask==2.1.3 +flask-smorest +python-dotenv +sqlalchemy +flask-sqlalchemy +flask-jwt-extended +passlib +flask-migrate +gunicorn +psycopg2 +requests +redis +rq \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/item.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/item.py new file mode 100644 index 00000000..545f73b5 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/item.py @@ -0,0 +1,67 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import jwt_required, get_jwt +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import ItemModel +from schemas import ItemSchema, ItemUpdateSchema + +blp = Blueprint("Items", __name__, description="Operations on items") + + +@blp.route("/item/") +class Item(MethodView): + @jwt_required() + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + @jwt_required() + def delete(self, item_id): + jwt = get_jwt() + if not jwt.get("is_admin"): + abort(401, message="Admin privilege required.") + + item = ItemModel.query.get_or_404(item_id) + db.session.delete(item) + db.session.commit() + return {"message": "Item deleted."} + + @blp.arguments(ItemUpdateSchema) + @blp.response(200, ItemSchema) + def put(self, item_data, item_id): + item = ItemModel.query.get(item_id) + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(id=item_id, **item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/item") +class ItemList(MethodView): + @jwt_required() + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @jwt_required(fresh=True) + @blp.arguments(ItemSchema) + @blp.response(201, ItemSchema) + def post(self, item_data): + item = ItemModel(**item_data) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred whilte inserting the item.") + + return item \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/store.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/store.py new file mode 100644 index 00000000..488c1f67 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/store.py @@ -0,0 +1,51 @@ +import uuid +from flask import request +from flask.views import MethodView +from flask_smorest import Blueprint, abort + +from sqlalchemy.exc import SQLAlchemyError, IntegrityError + +from db import db +from models import StoreModel +from schemas import StoreSchema + + +blp = Blueprint("stores", __name__, description="Operations on stores") + + +@blp.route("/store/") +class Store(MethodView): + @blp.response(200, StoreSchema) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + return store + + def delete(self, store_id): + store = StoreModel.query.get_or_404(store_id) + db.session.delete(store) + db.session.commit() + return {"message": "Store deleted"} + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(200, StoreSchema) + def post(self, store_data): + store = StoreModel(**store_data) + try: + db.session.add(store) + db.session.commit() + except IntegrityError: + abort( + 400, + message="A store with that name already exists.", + ) + except SQLAlchemyError: + abort(500, message="An error occurred creating the store.") + + return store \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/tag.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/tag.py new file mode 100644 index 00000000..f15c41b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/tag.py @@ -0,0 +1,97 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from sqlalchemy.exc import SQLAlchemyError + +from db import db +from models import TagModel, StoreModel, ItemModel +from schemas import TagSchema, TagAndItemSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/store//tag") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + tag = TagModel(**tag_data, store_id=store_id) + + try: + db.session.add(tag) + db.session.commit() + except SQLAlchemyError as e: + abort( + 500, + message=str(e) + ) + + return tag + + +@blp.route("/item//tag/") +class LinkTagsToItem(MethodView): + @blp.response(201, TagSchema) + def post(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.append(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return tag + + @blp.response(200, TagAndItemSchema) + def delete(self, item_id, tag_id): + item = ItemModel.query.get_or_404(item_id) + tag = TagModel.query.get_or_404(tag_id) + + item.tags.remove(tag) + + try: + db.session.add(item) + db.session.commit() + except SQLAlchemyError: + abort(500, message="An error occurred while inserting the tag.") + + return {"message": "Item removed from tag", "item": item, "tag": tag} + + +@blp.route("/tag/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + @blp.response( + 202, + description="Deletes a tag if no item is tagged with it.", + example={"message": "Tag deleted."} + ) + @blp.alt_response(404, description="Tag not found.") + @blp.alt_response( + 400, + description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." + ) + def delete(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + + if not tag.items: + db.session.delete(tag) + db.session.commit() + return {"message": "Tag deleted."} + abort( + 400, + message="Could not delete tag. Make sure tag is not associated with any items, then try again.", + ) \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/user.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/user.py new file mode 100644 index 00000000..15fc1e04 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/user.py @@ -0,0 +1,100 @@ +import os +import redis +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from passlib.hash import pbkdf2_sha256 +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, + get_jwt, +) +from rq import Queue +from sqlalchemy import or_ + +from db import db +from blocklist import BLOCKLIST +from models import UserModel +from schemas import UserSchema, UserRegisterSchema +from tasks import send_user_registration_email + + +blp = Blueprint("Users", "users", description="Operations on users") +connection = redis.from_url( + os.getenv("REDIS_URL") +) # Get this from Render.com or run in Docker +queue = Queue("emails", connection=connection) + + +@blp.route("/register") +class UserRegister(MethodView): + @blp.arguments(UserRegisterSchema) + def post(self, user_data): + if UserModel.query.filter( + or_( + UserModel.username == user_data["username"], + UserModel.email == user_data["email"], + ) + ).first(): + abort(409, message="A user with that username or email already exists.") + + user = UserModel( + username=user_data["username"], + email=user_data["email"], + password=pbkdf2_sha256.hash(user_data["password"]), + ) + db.session.add(user) + db.session.commit() + + queue.enqueue(send_user_registration_email, user.email, user.username) + + return {"message": "User created successfully."}, 201 + + +@blp.route("/login") +class UserLogin(MethodView): + @blp.arguments(UserSchema) + def post(self, user_data): + user = UserModel.query.filter( + UserModel.username == user_data["username"] + ).first() + + if user and pbkdf2_sha256.verify(user_data["password"], user.password): + access_token = create_access_token(identity=user.id, fresh=True) + refresh_token = create_refresh_token(identity=user.id) + return {"access_token": access_token, "refresh_token": refresh_token} + + abort(401, message="Invalid credentials.") + + +@blp.route("/refresh") +class TokenRefresh(MethodView): + @jwt_required(refresh=True) + def post(self): + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + return {"access_token": new_token} + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out."} + + +@blp.route("/user/") +class User(MethodView): + @blp.response(200, UserSchema) + def get(self, user_id): + user = UserModel.query.get_or_404(user_id) + return user + + def delete(self, user_id): + user = UserModel.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + return {"message": "User deleted."}, 200 diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/schemas.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/schemas.py new file mode 100644 index 00000000..8c145440 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/schemas.py @@ -0,0 +1,56 @@ +from marshmallow import Schema, fields + + +class PlainItemSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + price = fields.Float(required=True) + + +class PlainStoreSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str(required=True) + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + store_id = fields.Int() + + +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + + +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) + + +class UserSchema(Schema): + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + password = fields.Str(required=True) + + +class UserRegisterSchema(UserSchema): + email = fields.Str(required=True) diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/tasks.py b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/tasks.py new file mode 100644 index 00000000..f37853c0 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/tasks.py @@ -0,0 +1,37 @@ +import os +import requests +from dotenv import load_dotenv +import jinja2 + +load_dotenv() + +DOMAIN = os.getenv("MAILGUN_DOMAIN") +template_loader = jinja2.FileSystemLoader("templates") +template_env = jinja2.Environment(loader=template_loader) + + +def render_template(template_filename, **context): + return template_env.get_template(template_filename).render(**context) + + +def send_simple_message(to, subject, body, html): + return requests.post( + f"https://api.mailgun.net/v3/{DOMAIN}/messages", + auth=("api", os.getenv("MAILGUN_API_KEY")), + data={ + "from": f"Jose Salvatierra ", + "to": [to], + "subject": subject, + "text": body, + "html": html, + }, + ) + + +def send_user_registration_email(email, username): + return send_simple_message( + email, + "Successfully signed up", + f"Hi {username}! You have successfully signed up to the Stores REST API.", + render_template("email/registration.html", username=username), + ) diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/templates/email/diff.txt b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/templates/email/diff.txt new file mode 100644 index 00000000..40e840b4 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/templates/email/diff.txt @@ -0,0 +1,43 @@ +diff --git a/registration.original.html b/registration.html +index 6a2d39b..ca38bbd 100644 +--- a/registration.original.html ++++ b/registration.html +@@ -3,7 +3,7 @@ + + + +-Actionable emails e.g. reset password ++Welcome to Stores REST API + + + + + + + + + + +
+
+ +
+ + + + +
+ Welcome to the Stores REST API. +
+ Your account with username {{ username }} has been created successfully. +
+ +
+ — Stores REST API +
+
+ diff --git a/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/templates/email/registration.original.html b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/templates/email/registration.original.html new file mode 100644 index 00000000..6a2d39b9 --- /dev/null +++ b/docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/templates/email/registration.original.html @@ -0,0 +1,85 @@ + + + + + +Actionable emails e.g. reset password + + + + + + + + + + +
+
+ +
+ + + + +
+ Please confirm your email address by clicking the link below. +
+ We may need to send you critical information about our service and it is important that we have an accurate email address. +
+ +
+ — The Mailgunners +
+
+ \ No newline at end of file diff --git a/docs/docs/12_task_queues_emails/_category_.json b/docs/docs/12_task_queues_emails/_category_.json new file mode 100644 index 00000000..910ee98e --- /dev/null +++ b/docs/docs/12_task_queues_emails/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Task queues with rq and e-mail sending", + "position": 12 +} diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 6f6e9dc1..acb4da42 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -31,7 +31,8 @@ const config = { sidebarPath: require.resolve("./sidebars.js"), exclude: ["**/start/**", "**/end/**"], // Please change this to your repo. - editUrl: "https://github.com/tecladocode/rest-apis-flask-python/", + editUrl: + "https://github.com/tecladocode/rest-apis-flask-python/tree/develop/docs/", }, theme: { customCss: require.resolve("./src/css/custom.css"), diff --git a/project/03-items-stores-smorest/.flaskenv b/project/03-items-stores-smorest/.flaskenv index 75473901..ccb3106e 100644 --- a/project/03-items-stores-smorest/.flaskenv +++ b/project/03-items-stores-smorest/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/project/04-items-stores-smorest-sqlalchemy/.flaskenv b/project/04-items-stores-smorest-sqlalchemy/.flaskenv index 75473901..ccb3106e 100644 --- a/project/04-items-stores-smorest-sqlalchemy/.flaskenv +++ b/project/04-items-stores-smorest-sqlalchemy/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/project/04-items-stores-smorest-sqlalchemy/app.py b/project/04-items-stores-smorest-sqlalchemy/app.py index f3f475ca..539afd91 100644 --- a/project/04-items-stores-smorest-sqlalchemy/app.py +++ b/project/04-items-stores-smorest-sqlalchemy/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/project/05-add-many-to-many/.flaskenv b/project/05-add-many-to-many/.flaskenv index 75473901..ccb3106e 100644 --- a/project/05-add-many-to-many/.flaskenv +++ b/project/05-add-many-to-many/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/project/05-add-many-to-many/app.py b/project/05-add-many-to-many/app.py index 8d1cee05..00f1c1bf 100644 --- a/project/05-add-many-to-many/app.py +++ b/project/05-add-many-to-many/app.py @@ -25,8 +25,7 @@ def create_app(db_url=None): db.init_app(app) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/project/06-add-db-migrations/.flaskenv b/project/06-add-db-migrations/.flaskenv index 75473901..ccb3106e 100644 --- a/project/06-add-db-migrations/.flaskenv +++ b/project/06-add-db-migrations/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/project/06-add-db-migrations/app.py b/project/06-add-db-migrations/app.py index 9a2d3940..71b63732 100644 --- a/project/06-add-db-migrations/app.py +++ b/project/06-add-db-migrations/app.py @@ -27,8 +27,7 @@ def create_app(db_url=None): migrate = Migrate(app, db) api = Api(app) - @app.before_first_request - def create_tables(): + with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) diff --git a/project/using-flask-restful/.flaskenv b/project/using-flask-restful/.flaskenv index 75473901..ccb3106e 100644 --- a/project/using-flask-restful/.flaskenv +++ b/project/using-flask-restful/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/project/using-flask-restful/app.py b/project/using-flask-restful/app.py index a2b33ef8..006f628e 100644 --- a/project/using-flask-restful/app.py +++ b/project/using-flask-restful/app.py @@ -73,7 +73,7 @@ def missing_token_callback(error): @jwt.needs_fresh_token_loader -def token_not_fresh_callback(): +def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token is not fresh.", "error": "fresh_token_required"} @@ -83,7 +83,7 @@ def token_not_fresh_callback(): @jwt.revoked_token_loader -def revoked_token_callback(): +def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} @@ -95,8 +95,7 @@ def revoked_token_callback(): # JWT configuration ends -@app.before_first_request -def create_tables(): +with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/project/using-flask-restx/.flaskenv b/project/using-flask-restx/.flaskenv index 75473901..ccb3106e 100644 --- a/project/using-flask-restx/.flaskenv +++ b/project/using-flask-restx/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/project/using-flask-restx/app.py b/project/using-flask-restx/app.py index 68736440..7afde07f 100644 --- a/project/using-flask-restx/app.py +++ b/project/using-flask-restx/app.py @@ -73,7 +73,7 @@ def missing_token_callback(error): @jwt.needs_fresh_token_loader -def token_not_fresh_callback(): +def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token is not fresh.", "error": "fresh_token_required"} @@ -83,7 +83,7 @@ def token_not_fresh_callback(): @jwt.revoked_token_loader -def revoked_token_callback(): +def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} @@ -95,8 +95,7 @@ def revoked_token_callback(): # JWT configuration ends -@app.before_first_request -def create_tables(): +with app.app_context(): import models # noqa: F401 db.create_all() diff --git a/project/using-flask-smorest-docker b/project/using-flask-smorest-docker index d293eb98..c9a637e0 160000 --- a/project/using-flask-smorest-docker +++ b/project/using-flask-smorest-docker @@ -1 +1 @@ -Subproject commit d293eb9867c13d56a302e898bb0c41dd186b265b +Subproject commit c9a637e0b280ffbb0d746538884f895bf8fafc81 diff --git a/project/using-flask-smorest/.flaskenv b/project/using-flask-smorest/.flaskenv index 75473901..ccb3106e 100644 --- a/project/using-flask-smorest/.flaskenv +++ b/project/using-flask-smorest/.flaskenv @@ -1,2 +1,2 @@ FLASK_APP=app -FLASK_ENV=development \ No newline at end of file +FLASK_DEBUG=True \ No newline at end of file diff --git a/project/using-flask-smorest/app.py b/project/using-flask-smorest/app.py index a6b06786..b3c6af9e 100644 --- a/project/using-flask-smorest/app.py +++ b/project/using-flask-smorest/app.py @@ -81,7 +81,7 @@ def missing_token_callback(error): @jwt.needs_fresh_token_loader -def token_not_fresh_callback(): +def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token is not fresh.", "error": "fresh_token_required"} @@ -91,7 +91,7 @@ def token_not_fresh_callback(): @jwt.revoked_token_loader -def revoked_token_callback(): +def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} @@ -103,8 +103,7 @@ def revoked_token_callback(): # JWT configuration ends -@app.before_first_request -def create_tables(): +with app.app_context(): import models # noqa: F401 db.create_all()