├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml ├── dependabot.yml ├── stale.yml └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── fastapi_users_db_tortoise ├── __init__.py ├── access_token.py └── py.typed ├── pyproject.toml ├── requirements.dev.txt ├── requirements.txt ├── setup.cfg ├── test_build.py └── tests ├── __init__.py ├── conftest.py ├── test_access_token.py └── test_users.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.yml] 14 | indent_size = 2 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: frankie567 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## To Reproduce 15 | 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## Configuration 27 | - Python version : 28 | - FastAPI version : 29 | - FastAPI Users version : 30 | 31 | ### FastAPI Users configuration 32 | 33 | ```py 34 | # Please copy/paste your FastAPI Users configuration here. 35 | ``` 36 | 37 | ## Additional context 38 | 39 | Add any other context about the problem here. 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: I have a question 🤔 3 | url: https://github.com/fastapi-users/fastapi-users/discussions 4 | about: If you have any question about the usage of FastAPI Users that's not clearly a bug, please open a discussion first. 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - frankie567 11 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 14 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | - bug 10 | - enhancement 11 | - documentation 12 | # Label to use when marking an issue as stale 13 | staleLabel: stale 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | # Comment to post when closing a stale issue. Set to `false` to disable 20 | closeComment: false 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | test: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python_version: [3.7, 3.8, 3.9, '3.10'] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: ${{ matrix.python_version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements.dev.txt 23 | - name: Test with pytest 24 | env: 25 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 26 | run: | 27 | pytest --cov=fastapi_users_db_tortoise/ 28 | codecov 29 | - name: Build and install it on system host 30 | run: | 31 | flit build 32 | flit install --python $(which python) 33 | python test_build.py 34 | 35 | release: 36 | runs-on: ubuntu-latest 37 | needs: test 38 | if: startsWith(github.ref, 'refs/tags/') 39 | 40 | steps: 41 | - uses: actions/checkout@v1 42 | - name: Set up Python 43 | uses: actions/setup-python@v1 44 | with: 45 | python-version: 3.7 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install --upgrade pip 49 | pip install -r requirements.dev.txt 50 | - name: Release on PyPI 51 | env: 52 | FLIT_USERNAME: ${{ secrets.FLIT_USERNAME }} 53 | FLIT_PASSWORD: ${{ secrets.FLIT_PASSWORD }} 54 | run: | 55 | flit publish 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | junit/ 50 | junit.xml 51 | test*.db* 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # .vscode 108 | .vscode/ 109 | 110 | # OS files 111 | .DS_Store 112 | 113 | # .idea 114 | .idea/ 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 François Voron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | isort: 2 | isort ./fastapi_users_db_tortoise ./tests 3 | 4 | format: isort 5 | black . 6 | 7 | test: 8 | pytest --cov=fastapi_users_db_tortoise/ --cov-report=term-missing --cov-fail-under=100 9 | 10 | bumpversion-major: 11 | bumpversion major 12 | 13 | bumpversion-minor: 14 | bumpversion minor 15 | 16 | bumpversion-patch: 17 | bumpversion patch 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Users - Database adapter for Tortoise ORM 2 | 3 |

4 | FastAPI Users 5 |

6 | 7 |

8 | Ready-to-use and customizable users management for FastAPI 9 |

