From aa299a200341afd1cf124e21e5dbcb19c3194c24 Mon Sep 17 00:00:00 2001 From: Jose Salvatierra Date: Wed, 1 Jun 2022 13:40:59 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20(Many-to-many)=20Add=20SQLAlche?= =?UTF-8?q?my=20section=20with=20many-to-many=20relationships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../01_section_changes/README.md | 43 +++ .../assets/db_model.drawio.png | Bin 0 -> 115633 bytes .../02_one_to_many_review/README.md | 143 ++++++++++ .../03_many_to_many_relationships/README.md | 245 ++++++++++++++++++ .../_category_.json | 4 + project/05-add-many-to-many/.flaskenv | 2 + project/05-add-many-to-many/Dockerfile | 7 + project/05-add-many-to-many/app.py | 36 +++ project/05-add-many-to-many/conftest.py | 19 ++ project/05-add-many-to-many/db.py | 3 + .../05-add-many-to-many/models/__init__.py | 4 + project/05-add-many-to-many/models/item.py | 16 ++ .../05-add-many-to-many/models/item_tags.py | 9 + project/05-add-many-to-many/models/store.py | 11 + project/05-add-many-to-many/models/tag.py | 12 + project/05-add-many-to-many/requirements.txt | 7 + .../05-add-many-to-many/resources/__init__.py | 0 .../resources/__tests__/conftest.py | 31 +++ .../resources/__tests__/test_item.py | 120 +++++++++ .../resources/__tests__/test_store.py | 217 ++++++++++++++++ .../resources/__tests__/test_tag.py | 121 +++++++++ project/05-add-many-to-many/resources/item.py | 59 +++++ .../05-add-many-to-many/resources/store.py | 48 ++++ project/05-add-many-to-many/resources/tag.py | 100 +++++++ project/05-add-many-to-many/schemas.py | 45 ++++ 25 files changed, 1302 insertions(+) create mode 100644 docs/docs/07_sqlalchemy_many_to_many/01_section_changes/README.md create mode 100644 docs/docs/07_sqlalchemy_many_to_many/01_section_changes/assets/db_model.drawio.png create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md create mode 100644 docs/docs/07_sqlalchemy_many_to_many/_category_.json create mode 100644 project/05-add-many-to-many/.flaskenv create mode 100644 project/05-add-many-to-many/Dockerfile create mode 100644 project/05-add-many-to-many/app.py create mode 100644 project/05-add-many-to-many/conftest.py create mode 100644 project/05-add-many-to-many/db.py create mode 100644 project/05-add-many-to-many/models/__init__.py create mode 100644 project/05-add-many-to-many/models/item.py create mode 100644 project/05-add-many-to-many/models/item_tags.py create mode 100644 project/05-add-many-to-many/models/store.py create mode 100644 project/05-add-many-to-many/models/tag.py create mode 100644 project/05-add-many-to-many/requirements.txt create mode 100644 project/05-add-many-to-many/resources/__init__.py create mode 100644 project/05-add-many-to-many/resources/__tests__/conftest.py create mode 100644 project/05-add-many-to-many/resources/__tests__/test_item.py create mode 100644 project/05-add-many-to-many/resources/__tests__/test_store.py create mode 100644 project/05-add-many-to-many/resources/__tests__/test_tag.py create mode 100644 project/05-add-many-to-many/resources/item.py create mode 100644 project/05-add-many-to-many/resources/store.py create mode 100644 project/05-add-many-to-many/resources/tag.py create mode 100644 project/05-add-many-to-many/schemas.py diff --git a/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/README.md b/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/README.md new file mode 100644 index 00000000..f444edc0 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/README.md @@ -0,0 +1,43 @@ +--- +title: Changes in this section +description: In this section we add Tags to our Stores, and link these to Items using a many-to-many relationship. +--- + +# Changes in this section + +It's common for online stores to use "tags" to group items and to be able to search for them a bit more easily. + +For example, an item "Chair" could be tagged with "Furniture" and "Office". + +Another item, "Laptop", could be tagged with "Tech" and "Office". + +So one item can be associated with many tags, and one tag can be associated with many items. + +This is a many-to-many relationship, which is bit trickier to implement than the one-to-many we've already implemented between Items and Stores. + +## When you have many stores + +We want to add one more constraint to tags, however. That is that if we have many stores, it's possible each store wants to use different tags. So the tags we create will be unique to each store. + +This means that tags will have: + +- A many-to-one relationship with stores +- A many-to-many relationship with items + +Here's a diagram to illustrate what this looks like: + +![ER database model showing relationships](./assets/db_model.drawio.png) + +## New API endpoints to be added + +In this section we will add all the Tag endpoints: + + +| Method | Endpoint | Description | +| -------- | ----------------------- | ------------------------------------------------------- | +| `GET` | `/stores/{id}/tags` | Get a list of tags in a store. | +| `POST` | `/stores/{id}/tags` | Create a new tag. | +| `POST` | `/items/{id}/tags/{id}` | Link an item in a store with a tag from the same store. | +| `DELETE` | `/items/{id}/tags/{id}` | Unlink a tag from an item. | +| `GET` | `/tags/{id}` | Get information about a tag given its unique id. | +| `DELETE` | `/tags/{id}` | Delete a tag, which must have no associated items. | \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/assets/db_model.drawio.png b/docs/docs/07_sqlalchemy_many_to_many/01_section_changes/assets/db_model.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..a156618d84a618e7d6151db8cf12a6d569d7d088 GIT binary patch literal 115633 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 literal 0 HcmV?d00001 diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md new file mode 100644 index 00000000..06378d1a --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md @@ -0,0 +1,143 @@ +--- +title: One-to-many relationships review +description: A super-quick look at creating the Tag model and setting up the one-to-many relationship with Stores. +--- + +# One-to-many relationship between Tag and Store + +Since we've already learned how to set up one-to-many relationships with SQLAlchemy when we looked at Items and Stores, let's go quickly in this section. + +## The SQLAlchemy models + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +
+ + + +```python title="models/tag.py" +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") +``` + + + + +```python title="models/store.py" +from db import db + + +class StoreModel(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + + items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") + # highlight-start + tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") + # highlight-end +``` + + + +
+ +## The marshmallow schemas + +These are the new schemas we'll add. Note that none of the tag schemas have any notion of "items". We'll add those to the schemas when we construct the many-to-many relationship. + +In the `StoreSchema` we add a new list field for the nested `PlainTagSchema`, just as it has with `PlainItemSchema`. + +```python title="schemas.py" +class PlainTagSchema(Schema): + id = fields.Int(dump_only=True) + name = fields.Str() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + # highlight-start + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + # highlight-end + + +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) +``` + +## The API endpoints + +Let's add the Tag endpoints that aren't related to Items: + + +| Method | Endpoint | Description | +| ---------- | ----------------------- | ------------------------------------------------------- | +| ✅ `GET` | `/stores/{id}/tags` | Get a list of tags in a store. | +| ✅ `POST` | `/stores/{id}/tags` | Create a new tag. | +| ❌ `POST` | `/items/{id}/tags/{id}` | Link an item in a store with a tag from the same store. | +| ❌ `DELETE` | `/items/{id}/tags/{id}` | Unlink a tag from an item. | +| ✅ `GET` | `/tags/{id}` | Get information about a tag given its unique id. | +| ❌ `DELETE` | `/tags/{id}` | Delete a tag, which must have no associated items. | + +Here's the code we need to write to add these endpoints: + +```python title="resources/tag.py" +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 +from schemas import TagSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/stores//tags") +class TagsInStore(MethodView): + @blp.response(200, TagSchema(many=True)) + def get(self, store_id): + store = StoreModel.query.get_or_404(store_id) + + return store.tags.all() + + @blp.arguments(TagSchema) + @blp.response(201, TagSchema) + def post(self, tag_data, store_id): + if TagModel.query.filter(TagModel.store_id == store_id).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("/tags/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + +``` \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md new file mode 100644 index 00000000..dadf2651 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md @@ -0,0 +1,245 @@ +--- +title: Many-to-many relationships +description: Learn to set up a many-to-many relationship between two models using SQLAlchemy. +--- + +# Many-to-many relationships + +## The SQLAlchemy models + +In one-to-many relationships, one of the models has a foreign key that links it to another model. + +However, for a many-to-many relationship, one model can't have a single value as a foreign key (otherwise it would be a one-to-many!). Instead, what we do is construct a **secondary table** that has, in each row, a tag ID and and item ID. + +| id | tag_id | item_id | +| --- | ------ | ------- | +| 1 | 2 | 5 | +| 2 | 1 | 4 | +| 3 | 4 | 5 | +| 4 | 1 | 3 | + +
+ Explanation of the table above +
+

