-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
19 changed files
with
2,819 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
name: Pypi Publish | ||
|
||
on: | ||
release: | ||
types: | ||
- created | ||
env: | ||
HATCH_INDEX_AUTH: ${{ secrets.HATCH_INDEX_AUTH }} | ||
HATCH_INDEX_USER: ${{ secrets.HATCH_INDEX_USER }} | ||
|
||
jobs: | ||
publish: | ||
name: Pypi Publish | ||
runs-on: ubuntu-latest | ||
|
||
steps: | ||
- uses: actions/checkout@v3 | ||
- uses: actions/setup-python@v1 | ||
with: | ||
python-version: 3.9 | ||
- name: Extract Release Name | ||
run: echo "RELEASE_NAME=$(echo $GITHUB_REF | sed -n 's/refs\/tags\///p')" >> $GITHUB_ENV | ||
- name: Install hatch | ||
run: pip install hatch | ||
- name: Publish to Pypi | ||
run: | | ||
hatch version $RELEASE_NAME | ||
hatch build | ||
hatch publish -r test -n | ||
- name: Checkout Main | ||
run: | | ||
git fetch | ||
git stash | ||
git checkout main | ||
git stash apply | ||
- name: Add & Commit | ||
uses: EndBug/[email protected] | ||
with: | ||
add: 'src/flask_muck/__init__.py' | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
name: CI Testing | ||
|
||
on: | ||
pull_request: | ||
branches: | ||
- main | ||
push: | ||
branches: | ||
- main | ||
|
||
jobs: | ||
test: | ||
name: Test | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v3 | ||
- uses: psf/black@stable | ||
- uses: actions/setup-python@v1 | ||
with: | ||
python-version: 3.9 | ||
- run: pip install poetry | ||
- run: poetry install --with dev | ||
- run: poetry run mypy src/ | ||
- run: poetry run pytest --cov=flask_muck | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,6 @@ | ||
[](https://github.com/psf/black) | ||
|
||
# flask-muck | ||
Batteries included framework for generating RESTful apis using Flask and SqlAlchemy. | ||
Batteries included framework for generating RESTful APIs with built-in CRUD in a Flask/SqlAlchemy stack. | ||
|
||
## [DOCUMENTATION COMING SOON, SEE EXAMPLES FOLDER TO GET STARTED] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
[[source]] | ||
url = "https://pypi.org/simple" | ||
verify_ssl = true | ||
name = "pypi" | ||
|
||
[packages] | ||
flask = "*" | ||
flask-sqlalchemy = "*" | ||
pipenv = "*" | ||
install = "*" | ||
marshmallow = "*" | ||
flask-muck = {file = "../.."} | ||
flask-login = "*" | ||
webargs = "*" | ||
|
||
[dev-packages] | ||
|
||
[requires] | ||
python_version = "3.11" |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
# Simple ToDo App API Example | ||
|
||
This is a simple example of a complete Flask app hosting a REST API. This example demonstrates the tech stack that | ||
Flask-Muck sits in and how to set create and register the views. | ||
|
||
Below are instructions are running the testing the example. | ||
|
||
## Prequisites | ||
|
||
- [Python 3.11](https://www.python.org/downloads/) | ||
- [pipenv](https://pipenv.pypa.io/en/latest/#install-pipenv-today) | ||
|
||
## Running The App | ||
|
||
`pipenv run python3 app.py` | ||
|
||
## CURL Commands | ||
|
||
### Login | ||
`curl -X POST --location "http://127.0.0.1:5000/api/v1/login"` | ||
|
||
### Logout | ||
`curl -X POST --location "http://127.0.0.1:5000/api/v1/logout"` | ||
|
||
### Create a ToDo item | ||
``` | ||
curl -X POST --location "http://127.0.0.1:5000/api/v1/todos" \ | ||
-H "Content-Type: application/json" \ | ||
-d "{ | ||
\"text\": \"take out garbage again\" | ||
}" | ||
``` | ||
|
||
### List all ToDo items (flat) | ||
``` | ||
curl -X GET --location "http://127.0.0.1:5000/api/v1/todos" \ | ||
-d "Accept: application/json" | ||
``` | ||
|
||
### List all ToDo items (paginated) | ||
``` | ||
curl -X GET --location "http://127.0.0.1:5000/api/v1/todos?limit=2&offset=1" \ | ||
-d "Accept: application/json" | ||
``` | ||
|
||
### Get ToDo item | ||
``` | ||
curl -X GET --location "http://127.0.0.1:5000/api/v1/todos/1" \ | ||
-d "Accept: application/json" | ||
``` | ||
|
||
### Update ToDo item | ||
``` | ||
curl -X PUT --location "http://127.0.0.1:5000/api/v1/todos/1" \ | ||
-H "Content-Type: application/json" \ | ||
-d "{ | ||
\"text\": \"Updated todo item\" | ||
}" | ||
``` | ||
|
||
### Patch ToDo item | ||
``` | ||
curl -X PATCH --location "http://127.0.0.1:5000/api/v1/todos/1" \ | ||
-H "Content-Type: application/json" \ | ||
-d "{ | ||
\"text\": \"Updated todo item\" | ||
}" | ||
``` | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import marshmallow as ma | ||
from flask import Flask, Blueprint | ||
from flask_login import ( | ||
LoginManager, | ||
login_required, | ||
UserMixin, | ||
login_user, | ||
logout_user, | ||
) | ||
from flask_sqlalchemy import SQLAlchemy | ||
from marshmallow import fields as mf | ||
from sqlalchemy.orm import DeclarativeBase | ||
|
||
from flask_muck.views import MuckApiView | ||
|
||
# Create a Flask app | ||
app = Flask(__name__) | ||
app.config["SECRET_KEY"] = "super-secret" | ||
|
||
|
||
# Init Flask-SQLAlchemy and set database to a local sqlite file. | ||
class Base(DeclarativeBase): | ||
pass | ||
|
||
|
||
db = SQLAlchemy(model_class=Base) | ||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///todo_example.db" | ||
db.init_app(app) | ||
|
||
|
||
# Create SQLAlchemy database models. | ||
class UserModel(db.Model, UserMixin): | ||
"""Flask-Login User model to use for authentication.""" | ||
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True) | ||
|
||
|
||
class TodoModel(db.Model): | ||
"""Simple model to track ToDo items.""" | ||
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True) | ||
text = db.Column(db.String, nullable=False) | ||
|
||
|
||
# Create Marshmallow schemas for serialization. | ||
class TodoSchema(ma.Schema): | ||
"""ToDo model schema that can be used for CRUD operations.""" | ||
|
||
id = mf.Integer(required=True, dump_only=True) | ||
text = mf.String(required=True) | ||
|
||
|
||
# Add a Flask blueprint for the base of the REST API and register it with the app. | ||
api_blueprint = Blueprint("v1_api", __name__, url_prefix="/api/v1/") | ||
|
||
|
||
# Init Flask-Login for user authentication and add login/logout endpoints. | ||
login_manager = LoginManager() | ||
login_manager.init_app(app) | ||
|
||
|
||
@login_manager.user_loader | ||
def load_user(user_id): | ||
return UserModel.query.get(user_id) | ||
|
||
|
||
@api_blueprint.route("login", methods=["POST"]) | ||
def login_view(): | ||
"""Dummy login view that creates a User and authenticates them.""" | ||
user = UserModel() | ||
db.session.add(user) | ||
db.session.commit() | ||
login_user(user) | ||
return {}, 200 | ||
|
||
|
||
@api_blueprint.route("logout", methods=["POST"]) | ||
def logout_view(): | ||
logout_user() | ||
return {}, 200 | ||
|
||
|
||
# Add Muck views to generate CRUD REST API. | ||
class BaseApiView(MuckApiView): | ||
"""Base view to inherit from. Helpful for setting class variables shared with all API views such as "sqlalchemy_db" | ||
and "decorators". | ||
""" | ||
|
||
session = db.session | ||
decorators = [login_required] | ||
|
||
|
||
class TodoApiView(BaseApiView): | ||
"""ToDo API view that provides all RESTful CRUD operations.""" | ||
|
||
Model = TodoModel | ||
ResponseSchema = TodoSchema | ||
CreateSchema = TodoSchema | ||
PatchSchema = TodoSchema | ||
UpdateSchema = TodoSchema | ||
searchable_columns = [TodoModel.text] | ||
|
||
|
||
# Add all url rules to the blueprint. | ||
TodoApiView.add_crud_to_blueprint(api_blueprint, url_prefix="todos") | ||
|
||
# Register api blueprint with the app. | ||
app.register_blueprint(api_blueprint) | ||
|
||
if __name__ == "__main__": | ||
with app.app_context(): | ||
db.create_all() | ||
app.run(debug=True) |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
[build-system] | ||
requires = ["hatchling"] | ||
build-backend = "hatchling.build" | ||
|
||
[project] | ||
name = "Flask-Muck" | ||
dynamic = ["version"] | ||
authors = [ | ||
{ name="Daniel Tiesling", email="tiesling@gmail.com" }, | ||
] | ||
description = "Batteries included framework for generating RESTful apis using Flask and SqlAlchemy." | ||
readme = "README.md" | ||
requires-python = ">=3.9" | ||
classifiers = [ | ||
"Programming Language :: Python :: 3", | ||
"License :: OSI Approved :: MIT License", | ||
"Operating System :: OS Independent", | ||
] | ||
|
||
[project.urls] | ||
"Homepage" = "https://github.com/dtiesling/flask-muck" | ||
|
||
[tool] | ||
[tool.hatch.version] | ||
path = "src/flask_muck/__init__.py" | ||
|
||
[tool.hatch.build.targets.sdist] | ||
exclude = [ | ||
"/.github", | ||
"/examples", | ||
] | ||
|
||
[tool.hatch.build.targets.wheel] | ||
packages = ["src/flask_muck"] | ||
|
||
[tool.poetry] | ||
name = "flask-muck" | ||
description = "Batteries included framework for generating RESTful apis using Flask and SqlAlchemy." | ||
version = "0.0.1" | ||
authors = ["Daniel Tiesling <tiesling@gmail.com>"] | ||
|
||
[tool.poetry.dependencies] | ||
python = "^3.9" | ||
Flask = "^2.0.0" | ||
sqlalchemy = "^2.0.23" | ||
webargs = "^8.3.0" | ||
types-requests = "^2.31.0.10" | ||
marshmallow = "^3.20.1" | ||
|
||
[tool.poetry.group.dev.dependencies] | ||
mypy = "^1.6.1" | ||
types-flask = "^1.1.6" | ||
types-requests = "^2.31.0.10" | ||
sqlalchemy-stubs = "^0.4" | ||
pytest = "^7.4.3" | ||
flask-login = "^0.6.3" | ||
flask-sqlalchemy = "^3.1.1" | ||
coverage = "^7.3.2" | ||
pytest-cov = "^4.1.0" | ||
|
||
[tool.mypy] | ||
packages = "src" | ||
strict = true | ||
disallow_untyped_calls = false | ||
warn_return_any = false | ||
disallow_any_generics = false | ||
|
||
[tool.pytest.ini_options] | ||
testpaths = ["tests"] | ||
python_files = "test*.py" | ||
pythonpath = ["src"] | ||
|
||
[tool.coverage.run] | ||
branch = true | ||
|
||
[tool.coverage.report] | ||
fail_under = 95 | ||
exclude_also = [ | ||
"@(abc\\.)?abstractmethod", | ||
"if TYPE_CHECKING:", | ||
"raise NotImplementedError" | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from .views import MuckApiView | ||
from .callback import MuckCallback | ||
|
||
VERSION = "0.0.2rc1" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
from abc import ABC, abstractmethod | ||
from enum import Enum | ||
|
||
from flask_muck.types import SqlaModel, JsonDict | ||
|
||
|
||
class CallbackType(Enum): | ||
pre = "pre" | ||
post = "post" | ||
|
||
|
||
class MuckCallback(ABC): | ||
def __init__(self, resource: SqlaModel, kwargs: JsonDict): | ||
self.resource = resource | ||
self.kwargs = kwargs | ||
|
||
@abstractmethod | ||
def execute(self) -> None: | ||
... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
class MuckImplementationError(Exception): | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
from typing import Any, Union | ||
|
||
from sqlalchemy.orm import DeclarativeBase # type: ignore | ||
|
||
JsonDict = dict[str, Any] | ||
ResourceId = Union[str, int] | ||
SqlaModelType = type[DeclarativeBase] | ||
SqlaModel = DeclarativeBase |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
from __future__ import annotations | ||
from typing import Optional, TYPE_CHECKING, Union | ||
|
||
from flask import request | ||
from sqlalchemy import Column, inspect | ||
|
||
from flask_muck.exceptions import MuckImplementationError | ||
from flask_muck.types import SqlaModelType | ||
|
||
if TYPE_CHECKING: | ||
from flask_muck.views import MuckApiView | ||
|
||
|
||
def get_url_rule(muck_view: type[MuckApiView], append_rule: Optional[str]) -> str: | ||
"""Recursively build the url rule for a MuckApiView by looking at its parent if it exists.""" | ||
rule = muck_view.api_name | ||
if append_rule: | ||
rule = f"{rule}/{append_rule}" | ||
if muck_view.parent: | ||
rule = f"<{muck_view.parent.primary_key_type.__name__}:{muck_view.parent.api_name}_id>/{rule}" | ||
return get_url_rule(muck_view.parent, rule) | ||
if not rule.endswith("/"): | ||
rule = rule + "/" | ||
return rule | ||
|
||
|
||
def get_fk_column( | ||
parent_model: SqlaModelType, child_model: SqlaModelType | ||
) -> Optional[Column]: | ||
"""Get the foreign key column for a child model.""" | ||
for column in inspect(child_model).columns: | ||
if column.foreign_keys: | ||
for fk in column.foreign_keys: | ||
if fk.column.table == parent_model.__table__: | ||
return column | ||
raise MuckImplementationError( | ||
f"The {child_model.__name__} model does not have a foreign key to the {parent_model.__name__} model. " | ||
f"Your MuckApiView parents are not configured correctly." | ||
) | ||
|
||
|
||
def get_query_filters_from_request_path( | ||
view: Union[type[MuckApiView], MuckApiView], query_filters: list | ||
) -> list: | ||
"""Recursively builds query kwargs from the request path based on nested MuckApiViews. If the view has no parent | ||
then nothing is done and original query_kwargs are returned. | ||
""" | ||
if view.parent: | ||
child_model = view.Model | ||
parent_model = view.parent.Model | ||
fk_column = get_fk_column(parent_model, child_model) | ||
query_filters.append( | ||
fk_column == request.view_args[f"{view.parent.api_name}_id"] | ||
) | ||
return get_query_filters_from_request_path(view.parent, query_filters) | ||
return query_filters | ||
|
||
|
||
def get_join_models_from_parent_views( | ||
view: Union[type[MuckApiView], MuckApiView], join_models: list[SqlaModelType] | ||
) -> list[SqlaModelType]: | ||
"""Recursively builds a list of models that need to be joined in queries based on the view's parents..""" | ||
if view.parent: | ||
join_models.append(view.parent.Model) | ||
return get_join_models_from_parent_views(view.parent, join_models) | ||
join_models.reverse() | ||
return join_models |
Large diffs are not rendered by default.
Oops, something went wrong.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
import marshmallow as ma | ||
from flask import Flask, Blueprint | ||
from flask_login import ( | ||
LoginManager, | ||
login_required, | ||
UserMixin, | ||
login_user, | ||
logout_user, | ||
FlaskLoginClient, | ||
) | ||
from flask_sqlalchemy import SQLAlchemy | ||
from marshmallow import fields as mf | ||
from sqlalchemy.orm import DeclarativeBase, Mapped | ||
|
||
from flask_muck import MuckCallback | ||
from flask_muck.views import MuckApiView | ||
|
||
|
||
login_manager = LoginManager() | ||
|
||
|
||
# Init Flask-SQLAlchemy and set database to a local sqlite file. | ||
class Base(DeclarativeBase): | ||
pass | ||
|
||
|
||
db = SQLAlchemy(model_class=Base) | ||
|
||
|
||
# Create SQLAlchemy database models. | ||
class UserModel(db.Model, UserMixin): | ||
"""Flask-Login User model to use for authentication.""" | ||
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True) | ||
|
||
|
||
class FamilyModel(db.Model): | ||
id = db.Column(db.Integer, primary_key=True, autoincrement=True) | ||
surname = db.Column(db.String, nullable=False) | ||
|
||
|
||
class GuardianModel(db.Model): | ||
id = db.Column(db.Integer, primary_key=True, autoincrement=True) | ||
name = db.Column(db.String, nullable=False, unique=True) | ||
age = db.Column(db.Integer, nullable=True) | ||
family_id = db.Column(db.Integer, db.ForeignKey(FamilyModel.id)) | ||
family = db.relationship(FamilyModel) | ||
children: Mapped[list["ChildModel"]] = db.relationship() | ||
|
||
|
||
class ChildModel(db.Model): | ||
id = db.Column(db.Integer, primary_key=True, autoincrement=True) | ||
name = db.Column(db.String, nullable=False) | ||
age = db.Column(db.Integer, nullable=True) | ||
family_id = db.Column(db.Integer, db.ForeignKey(FamilyModel.id)) | ||
guardian_id = db.Column(db.Integer, db.ForeignKey(GuardianModel.id)) | ||
guardian = db.relationship(GuardianModel, back_populates="children") | ||
toy: Mapped["ToyModel"] = db.relationship(uselist=False) | ||
|
||
|
||
class ToyModel(db.Model): | ||
id = db.Column(db.Integer, primary_key=True, autoincrement=True) | ||
name = db.Column(db.String, nullable=False) | ||
family_id = db.Column(db.Integer, db.ForeignKey(FamilyModel.id)) | ||
child_id = db.Column(db.Integer, db.ForeignKey(ChildModel.id)) | ||
child = db.relationship(ChildModel, back_populates="toy") | ||
|
||
|
||
class GuardianSchema(ma.Schema): | ||
name = mf.String(required=True) | ||
|
||
|
||
class ChildSchema(ma.Schema): | ||
name = mf.String(required=True) | ||
guardian_id = mf.Integer(required=True, load_only=True) | ||
|
||
|
||
class GuardianDetailSchema(ma.Schema): | ||
name = mf.String(required=True) | ||
children = mf.Nested(ChildSchema, many=True) | ||
|
||
|
||
class ToySchema(ma.Schema): | ||
name = mf.String(required=True) | ||
child_id = mf.Integer(required=True, load_only=True) | ||
|
||
|
||
api_blueprint = Blueprint("api", __name__, url_prefix="/") | ||
|
||
|
||
@login_manager.user_loader | ||
def load_user(user_id): | ||
return UserModel.query.get(user_id) | ||
|
||
|
||
@api_blueprint.route("login", methods=["POST"]) | ||
def login_view(): | ||
"""Dummy login view that creates a User and authenticates them.""" | ||
user = UserModel() | ||
db.session.add(user) | ||
db.session.commit() | ||
login_user(user) | ||
return {}, 200 | ||
|
||
|
||
@api_blueprint.route("logout", methods=["POST"]) | ||
def logout_view(): | ||
logout_user() | ||
return {}, 200 | ||
|
||
|
||
# Add Muck views to generate CRUD REST API. | ||
class PreCallback(MuckCallback): | ||
def execute(self) -> None: | ||
return | ||
|
||
|
||
class PostCallback(MuckCallback): | ||
def execute(self) -> None: | ||
return | ||
|
||
|
||
class BaseApiView(MuckApiView): | ||
"""Base view to inherit from. Helpful for setting class variables shared with all API views such as "sqlalchemy_db" | ||
and "decorators". | ||
""" | ||
|
||
session = db.session | ||
decorators = [login_required] | ||
pre_create_callbacks = [PreCallback] | ||
pre_update_callbacks = [PreCallback] | ||
pre_patch_callbacks = [PreCallback] | ||
pre_delete_callbacks = [PreCallback] | ||
post_create_callbacks = [PostCallback] | ||
post_update_callbacks = [PostCallback] | ||
post_patch_callbacks = [PostCallback] | ||
post_delete_callbacks = [PostCallback] | ||
|
||
|
||
class GuardianApiView(BaseApiView): | ||
api_name = "guardians" | ||
Model = GuardianModel | ||
ResponseSchema = GuardianSchema | ||
CreateSchema = GuardianSchema | ||
PatchSchema = GuardianSchema | ||
UpdateSchema = GuardianSchema | ||
DetailSchema = GuardianDetailSchema | ||
searchable_columns = [GuardianModel.name, GuardianModel.age] | ||
|
||
|
||
class ChildApiView(BaseApiView): | ||
api_name = "children" | ||
Model = ChildModel | ||
ResponseSchema = ChildSchema | ||
CreateSchema = ChildSchema | ||
PatchSchema = ChildSchema | ||
UpdateSchema = ChildSchema | ||
parent = GuardianApiView | ||
searchable_columns = [ChildModel.name] | ||
|
||
|
||
class ToyApiView(BaseApiView): | ||
api_name = "toy" | ||
Model = ToyModel | ||
ResponseSchema = ToySchema | ||
CreateSchema = ToySchema | ||
PatchSchema = ToySchema | ||
UpdateSchema = ToySchema | ||
parent = ChildApiView | ||
one_to_one_api = True | ||
|
||
|
||
# Add all url rules to the blueprint. | ||
GuardianApiView.add_crud_to_blueprint(api_blueprint) | ||
ChildApiView.add_crud_to_blueprint(api_blueprint) | ||
ToyApiView.add_crud_to_blueprint(api_blueprint) | ||
|
||
|
||
def create_app() -> Flask: | ||
app = Flask(__name__) | ||
app.config["SECRET_KEY"] = "super-secret" | ||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///todo_example.db" | ||
app.config["TESTING"] = True | ||
app.config["DEBUG"] = True | ||
app.test_client_class = FlaskLoginClient | ||
login_manager.init_app(app) | ||
db.init_app(app) | ||
app.register_blueprint(api_blueprint) | ||
return app |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
from typing import Callable, Literal | ||
|
||
import pytest | ||
from flask import Flask | ||
from flask.testing import FlaskClient | ||
from flask_sqlalchemy import SQLAlchemy | ||
from sqlalchemy.orm import DeclarativeBase | ||
|
||
from flask_muck.types import JsonDict | ||
from tests.app import ( | ||
create_app, | ||
UserModel, | ||
GuardianModel, | ||
ChildModel, | ||
ToyModel, | ||
FamilyModel, | ||
) | ||
from tests.app import db as _db | ||
|
||
|
||
def make_request( | ||
client: FlaskClient, | ||
method: Literal["get", "put", "patch", "post", "delete"], | ||
url: str, | ||
expected_status_code: int, | ||
**kwargs, | ||
) -> JsonDict: | ||
response = getattr(client, method)(url, **kwargs) | ||
if response.status_code != expected_status_code: | ||
raise AssertionError( | ||
f"Expected status code {expected_status_code}, got {response.status_code}" | ||
) | ||
return response.json | ||
|
||
|
||
@pytest.fixture | ||
def create_model(db) -> Callable: | ||
def _create_model(model_instance: DeclarativeBase) -> DeclarativeBase: | ||
db.session.add(model_instance) | ||
db.session.flush() | ||
return model_instance | ||
|
||
return _create_model | ||
|
||
|
||
@pytest.fixture | ||
def get(client) -> Callable: | ||
def _get(url, expected_status_code=200, **kwargs): | ||
return make_request(client, "get", url, expected_status_code, **kwargs) | ||
|
||
return _get | ||
|
||
|
||
@pytest.fixture | ||
def post(client) -> Callable: | ||
def _post(url, expected_status_code=201, **kwargs): | ||
return make_request(client, "post", url, expected_status_code, **kwargs) | ||
|
||
return _post | ||
|
||
|
||
@pytest.fixture | ||
def put(client) -> Callable: | ||
def _put(url, expected_status_code=200, **kwargs): | ||
return make_request(client, "put", url, expected_status_code, **kwargs) | ||
|
||
return _put | ||
|
||
|
||
@pytest.fixture | ||
def patch(client) -> Callable: | ||
def _patch(url, expected_status_code=200, **kwargs): | ||
return make_request(client, "patch", url, expected_status_code, **kwargs) | ||
|
||
return _patch | ||
|
||
|
||
@pytest.fixture | ||
def delete(client) -> Callable: | ||
def _delete(url, expected_status_code=204, **kwargs): | ||
return make_request(client, "delete", url, expected_status_code, **kwargs) | ||
|
||
return _delete | ||
|
||
|
||
@pytest.fixture | ||
def db(app) -> SQLAlchemy: | ||
_db.create_all() | ||
yield _db | ||
_db.session.close() | ||
_db.drop_all() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def app() -> Flask: | ||
app = create_app() | ||
with app.app_context(): | ||
yield app | ||
|
||
|
||
@pytest.fixture | ||
def client(app, user) -> FlaskClient: | ||
return app.test_client(user=user) | ||
|
||
|
||
@pytest.fixture | ||
def user(create_model) -> UserModel: | ||
return create_model(UserModel()) | ||
|
||
|
||
@pytest.fixture | ||
def family(create_model) -> FamilyModel: | ||
return create_model(FamilyModel(surname="Brown")) | ||
|
||
|
||
@pytest.fixture | ||
def guardian(family, create_model) -> GuardianModel: | ||
return create_model(GuardianModel(name="Samantha", family_id=family.id)) | ||
|
||
|
||
@pytest.fixture | ||
def child(family, guardian, create_model) -> ChildModel: | ||
return create_model( | ||
ChildModel(name="Tamara", family_id=family.id, guardian_id=guardian.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def simpson_family(create_model) -> FamilyModel: | ||
return create_model(FamilyModel(surname="Simpsons")) | ||
|
||
|
||
@pytest.fixture | ||
def marge(simpson_family, create_model) -> GuardianModel: | ||
return create_model( | ||
GuardianModel(name="Marge", age=34, family_id=simpson_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def bart(simpson_family, marge, create_model) -> ChildModel: | ||
return create_model( | ||
ChildModel( | ||
name="Bart", age=10, guardian_id=marge.id, family_id=simpson_family.id | ||
) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def maggie(simpson_family, marge, create_model) -> ChildModel: | ||
return create_model( | ||
ChildModel( | ||
name="Maggie", age=1, guardian_id=marge.id, family_id=simpson_family.id | ||
) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def lisa(simpson_family, marge, create_model) -> ChildModel: | ||
return create_model( | ||
ChildModel( | ||
name="Lisa", age=8, guardian_id=marge.id, family_id=simpson_family.id | ||
) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def skateboard(simpson_family, bart, create_model) -> ToyModel: | ||
return create_model( | ||
ToyModel(name="Skateboard", child_id=bart.id, family_id=simpson_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def saxophone(simpson_family, lisa, create_model) -> ToyModel: | ||
return create_model( | ||
ToyModel(name="Saxophone", child_id=lisa.id, family_id=simpson_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def pacifier(simpson_family, maggie, create_model) -> ToyModel: | ||
return create_model( | ||
ToyModel(name="Pacifier", child_id=maggie.id, family_id=simpson_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def simpsons(marge, bart, maggie, lisa, skateboard, saxophone, pacifier) -> None: | ||
pass | ||
|
||
|
||
@pytest.fixture | ||
def belcher_family(create_model) -> FamilyModel: | ||
return create_model(FamilyModel(surname="Belcher")) | ||
|
||
|
||
@pytest.fixture | ||
def bob(belcher_family, create_model) -> GuardianModel: | ||
return create_model(GuardianModel(name="Bob", age=46, family_id=belcher_family.id)) | ||
|
||
|
||
@pytest.fixture | ||
def tina(belcher_family, bob, create_model) -> ChildModel: | ||
return create_model( | ||
ChildModel(name="Tina", age=12, guardian_id=bob.id, family_id=belcher_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def louise(belcher_family, bob, create_model) -> ChildModel: | ||
return create_model( | ||
ChildModel( | ||
name="Louise", age=9, guardian_id=bob.id, family_id=belcher_family.id | ||
) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def gene(belcher_family, bob, create_model) -> ChildModel: | ||
return create_model( | ||
ChildModel(name="Gene", age=11, guardian_id=bob.id, family_id=belcher_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def pony(tina, belcher_family, create_model): | ||
return create_model( | ||
ToyModel(name="Pony", child_id=tina.id, family_id=belcher_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def hat(louise, belcher_family, create_model): | ||
return create_model( | ||
ToyModel(name="Hat", child_id=louise.id, family_id=belcher_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def keyboard(gene, belcher_family, create_model): | ||
return create_model( | ||
ToyModel(name="Keyboard", child_id=gene.id, family_id=belcher_family.id) | ||
) | ||
|
||
|
||
@pytest.fixture | ||
def belchers(bob, tina, louise, gene, pony, hat, keyboard) -> None: | ||
return |
Large diffs are not rendered by default.
Oops, something went wrong.