diff --git a/.gitignore b/.gitignore index 32d1eb98..3b886c6b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ __pycache__/ venv/ .venv/ docs/docs/.nota/config.ini +section-start-code.zip +section-end-code.zip \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/.dockerignore b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/.flaskenv b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +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_video/Dockerfile b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/app.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/app.py new file mode 100644 index 00000000..5b20b22c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/app.py @@ -0,0 +1,105 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + 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 "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @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, + ) + + @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.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + # JWT configuration ends + + with app.app_context(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/blocklist.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/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() diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/db.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/__init__.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/item.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/item.py @@ -0,0 +1,16 @@ +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) + 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") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/item_tags.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(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")) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/store.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/store.py @@ -0,0 +1,11 @@ +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) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/tag.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/tag.py new file mode 100644 index 00000000..32103cb1 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/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=False, 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") diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/user.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/user.py new file mode 100644 index 00000000..029ca833 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/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(80), nullable=False) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/requirements.txt b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/__init__.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/item.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/item.py new file mode 100644 index 00000000..28e9b186 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/item.py @@ -0,0 +1,68 @@ +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", "items", 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 while inserting the item.") + + return item diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/store.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/store.py @@ -0,0 +1,48 @@ +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", "stores", 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"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, 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 diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/tag.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/tag.py new file mode 100644 index 00000000..b2450fe5 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/tag.py @@ -0,0 +1,100 @@ +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() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): + abort(400, message="A tag with that name already exists in that store.") + + 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.", # noqa: E501 + ) diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/user.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/user.py new file mode 100644 index 00000000..e21a8595 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/user.py @@ -0,0 +1,93 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +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(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @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 + + +@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) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 diff --git a/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/schemas.py b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/schemas.py @@ -0,0 +1,51 @@ +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() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +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 ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +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) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), 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, load_only=True) diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/.dockerignore b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/.flaskenv b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/Dockerfile b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/app.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/app.py new file mode 100644 index 00000000..f4e41033 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/app.py @@ -0,0 +1,101 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + 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 "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @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, + ) + + @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.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/blocklist.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/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() diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/db.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/__init__.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/item.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/item.py @@ -0,0 +1,16 @@ +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) + 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") diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/item_tags.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(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")) diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/store.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/store.py @@ -0,0 +1,11 @@ +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) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/tag.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/tag.py new file mode 100644 index 00000000..32103cb1 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/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=False, 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") diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/user.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/user.py new file mode 100644 index 00000000..029ca833 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/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(80), nullable=False) diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/requirements.txt b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/requirements.txt new file mode 100644 index 00000000..a082a91d --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/requirements.txt @@ -0,0 +1,8 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn +Flask-Migrate \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/__init__.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/item.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/item.py new file mode 100644 index 00000000..28e9b186 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/item.py @@ -0,0 +1,68 @@ +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", "items", 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 while inserting the item.") + + return item diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/store.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/store.py @@ -0,0 +1,48 @@ +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", "stores", 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"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, 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 diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/tag.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/tag.py new file mode 100644 index 00000000..b2450fe5 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/tag.py @@ -0,0 +1,100 @@ +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() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): + abort(400, message="A tag with that name already exists in that store.") + + 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.", # noqa: E501 + ) diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/user.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/user.py new file mode 100644 index 00000000..e21a8595 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/user.py @@ -0,0 +1,93 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +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(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @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 + + +@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) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/schemas.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/schemas.py @@ -0,0 +1,51 @@ +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() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +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 ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +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) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), 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, load_only=True) diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/.dockerignore b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/.flaskenv b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/Dockerfile b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/app.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/app.py new file mode 100644 index 00000000..5b20b22c --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/app.py @@ -0,0 +1,105 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + 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 "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @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, + ) + + @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.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + # JWT configuration ends + + with app.app_context(): + import models # noqa: F401 + + db.create_all() + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/blocklist.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/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() diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/db.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/__init__.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/item.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/item.py @@ -0,0 +1,16 @@ +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) + 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") diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/item_tags.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(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")) diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/store.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/store.py @@ -0,0 +1,11 @@ +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) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/tag.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/tag.py new file mode 100644 index 00000000..32103cb1 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/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=False, 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") diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/user.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/user.py new file mode 100644 index 00000000..029ca833 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/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(80), nullable=False) diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/requirements.txt b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/requirements.txt @@ -0,0 +1,7 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/__init__.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/item.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/item.py new file mode 100644 index 00000000..28e9b186 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/item.py @@ -0,0 +1,68 @@ +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", "items", 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 while inserting the item.") + + return item diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/store.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/store.py @@ -0,0 +1,48 @@ +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", "stores", 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"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, 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 diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/tag.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/tag.py new file mode 100644 index 00000000..b2450fe5 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/tag.py @@ -0,0 +1,100 @@ +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() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): + abort(400, message="A tag with that name already exists in that store.") + + 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.", # noqa: E501 + ) diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/user.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/user.py new file mode 100644 index 00000000..e21a8595 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/user.py @@ -0,0 +1,93 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +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(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @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 + + +@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) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 diff --git a/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/schemas.py b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/schemas.py @@ -0,0 +1,51 @@ +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() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +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 ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +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) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), 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, load_only=True) diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.dockerignore b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.flaskenv b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.python-version b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/Dockerfile b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/app.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/app.py new file mode 100644 index 00000000..f4e41033 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/app.py @@ -0,0 +1,101 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + 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 "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @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, + ) + + @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.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/blocklist.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/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() diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/db.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/README b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/alembic.ini b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/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/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/env.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/env.py new file mode 100644 index 00000000..fa412e8c --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/env.py @@ -0,0 +1,105 @@ +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') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except TypeError: + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +# 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(get_engine().url).replace('%', '%%')) +target_db = current_app.extensions['migrate'].db + +# 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 get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +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=get_metadata(), 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 = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + process_revision_directives=process_revision_directives, + **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/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/script.py.mako b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/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/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/versions/c575166f6192_.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/versions/c575166f6192_.py new file mode 100644 index 00000000..e041f02e --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/versions/c575166f6192_.py @@ -0,0 +1,67 @@ +"""empty message + +Revision ID: c575166f6192 +Revises: +Create Date: 2023-01-23 15:14:42.094596 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c575166f6192' +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') + ) + 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/09_flask_migrate/03_initialize_database_flask_db_init/end/models/__init__.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/item.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/item.py @@ -0,0 +1,16 @@ +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) + 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") diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/item_tags.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(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")) diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/store.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/store.py @@ -0,0 +1,11 @@ +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) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/tag.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/tag.py new file mode 100644 index 00000000..32103cb1 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/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=False, 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") diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/user.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/user.py new file mode 100644 index 00000000..029ca833 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/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(80), nullable=False) diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/requirements.txt b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/requirements.txt new file mode 100644 index 00000000..a082a91d --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/requirements.txt @@ -0,0 +1,8 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn +Flask-Migrate \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/__init__.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/item.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/item.py new file mode 100644 index 00000000..28e9b186 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/item.py @@ -0,0 +1,68 @@ +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", "items", 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 while inserting the item.") + + return item diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/store.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/store.py @@ -0,0 +1,48 @@ +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", "stores", 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"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, 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 diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/tag.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/tag.py new file mode 100644 index 00000000..b2450fe5 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/tag.py @@ -0,0 +1,100 @@ +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() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): + abort(400, message="A tag with that name already exists in that store.") + + 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.", # noqa: E501 + ) diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/user.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/user.py new file mode 100644 index 00000000..e21a8595 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/user.py @@ -0,0 +1,93 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +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(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @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 + + +@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) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/schemas.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/schemas.py @@ -0,0 +1,51 @@ +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() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +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 ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +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) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), 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, load_only=True) diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/.dockerignore b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/.flaskenv b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/Dockerfile b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/app.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/app.py new file mode 100644 index 00000000..f4e41033 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/app.py @@ -0,0 +1,101 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + 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 "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @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, + ) + + @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.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/blocklist.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/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() diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/db.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/__init__.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/item.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/item.py @@ -0,0 +1,16 @@ +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) + 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") diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/item_tags.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(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")) diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/store.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/store.py @@ -0,0 +1,11 @@ +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) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/tag.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/tag.py new file mode 100644 index 00000000..32103cb1 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/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=False, 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") diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/user.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/user.py new file mode 100644 index 00000000..029ca833 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/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(80), nullable=False) diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/requirements.txt b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/requirements.txt new file mode 100644 index 00000000..a082a91d --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/requirements.txt @@ -0,0 +1,8 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn +Flask-Migrate \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/__init__.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/item.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/item.py new file mode 100644 index 00000000..28e9b186 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/item.py @@ -0,0 +1,68 @@ +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", "items", 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 while inserting the item.") + + return item diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/store.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/store.py @@ -0,0 +1,48 @@ +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", "stores", 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"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, 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 diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/tag.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/tag.py new file mode 100644 index 00000000..b2450fe5 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/tag.py @@ -0,0 +1,100 @@ +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() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): + abort(400, message="A tag with that name already exists in that store.") + + 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.", # noqa: E501 + ) diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/user.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/user.py new file mode 100644 index 00000000..e21a8595 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/user.py @@ -0,0 +1,93 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +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(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @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 + + +@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) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 diff --git a/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/schemas.py b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/schemas.py @@ -0,0 +1,51 @@ +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() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +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 ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +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) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), 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, load_only=True) diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.dockerignore b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.flaskenv b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.python-version b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/Dockerfile b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/app.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/app.py new file mode 100644 index 00000000..f4e41033 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/app.py @@ -0,0 +1,101 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + 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 "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @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, + ) + + @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.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/blocklist.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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() diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/db.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/README b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/alembic.ini b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/env.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/env.py new file mode 100644 index 00000000..fa412e8c --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/env.py @@ -0,0 +1,105 @@ +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') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except TypeError: + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +# 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(get_engine().url).replace('%', '%%')) +target_db = current_app.extensions['migrate'].db + +# 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 get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +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=get_metadata(), 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 = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + process_revision_directives=process_revision_directives, + **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/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/script.py.mako b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/versions/bcc005bc255c_.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/versions/bcc005bc255c_.py new file mode 100644 index 00000000..521cd52c --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/versions/bcc005bc255c_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: bcc005bc255c +Revises: c575166f6192 +Create Date: 2023-01-23 15:21:21.002304 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bcc005bc255c' +down_revision = 'c575166f6192' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('items', schema=None) as batch_op: + batch_op.add_column(sa.Column('description', sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('items', schema=None) as batch_op: + batch_op.drop_column('description') + + # ### end Alembic commands ### diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/versions/c575166f6192_.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/versions/c575166f6192_.py new file mode 100644 index 00000000..e041f02e --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/versions/c575166f6192_.py @@ -0,0 +1,67 @@ +"""empty message + +Revision ID: c575166f6192 +Revises: +Create Date: 2023-01-23 15:14:42.094596 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c575166f6192' +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') + ) + 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/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/__init__.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/item.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/item.py new file mode 100644 index 00000000..f1cffd20 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/item.py @@ -0,0 +1,17 @@ +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") diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/item_tags.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(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")) diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/store.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/store.py @@ -0,0 +1,11 @@ +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) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/tag.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/tag.py new file mode 100644 index 00000000..32103cb1 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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=False, 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") diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/user.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/user.py new file mode 100644 index 00000000..029ca833 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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(80), nullable=False) diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/requirements.txt b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/requirements.txt new file mode 100644 index 00000000..a082a91d --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/requirements.txt @@ -0,0 +1,8 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn +Flask-Migrate \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/__init__.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/item.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/item.py new file mode 100644 index 00000000..28e9b186 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/item.py @@ -0,0 +1,68 @@ +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", "items", 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 while inserting the item.") + + return item diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/store.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/store.py @@ -0,0 +1,48 @@ +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", "stores", 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"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, 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 diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/tag.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/tag.py new file mode 100644 index 00000000..b2450fe5 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/tag.py @@ -0,0 +1,100 @@ +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() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): + abort(400, message="A tag with that name already exists in that store.") + + 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.", # noqa: E501 + ) diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/user.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/user.py new file mode 100644 index 00000000..e21a8595 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/user.py @@ -0,0 +1,93 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +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(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @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 + + +@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) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/schemas.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/schemas.py @@ -0,0 +1,51 @@ +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() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +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 ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +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) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), 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, load_only=True) diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.dockerignore b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.dockerignore new file mode 100644 index 00000000..3e4919a2 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.dockerignore @@ -0,0 +1,4 @@ +.venv +*.pyc +__pycache__ +data.db \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.flaskenv b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.flaskenv new file mode 100644 index 00000000..ccb3106e --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_DEBUG=True \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.python-version b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/Dockerfile b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +EXPOSE 5000 +WORKDIR /app +COPY ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . . +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/app.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/app.py new file mode 100644 index 00000000..f4e41033 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/app.py @@ -0,0 +1,101 @@ +from flask import Flask, jsonify +from flask_smorest import Api +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate + +from db import db +from blocklist import BLOCKLIST + +from resources.user import blp as UserBlueprint +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +from resources.tag import blp as TagBlueprint + + +def create_app(db_url=None): + app = Flask(__name__) + 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 "sqlite:///data.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["PROPAGATE_EXCEPTIONS"] = True + db.init_app(app) + migrate = Migrate(app, db) + api = Api(app) + + app.config["JWT_SECRET_KEY"] = "jose" + jwt = JWTManager(app) + + # @jwt.additional_claims_loader + # def add_claims_to_jwt(identity): + # # TODO: Read from a config file instead of hard-coding + # if identity == 1: + # return {"is_admin": True} + # return {"is_admin": False} + + @jwt.token_in_blocklist_loader + def check_if_token_in_blocklist(jwt_header, jwt_payload): + return jwt_payload["jti"] in BLOCKLIST + + @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, + ) + + @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.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + jsonify( + {"description": "The token has been revoked.", "error": "token_revoked"} + ), + 401, + ) + + + api.register_blueprint(UserBlueprint) + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/blocklist.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/blocklist.py new file mode 100644 index 00000000..66e6c716 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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() diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/db.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/README b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/alembic.ini b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/alembic.ini new file mode 100644 index 00000000..ec9d45c2 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/env.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/env.py new file mode 100644 index 00000000..fa412e8c --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/env.py @@ -0,0 +1,105 @@ +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') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except TypeError: + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +# 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(get_engine().url).replace('%', '%%')) +target_db = current_app.extensions['migrate'].db + +# 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 get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +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=get_metadata(), 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 = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + process_revision_directives=process_revision_directives, + **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/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/script.py.mako b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/versions/c575166f6192_.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/versions/c575166f6192_.py new file mode 100644 index 00000000..e041f02e --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/versions/c575166f6192_.py @@ -0,0 +1,67 @@ +"""empty message + +Revision ID: c575166f6192 +Revises: +Create Date: 2023-01-23 15:14:42.094596 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c575166f6192' +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') + ) + 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/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/__init__.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/__init__.py new file mode 100644 index 00000000..b57f3f8a --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/__init__.py @@ -0,0 +1,5 @@ +from models.user import UserModel +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/item.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/item.py new file mode 100644 index 00000000..57036ad3 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/item.py @@ -0,0 +1,16 @@ +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) + 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") diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/item_tags.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/item_tags.py @@ -0,0 +1,9 @@ +from db import db + + +class ItemsTags(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")) diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/store.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/store.py @@ -0,0 +1,11 @@ +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) + + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/tag.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/tag.py new file mode 100644 index 00000000..32103cb1 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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=False, 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") diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/user.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/user.py new file mode 100644 index 00000000..029ca833 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/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(80), nullable=False) diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/requirements.txt b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/requirements.txt new file mode 100644 index 00000000..a082a91d --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/requirements.txt @@ -0,0 +1,8 @@ +Flask-JWT-Extended +Flask-Smorest +Flask-SQLAlchemy +passlib +marshmallow +python-dotenv +gunicorn +Flask-Migrate \ No newline at end of file diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/__init__.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/item.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/item.py new file mode 100644 index 00000000..28e9b186 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/item.py @@ -0,0 +1,68 @@ +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", "items", 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 while inserting the item.") + + return item diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/store.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/store.py new file mode 100644 index 00000000..06bc0e24 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/store.py @@ -0,0 +1,48 @@ +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", "stores", 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"}, 200 + + +@blp.route("/store") +class StoreList(MethodView): + @blp.response(200, StoreSchema(many=True)) + def get(self): + return StoreModel.query.all() + + @blp.arguments(StoreSchema) + @blp.response(201, 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 diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/tag.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/tag.py new file mode 100644 index 00000000..b2450fe5 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/tag.py @@ -0,0 +1,100 @@ +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() # lazy="dynamic" means 'tags' is a query + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): + abort(400, message="A tag with that name already exists in that store.") + + 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.", # noqa: E501 + ) diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/user.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/user.py new file mode 100644 index 00000000..e21a8595 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/user.py @@ -0,0 +1,93 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + get_jwt, + jwt_required, +) +from passlib.hash import pbkdf2_sha256 + +from db import db +from models import UserModel +from schemas import UserSchema +from blocklist import BLOCKLIST + + +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(user.id) + return {"access_token": access_token, "refresh_token": refresh_token}, 200 + + abort(401, message="Invalid credentials.") + + +@blp.route("/logout") +class UserLogout(MethodView): + @jwt_required() + def post(self): + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"message": "Successfully logged out"}, 200 + + +@blp.route("/user/") +class User(MethodView): + """ + This resource can be useful when testing our Flask app. + We may not want to expose it to public users, but for the + sake of demonstration in this course, it can be useful + when we are manipulating data regarding the users. + """ + + @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 + + +@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) + # Make it clear that when to add the refresh token to the blocklist will depend on the app design + jti = get_jwt()["jti"] + BLOCKLIST.add(jti) + return {"access_token": new_token}, 200 diff --git a/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/schemas.py b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/schemas.py new file mode 100644 index 00000000..ae63eff2 --- /dev/null +++ b/docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/schemas.py @@ -0,0 +1,51 @@ +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() + + +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +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 ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +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) + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), 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, load_only=True) diff --git a/docs/docs/12_task_queues_emails/06_sending_html_emails/end/templates/email/diff.txt b/docs/docs/12_task_queues_emails/06_sending_html_emails/end/templates/email/diff.txt deleted file mode 100644 index 40e840b4..00000000 --- a/docs/docs/12_task_queues_emails/06_sending_html_emails/end/templates/email/diff.txt +++ /dev/null @@ -1,43 +0,0 @@ -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 - - -