The table above has 4 rows, which tell us the following:

+
    +
  1. Tag with ID 1 is linked to Items with IDs 3 and 4.
  2. +
  3. Tag with ID 2 is linked to Item with ID 5.
  4. +
  5. Tag with ID 4 is linked to Item with ID 5.
  6. +
+

And therefore:

+
    +
  1. Item with ID 3 is linked to Tag with ID 1.
  2. +
  3. Item with ID 4 is linked to Tag with ID 1.
  4. +
  5. Item with ID 5 is linked to Tags with IDs 2 and 4.
  6. +
+

This is how many-to-many relationships work, and through this secondary table, the Tag.items and Item.tags attributes will be populated by SQLAlchemy.

+
+
+ +The rows in this table then signify a link between a specific tag and a specific item, but without the need for those values to be stored in the tag or item models themselves. + +### Writing the secondary table for many-to-many relationships + +As we've just seen, many-to-many relationships use a secondary table which stores which models of one side are related to which models of the other side. + +Just as we did with `Item`, `Store`, and `Tag`, we'll create a model for this secondary table: + +```python title="models/item_tags.py" +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")) +``` + +### Using the secondary table in the main models + + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +
+ + + +```python title="models/tag.py" +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") + # highlight-start + items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") + # highlight-end +``` + + + + +```python title="models/item.py" +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=True, 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") + + # highlight-start + tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") + # highlight-end +``` + + + +
+ +## The marshmallow schemas + +Next up, let's add the nested fields to the marshmallow schemas. + +The `TagAndItemSchema` will be used to return information about both the Item and Tag that have been modified in an endpoint, together with an informative message. + +```python title="schemas.py" +class TagSchema(PlainTagSchema): + store_id = fields.Int(load_only=True) + # highlight-start + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) + # highlight-end + store = fields.Nested(PlainStoreSchema(), dump_only=True) + +# highlight-start +class TagAndItemSchema(Schema): + message = fields.Str() + item = fields.Nested(ItemSchema) + tag = fields.Nested(TagSchema) +# highlight-end +``` + +## The API endpoints + +```python title="resources/tag.py" +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 +# highlight-start +from schemas import TagSchema, TagAndItemSchema +# highlight-end + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/stores//tags") +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).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 + +# highlight-start +@blp.route("/items//tags/") +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} +# highlight-end + + +@blp.route("/tags/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag + + # highlight-start + @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.", + ) + # highlight-end +``` \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/_category_.json b/docs/docs/07_sqlalchemy_many_to_many/_category_.json new file mode 100644 index 00000000..c21b5700 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Many-to-many relationships with SQLAlchemy", + "position": 7 +} diff --git a/project/05-add-many-to-many/.flaskenv b/project/05-add-many-to-many/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/project/05-add-many-to-many/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/project/05-add-many-to-many/Dockerfile b/project/05-add-many-to-many/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/project/05-add-many-to-many/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/project/05-add-many-to-many/app.py b/project/05-add-many-to-many/app.py new file mode 100644 index 00000000..8d1cee05 --- /dev/null +++ b/project/05-add-many-to-many/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +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.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/project/05-add-many-to-many/conftest.py b/project/05-add-many-to-many/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/project/05-add-many-to-many/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/project/05-add-many-to-many/db.py b/project/05-add-many-to-many/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/project/05-add-many-to-many/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/project/05-add-many-to-many/models/__init__.py b/project/05-add-many-to-many/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/project/05-add-many-to-many/models/__init__.py @@ -0,0 +1,4 @@ +from models.item import ItemModel +from models.tag import TagModel +from models.store import StoreModel +from models.item_tags import ItemsTags diff --git a/project/05-add-many-to-many/models/item.py b/project/05-add-many-to-many/models/item.py new file mode 100644 index 00000000..f6f86c97 --- /dev/null +++ b/project/05-add-many-to-many/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=True, 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/project/05-add-many-to-many/models/item_tags.py b/project/05-add-many-to-many/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/project/05-add-many-to-many/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/project/05-add-many-to-many/models/store.py b/project/05-add-many-to-many/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/project/05-add-many-to-many/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/project/05-add-many-to-many/models/tag.py b/project/05-add-many-to-many/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/project/05-add-many-to-many/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), 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/project/05-add-many-to-many/requirements.txt b/project/05-add-many-to-many/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/project/05-add-many-to-many/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/project/05-add-many-to-many/resources/__init__.py b/project/05-add-many-to-many/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/05-add-many-to-many/resources/__tests__/conftest.py b/project/05-add-many-to-many/resources/__tests__/conftest.py new file mode 100644 index 00000000..4d4a91ae --- /dev/null +++ b/project/05-add-many-to-many/resources/__tests__/conftest.py @@ -0,0 +1,31 @@ +import pytest + + +@pytest.fixture() +def created_store_id(client): + response = client.post( + "/stores", + json={"name": "Test Store"}, + ) + + return response.json["id"] + + +@pytest.fixture() +def created_item_id(client, created_store_id): + response = client.post( + "/items", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id} + ) + + return response.json["id"] + + +@pytest.fixture() +def created_tag_id(client, created_store_id): + response = client.post( + f"/stores/{created_store_id}/tags", + json={"name": "Test Tag"}, + ) + + return response.json["id"] diff --git a/project/05-add-many-to-many/resources/__tests__/test_item.py b/project/05-add-many-to-many/resources/__tests__/test_item.py new file mode 100644 index 00000000..94f78c19 --- /dev/null +++ b/project/05-add-many-to-many/resources/__tests__/test_item.py @@ -0,0 +1,120 @@ +def test_create_item_in_store(client, created_store_id): + response = client.post( + "/items", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_create_item_with_store_id_not_found(client): + # Note that this will fail if foreign key constraints are enabled. + response = client.post( + "/items", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] is None + + +def test_create_item_with_unknown_data(client): + response = client.post( + "/items", + json={ + "name": "Test Item", + "price": 10.5, + "store_id": 1, + "unknown_field": "unknown", + }, + ) + + assert response.status_code == 422 + assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] + + +def test_delete_item(client, created_item_id): + response = client.delete( + f"/items/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["message"] == "Item deleted." + + +def test_update_item(client, created_item_id): + response = client.put( + f"/items/{created_item_id}", + json={"name": "Test Item (updated)", "price": 12.5}, + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item (updated)" + assert response.json["price"] == 12.5 + + +def test_get_all_items(client): + response = client.post( + "/items", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + response = client.post( + "/items", + json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/items", + ) + + assert response.status_code == 200 + assert len(response.json) == 2 + assert response.json[0]["name"] == "Test Item" + assert response.json[0]["price"] == 10.5 + assert response.json[1]["name"] == "Test Item 2" + + +def test_get_all_items_empty(client): + response = client.get( + "/items", + ) + + assert response.status_code == 200 + assert len(response.json) == 0 + + +def test_get_item_details(client, created_item_id, created_store_id): + response = client.get( + f"/items/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} + + +def test_get_item_details_with_tag(client, created_item_id, created_tag_id): + client.post(f"/items/{created_item_id}/tags/{created_tag_id}") + response = client.get( + f"/items/{created_item_id}", + ) + + assert response.status_code == 200 + assert response.json["name"] == "Test Item" + assert response.json["price"] == 10.5 + assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] + + +def test_get_item_detail_not_found(client): + response = client.get( + "/items/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/project/05-add-many-to-many/resources/__tests__/test_store.py b/project/05-add-many-to-many/resources/__tests__/test_store.py new file mode 100644 index 00000000..8abf9099 --- /dev/null +++ b/project/05-add-many-to-many/resources/__tests__/test_store.py @@ -0,0 +1,217 @@ +def test_get_store(client, created_store_id): + response = client.get( + f"/stores/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [], + } + + +def test_get_store_not_found(client): + response = client.get( + "/stores/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_with_item(client, created_store_id): + client.post( + "/items", + json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, + ) + + response = client.get( + f"/stores/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_get_store_with_tag(client, created_store_id): + client.post( + f"/stores/{created_store_id}/tags", + json={"name": "Test Tag"}, + ) + + response = client.get( + f"/stores/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] + + +def test_create_store(client): + response = client.post( + "/stores", + json={"name": "Test Store"}, + ) + + assert response.status_code == 201 + assert response.json["name"] == "Test Store" + + +def test_create_store_with_items(client, created_store_id): + client.post( + "/items", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + # Get the store with id 1 and check the items contains the newly created item + response = client.get( + f"/stores/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_delete_store(client, created_store_id): + response = client.delete( + f"/stores/{created_store_id}", + ) + + assert response.status_code == 200 + assert response.json == {"message": "Store deleted"} + + +def test_delete_store_doesnt_exist(client): + response = client.delete( + "/stores/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_store_list_empty(client): + response = client.get( + "/stores", + ) + + assert response.status_code == 200 + assert response.json == [] + + +def test_get_store_list_single(client): + client.post( + "/stores", + json={"name": "Test Store"}, + ) + + response = client.get( + "/stores", + ) + + assert response.status_code == 200 + assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] + + +def test_get_store_list_multiple(client): + client.post( + "/stores", + json={"name": "Test Store"}, + ) + client.post( + "/stores", + json={"name": "Test Store 2"}, + ) + + response = client.get( + "/stores", + ) + + assert response.status_code == 200 + assert response.json == [ + {"id": 1, "name": "Test Store", "items": [], "tags": []}, + {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, + ] + + +def test_get_store_list_with_items(client): + client.post( + "/stores", + json={"name": "Test Store"}, + ) + client.post( + "/items", + json={"name": "Test Item", "price": 10.5, "store_id": 1}, + ) + + response = client.get( + "/stores", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ], + "tags": [], + } + ] + + +def test_get_store_list_with_tags(client): + resp = client.post( + "/stores", + json={"name": "Test Store"}, + ) + client.post( + f"/stores/{resp.json['id']}/tags", + json={"name": "Test Tag"}, + ) + response = client.get( + "/stores", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": 1, + "name": "Test Store", + "items": [], + "tags": [{"id": 1, "name": "Test Tag"}], + } + ] + + +def test_create_store_duplicate_name(client): + client.post( + "/stores", + json={"name": "Test Store"}, + ) + + response = client.post( + "/stores", + json={"name": "Test Store"}, + ) + assert response.status_code == 400 + assert response.json["message"] == "A store with that name already exists." diff --git a/project/05-add-many-to-many/resources/__tests__/test_tag.py b/project/05-add-many-to-many/resources/__tests__/test_tag.py new file mode 100644 index 00000000..e68baacc --- /dev/null +++ b/project/05-add-many-to-many/resources/__tests__/test_tag.py @@ -0,0 +1,121 @@ +import pytest +import logging + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def created_tag_with_item_id(client, created_item_id, created_tag_id): + client.post(f"/items/{created_item_id}/tags/{created_tag_id}") + + response = client.get( + f"/tags/{created_tag_id}", + ) + + return response.json["id"] + + +def test_get_tag(client, created_tag_id): + response = client.get( + f"/tags/{created_tag_id}", + ) + + assert response.status_code == 200 + assert response.json == { + "id": 1, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + + +def test_get_tag_not_found(client): + response = client.get( + "/tags/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_items_linked_with_tag(client, created_tag_with_item_id): + response = client.get( + f"/tags/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [ + { + "id": 1, + "name": "Test Item", + "price": 10.5, + } + ] + + +def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): + client.delete(f"/items/{created_item_id}/tags/{created_tag_with_item_id}") + + response = client.get( + f"/tags/{created_tag_with_item_id}", + ) + + assert response.status_code == 200 + assert response.json["items"] == [] + + +def test_delete_tag_without_items(client, created_tag_id): + delete_response = client.delete(f"/tags/{created_tag_id}") + + response = client.get( + f"/tags/{created_tag_id}", + ) + + assert delete_response.status_code == 202 + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_delete_tag_still_has_items(client, created_tag_with_item_id): + response = client.delete(f"/tags/{created_tag_with_item_id}") + + assert response.status_code == 400 + assert ( + response.json["message"] + == "Could not delete tag. Make sure tag is not associated with any items, then try again." + ) + + +def test_delete_tag_not_found(client): + response = client.delete( + "/tags/1", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} + + +def test_get_all_tags_in_store(client, created_store_id, created_tag_id): + response = client.get( + f"/stores/{created_store_id}/tags", + ) + + assert response.status_code == 200 + assert response.json == [ + { + "id": created_tag_id, + "name": "Test Tag", + "items": [], + "store": {"id": 1, "name": "Test Store"}, + } + ] + + +def test_get_all_tags_in_store_not_found(client): + response = client.get( + "/stores/1/tags", + ) + + assert response.status_code == 404 + assert response.json == {"code": 404, "status": "Not Found"} diff --git a/project/05-add-many-to-many/resources/item.py b/project/05-add-many-to-many/resources/item.py new file mode 100644 index 00000000..35190b12 --- /dev/null +++ b/project/05-add-many-to-many/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +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("/items/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + 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_or_404(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(**item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/items") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @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/project/05-add-many-to-many/resources/store.py b/project/05-add-many-to-many/resources/store.py new file mode 100644 index 00000000..c04c6dc3 --- /dev/null +++ b/project/05-add-many-to-many/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("/stores/") +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("/stores") +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/project/05-add-many-to-many/resources/tag.py b/project/05-add-many-to-many/resources/tag.py new file mode 100644 index 00000000..bec8b97d --- /dev/null +++ b/project/05-add-many-to-many/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("/stores//tags") +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).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("/items//tags/") +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("/tags/") +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/project/05-add-many-to-many/schemas.py b/project/05-add-many-to-many/schemas.py new file mode 100644 index 00000000..7fba5609 --- /dev/null +++ b/project/05-add-many-to-many/schemas.py @@ -0,0 +1,45 @@ +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(lambda: PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(lambda: 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(lambda: 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) From d1c531a4d94809cfe92ac5dc4f374c636bd05cc1 Mon Sep 17 00:00:00 2001 From: Jose Salvatierra Date: Wed, 1 Jun 2022 14:51:09 +0100 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20(Many-to-many)=20Add=20missing?= =?UTF-8?q?=20schema=20and=20improve=20legibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grayed out endpoints that had been implemented in previous lectures. --- .../03_many_to_many_relationships/README.md | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md index dadf2651..7abd7cf9 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md @@ -119,6 +119,13 @@ Next up, let's add the nested fields to the marshmallow schemas. The `TagAndItemSchema` will be used to return information about both the Item and Tag that have been modified in an endpoint, together with an informative message. ```python title="schemas.py" +class ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + # highlight-start + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) + # highlight-end + class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) # highlight-start @@ -136,6 +143,19 @@ class TagAndItemSchema(Schema): ## The API endpoints +Now let's add the rest of our API endpoints (grayed out are the ones we implemented in [one-to-many relationships review](../one_to_many_review/))! + +| Method | Endpoint | Description | +| ---------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| ✅ `GET` | `/stores/{id}/tags` | Get a list of tags in a store. | +| ✅ `POST` | `/stores/{id}/tags` | Create a new tag. | +| ✅ `POST` | `/items/{id}/tags/{id}` | Link an item in a store with a tag from the same store. | +| ✅ `DELETE` | `/items/{id}/tags/{id}` | Unlink a tag from an item. | +| ✅ `GET` | `/tags/{id}` | Get information about a tag given its unique id. | +| ✅ `DELETE` | `/tags/{id}` | Delete a tag, which must have no associated items. | + +Here's the code (new lines highlighted): + ```python title="resources/tag.py" from flask.views import MethodView from flask_smorest import Blueprint, abort @@ -242,4 +262,8 @@ class Tag(MethodView): message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) # highlight-end -``` \ No newline at end of file +``` + +And with that, we're done! + +Now we're ready to look at securing API endpoints with user authentication. \ No newline at end of file From 48f25f8752a571ef710fa889a1a84be4e39944a6 Mon Sep 17 00:00:00 2001 From: Jose Salvatierra Date: Wed, 1 Jun 2022 14:52:16 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=8E=A8=20(marshmallow)=20Improve=20sc?= =?UTF-8?q?hemas=20by=20removing=20needless=20lambdas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- project/04-items-stores-smorest-sqlalchemy/schemas.py | 2 +- project/05-add-many-to-many/schemas.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/project/04-items-stores-smorest-sqlalchemy/schemas.py b/project/04-items-stores-smorest-sqlalchemy/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/project/04-items-stores-smorest-sqlalchemy/schemas.py +++ b/project/04-items-stores-smorest-sqlalchemy/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/project/05-add-many-to-many/schemas.py b/project/05-add-many-to-many/schemas.py index 7fba5609..99b9c94a 100644 --- a/project/05-add-many-to-many/schemas.py +++ b/project/05-add-many-to-many/schemas.py @@ -19,8 +19,8 @@ class PlainTagSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) - tags = fields.List(fields.Nested(lambda: PlainTagSchema()), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): @@ -30,7 +30,7 @@ class ItemUpdateSchema(Schema): class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) - tags = fields.List(fields.Nested(lambda: PlainTagSchema()), dump_only=True) + tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): From da9004204ece7f1ae7938c2f0235cb15cc5b4b43 Mon Sep 17 00:00:00 2001 From: Jose Salvatierra Date: Wed, 1 Jun 2022 14:52:52 +0100 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=93=A6=EF=B8=8F=20(marshmallow)=20Upd?= =?UTF-8?q?ate=20final=20project=20submodule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- project/using-flask-smorest-docker | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/using-flask-smorest-docker b/project/using-flask-smorest-docker index 26db6beb..18e6405b 160000 --- a/project/using-flask-smorest-docker +++ b/project/using-flask-smorest-docker @@ -1 +1 @@ -Subproject commit 26db6beb306a2552bf0505871f9b71cca66e4558 +Subproject commit 18e6405b3897f6fa0cbcda2fd290596609e9ebb2 From c554761bd3c7758f960364cf5a7a90d7fc219745 Mon Sep 17 00:00:00 2001 From: Jose Salvatierra Date: Wed, 1 Jun 2022 16:12:29 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8=20(Many-to-many)=20Add=20start=20?= =?UTF-8?q?and=20end=20folders=20in=20lectures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../02_one_to_many_review/README.md | 52 +++++++++ .../02_one_to_many_review/end/.flaskenv | 2 + .../02_one_to_many_review/end/Dockerfile | 7 ++ .../02_one_to_many_review/end/app.py | 36 +++++++ .../02_one_to_many_review/end/db.py | 3 + .../end/models/__init__.py | 4 + .../02_one_to_many_review/end/models/item.py | 14 +++ .../02_one_to_many_review/end/models/store.py | 11 ++ .../02_one_to_many_review/end/models/tag.py | 11 ++ .../end/requirements.txt | 7 ++ .../end/resources/__init__.py | 0 .../end/resources/item.py | 59 +++++++++++ .../end/resources/store.py | 48 +++++++++ .../end/resources/tag.py | 45 ++++++++ .../02_one_to_many_review/end/schemas.py | 37 +++++++ .../02_one_to_many_review/start/.flaskenv | 2 + .../02_one_to_many_review/start/Dockerfile | 7 ++ .../02_one_to_many_review/start/app.py | 34 ++++++ .../02_one_to_many_review/start/db.py | 3 + .../start/models/__init__.py | 4 + .../start/models/item.py | 14 +++ .../start/models/store.py | 11 ++ .../start/requirements.txt | 7 ++ .../start/resources/__init__.py | 0 .../start/resources/item.py | 59 +++++++++++ .../start/resources/store.py | 48 +++++++++ .../02_one_to_many_review/start/schemas.py | 26 +++++ .../03_many_to_many_relationships/README.md | 8 +- .../end/.flaskenv | 2 + .../end/Dockerfile | 7 ++ .../03_many_to_many_relationships/end/app.py | 36 +++++++ .../end/conftest.py | 19 ++++ .../03_many_to_many_relationships/end/db.py | 3 + .../end/models/__init__.py | 4 + .../end/models/item.py | 16 +++ .../end/models/item_tags.py | 9 ++ .../end/models/store.py | 11 ++ .../end/models/tag.py | 12 +++ .../end/requirements.txt | 7 ++ .../end/resources/__init__.py | 0 .../end/resources/item.py | 59 +++++++++++ .../end/resources/store.py | 48 +++++++++ .../end/resources/tag.py | 100 ++++++++++++++++++ .../end/schemas.py | 45 ++++++++ .../start/.flaskenv | 2 + .../start/Dockerfile | 7 ++ .../start/app.py | 36 +++++++ .../03_many_to_many_relationships/start/db.py | 3 + .../start/models/__init__.py | 4 + .../start/models/item.py | 14 +++ .../start/models/store.py | 11 ++ .../start/models/tag.py | 11 ++ .../start/requirements.txt | 7 ++ .../start/resources/__init__.py | 0 .../start/resources/item.py | 59 +++++++++++ .../start/resources/store.py | 48 +++++++++ .../start/resources/tag.py | 45 ++++++++ .../start/schemas.py | 37 +++++++ 58 files changed, 1220 insertions(+), 1 deletion(-) create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/Dockerfile create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/db.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/__init__.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/item.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/store.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/tag.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/requirements.txt create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/__init__.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/item.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/store.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/tag.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/schemas.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/Dockerfile create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/db.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/__init__.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/item.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/store.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/requirements.txt create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/__init__.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/item.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/store.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/schemas.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/Dockerfile create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/conftest.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/db.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/__init__.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item_tags.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/store.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/tag.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/requirements.txt create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/__init__.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/item.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/store.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/tag.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/schemas.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/Dockerfile create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/db.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/__init__.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/item.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/store.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/tag.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/requirements.txt create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/__init__.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/item.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/store.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/tag.py create mode 100644 docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/schemas.py diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md index 06378d1a..225afc00 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md @@ -3,6 +3,12 @@ title: One-to-many relationships review description: A super-quick look at creating the Tag model and setting up the one-to-many relationship with Stores. --- +- [x] Set metadata above +- [x] Start writing! +- [x] Create `start` folder +- [x] Create `end` folder +- [ ] Create per-file diff between `end` and `start` (use "Compare Folders") + # One-to-many relationship between Tag and Store Since we've already learned how to set up one-to-many relationships with SQLAlchemy when we looked at Items and Stores, let's go quickly in this section. @@ -139,5 +145,51 @@ class Tag(MethodView): def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag +``` + +## Register the Tag blueprint in `app.py` + +Finally, we need to remember to import the blueprint and register it! + +```python title="app.py" +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint +# highlight-start +from resources.tag import blp as TagBlueprint +# highlight-end + + +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.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + # highlight-start + api.register_blueprint(TagBlueprint) + # highlight-end + return app ``` \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/Dockerfile b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/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/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py new file mode 100644 index 00000000..8d1cee05 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +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.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/db.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/__init__.py @@ -0,0 +1,4 @@ +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/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/item.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/item.py new file mode 100644 index 00000000..74797e0e --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/item.py @@ -0,0 +1,14 @@ +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=True, 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") diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/store.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/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/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/tag.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/tag.py new file mode 100644 index 00000000..c84ee7cb --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/tag.py @@ -0,0 +1,11 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/requirements.txt b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/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/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/item.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/item.py new file mode 100644 index 00000000..35190b12 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +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("/items/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + 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_or_404(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(**item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/items") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @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/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/store.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/store.py new file mode 100644 index 00000000..c04c6dc3 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/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("/stores/") +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("/stores") +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/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/tag.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/tag.py new file mode 100644 index 00000000..33c17a4a --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/tag.py @@ -0,0 +1,45 @@ +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 +from schemas import TagSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/stores//tags") +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).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("/tags/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/schemas.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/schemas.py new file mode 100644 index 00000000..a1083164 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/schemas.py @@ -0,0 +1,37 @@ +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) + + +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) + store = fields.Nested(PlainStoreSchema(), dump_only=True) diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/Dockerfile b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/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/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py new file mode 100644 index 00000000..0af1f37f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py @@ -0,0 +1,34 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +from resources.item import blp as ItemBlueprint +from resources.store import blp as StoreBlueprint + + +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.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + + return app diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/db.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/__init__.py @@ -0,0 +1,4 @@ +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/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/item.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/item.py new file mode 100644 index 00000000..74797e0e --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/item.py @@ -0,0 +1,14 @@ +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=True, 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") diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/store.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/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/07_sqlalchemy_many_to_many/02_one_to_many_review/start/requirements.txt b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/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/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/item.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/item.py new file mode 100644 index 00000000..35190b12 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +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("/items/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + 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_or_404(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(**item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/items") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @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/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/store.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/store.py new file mode 100644 index 00000000..c04c6dc3 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/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("/stores/") +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("/stores") +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/07_sqlalchemy_many_to_many/02_one_to_many_review/start/schemas.py b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/schemas.py new file mode 100644 index 00000000..fbdcf3de --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/schemas.py @@ -0,0 +1,26 @@ +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 ItemSchema(PlainItemSchema): + store_id = fields.Int(required=True, load_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) + + +class ItemUpdateSchema(Schema): + name = fields.Str() + price = fields.Float() + + +class StoreSchema(PlainStoreSchema): + items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md index 7abd7cf9..1f8ee955 100644 --- a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md @@ -5,6 +5,12 @@ description: Learn to set up a many-to-many relationship between two models usin # Many-to-many relationships +- [x] Set metadata above +- [x] Start writing! +- [x] Create `start` folder +- [x] Create `end` folder +- [ ] Create per-file diff between `end` and `start` (use "Compare Folders") + ## The SQLAlchemy models In one-to-many relationships, one of the models has a foreign key that links it to another model. @@ -162,8 +168,8 @@ from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db -from models import TagModel, StoreModel, ItemModel # highlight-start +from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema # highlight-end diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/Dockerfile b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py new file mode 100644 index 00000000..8d1cee05 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +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.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/conftest.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/conftest.py new file mode 100644 index 00000000..f543eab0 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import create_app + + +@pytest.fixture() +def app(): + app = create_app("sqlite://") + app.config.update( + { + "TESTING": True, + } + ) + + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/db.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/__init__.py @@ -0,0 +1,4 @@ +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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item.py new file mode 100644 index 00000000..f6f86c97 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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=True, 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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item_tags.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item_tags.py new file mode 100644 index 00000000..bc314981 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/store.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/tag.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/tag.py new file mode 100644 index 00000000..ec3e3530 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/tag.py @@ -0,0 +1,12 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), 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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/requirements.txt b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/item.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/item.py new file mode 100644 index 00000000..35190b12 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +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("/items/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + 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_or_404(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(**item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/items") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/store.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/store.py new file mode 100644 index 00000000..c04c6dc3 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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("/stores/") +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("/stores") +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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/tag.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/tag.py new file mode 100644 index 00000000..bec8b97d --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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("/stores//tags") +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).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("/items//tags/") +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("/tags/") +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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/schemas.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/schemas.py new file mode 100644 index 00000000..99b9c94a --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/schemas.py @@ -0,0 +1,45 @@ +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) diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv new file mode 100644 index 00000000..75473901 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=app +FLASK_ENV=development \ No newline at end of file diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/Dockerfile b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/Dockerfile new file mode 100644 index 00000000..652afba1 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py new file mode 100644 index 00000000..8d1cee05 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_smorest import Api + +import models + +from db import db +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.before_first_request + def create_tables(): + db.create_all() + + api.register_blueprint(ItemBlueprint) + api.register_blueprint(StoreBlueprint) + api.register_blueprint(TagBlueprint) + + return app diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/db.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/db.py new file mode 100644 index 00000000..f0b13d6f --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/__init__.py new file mode 100644 index 00000000..f89059c5 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/__init__.py @@ -0,0 +1,4 @@ +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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/item.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/item.py new file mode 100644 index 00000000..74797e0e --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/item.py @@ -0,0 +1,14 @@ +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=True, 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") diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/store.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/store.py new file mode 100644 index 00000000..e0e3831c --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/tag.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/tag.py new file mode 100644 index 00000000..c84ee7cb --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/tag.py @@ -0,0 +1,11 @@ +from db import db + + +class TagModel(db.Model): + __tablename__ = "tags" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + store_id = db.Column(db.String(), db.ForeignKey("stores.id"), nullable=False) + + store = db.relationship("StoreModel", back_populates="tags") diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/requirements.txt b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/requirements.txt new file mode 100644 index 00000000..7fd2f6a6 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/__init__.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/item.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/item.py new file mode 100644 index 00000000..35190b12 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/item.py @@ -0,0 +1,59 @@ +from flask.views import MethodView +from flask_smorest import Blueprint, abort +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("/items/") +class Item(MethodView): + @blp.response(200, ItemSchema) + def get(self, item_id): + item = ItemModel.query.get_or_404(item_id) + return item + + def delete(self, item_id): + 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_or_404(item_id) + + if item: + item.price = item_data["price"] + item.name = item_data["name"] + else: + item = ItemModel(**item_data) + + db.session.add(item) + db.session.commit() + + return item + + +@blp.route("/items") +class ItemList(MethodView): + @blp.response(200, ItemSchema(many=True)) + def get(self): + return ItemModel.query.all() + + @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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/store.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/store.py new file mode 100644 index 00000000..c04c6dc3 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/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("/stores/") +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("/stores") +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/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/tag.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/tag.py new file mode 100644 index 00000000..33c17a4a --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/tag.py @@ -0,0 +1,45 @@ +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 +from schemas import TagSchema + +blp = Blueprint("Tags", "tags", description="Operations on tags") + + +@blp.route("/stores//tags") +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).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("/tags/") +class Tag(MethodView): + @blp.response(200, TagSchema) + def get(self, tag_id): + tag = TagModel.query.get_or_404(tag_id) + return tag diff --git a/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/schemas.py b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/schemas.py new file mode 100644 index 00000000..a1083164 --- /dev/null +++ b/docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/schemas.py @@ -0,0 +1,37 @@ +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) + + +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) + store = fields.Nested(PlainStoreSchema(), dump_only=True) From 22842ebd2c1b6d06b2ac3bb737a7256062b41f47 Mon Sep 17 00:00:00 2001 From: Jose Salvatierra Date: Wed, 1 Jun 2022 16:12:52 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=8E=A8=20(marshmallow)=20Remove=20nee?= =?UTF-8?q?dles=20lambdas=20in=20one-to-many=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../03_one_to_many_relationships_sqlalchemy/README.md | 2 +- .../03_one_to_many_relationships_sqlalchemy/end/schemas.py | 2 +- .../03_one_to_many_relationships_sqlalchemy/start/schemas.py | 2 +- .../04_configure_flask_sqlalchemy/end/schemas.py | 2 +- .../04_configure_flask_sqlalchemy/start/schemas.py | 2 +- .../05_insert_models_sqlalchemy/end/schemas.py | 2 +- .../05_insert_models_sqlalchemy/start/schemas.py | 2 +- .../06_get_models_or_404/end/schemas.py | 2 +- .../06_get_models_or_404/start/schemas.py | 2 +- .../07_updating_models_sqlalchemy/end/schemas.py | 2 +- .../07_updating_models_sqlalchemy/start/schemas.py | 2 +- .../08_retrieve_list_all_models/end/schemas.py | 2 +- .../08_retrieve_list_all_models/start/schemas.py | 2 +- .../09_delete_models_sqlalchemy/end/schemas.py | 2 +- .../09_delete_models_sqlalchemy/start/schemas.py | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/README.md b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/README.md index 6fb43a92..249fb33b 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/README.md +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/README.md @@ -107,7 +107,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): diff --git a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/schemas.py b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/schemas.py index 2619c4ee..fbdcf3de 100644 --- a/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/schemas.py +++ b/docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/schemas.py @@ -14,7 +14,7 @@ class PlainStoreSchema(Schema): class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) - store = fields.Nested(lambda: PlainStoreSchema(), dump_only=True) + store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema):