From 6388dada7f426c43c05b905a594d86e7cbd83f20 Mon Sep 17 00:00:00 2001 From: lchen-2101 <73617864+lchen-2101@users.noreply.github.com> Date: Tue, 26 Sep 2023 09:53:25 -0700 Subject: [PATCH 1/4] feat: refactor user model, and add institutions attribute (#31) closes #26 --- src/entities/models/__init__.py | 2 ++ src/entities/models/dto.py | 47 ++++++++++++++++++++++++++++- src/oauth2/__init__.py | 4 +-- src/oauth2/oauth2_backend.py | 25 ++------------- src/routers/admin.py | 3 +- tests/api/conftest.py | 2 +- tests/api/routers/test_admin_api.py | 20 +++++++++++- 7 files changed, 74 insertions(+), 29 deletions(-) diff --git a/src/entities/models/__init__.py b/src/entities/models/__init__.py index 6dfd58b..c772155 100644 --- a/src/entities/models/__init__.py +++ b/src/entities/models/__init__.py @@ -8,6 +8,7 @@ "FinancialInsitutionDomainCreate", "DeniedDomainDao", "DeniedDomainDto", + "AuthenticatedUser", ] from .dao import ( @@ -22,4 +23,5 @@ FinancialInsitutionDomainDto, FinancialInsitutionDomainCreate, DeniedDomainDto, + AuthenticatedUser, ) diff --git a/src/entities/models/dto.py b/src/entities/models/dto.py index bc84375..ac389f8 100644 --- a/src/entities/models/dto.py +++ b/src/entities/models/dto.py @@ -1,5 +1,7 @@ -from typing import List +from typing import Any, Dict, List + from pydantic import BaseModel +from starlette.authentication import BaseUser class FinancialInsitutionDomainBase(BaseModel): @@ -37,3 +39,46 @@ class DeniedDomainDto(BaseModel): class Config: orm_mode = True + + +class AuthenticatedUser(BaseUser, BaseModel): + claims: Dict[str, Any] + name: str + username: str + email: str + id: str + institutions: List[str] + + @classmethod + def from_claim(cls, claims: Dict[str, Any]) -> "AuthenticatedUser": + return cls( + claims=claims, + name=claims.get("name", ""), + username=claims.get("preferred_username", ""), + email=claims.get("email", ""), + id=claims.get("sub", ""), + institutions=cls.parse_institutions(claims.get("institutions")), + ) + + @classmethod + def parse_institutions(cls, institutions: List[str] | None) -> List[str]: + """ + Parse out the list of institutions returned by Keycloak + + Args: + institutions(List[str]): list of full institution paths provided by keycloak, + it is possible to have nested paths, though we may not use the feature. + e.g. ["/ROOT_INSTITUTION/CHILD_INSTITUTION/GRAND_CHILD_INSTITUTION"] + + Returns: + List[str]: List of cleaned up institutions. + e.g. ["GRAND_CHILD_INSTITUTION"] + """ + if institutions: + return [institution.split("/")[-1] for institution in institutions] + else: + return [] + + @property + def is_authenticated(self) -> bool: + return True diff --git a/src/oauth2/__init__.py b/src/oauth2/__init__.py index ec2dc7f..94759fc 100644 --- a/src/oauth2/__init__.py +++ b/src/oauth2/__init__.py @@ -1,4 +1,4 @@ -__all__ = ["oauth2_admin", "BearerTokenAuthBackend", "AuthenticatedUser"] +__all__ = ["oauth2_admin", "BearerTokenAuthBackend"] from .oauth2_admin import oauth2_admin -from .oauth2_backend import BearerTokenAuthBackend, AuthenticatedUser +from .oauth2_backend import BearerTokenAuthBackend diff --git a/src/oauth2/oauth2_backend.py b/src/oauth2/oauth2_backend.py index 22c682f..9bffc6d 100644 --- a/src/oauth2/oauth2_backend.py +++ b/src/oauth2/oauth2_backend.py @@ -1,7 +1,6 @@ import logging from typing import Coroutine, Any, Dict, List, Tuple from fastapi import HTTPException -from pydantic import BaseModel from starlette.authentication import ( AuthCredentials, AuthenticationBackend, @@ -11,33 +10,13 @@ from fastapi.security import OAuth2AuthorizationCodeBearer from starlette.requests import HTTPConnection +from entities.models import AuthenticatedUser + from .oauth2_admin import oauth2_admin log = logging.getLogger(__name__) -class AuthenticatedUser(BaseUser, BaseModel): - claims: Dict[str, Any] - name: str | None - username: str | None - email: str | None - id: str | None - - @classmethod - def from_claim(cls, claims: Dict[str, Any]) -> "AuthenticatedUser": - return cls( - claims=claims, - name=claims.get("name"), - username=claims.get("preferred_username"), - email=claims.get("email"), - id=claims.get("sub"), - ) - - @property - def is_authenticated(self) -> bool: - return True - - class BearerTokenAuthBackend(AuthenticationBackend): def __init__(self, token_bearer: OAuth2AuthorizationCodeBearer) -> None: self.token_bearer = token_bearer diff --git a/src/routers/admin.py b/src/routers/admin.py index 676dc1e..abf7467 100644 --- a/src/routers/admin.py +++ b/src/routers/admin.py @@ -4,7 +4,8 @@ from starlette.authentication import requires from util import Router -from oauth2 import AuthenticatedUser, oauth2_admin +from entities.models import AuthenticatedUser +from oauth2 import oauth2_admin router = Router() diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 54ef356..201c5fa 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from starlette.authentication import AuthCredentials, UnauthenticatedUser -from oauth2.oauth2_backend import AuthenticatedUser +from entities.models import AuthenticatedUser @pytest.fixture diff --git a/tests/api/routers/test_admin_api.py b/tests/api/routers/test_admin_api.py index c2b1ee2..f530add 100644 --- a/tests/api/routers/test_admin_api.py +++ b/tests/api/routers/test_admin_api.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from starlette.authentication import AuthCredentials -from oauth2.oauth2_backend import AuthenticatedUser +from entities.models import AuthenticatedUser class TestAdminApi: @@ -19,6 +19,24 @@ def test_get_me_authed(self, mocker: MockerFixture, app_fixture: FastAPI, authed res = client.get("/v1/admin/me") assert res.status_code == 200 assert res.json().get("name") == "test" + assert res.json().get("institutions") == [] + + def test_get_me_authed_with_institutions(self, app_fixture: FastAPI, auth_mock: Mock): + claims = { + "name": "test", + "preferred_username": "test_user", + "email": "test@local.host", + "sub": "testuser123", + "institutions": ["/TEST1LEI", "/TEST2LEI/TEST2CHILDLEI"], + } + auth_mock.return_value = ( + AuthCredentials(["authenticated"]), + AuthenticatedUser.from_claim(claims), + ) + client = TestClient(app_fixture) + res = client.get("/v1/admin/me") + assert res.status_code == 200 + assert res.json().get("institutions") == ["TEST1LEI", "TEST2CHILDLEI"] def test_update_me_unauthed(self, app_fixture: FastAPI, unauthed_user_mock: Mock): client = TestClient(app_fixture) From 3a3d65faa68b299c942f971428726fe85e9cc538 Mon Sep 17 00:00:00 2001 From: lchen-2101 <73617864+lchen-2101@users.noreply.github.com> Date: Tue, 26 Sep 2023 09:54:54 -0700 Subject: [PATCH 2/4] Feature/29 check domain on endpoints (#33) closes #29 --- src/dependencies.py | 20 ++++---------------- src/main.py | 5 ++--- src/routers/admin.py | 14 ++++---------- src/routers/institutions.py | 6 +++--- tests/api/routers/test_institutions_api.py | 20 +++++++++++++++++++- 5 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/dependencies.py b/src/dependencies.py index 02ddcb0..0d36f97 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -10,24 +10,12 @@ from entities.engine import get_session from entities.repos import institutions_repo as repo -OPEN_DOMAIN_REQUESTS = { - "/v1/admin/me": {"GET"}, - "/v1/institutions": {"GET"}, - "/v1/institutions/domains/allowed": {"GET"}, -} - async def check_domain(request: Request, session: Annotated[AsyncSession, Depends(get_session)]) -> None: - if request_needs_domain_check(request): - if not request.user.is_authenticated: - raise HTTPException(status_code=HTTPStatus.FORBIDDEN) - if await email_domain_denied(session, request.user.email): - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="email domain denied") - - -def request_needs_domain_check(request: Request) -> bool: - path = request.scope["path"].rstrip("/") - return not (path in OPEN_DOMAIN_REQUESTS and request.scope["method"] in OPEN_DOMAIN_REQUESTS[path]) + if not request.user.is_authenticated: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN) + if await email_domain_denied(session, request.user.email): + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="email domain denied") async def email_domain_denied(session: AsyncSession, email: str) -> bool: diff --git a/src/main.py b/src/main.py index bc54b7a..1111860 100644 --- a/src/main.py +++ b/src/main.py @@ -2,12 +2,11 @@ import logging import env # noqa: F401 from http import HTTPStatus -from fastapi import FastAPI, HTTPException, Request, Depends +from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from fastapi.security import OAuth2AuthorizationCodeBearer from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.authentication import AuthenticationMiddleware -from dependencies import check_domain from routers import admin_router, institutions_router @@ -15,7 +14,7 @@ log = logging.getLogger() -app = FastAPI(dependencies=[Depends(check_domain)]) +app = FastAPI() @app.exception_handler(HTTPException) diff --git a/src/routers/admin.py b/src/routers/admin.py index abf7467..816b075 100644 --- a/src/routers/admin.py +++ b/src/routers/admin.py @@ -1,7 +1,8 @@ from http import HTTPStatus from typing import Dict, Any, Set -from fastapi import Request +from fastapi import Depends, Request from starlette.authentication import requires +from dependencies import check_domain from util import Router from entities.models import AuthenticatedUser @@ -16,20 +17,13 @@ async def get_me(request: Request): return request.user -@router.put("/me/", status_code=HTTPStatus.ACCEPTED) +@router.put("/me/", status_code=HTTPStatus.ACCEPTED, dependencies=[Depends(check_domain)]) @requires("manage-account") async def update_me(request: Request, user: Dict[str, Any]): oauth2_admin.update_user(request.user.id, user) -@router.put("/me/groups/", status_code=HTTPStatus.ACCEPTED) -@requires("manage-account") -async def associate_group(request: Request, groups: Set[str]): - for group in groups: - oauth2_admin.associate_to_group(request.user.id, group) - - -@router.put("/me/institutions/", status_code=HTTPStatus.ACCEPTED) +@router.put("/me/institutions/", status_code=HTTPStatus.ACCEPTED, dependencies=[Depends(check_domain)]) @requires("manage-account") async def associate_lei(request: Request, leis: Set[str]): for lei in leis: diff --git a/src/routers/institutions.py b/src/routers/institutions.py index 1de7b94..012179c 100644 --- a/src/routers/institutions.py +++ b/src/routers/institutions.py @@ -2,7 +2,7 @@ from http import HTTPStatus from oauth2 import oauth2_admin from util import Router -from dependencies import parse_leis +from dependencies import check_domain, parse_leis from typing import Annotated, List, Tuple from entities.engine import get_session from entities.repos import institutions_repo as repo @@ -35,7 +35,7 @@ async def get_institutions( return await repo.get_institutions(request.state.db_session, leis, domain, page, count) -@router.post("/", response_model=Tuple[str, FinancialInstitutionDto]) +@router.post("/", response_model=Tuple[str, FinancialInstitutionDto], dependencies=[Depends(check_domain)]) @requires(["query-groups", "manage-users"]) async def create_institution( request: Request, @@ -58,7 +58,7 @@ async def get_institution( return res -@router.post("/{lei}/domains/", response_model=List[FinancialInsitutionDomainDto]) +@router.post("/{lei}/domains/", response_model=List[FinancialInsitutionDomainDto], dependencies=[Depends(check_domain)]) @requires(["query-groups", "manage-users"]) async def add_domains( request: Request, diff --git a/tests/api/routers/test_institutions_api.py b/tests/api/routers/test_institutions_api.py index 7130437..2465dec 100644 --- a/tests/api/routers/test_institutions_api.py +++ b/tests/api/routers/test_institutions_api.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock +from unittest.mock import Mock, ANY from fastapi import FastAPI from fastapi.testclient import TestClient @@ -81,6 +81,15 @@ def test_get_institution_authed(self, mocker: MockerFixture, app_fixture: FastAP assert res.status_code == 200 assert res.json().get("name") == "Test Bank 123" + def test_get_institution_not_exists(self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock): + get_institution_mock = mocker.patch("entities.repos.institutions_repo.get_institution") + get_institution_mock.return_value = None + client = TestClient(app_fixture) + lei_path = "testLeiPath" + res = client.get(f"/v1/institutions/{lei_path}") + get_institution_mock.assert_called_once_with(ANY, lei_path) + assert res.status_code == 404 + def test_add_domains_unauthed(self, app_fixture: FastAPI, unauthed_user_mock: Mock): client = TestClient(app_fixture) @@ -124,3 +133,12 @@ def test_add_domains_authed_with_denied_email_domain( res = client.post(f"/v1/institutions/{lei_path}/domains/", json=[{"domain": "testDomain"}]) assert res.status_code == 403 assert "domain denied" in res.json()["detail"] + + def test_check_domain_allowed(self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock): + domain_allowed_mock = mocker.patch("entities.repos.institutions_repo.is_email_domain_allowed") + domain_allowed_mock.return_value = True + domain_to_check = "local.host" + client = TestClient(app_fixture) + res = client.get(f"/v1/institutions/domains/allowed?domain={domain_to_check}") + domain_allowed_mock.assert_called_once_with(ANY, domain_to_check) + assert res.json() is True From 6891a4f425269c26102f00a743fa2d9c49d6909d Mon Sep 17 00:00:00 2001 From: Aldrian Harjati Date: Thu, 28 Sep 2023 13:11:42 -0400 Subject: [PATCH 3/4] add alembic support (#36) Add alembic to keep track db revisions. For Test: - Created two revision script to add columns. - Ran upgrade - Verified that new columns were added to financial_institutions db - Ran downgrade - Verified that downgrade instructions were executed (column was removed) --------- Co-authored-by: Aldrian Harjati Co-authored-by: lchen-2101 <73617864+lchen-2101@users.noreply.github.com> --- alembic.ini | 116 ++++++++++++++++++++++++ db_revisions/README | 15 ++++ db_revisions/env.py | 98 ++++++++++++++++++++ db_revisions/script.py.mako | 26 ++++++ db_revisions/versions/README | 2 + poetry.lock | 167 ++++++++++++++++++++++++----------- pyproject.toml | 1 + 7 files changed, 375 insertions(+), 50 deletions(-) create mode 100644 alembic.ini create mode 100644 db_revisions/README create mode 100644 db_revisions/env.py create mode 100644 db_revisions/script.py.mako create mode 100644 db_revisions/versions/README diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d00656c --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = ./db_revisions + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = ./src:. + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to ./alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:./alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +#sqlalchemy.url = driver://user:pass@localhost/dbname +#sqlalchemy.url = postgres://{{username}}:{{password}}@{{address}}/{{db_name}} + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/db_revisions/README b/db_revisions/README new file mode 100644 index 0000000..ed4f01c --- /dev/null +++ b/db_revisions/README @@ -0,0 +1,15 @@ +Alembic [link](https://alembic.sqlalchemy.org/) is the tool to maintain SBL database upgrades and downgrades. +Each of upgrade and downgrade instructions are grouped into different files that can be found under `versions` folder. +Alembic is using table named `alembic_version` in database to keep track the latest executed alembic_version + +To create new database update: +- change current directory to root. +- run `poetry run alembic revision -m ""` to generate empty revision file. +- update new revision file (under `versions` folder) with upgrade and downgrade instructions. + +To execute alembic: `poetry run alembic upgrade ` or `poetry run alembix downgrade ` +For more details: [link] (https://alembic.sqlalchemy.org/en/latest/tutorial.html) + + + + diff --git a/db_revisions/env.py b/db_revisions/env.py new file mode 100644 index 0000000..4ddfad5 --- /dev/null +++ b/db_revisions/env.py @@ -0,0 +1,98 @@ +import os +from dotenv import load_dotenv + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from entities import models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = models.Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + +# this specific to SBL configuration + +ENV = os.getenv("ENV", "LOCAL") + +if ENV == "LOCAL": + load_dotenv("src/.env.local") +else: + load_dotenv() + +INST_DB_USER = os.environ.get("INST_DB_USER") +INST_DB_PWD = os.environ.get("INST_DB_PWD") +INST_DB_HOST = os.environ.get("INST_DB_HOST") +INST_DB_NAME = os.environ.get("INST_DB_NAME") +INST_CONN = f"postgresql://{INST_DB_USER}:{INST_DB_PWD}@{INST_DB_HOST}/{INST_DB_NAME}" +config.set_main_option("sqlalchemy.url", INST_CONN) + +# end specific SBL configuration + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. This generates the SQL script without executing on the database. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/db_revisions/script.py.mako b/db_revisions/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/db_revisions/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/db_revisions/versions/README b/db_revisions/versions/README new file mode 100644 index 0000000..4db807c --- /dev/null +++ b/db_revisions/versions/README @@ -0,0 +1,2 @@ +This file added to force git to add `versions` folder. +This file can be removed once revision script is added. \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index cdf74df..06a709c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "aiosqlite" version = "0.19.0" description = "asyncio bridge to the standard sqlite3 module" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -16,11 +15,29 @@ files = [ dev = ["aiounittest (==1.4.1)", "attribution (==1.6.2)", "black (==23.3.0)", "coverage[toml] (==7.2.3)", "flake8 (==5.0.4)", "flake8-bugbear (==23.3.12)", "flit (==3.7.1)", "mypy (==1.2.0)", "ufmt (==2.1.0)", "usort (==1.0.6)"] docs = ["sphinx (==6.1.3)", "sphinx-mdinclude (==0.5.3)"] +[[package]] +name = "alembic" +version = "1.12.0" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.7" +files = [ + {file = "alembic-1.12.0-py3-none-any.whl", hash = "sha256:03226222f1cf943deee6c85d9464261a6c710cd19b4fe867a3ad1f25afda610f"}, + {file = "alembic-1.12.0.tar.gz", hash = "sha256:8e7645c32e4f200675e69f0745415335eb59a3663f5feb487abfa0b30c45888b"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["python-dateutil"] + [[package]] name = "anyio" version = "3.7.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -41,7 +58,6 @@ trio = ["trio (<0.22)"] name = "asyncpg" version = "0.27.0" description = "An asyncio PostgreSQL driver" -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -92,7 +108,6 @@ test = ["flake8 (>=5.0.4,<5.1.0)", "uvloop (>=0.15.3)"] name = "black" version = "23.7.0" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -137,7 +152,6 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -149,7 +163,6 @@ files = [ name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -234,7 +247,6 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -249,7 +261,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -261,7 +272,6 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -334,7 +344,6 @@ toml = ["tomli"] name = "deprecation" version = "2.1.0" description = "A library to handle automated deprecations" -category = "main" optional = false python-versions = "*" files = [ @@ -349,7 +358,6 @@ packaging = "*" name = "ecdsa" version = "0.18.0" description = "ECDSA cryptographic signature library (pure python)" -category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -368,7 +376,6 @@ gmpy2 = ["gmpy2"] name = "fastapi" version = "0.97.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -387,7 +394,6 @@ all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)" name = "greenlet" version = "2.0.2" description = "Lightweight in-process concurrent programming" -category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" files = [ @@ -396,6 +402,7 @@ files = [ {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, + {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, @@ -404,6 +411,7 @@ files = [ {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, + {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, @@ -433,6 +441,7 @@ files = [ {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, + {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, @@ -441,6 +450,7 @@ files = [ {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, + {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, @@ -461,7 +471,6 @@ test = ["objgraph", "psutil"] name = "h11" version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -473,7 +482,6 @@ files = [ name = "httpcore" version = "0.17.3" description = "A minimal low-level HTTP client." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -485,17 +493,16 @@ files = [ anyio = ">=3.0,<5.0" certifi = "*" h11 = ">=0.13,<0.15" -sniffio = ">=1.0.0,<2.0.0" +sniffio = "==1.*" [package.extras] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "httpx" version = "0.24.1" description = "The next generation HTTP client." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -511,15 +518,14 @@ sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -531,7 +537,6 @@ files = [ name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -539,11 +544,98 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "mako" +version = "1.2.4" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818"}, + {file = "Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + [[package]] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -555,7 +647,6 @@ files = [ name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -567,7 +658,6 @@ files = [ name = "pathspec" version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -579,7 +669,6 @@ files = [ name = "platformdirs" version = "3.9.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -595,7 +684,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -611,7 +699,6 @@ testing = ["pytest", "pytest-benchmark"] name = "psycopg2-binary" version = "2.9.6" description = "psycopg2 - Python-PostgreSQL Database Adapter" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -683,7 +770,6 @@ files = [ name = "pyasn1" version = "0.5.0" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ @@ -695,7 +781,6 @@ files = [ name = "pydantic" version = "1.10.9" description = "Data validation and settings management using python type hints" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -748,7 +833,6 @@ email = ["email-validator (>=1.0.3)"] name = "pytest" version = "7.4.0" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -769,7 +853,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-asyncio" version = "0.21.1" description = "Pytest support for asyncio" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -788,7 +871,6 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -807,7 +889,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-mock" version = "3.11.1" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -825,7 +906,6 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] name = "python-dotenv" version = "1.0.0" description = "Read key-value pairs from a .env file and set them as environment variables" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -840,7 +920,6 @@ cli = ["click (>=5.0)"] name = "python-jose" version = "3.3.0" description = "JOSE implementation in Python" -category = "main" optional = false python-versions = "*" files = [ @@ -862,7 +941,6 @@ pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"] name = "python-keycloak" version = "3.0.0" description = "python-keycloak is a Python package providing access to the Keycloak API." -category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -883,7 +961,6 @@ docs = ["Sphinx (>=5.3.0,<6.0.0)", "alabaster (>=0.7.12,<0.8.0)", "commonmark (> name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -905,7 +982,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "requests-toolbelt" version = "1.0.0" description = "A utility belt for advanced users of python-requests" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -920,7 +996,6 @@ requests = ">=2.0.1,<3.0.0" name = "rsa" version = "4.9" description = "Pure-Python RSA implementation" -category = "main" optional = false python-versions = ">=3.6,<4" files = [ @@ -935,7 +1010,6 @@ pyasn1 = ">=0.1.3" name = "ruff" version = "0.0.278" description = "An extremely fast Python linter, written in Rust." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -962,7 +1036,6 @@ files = [ name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -974,7 +1047,6 @@ files = [ name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -986,7 +1058,6 @@ files = [ name = "sqlalchemy" version = "2.0.16" description = "Database Abstraction Library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1065,7 +1136,6 @@ sqlcipher = ["sqlcipher3-binary"] name = "starlette" version = "0.27.0" description = "The little ASGI library that shines." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1083,7 +1153,6 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam name = "typing-extensions" version = "4.6.3" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1095,7 +1164,6 @@ files = [ name = "urllib3" version = "2.0.3" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1113,7 +1181,6 @@ zstd = ["zstandard (>=0.18.0)"] name = "uvicorn" version = "0.22.0" description = "The lightning-fast ASGI server." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1131,4 +1198,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "540bdc4533c6d8eca8a63b048ba17f4e9c4fc65c83ec0631d31d284362f14054" +content-hash = "3a2a7103b8ae9e008168d9fc7794ed36c8f0c5186df01e4bf368b5641dd321dd" diff --git a/pyproject.toml b/pyproject.toml index d2412b7..911e5f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ psycopg2-binary = "^2.9.6" python-jose = "^3.3.0" requests = "^2.31.0" asyncpg = "^0.27.0" +alembic = "^1.12.0" [tool.poetry.group.dev.dependencies] ruff = "^0.0.278" From e19d50ef50bf9519a9485aca4937f44d81682b9e Mon Sep 17 00:00:00 2001 From: lchen-2101 <73617864+lchen-2101@users.noreply.github.com> Date: Tue, 3 Oct 2023 07:14:52 -0700 Subject: [PATCH 4/4] feat: add associated institutions endpoint, refactored domain parsing (#39) closes #34 --- src/dependencies.py | 10 +++- src/entities/models/__init__.py | 2 + src/entities/models/dao.py | 7 ++- src/entities/models/dto.py | 4 ++ src/entities/repos/institutions_repo.py | 9 +--- src/routers/institutions.py | 22 +++++++- tests/api/conftest.py | 15 +++++- tests/api/routers/test_institutions_api.py | 50 +++++++++++++++---- tests/app/conftest.py | 12 +++++ tests/app/test_dependencies.py | 31 ++++++++++++ .../entities/repos/test_institutions_repo.py | 4 +- 11 files changed, 139 insertions(+), 27 deletions(-) create mode 100644 tests/app/conftest.py create mode 100644 tests/app/test_dependencies.py diff --git a/src/dependencies.py b/src/dependencies.py index 0d36f97..90a3c94 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -14,12 +14,12 @@ async def check_domain(request: Request, session: Annotated[AsyncSession, Depends(get_session)]) -> None: if not request.user.is_authenticated: raise HTTPException(status_code=HTTPStatus.FORBIDDEN) - if await email_domain_denied(session, request.user.email): + if await email_domain_denied(session, get_email_domain(request.user.email)): raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="email domain denied") async def email_domain_denied(session: AsyncSession, email: str) -> bool: - return not await repo.is_email_domain_allowed(session, email) + return not await repo.is_domain_allowed(session, email) def parse_leis(leis: List[str] = Query(None)) -> Optional[List]: @@ -35,3 +35,9 @@ def parse_leis(leis: List[str] = Query(None)) -> Optional[List]: return list(chain.from_iterable([x.split(",") for x in leis])) else: return None + + +def get_email_domain(email: str) -> str: + if email: + return email.split("@")[-1] + return None diff --git a/src/entities/models/__init__.py b/src/entities/models/__init__.py index c772155..3c1779c 100644 --- a/src/entities/models/__init__.py +++ b/src/entities/models/__init__.py @@ -6,6 +6,7 @@ "FinancialInstitutionWithDomainsDto", "FinancialInsitutionDomainDto", "FinancialInsitutionDomainCreate", + "FinanicialInstitutionAssociationDto", "DeniedDomainDao", "DeniedDomainDto", "AuthenticatedUser", @@ -22,6 +23,7 @@ FinancialInstitutionWithDomainsDto, FinancialInsitutionDomainDto, FinancialInsitutionDomainCreate, + FinanicialInstitutionAssociationDto, DeniedDomainDto, AuthenticatedUser, ) diff --git a/src/entities/models/dao.py b/src/entities/models/dao.py index 3213524..3599188 100644 --- a/src/entities/models/dao.py +++ b/src/entities/models/dao.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import List from sqlalchemy import ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase @@ -17,14 +18,16 @@ class FinancialInstitutionDao(AuditMixin, Base): __tablename__ = "financial_institutions" lei: Mapped[str] = mapped_column(unique=True, index=True, primary_key=True) name: Mapped[str] = mapped_column(index=True) - domains = relationship("FinancialInstitutionDomainDao", back_populates="fi") + domains: Mapped[List["FinancialInstitutionDomainDao"]] = relationship( + "FinancialInstitutionDomainDao", back_populates="fi" + ) class FinancialInstitutionDomainDao(AuditMixin, Base): __tablename__ = "financial_institution_domains" domain: Mapped[str] = mapped_column(index=True, primary_key=True) lei: Mapped[str] = mapped_column(ForeignKey("financial_institutions.lei"), index=True, primary_key=True) - fi = relationship("FinancialInstitutionDao", back_populates="domains") + fi: Mapped["FinancialInstitutionDao"] = relationship("FinancialInstitutionDao", back_populates="domains") class DeniedDomainDao(AuditMixin, Base): diff --git a/src/entities/models/dto.py b/src/entities/models/dto.py index ac389f8..5214f1c 100644 --- a/src/entities/models/dto.py +++ b/src/entities/models/dto.py @@ -41,6 +41,10 @@ class Config: orm_mode = True +class FinanicialInstitutionAssociationDto(FinancialInstitutionDto): + approved: bool + + class AuthenticatedUser(BaseUser, BaseModel): claims: Dict[str, Any] name: str diff --git a/src/entities/repos/institutions_repo.py b/src/entities/repos/institutions_repo.py index ba33cf1..b4820e5 100644 --- a/src/entities/repos/institutions_repo.py +++ b/src/entities/repos/institutions_repo.py @@ -78,17 +78,10 @@ async def add_domains( return daos -async def is_email_domain_allowed(session: AsyncSession, email: str) -> bool: - domain = get_email_domain(email) +async def is_domain_allowed(session: AsyncSession, domain: str) -> bool: if domain: async with session: stmt = select(func.count()).filter(DeniedDomainDao.domain == domain) res = await session.scalar(stmt) return res == 0 return False - - -def get_email_domain(email: str) -> str: - if email: - return email.split("@")[-1] - return None diff --git a/src/routers/institutions.py b/src/routers/institutions.py index 012179c..78b5d5b 100644 --- a/src/routers/institutions.py +++ b/src/routers/institutions.py @@ -2,7 +2,7 @@ from http import HTTPStatus from oauth2 import oauth2_admin from util import Router -from dependencies import check_domain, parse_leis +from dependencies import check_domain, parse_leis, get_email_domain from typing import Annotated, List, Tuple from entities.engine import get_session from entities.repos import institutions_repo as repo @@ -11,6 +11,8 @@ FinancialInstitutionWithDomainsDto, FinancialInsitutionDomainDto, FinancialInsitutionDomainCreate, + FinanicialInstitutionAssociationDto, + AuthenticatedUser, ) from sqlalchemy.ext.asyncio import AsyncSession from starlette.authentication import requires @@ -46,6 +48,22 @@ async def create_institution( return kc_id, db_fi +@router.get("/associated", response_model=List[FinanicialInstitutionAssociationDto]) +@requires("authenticated") +async def get_associated_institutions(request: Request): + user: AuthenticatedUser = request.user + email_domain = get_email_domain(user.email) + associated_institutions = await repo.get_institutions(request.state.db_session, user.institutions) + return [ + FinanicialInstitutionAssociationDto( + name=institution.name, + lei=institution.lei, + approved=email_domain in [inst_domain.domain for inst_domain in institution.domains], + ) + for institution in associated_institutions + ] + + @router.get("/{lei}", response_model=FinancialInstitutionWithDomainsDto) @requires("authenticated") async def get_institution( @@ -70,4 +88,4 @@ async def add_domains( @router.get("/domains/allowed", response_model=bool) async def is_domain_allowed(request: Request, domain: str): - return await repo.is_email_domain_allowed(request.state.db_session, domain) + return await repo.is_domain_allowed(request.state.db_session, domain) diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 201c5fa..451ba6b 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from starlette.authentication import AuthCredentials, UnauthenticatedUser -from entities.models import AuthenticatedUser +from entities.models import AuthenticatedUser, FinancialInstitutionDao, FinancialInstitutionDomainDao @pytest.fixture @@ -45,3 +45,16 @@ def authed_user_mock(auth_mock: Mock) -> Mock: def unauthed_user_mock(auth_mock: Mock) -> Mock: auth_mock.return_value = (AuthCredentials("unauthenticated"), UnauthenticatedUser()) return auth_mock + + +@pytest.fixture +def get_institutions_mock(mocker: MockerFixture) -> Mock: + mock = mocker.patch("entities.repos.institutions_repo.get_institutions") + mock.return_value = [ + FinancialInstitutionDao( + name="Test Bank 123", + lei="TESTBANK123", + domains=[FinancialInstitutionDomainDao(domain="test.bank", lei="TESTBANK123")], + ) + ] + return mock diff --git a/tests/api/routers/test_institutions_api.py b/tests/api/routers/test_institutions_api.py index 2465dec..3af621f 100644 --- a/tests/api/routers/test_institutions_api.py +++ b/tests/api/routers/test_institutions_api.py @@ -14,15 +14,9 @@ def test_get_institutions_unauthed(self, app_fixture: FastAPI, unauthed_user_moc res = client.get("/v1/institutions/") assert res.status_code == 403 - def test_get_institutions_authed(self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock): - get_institutions_mock = mocker.patch("entities.repos.institutions_repo.get_institutions") - get_institutions_mock.return_value = [ - FinancialInstitutionDao( - name="Test Bank 123", - lei="TESTBANK123", - domains=[FinancialInstitutionDomainDao(domain="test.bank", lei="TESTBANK123")], - ) - ] + def test_get_institutions_authed( + self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock, get_institutions_mock: Mock + ): client = TestClient(app_fixture) res = client.get("/v1/institutions/") assert res.status_code == 200 @@ -135,10 +129,46 @@ def test_add_domains_authed_with_denied_email_domain( assert "domain denied" in res.json()["detail"] def test_check_domain_allowed(self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock): - domain_allowed_mock = mocker.patch("entities.repos.institutions_repo.is_email_domain_allowed") + domain_allowed_mock = mocker.patch("entities.repos.institutions_repo.is_domain_allowed") domain_allowed_mock.return_value = True domain_to_check = "local.host" client = TestClient(app_fixture) res = client.get(f"/v1/institutions/domains/allowed?domain={domain_to_check}") domain_allowed_mock.assert_called_once_with(ANY, domain_to_check) assert res.json() is True + + def test_get_associated_institutions( + self, mocker: MockerFixture, app_fixture: FastAPI, auth_mock: Mock, get_institutions_mock: Mock + ): + get_institutions_mock.return_value = [ + FinancialInstitutionDao( + name="Test Bank 123", + lei="TESTBANK123", + domains=[FinancialInstitutionDomainDao(domain="test123.bank", lei="TESTBANK123")], + ), + FinancialInstitutionDao( + name="Test Bank 234", + lei="TESTBANK234", + domains=[FinancialInstitutionDomainDao(domain="test234.bank", lei="TESTBANK234")], + ), + ] + claims = { + "name": "test", + "preferred_username": "test_user", + "email": "test@test234.bank", + "sub": "testuser123", + "institutions": ["/TESTBANK123", "/TESTBANK234"], + } + auth_mock.return_value = ( + AuthCredentials(["authenticated"]), + AuthenticatedUser.from_claim(claims), + ) + client = TestClient(app_fixture) + res = client.get("/v1/institutions/associated") + assert res.status_code == 200 + get_institutions_mock.assert_called_once_with(ANY, ["TESTBANK123", "TESTBANK234"]) + data = res.json() + inst1 = next(filter(lambda inst: inst["lei"] == "TESTBANK123", data)) + inst2 = next(filter(lambda inst: inst["lei"] == "TESTBANK234", data)) + assert inst1["approved"] is False + assert inst2["approved"] is True diff --git a/tests/app/conftest.py b/tests/app/conftest.py new file mode 100644 index 0000000..18cc99d --- /dev/null +++ b/tests/app/conftest.py @@ -0,0 +1,12 @@ +import pytest + +from pytest_mock import MockerFixture + + +@pytest.fixture(autouse=True) +def setup(mocker: MockerFixture): + mocked_engine = mocker.patch("sqlalchemy.ext.asyncio.create_async_engine") + MockedEngine = mocker.patch("sqlalchemy.ext.asyncio.AsyncEngine") + mocked_engine.return_value = MockedEngine.return_value + mocker.patch("fastapi.security.OAuth2AuthorizationCodeBearer") + mocker.patch("entities.engine.get_session") diff --git a/tests/app/test_dependencies.py b/tests/app/test_dependencies.py new file mode 100644 index 0000000..f44692a --- /dev/null +++ b/tests/app/test_dependencies.py @@ -0,0 +1,31 @@ +from pytest_mock import MockerFixture +from sqlalchemy.ext.asyncio import AsyncSession + +import pytest + + +@pytest.fixture +def mock_session(mocker: MockerFixture) -> AsyncSession: + return mocker.patch("sqlalchemy.ext.asyncio.AsyncSession").return_value + + +async def test_domain_denied(mocker: MockerFixture, mock_session: AsyncSession): + domain_allowed_mock = mocker.patch("entities.repos.institutions_repo.is_domain_allowed") + domain_allowed_mock.return_value = False + from dependencies import email_domain_denied + + denied_domain = "denied.domain" + + assert await email_domain_denied(mock_session, denied_domain) is True + domain_allowed_mock.assert_called_once_with(mock_session, denied_domain) + + +async def test_domain_allowed(mocker: MockerFixture, mock_session: AsyncSession): + domain_allowed_mock = mocker.patch("entities.repos.institutions_repo.is_domain_allowed") + domain_allowed_mock.return_value = True + from dependencies import email_domain_denied + + allowed_domain = "allowed.domain" + + assert await email_domain_denied(mock_session, allowed_domain) is False + domain_allowed_mock.assert_called_once_with(mock_session, allowed_domain) diff --git a/tests/entities/repos/test_institutions_repo.py b/tests/entities/repos/test_institutions_repo.py index 5acdfd6..3b7279a 100644 --- a/tests/entities/repos/test_institutions_repo.py +++ b/tests/entities/repos/test_institutions_repo.py @@ -79,5 +79,5 @@ async def test_domain_allowed(self, transaction_session: AsyncSession): denied_domain = DeniedDomainDao(domain="yahoo.com") transaction_session.add(denied_domain) await transaction_session.commit() - assert await repo.is_email_domain_allowed(transaction_session, "test@yahoo.com") is False - assert await repo.is_email_domain_allowed(transaction_session, "test@gmail.com") is True + assert await repo.is_domain_allowed(transaction_session, "yahoo.com") is False + assert await repo.is_domain_allowed(transaction_session, "gmail.com") is True