10 | 11 | [![build](https://github.com/fastapi-users/fastapi-users-db-tortoise/workflows/Build/badge.svg)](https://github.com/fastapi-users/fastapi-users/actions) 12 | [![codecov](https://codecov.io/gh/fastapi-users/fastapi-users-db-tortoise/branch/master/graph/badge.svg)](https://codecov.io/gh/fastapi-users/fastapi-users-db-tortoise) 13 | [![PyPI version](https://badge.fury.io/py/fastapi-users-db-tortoise.svg)](https://badge.fury.io/py/fastapi-users-db-tortoise) 14 | [![Downloads](https://pepy.tech/badge/fastapi-users-db-tortoise)](https://pepy.tech/project/fastapi-users-db-tortoise) 15 |

16 | 17 |

18 | 19 | --- 20 | 21 | **Documentation**: https://fastapi-users.github.io/fastapi-users/ 22 | 23 | **Source Code**: https://github.com/fastapi-users/fastapi-users 24 | 25 | --- 26 | 27 | Add quickly a registration and authentication system to your [FastAPI](https://fastapi.tiangolo.com/) project. **FastAPI Users** is designed to be as customizable and adaptable as possible. 28 | 29 | **Sub-package for Tortoise ORM support in FastAPI Users.** 30 | 31 | ## Development 32 | 33 | ### Setup environment 34 | 35 | You should create a virtual environment and activate it: 36 | 37 | ```bash 38 | python -m venv venv/ 39 | ``` 40 | 41 | ```bash 42 | source venv/bin/activate 43 | ``` 44 | 45 | And then install the development dependencies: 46 | 47 | ```bash 48 | pip install -r requirements.dev.txt 49 | ``` 50 | 51 | ### Run unit tests 52 | 53 | You can run all the tests with: 54 | 55 | ```bash 56 | make test 57 | ``` 58 | 59 | Alternatively, you can run `pytest` yourself: 60 | 61 | ```bash 62 | pytest 63 | ``` 64 | 65 | There are quite a few unit tests, so you might run into ulimit issues where there are too many open file descriptors. You may be able to set a new, higher limit temporarily with: 66 | 67 | ```bash 68 | ulimit -n 2048 69 | ``` 70 | 71 | ### Format the code 72 | 73 | Execute the following command to apply `isort` and `black` formatting: 74 | 75 | ```bash 76 | make format 77 | ``` 78 | 79 | ## License 80 | 81 | This project is licensed under the terms of the MIT license. 82 | -------------------------------------------------------------------------------- /fastapi_users_db_tortoise/__init__.py: -------------------------------------------------------------------------------- 1 | """FastAPI Users database adapter for Tortoise ORM.""" 2 | from typing import Optional, Type, cast 3 | 4 | from fastapi_users.db.base import BaseUserDatabase 5 | from fastapi_users.models import UD 6 | from pydantic import UUID4 7 | from tortoise import fields, models 8 | from tortoise.contrib.pydantic import PydanticModel 9 | from tortoise.exceptions import DoesNotExist 10 | from tortoise.queryset import QuerySetSingle 11 | 12 | __version__ = "2.0.0" 13 | 14 | 15 | class TortoiseBaseUserModel(models.Model): 16 | id = fields.UUIDField(pk=True, generated=False) 17 | email = fields.CharField(index=True, unique=True, null=False, max_length=255) 18 | hashed_password = fields.CharField(null=False, max_length=1024) 19 | is_active = fields.BooleanField(default=True, null=False) 20 | is_superuser = fields.BooleanField(default=False, null=False) 21 | is_verified = fields.BooleanField(default=False, null=False) 22 | 23 | class Meta: 24 | abstract = True 25 | 26 | 27 | class TortoiseBaseOAuthAccountModel(models.Model): 28 | id = fields.UUIDField(pk=True, generated=False, max_length=255) 29 | oauth_name = fields.CharField(null=False, max_length=255) 30 | access_token = fields.CharField(null=False, max_length=1024) 31 | expires_at = fields.IntField(null=True) 32 | refresh_token = fields.CharField(null=True, max_length=1024) 33 | account_id = fields.CharField(index=True, null=False, max_length=255) 34 | account_email = fields.CharField(null=False, max_length=255) 35 | 36 | class Meta: 37 | abstract = True 38 | 39 | 40 | class TortoiseUserDatabase(BaseUserDatabase[UD]): 41 | """ 42 | Database adapter for Tortoise ORM. 43 | 44 | :param user_db_model: Pydantic model of a DB representation of a user. 45 | :param model: Tortoise ORM model. 46 | :param oauth_account_model: Optional Tortoise ORM model of a OAuth account. 47 | """ 48 | 49 | model: Type[TortoiseBaseUserModel] 50 | oauth_account_model: Optional[Type[TortoiseBaseOAuthAccountModel]] 51 | 52 | def __init__( 53 | self, 54 | user_db_model: Type[UD], 55 | model: Type[TortoiseBaseUserModel], 56 | oauth_account_model: Optional[Type[TortoiseBaseOAuthAccountModel]] = None, 57 | ): 58 | super().__init__(user_db_model) 59 | self.model = model 60 | self.oauth_account_model = oauth_account_model 61 | 62 | async def get(self, id: UUID4) -> Optional[UD]: 63 | try: 64 | query = self.model.get(id=id) 65 | 66 | if self.oauth_account_model is not None: 67 | query = query.prefetch_related("oauth_accounts") 68 | 69 | user = await query 70 | pydantic_user = await cast( 71 | PydanticModel, self.user_db_model 72 | ).from_tortoise_orm(user) 73 | 74 | return cast(UD, pydantic_user) 75 | except DoesNotExist: 76 | return None 77 | 78 | async def get_by_email(self, email: str) -> Optional[UD]: 79 | query = self.model.filter(email__iexact=email).first() 80 | 81 | if self.oauth_account_model is not None: 82 | query = query.prefetch_related("oauth_accounts") 83 | 84 | user = await query 85 | 86 | if user is None: 87 | return None 88 | 89 | pydantic_user = await cast(PydanticModel, self.user_db_model).from_tortoise_orm( 90 | user 91 | ) 92 | 93 | return cast(UD, pydantic_user) 94 | 95 | async def get_by_oauth_account(self, oauth: str, account_id: str) -> Optional[UD]: 96 | try: 97 | query: QuerySetSingle[TortoiseBaseUserModel] = self.model.get( 98 | oauth_accounts__oauth_name=oauth, oauth_accounts__account_id=account_id 99 | ).prefetch_related("oauth_accounts") 100 | 101 | user = await query 102 | pydantic_user = await cast( 103 | PydanticModel, self.user_db_model 104 | ).from_tortoise_orm(user) 105 | 106 | return cast(UD, pydantic_user) 107 | except DoesNotExist: 108 | return None 109 | 110 | async def create(self, user: UD) -> UD: 111 | user_dict = user.dict() 112 | oauth_accounts = user_dict.pop("oauth_accounts", None) 113 | 114 | model = self.model(**user_dict) 115 | await model.save() 116 | 117 | if oauth_accounts and self.oauth_account_model: 118 | oauth_account_objects = [] 119 | for oauth_account in oauth_accounts: 120 | oauth_account_objects.append( 121 | self.oauth_account_model(user=model, **oauth_account) 122 | ) 123 | await self.oauth_account_model.bulk_create(oauth_account_objects) 124 | 125 | return user 126 | 127 | async def update(self, user: UD) -> UD: 128 | user_dict = user.dict() 129 | user_dict.pop("id") # Tortoise complains if we pass the PK again 130 | oauth_accounts = user_dict.pop("oauth_accounts", None) 131 | 132 | model = await self.model.get(id=user.id) 133 | for field in user_dict: 134 | setattr(model, field, user_dict[field]) 135 | await model.save() 136 | 137 | if oauth_accounts and self.oauth_account_model: 138 | await model.oauth_accounts.all().delete() # type: ignore 139 | oauth_account_objects = [] 140 | for oauth_account in oauth_accounts: 141 | oauth_account_objects.append( 142 | self.oauth_account_model(user=model, **oauth_account) 143 | ) 144 | await self.oauth_account_model.bulk_create(oauth_account_objects) 145 | 146 | return user 147 | 148 | async def delete(self, user: UD) -> None: 149 | await self.model.filter(id=user.id).delete() 150 | -------------------------------------------------------------------------------- /fastapi_users_db_tortoise/access_token.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Generic, Optional, Type, cast 3 | 4 | from fastapi_users.authentication.strategy.db import A, AccessTokenDatabase 5 | from tortoise import fields, models 6 | from tortoise.contrib.pydantic import PydanticModel 7 | 8 | 9 | class TortoiseBaseAccessTokenModel(models.Model): 10 | token = fields.CharField(pk=True, max_length=43) 11 | created_at = fields.DatetimeField( 12 | null=False, 13 | auto_now_add=True, 14 | ) 15 | 16 | 17 | class TortoiseAccessTokenDatabase(AccessTokenDatabase, Generic[A]): 18 | """ 19 | Access token database adapter for Tortoise ORM. 20 | 21 | :param access_token_model: Pydantic model of a DB representation of an access token. 22 | :param model: Tortoise ORM model. 23 | """ 24 | 25 | def __init__( 26 | self, access_token_model: Type[A], model: Type[TortoiseBaseAccessTokenModel] 27 | ): 28 | self.access_token_model = access_token_model 29 | self.model = model 30 | 31 | async def get_by_token( 32 | self, token: str, max_age: Optional[datetime] = None 33 | ) -> Optional[A]: 34 | query = self.model.filter(token=token) 35 | if max_age is not None: 36 | query = query.filter(created_at__gte=max_age) 37 | 38 | access_token = await query.first() 39 | if access_token is not None: 40 | return await self._model_to_pydantic(access_token) 41 | return None 42 | 43 | async def create(self, access_token: A) -> A: 44 | model = self.model(**access_token.dict()) 45 | await model.save() 46 | await model.refresh_from_db() 47 | return await self._model_to_pydantic(model) 48 | 49 | async def update(self, access_token: A) -> A: 50 | model = await self.model.get(token=access_token.token) 51 | for field, value in access_token.dict().items(): 52 | setattr(model, field, value) 53 | await model.save() 54 | return await self._model_to_pydantic(model) 55 | 56 | async def delete(self, access_token: A) -> None: 57 | await self.model.filter(token=access_token.token).delete() 58 | 59 | async def _model_to_pydantic(self, model: TortoiseBaseAccessTokenModel) -> A: 60 | pydantic_access_token = await cast( 61 | PydanticModel, self.access_token_model 62 | ).from_tortoise_orm(model) 63 | return cast(A, pydantic_access_token) 64 | -------------------------------------------------------------------------------- /fastapi_users_db_tortoise/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-users/fastapi-users-db-tortoise/763236d32c4990228de2acaf716b5e0313fc2207/fastapi_users_db_tortoise/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | 4 | [tool.pytest.ini_options] 5 | asyncio_mode = "auto" 6 | addopts = "--ignore=test_build.py" 7 | markers = ["db"] 8 | 9 | [build-system] 10 | requires = ["flit_core >=2,<3"] 11 | build-backend = "flit_core.buildapi" 12 | 13 | [tool.flit.metadata] 14 | module = "fastapi_users_db_tortoise" 15 | dist-name = "fastapi-users-db-tortoise" 16 | author = "François Voron" 17 | author-email = "fvoron@gmail.com" 18 | home-page = "https://github.com/fastapi-users/fastapi-users-db-tortoise" 19 | classifiers = [ 20 | "License :: OSI Approved :: MIT License", 21 | "Development Status :: 5 - Production/Stable", 22 | "Framework :: AsyncIO", 23 | "Intended Audience :: Developers", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Topic :: Internet :: WWW/HTTP :: Session", 29 | ] 30 | description-file = "README.md" 31 | requires-python = ">=3.7" 32 | requires = [ 33 | "fastapi-users >= 9.1.0", 34 | "tortoise-orm >=0.17.6,<0.19.0" 35 | ] 36 | 37 | [tool.flit.metadata.urls] 38 | Documentation = "https://fastapi-users.github.io/fastapi-users" 39 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | flake8 4 | pytest 5 | requests 6 | isort 7 | pytest-asyncio 8 | flake8-docstrings 9 | black 10 | mypy 11 | codecov 12 | pytest-cov 13 | pytest-mock 14 | asynctest 15 | flit 16 | bumpversion 17 | httpx 18 | asgi_lifespan 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi-users >= 9.1.0 2 | tortoise-orm >= 0.17.6,<0.19.0 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.0.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:fastapi_users_db_tortoise/__init__.py] 7 | search = __version__ = "{current_version}" 8 | replace = __version__ = "{new_version}" 9 | 10 | [flake8] 11 | exclude = docs 12 | max-line-length = 88 13 | docstring-convention = numpy 14 | ignore = D1 15 | -------------------------------------------------------------------------------- /test_build.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | import sys 3 | 4 | try: 5 | from fastapi_users_db_tortoise import TortoiseUserDatabase 6 | except: 7 | sys.exit(1) 8 | 9 | sys.exit(0) 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-users/fastapi-users-db-tortoise/763236d32c4990228de2acaf716b5e0313fc2207/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | import pytest 5 | from fastapi_users import models 6 | 7 | 8 | class User(models.BaseUser): 9 | first_name: Optional[str] 10 | 11 | 12 | class UserCreate(models.BaseUserCreate): 13 | first_name: Optional[str] 14 | 15 | 16 | class UserUpdate(models.BaseUserUpdate): 17 | pass 18 | 19 | 20 | class UserDB(User, models.BaseUserDB): 21 | pass 22 | 23 | 24 | class UserOAuth(User, models.BaseOAuthAccountMixin): 25 | pass 26 | 27 | 28 | class UserDBOAuth(UserOAuth, UserDB): 29 | pass 30 | 31 | 32 | @pytest.fixture(scope="session") 33 | def event_loop(): 34 | """Force the pytest-asyncio loop to be the main one.""" 35 | loop = asyncio.new_event_loop() 36 | yield loop 37 | loop.close() 38 | 39 | 40 | @pytest.fixture 41 | def oauth_account1() -> models.BaseOAuthAccount: 42 | return models.BaseOAuthAccount( 43 | oauth_name="service1", 44 | access_token="TOKEN", 45 | expires_at=1579000751, 46 | account_id="user_oauth1", 47 | account_email="king.arthur@camelot.bt", 48 | ) 49 | 50 | 51 | @pytest.fixture 52 | def oauth_account2() -> models.BaseOAuthAccount: 53 | return models.BaseOAuthAccount( 54 | oauth_name="service2", 55 | access_token="TOKEN", 56 | expires_at=1579000751, 57 | account_id="user_oauth2", 58 | account_email="king.arthur@camelot.bt", 59 | ) 60 | -------------------------------------------------------------------------------- /tests/test_access_token.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime, timedelta, timezone 3 | from typing import AsyncGenerator 4 | 5 | import pytest 6 | from fastapi_users.authentication.strategy.db.models import BaseAccessToken 7 | from pydantic import UUID4 8 | from tortoise import Tortoise, fields 9 | from tortoise.contrib.pydantic import PydanticModel 10 | from tortoise.exceptions import IntegrityError 11 | 12 | from fastapi_users_db_tortoise import TortoiseBaseUserModel 13 | from fastapi_users_db_tortoise.access_token import ( 14 | TortoiseAccessTokenDatabase, 15 | TortoiseBaseAccessTokenModel, 16 | ) 17 | from tests.conftest import UserDB as BaseUserDB 18 | 19 | 20 | class UserModel(TortoiseBaseUserModel): 21 | pass 22 | 23 | 24 | class UserDB(BaseUserDB, PydanticModel): 25 | class Config: 26 | orm_mode = True 27 | orig_model = UserModel 28 | 29 | 30 | class AccessTokenModel(TortoiseBaseAccessTokenModel): 31 | user = fields.ForeignKeyField("models.UserModel", related_name="access_tokens") 32 | 33 | 34 | class AccessToken(BaseAccessToken, PydanticModel): 35 | class Config: 36 | orm_mode = True 37 | orig_model = AccessTokenModel 38 | 39 | 40 | @pytest.fixture 41 | def user_id() -> UUID4: 42 | return uuid.uuid4() 43 | 44 | 45 | @pytest.fixture 46 | async def tortoise_access_token_db( 47 | user_id: UUID4, 48 | ) -> AsyncGenerator[TortoiseAccessTokenDatabase, None]: 49 | DATABASE_URL = "sqlite://./test-tortoise-access-token.db" 50 | 51 | await Tortoise.init( 52 | db_url=DATABASE_URL, 53 | modules={"models": ["tests.test_access_token"]}, 54 | ) 55 | await Tortoise.generate_schemas() 56 | 57 | user = UserModel( 58 | id=user_id, 59 | email="lancelot@camelot.bt", 60 | hashed_password="guinevere", 61 | is_active=True, 62 | is_verified=True, 63 | is_superuser=False, 64 | ) 65 | await user.save() 66 | 67 | yield TortoiseAccessTokenDatabase(AccessToken, AccessTokenModel) 68 | 69 | await AccessTokenModel.all().delete() 70 | await UserModel.all().delete() 71 | await Tortoise.close_connections() 72 | 73 | 74 | @pytest.mark.asyncio 75 | @pytest.mark.db 76 | async def test_queries( 77 | tortoise_access_token_db: TortoiseAccessTokenDatabase[AccessToken], 78 | user_id: UUID4, 79 | ): 80 | access_token = AccessToken(token="TOKEN", user_id=user_id) 81 | 82 | # Create 83 | access_token_db = await tortoise_access_token_db.create(access_token) 84 | assert access_token_db.token == "TOKEN" 85 | assert access_token_db.user_id == user_id 86 | 87 | # Update 88 | access_token_db.created_at = datetime.now(timezone.utc) 89 | await tortoise_access_token_db.update(access_token_db) 90 | 91 | # Get by token 92 | access_token_by_token = await tortoise_access_token_db.get_by_token( 93 | access_token_db.token 94 | ) 95 | assert access_token_by_token is not None 96 | 97 | # Get by token expired 98 | access_token_by_token = await tortoise_access_token_db.get_by_token( 99 | access_token_db.token, max_age=datetime.now(timezone.utc) + timedelta(hours=1) 100 | ) 101 | assert access_token_by_token is None 102 | 103 | # Get by token not expired 104 | access_token_by_token = await tortoise_access_token_db.get_by_token( 105 | access_token_db.token, max_age=datetime.now(timezone.utc) - timedelta(hours=1) 106 | ) 107 | assert access_token_by_token is not None 108 | 109 | # Get by token unknown 110 | access_token_by_token = await tortoise_access_token_db.get_by_token( 111 | "NOT_EXISTING_TOKEN" 112 | ) 113 | assert access_token_by_token is None 114 | 115 | # Exception when inserting existing token 116 | with pytest.raises(IntegrityError): 117 | await tortoise_access_token_db.create(access_token_db) 118 | 119 | # Delete token 120 | await tortoise_access_token_db.delete(access_token_db) 121 | deleted_access_token = await tortoise_access_token_db.get_by_token( 122 | access_token_db.token 123 | ) 124 | assert deleted_access_token is None 125 | -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | import pytest 4 | from tortoise import Tortoise, fields 5 | from tortoise.contrib.pydantic import PydanticModel 6 | from tortoise.exceptions import IntegrityError 7 | 8 | from fastapi_users_db_tortoise import ( 9 | TortoiseBaseOAuthAccountModel, 10 | TortoiseBaseUserModel, 11 | TortoiseUserDatabase, 12 | ) 13 | from tests.conftest import UserDB as BaseUserDB 14 | from tests.conftest import UserDBOAuth as BaseUserDBOAuth 15 | 16 | 17 | class User(TortoiseBaseUserModel): 18 | first_name = fields.CharField(null=True, max_length=255) 19 | 20 | 21 | class UserDB(BaseUserDB, PydanticModel): 22 | class Config: 23 | orm_mode = True 24 | orig_model = User 25 | 26 | 27 | class OAuthAccount(TortoiseBaseOAuthAccountModel): 28 | user = fields.ForeignKeyField("models.User", related_name="oauth_accounts") 29 | 30 | 31 | class UserDBOAuth(BaseUserDBOAuth, PydanticModel): 32 | class Config: 33 | orm_mode = True 34 | orig_model = OAuthAccount 35 | 36 | 37 | @pytest.fixture 38 | async def tortoise_user_db() -> AsyncGenerator[TortoiseUserDatabase, None]: 39 | DATABASE_URL = "sqlite://./test-tortoise-user.db" 40 | 41 | await Tortoise.init( 42 | db_url=DATABASE_URL, 43 | modules={"models": ["tests.test_users"]}, 44 | ) 45 | await Tortoise.generate_schemas() 46 | 47 | yield TortoiseUserDatabase(UserDB, User) 48 | 49 | await User.all().delete() 50 | await Tortoise.close_connections() 51 | 52 | 53 | @pytest.fixture 54 | async def tortoise_user_db_oauth() -> AsyncGenerator[TortoiseUserDatabase, None]: 55 | DATABASE_URL = "sqlite://./test-tortoise-user-oauth.db" 56 | 57 | await Tortoise.init( 58 | db_url=DATABASE_URL, 59 | modules={"models": ["tests.test_users"]}, 60 | ) 61 | await Tortoise.generate_schemas() 62 | 63 | yield TortoiseUserDatabase(UserDBOAuth, User, OAuthAccount) 64 | 65 | await User.all().delete() 66 | await Tortoise.close_connections() 67 | 68 | 69 | @pytest.mark.asyncio 70 | @pytest.mark.db 71 | async def test_queries(tortoise_user_db: TortoiseUserDatabase[UserDB]): 72 | user = UserDB( 73 | email="lancelot@camelot.bt", 74 | hashed_password="guinevere", 75 | ) 76 | 77 | # Create 78 | user_db = await tortoise_user_db.create(user) 79 | assert user_db.id is not None 80 | assert user_db.is_active is True 81 | assert user_db.is_superuser is False 82 | assert user_db.email == user.email 83 | 84 | # Update 85 | user_db.is_superuser = True 86 | await tortoise_user_db.update(user_db) 87 | 88 | # Get by id 89 | id_user = await tortoise_user_db.get(user.id) 90 | assert id_user is not None 91 | assert id_user.id == user_db.id 92 | assert id_user.is_superuser is True 93 | 94 | # Get by email 95 | email_user = await tortoise_user_db.get_by_email(str(user.email)) 96 | assert email_user is not None 97 | assert email_user.id == user_db.id 98 | 99 | # Get by uppercased email 100 | email_user = await tortoise_user_db.get_by_email("Lancelot@camelot.bt") 101 | assert email_user is not None 102 | assert email_user.id == user_db.id 103 | 104 | # Exception when inserting existing email 105 | with pytest.raises(IntegrityError): 106 | await tortoise_user_db.create(user) 107 | 108 | # Exception when inserting non-nullable fields 109 | with pytest.raises(ValueError): 110 | wrong_user = UserDB(hashed_password="aaa") 111 | await tortoise_user_db.create(wrong_user) 112 | 113 | # Unknown user 114 | unknown_user = await tortoise_user_db.get_by_email("galahad@camelot.bt") 115 | assert unknown_user is None 116 | 117 | # Delete user 118 | await tortoise_user_db.delete(user) 119 | deleted_user = await tortoise_user_db.get(user.id) 120 | assert deleted_user is None 121 | 122 | 123 | @pytest.mark.asyncio 124 | @pytest.mark.db 125 | async def test_queries_custom_fields(tortoise_user_db: TortoiseUserDatabase[UserDB]): 126 | """It should output custom fields in query result.""" 127 | user = UserDB( 128 | email="lancelot@camelot.bt", 129 | hashed_password="guinevere", 130 | first_name="Lancelot", 131 | ) 132 | await tortoise_user_db.create(user) 133 | 134 | id_user = await tortoise_user_db.get(user.id) 135 | assert id_user is not None 136 | assert id_user.id == user.id 137 | assert id_user.first_name == user.first_name 138 | 139 | 140 | @pytest.mark.asyncio 141 | @pytest.mark.db 142 | async def test_queries_oauth( 143 | tortoise_user_db_oauth: TortoiseUserDatabase[UserDBOAuth], 144 | oauth_account1, 145 | oauth_account2, 146 | ): 147 | user = UserDBOAuth( 148 | email="lancelot@camelot.bt", 149 | hashed_password="guinevere", 150 | oauth_accounts=[oauth_account1, oauth_account2], 151 | ) 152 | 153 | # Create 154 | user_db = await tortoise_user_db_oauth.create(user) 155 | assert user_db.id is not None 156 | assert hasattr(user_db, "oauth_accounts") 157 | assert len(user_db.oauth_accounts) == 2 158 | 159 | # Update 160 | user_db.oauth_accounts[0].access_token = "NEW_TOKEN" 161 | await tortoise_user_db_oauth.update(user_db) 162 | 163 | # Get by id 164 | id_user = await tortoise_user_db_oauth.get(user.id) 165 | assert id_user is not None 166 | assert id_user.id == user_db.id 167 | assert id_user.oauth_accounts[0].access_token == "NEW_TOKEN" 168 | 169 | # Get by email 170 | email_user = await tortoise_user_db_oauth.get_by_email(str(user.email)) 171 | assert email_user is not None 172 | assert email_user.id == user_db.id 173 | assert len(email_user.oauth_accounts) == 2 174 | 175 | # Get by OAuth account 176 | oauth_user = await tortoise_user_db_oauth.get_by_oauth_account( 177 | oauth_account1.oauth_name, oauth_account1.account_id 178 | ) 179 | assert oauth_user is not None 180 | assert oauth_user.id == user.id 181 | 182 | # Unknown OAuth account 183 | unknown_oauth_user = await tortoise_user_db_oauth.get_by_oauth_account("foo", "bar") 184 | assert unknown_oauth_user is None 185 | --------------------------------------------------------------------------------