From b5b0bbbb01c62563b24c169b761e53d5685d2666 Mon Sep 17 00:00:00 2001 From: prostomarkeloff Date: Tue, 3 Dec 2019 23:24:27 +0300 Subject: [PATCH] Tortoise ORM support (#59) * add tortoise to dependencies * add tortoise as optional dependency in pyproject.toml * add tortoise support (tests needed) * Add tortoise support (also defined orm_mode in pydantic model * tests for tortoise support * format by black * docs for tortoise * delete type annotations * delete underscore * do it in 1 line * add 1 line before yield * fix in docs * fix bug and add annotation for test * Tweak documentation and fix Tortoise error about id update * Improve Tortoise coverage by using get instead of filter * Fix Pipfile.lock --- .gitignore | 2 +- Pipfile | 1 + Pipfile.lock | 35 +++++++++- docs/configuration/databases/tortoise.md | 53 +++++++++++++++ docs/configuration/full_example.md | 4 ++ docs/configuration/model.md | 2 + docs/installation.md | 6 ++ docs/src/db_tortoise.py | 16 +++++ docs/src/full_tortoise.py | 36 +++++++++++ fastapi_users/db/tortoise.py | 58 +++++++++++++++++ fastapi_users/models.py | 3 + mkdocs.yml | 1 + pyproject.toml | 3 + tests/test_db_tortoise.py | 82 ++++++++++++++++++++++++ 14 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 docs/configuration/databases/tortoise.md create mode 100644 docs/src/db_tortoise.py create mode 100644 docs/src/full_tortoise.py create mode 100644 fastapi_users/db/tortoise.py create mode 100644 tests/test_db_tortoise.py diff --git a/.gitignore b/.gitignore index a17a2822..fdac9e88 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,7 @@ coverage.xml .pytest_cache/ junit/ junit.xml -test.db +test.db* # Translations *.mo diff --git a/Pipfile b/Pipfile index 7740e475..afc94978 100644 --- a/Pipfile +++ b/Pipfile @@ -34,6 +34,7 @@ databases = "==0.2.6" pyjwt = "==1.7.1" python-multipart = "==0.0.5" motor = "==2.0.0" +tortoise-orm = "==0.15.1" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 6e250e71..58ffa2a5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4d00b916bd84a62b9047900bbec33a5eecf31c43baa93058122fb4b73bceedc4" + "sha256": "78d0c10e48c0662beb7525d9173388d78804f2a9ba7e4d993b50c63e9d57059b" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,12 @@ ] }, "default": { + "aiosqlite": { + "hashes": [ + "sha256:ad84fbd7516ca7065d799504fc41d6845c938e5306d1b7dd960caaeda12e22a9" + ], + "version": "==0.10.0" + }, "bcrypt": { "hashes": [ "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89", @@ -77,6 +83,12 @@ ], "version": "==1.13.2" }, + "ciso8601": { + "hashes": [ + "sha256:307342e8bb362ae41a3f3a089c11b374116823bce6fbe5d784e2a2dc37f2c753" + ], + "version": "==2.1.2" + }, "databases": { "hashes": [ "sha256:a04db1d158a91db7bd49db16e14266e8e6c7336f06f88c700147690683c769a3" @@ -198,6 +210,12 @@ ], "version": "==3.9.0" }, + "pypika": { + "hashes": [ + "sha256:2d23365f7d30e313d6d3f9a1670f2ac9ddb72b391a21ad4737644ace797c6ae1" + ], + "version": "==0.35.16" + }, "python-multipart": { "hashes": [ "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43" @@ -224,6 +242,21 @@ "sha256:c2ac9a42e0e0328ad20fe444115ac5e3760c1ee2ac1ff8cdb5ec915c4a453411" ], "version": "==0.12.9" + }, + "tortoise-orm": { + "hashes": [ + "sha256:590a036f224cc3627fbcd950b7492ec98b8411df3491b725eb49c8b5edeaf54c" + ], + "index": "pypi", + "version": "==0.15.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2", + "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d", + "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575" + ], + "version": "==3.7.4.1" } }, "develop": { diff --git a/docs/configuration/databases/tortoise.md b/docs/configuration/databases/tortoise.md new file mode 100644 index 00000000..072fb8a9 --- /dev/null +++ b/docs/configuration/databases/tortoise.md @@ -0,0 +1,53 @@ +# Tortoise ORM + +**FastAPI Users** provides the necessary tools to work with Tortoise ORM. + +## Installation + +Install the database driver that corresponds to your DBMS: + +```sh +pip install asyncpg +``` + +```sh +pip install aiomysql +``` + +```sh +pip install aiosqlite +``` + +For the sake of this tutorial from now on, we'll use a simple SQLite databse. + +## Setup User table + +Let's declare our User model. + +```py hl_lines="9 10" +{!./src/db_tortoise.py!} +``` + +As you can see, **FastAPI Users** provides a mixin that will include base fields for our User table. You can of course add you own fields there to fit to your needs! + +## Create the database adapter + +The database adapter of **FastAPI Users** makes the link between your database configuration and the users logic. Create it like this. + +```py hl_lines="13" +{!./src/db_tortoise.py!} +``` + +## Register Tortoise + +For using Tortoise ORM we must register our models and database. + +Tortoise ORM supports integration with Starlette/FastAPI out-of-the-box. It will automatically bind startup and shutdown events. + +```py hl_lines="16" +{!./src/db_tortoise.py!} +``` + +## Next steps + +We will now configure an [authentication method](../authentication/index.md). diff --git a/docs/configuration/full_example.md b/docs/configuration/full_example.md index a17c97c3..99143aca 100644 --- a/docs/configuration/full_example.md +++ b/docs/configuration/full_example.md @@ -10,6 +10,10 @@ Here is a full working example with JWT authentication to help get you started. {!./src/full_mongodb.py!} ``` +```py tab="Tortoise ORM" +{!./src/full_tortoise.py!} +``` + ## What now? You're ready to go! Be sure to check the [Usage](../usage/routes.md) section to understand how yo work with **FastAPI Users**. diff --git a/docs/configuration/model.md b/docs/configuration/model.md index 22af146d..e5f33477 100644 --- a/docs/configuration/model.md +++ b/docs/configuration/model.md @@ -28,3 +28,5 @@ Depending on your database backend, database configuration will differ a bit. [I'm using SQLAlchemy](databases/sqlalchemy.md) [I'm using MongoDB](databases/mongodb.md) + +[I'm using Tortoise ORM](databases/tortoise.md) diff --git a/docs/installation.md b/docs/installation.md index 5bb20147..c1a8f3bf 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -14,6 +14,12 @@ pip install fastapi-users[sqlalchemy] pip install fastapi-users[mongodb] ``` +## With Tortoise ORM support + +```sh +pip install fastapi-users[tortoise-orm] +``` + --- That's it! Now, let's have a look at our [User model](./configuration/model.md). diff --git a/docs/src/db_tortoise.py b/docs/src/db_tortoise.py new file mode 100644 index 00000000..6b9072b2 --- /dev/null +++ b/docs/src/db_tortoise.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from fastapi_users.db.tortoise import BaseUserModel, TortoiseUserDatabase +from tortoise import Model +from tortoise.contrib.starlette import register_tortoise + +DATABASE_URL = "sqlite://./test.db" + + +class UserModel(BaseUserModel, Model): + pass + + +user_db = TortoiseUserDatabase(UserModel) +app = FastAPI() + +register_tortoise(app, modules={"models": ["path_to_your_package"]}) diff --git a/docs/src/full_tortoise.py b/docs/src/full_tortoise.py new file mode 100644 index 00000000..8a631fcc --- /dev/null +++ b/docs/src/full_tortoise.py @@ -0,0 +1,36 @@ +from fastapi import FastAPI +from fastapi_users import BaseUser, FastAPIUsers +from fastapi_users.authentication import JWTAuthentication +from fastapi_users.db.tortoise import BaseUserModel, TortoiseUserDatabase +from tortoise import Model +from tortoise.contrib.starlette import register_tortoise + +DATABASE_URL = "sqlite://./test.db" +SECRET = "SECRET" + + +class UserModel(BaseUserModel, Model): + pass + + +class User(BaseUser): + pass + + +auth = JWTAuthentication(secret=SECRET, lifetime_seconds=3600) +user_db = TortoiseUserDatabase(UserModel) +app = FastAPI() + +register_tortoise(app, db_url=DATABASE_URL, modules={"models": ["test"]}) +fastapi_users = FastAPIUsers(user_db, auth, User, SECRET) +app.include_router(fastapi_users.router, prefix="/users", tags=["users"]) + + +@fastapi_users.on_after_register() +def on_after_register(user: User): + print(f"User {user.id} has registered.") + + +@fastapi_users.on_after_forgot_password() +def on_after_forgot_password(user: User, token: str): + print(f"User {user.id} has forgot their password. Reset token: {token}") diff --git a/fastapi_users/db/tortoise.py b/fastapi_users/db/tortoise.py new file mode 100644 index 00000000..9e62efae --- /dev/null +++ b/fastapi_users/db/tortoise.py @@ -0,0 +1,58 @@ +from typing import List, Optional, Type + +from tortoise import Model, fields +from tortoise.exceptions import DoesNotExist + +from fastapi_users.db import BaseUserDatabase +from fastapi_users.models import BaseUserDB + + +class BaseUserModel: + id = fields.TextField(pk=True, generated=False) + email = fields.CharField(index=True, unique=True, null=False, max_length=255) + hashed_password = fields.CharField(null=False, max_length=255) + is_active = fields.BooleanField(default=True, null=False) + is_superuser = fields.BooleanField(default=False, null=False) + + class Meta: + table = "user" + + +class TortoiseUserDatabase(BaseUserDatabase): + + model: Type[Model] + + def __init__(self, model: Type[Model]): + self.model = model + + async def list(self) -> List[BaseUserDB]: + users = await self.model.all() + return [BaseUserDB.from_orm(user) for user in users] + + async def get(self, id: str) -> Optional[BaseUserDB]: + try: + user = await self.model.get(id=id) + return BaseUserDB.from_orm(user) + except DoesNotExist: + return None + + async def get_by_email(self, email: str) -> Optional[BaseUserDB]: + try: + user = await self.model.get(email=email) + return BaseUserDB.from_orm(user) + except DoesNotExist: + return None + + async def create(self, user: BaseUserDB) -> BaseUserDB: + model = self.model(**user.dict()) + await model.save() + return user + + async def update(self, user: BaseUserDB) -> BaseUserDB: + user_dict = user.dict() + user_dict.pop("id") # Tortoise complains if we pass the PK again + await self.model.filter(id=user.id).update(**user_dict) + return user + + async def delete(self, user: BaseUserDB) -> None: + await self.model.filter(id=user.id).delete() diff --git a/fastapi_users/models.py b/fastapi_users/models.py index d6d6467e..1261a58c 100644 --- a/fastapi_users/models.py +++ b/fastapi_users/models.py @@ -25,6 +25,9 @@ class BaseUser(BaseModel): def create_update_dict_superuser(self): return self.dict(exclude_unset=True, exclude={"id"}) + class Config: + orm_mode = True + class BaseUserCreate(BaseUser): email: EmailStr diff --git a/mkdocs.yml b/mkdocs.yml index 5071b7a4..defcc42b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ nav: - Databases: - configuration/databases/sqlalchemy.md - configuration/databases/mongodb.md + - configuration/databases/tortoise.md - Authentication: - configuration/authentication/index.md - configuration/authentication/jwt.md diff --git a/pyproject.toml b/pyproject.toml index 486f3d03..bb2ea1ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ sqlalchemy = [ mongodb = [ "motor ==2.0.0", ] +tortoise-orm = [ + "tortoise-orm ==0.15.1" +] [tool.flit.metadata.urls] Documentation = "https://frankie567.github.io/fastapi-users/" diff --git a/tests/test_db_tortoise.py b/tests/test_db_tortoise.py new file mode 100644 index 00000000..cddabb84 --- /dev/null +++ b/tests/test_db_tortoise.py @@ -0,0 +1,82 @@ +from typing import AsyncGenerator + +import pytest +from tortoise import Model +from tortoise.exceptions import IntegrityError +from tortoise import Tortoise +from fastapi_users.db.tortoise import TortoiseUserDatabase, BaseUserModel +from fastapi_users.models import BaseUserDB +from fastapi_users.password import get_password_hash + + +class User(BaseUserModel, Model): + pass + + +@pytest.fixture +async def tortoise_user_db() -> AsyncGenerator[TortoiseUserDatabase, None]: + DATABASE_URL = "sqlite://./test.db" + + await Tortoise.init( + db_url=DATABASE_URL, modules={"models": ["tests.test_db_tortoise"]} + ) + await Tortoise.generate_schemas() + + yield TortoiseUserDatabase(User) + + await User.all().delete() + await Tortoise.close_connections() + + +@pytest.mark.asyncio +@pytest.mark.db +async def test_queries(tortoise_user_db: TortoiseUserDatabase): + user = BaseUserDB( + id="111", + email="lancelot@camelot.bt", + hashed_password=get_password_hash("guinevere"), + ) + + # Create + user_db = await tortoise_user_db.create(user) + assert user_db.id is not None + assert user_db.is_active is True + assert user_db.is_superuser is False + assert user_db.email == user.email + + # Update + user_db.is_superuser = True + await tortoise_user_db.update(user_db) + + # Get by id + id_user = await tortoise_user_db.get(user.id) + assert id_user.id == user_db.id + assert id_user.is_superuser is True + + # Get by email + email_user = await tortoise_user_db.get_by_email(user.email) + assert email_user.id == user_db.id + + # List + users = await tortoise_user_db.list() + assert len(users) == 1 + first_user = users[0] + assert first_user.id == user_db.id + + # Exception when inserting existing email + with pytest.raises(IntegrityError): + await tortoise_user_db.create(user) + + # Exception when inserting non-nullable fields + with pytest.raises(ValueError): + wrong_user = BaseUserDB(id="222", hashed_password="aaa") + await tortoise_user_db.create(wrong_user) + + # Unknown user + unknown_user = await tortoise_user_db.get_by_email("galahad@camelot.bt") + assert unknown_user is None + + # Delete user + await tortoise_user_db.delete(user) + deleted_user = await tortoise_user_db.get(user.id) + assert deleted_user is None