-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Реализовать эндпойнт регистрации (#88)
После того как была разработана архитектура эндпойнтов авторизации (#77), необходимо приступить к реализации этих эндпойнтов. Т.к. процессы аутентификации и авторизации требуют наличия зарегистрированного в системе Аккаунта, то в первую очередь нужно реализовать эндпойнт регистрации (`src/auth/routes.py :: create_account`). Планируемая архитектура бд: **Account** - id (primary key) - login (unique) - email (unique) - created_at - updated_at **PasswordHash** - account_id (foreign key, primary key) - value **Profile** - account_id (foreign key, primary key) - avatar (nullable, unique) - description (nullable) - name В рамках этой задачи необходимо написать реализацию для эндпойнта регистрации. --- В процессе реализации были приняты следующие решения: - **Использование foreign key в качестве primary key для таблиц, имеющих связь один к одному.** Например, Профиль пользователя всегда связан с одним и только одним Аккаунтом, таким образом в качестве primary key для таблицы Профиля будет использоваться foreign key на айди записи в таблице Аккаунта. Это позволит нам обеспечить соблюдение некоторых инвариантов доменной модели на уровне бд. - **Отказ от использования `default` и `onupdate` в колонках даты и времени.** Это решение было принято в качестве следствия из разработанного подхода работы с бд, а именно: "бд **должна** автоматизировать проверку как можно большего количества **ограничений**, но при этом бд **не должна** автоматизировать какое-либо **изменение** данных. Следование этому принципу позволит нам, во-первых, сохранить бизнес логику изменения данных на уровне кода приложения, т.е. сохранить её в явном виде, а не спрятать в бд в виде триггеров и процедур. А во-вторых, обеспечить поддержание целостности данных при попытке внесения изменений в обход кода нашего приложения (например, при проведении миграций). - **Расположение логики обработчиков ошибок в `src/app.py`.** Это решение является временным компромиссом и, возможно, будет изменено во время будущего рефакторинга. Мы приняли это решение в связи с тем, что fastapi позволяет привязывать обработчики ошибок только к экземпляру приложения, но не к отдельным роутам. - **Отказ от инкапсуляции логики создания зависимых моделей в методах основной модели.** Например, при создании Аккаунта всегда должен создаваться соответствующий аккаунту Профиль, но на данных момент эндпойнт создания Аккаунта не содержит в себе вызов эндпойнта создания Профиля. Это связано с тем, что после реализации MVP, мы планируем провести рефакторинг кода, связанного с внесением изменений в бд и созданием объектов модели, с помощью внедрения DDD репозиториев, фабрик и агрегатов. Наша "упрощенная" текущая реализация скорее всего позволит перейти на новый подход более безболезненно (по крайней мере сейчас так кажется). - **Сохранение таймзоны в бд и использование timezone-aware объектов даты и времени.** Это позволит нам всегда точно знать, что в нашей бд все записи даты и времени добавленные/измененные нашим приложением были сделаны с учетом таймзоны. - **Использование `.flush()` вместо `.commit()` в методах изменяющих данные в бд.** Все методы вносящие изменения в данные бд будут использовать `.flush()`, чтобы в нашем коде, в рамках сессии мы могли видеть данные в изменённом виде, но при этом `.commit()` будет вызываться только в тирдауне `get_db`, позволяя нам фиксировать изменения атомарно в рамках всего эндпойнта, а не в рамках вызовов отдельных методов. У это подхода тоже есть серьезный недостаток - в связи с особенностями работы `Dependency` в fastapi, применение изменений в бд будет происходить уже после того как ответ 200 будет отправлен (см [комментарий](zhanymkanov/fastapi-best-practices#1 (comment))). Но т.к. для решения этой проблемы необходимо проводить отдельный ресёрч, было принято решение исправить это в рамках [другой](#142) задачи.
- Loading branch information
1 parent
8dcfb08
commit 25060fe
Showing
26 changed files
with
660 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
65 changes: 65 additions & 0 deletions
65
...ons/versions/2023_11_05_1332-d7454b0101c0_add_account_profile_and_password_hash_tables.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
"""add account, profile and password_hash tables | ||
Revision ID: d7454b0101c0 | ||
Revises: 8f2f6221d1de | ||
Create Date: 2023-11-05 13:32:37.606097+00:00 | ||
""" | ||
from alembic import op | ||
import sqlalchemy as sa | ||
|
||
|
||
# revision identifiers, used by Alembic. | ||
revision = "d7454b0101c0" | ||
down_revision = "8f2f6221d1de" | ||
branch_labels = None | ||
depends_on = None | ||
|
||
|
||
def upgrade() -> None: | ||
# ### commands auto generated by Alembic - please adjust! ### | ||
op.create_table( | ||
"account", | ||
sa.Column("id", sa.Integer(), nullable=False), | ||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), | ||
sa.Column("email", sa.String(), nullable=False), | ||
sa.Column("login", sa.String(), nullable=False), | ||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), | ||
sa.PrimaryKeyConstraint("id"), | ||
sa.UniqueConstraint("email"), | ||
sa.UniqueConstraint("login"), | ||
) | ||
op.create_table( | ||
"password_hash", | ||
sa.Column("account_id", sa.Integer(), autoincrement=False, nullable=False), | ||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), | ||
sa.Column("value", sa.LargeBinary(), nullable=False), | ||
sa.ForeignKeyConstraint( | ||
["account_id"], | ||
["account.id"], | ||
), | ||
sa.PrimaryKeyConstraint("account_id"), | ||
) | ||
op.create_table( | ||
"profile", | ||
sa.Column("account_id", sa.Integer(), autoincrement=False, nullable=False), | ||
sa.Column("avatar", sa.String(), nullable=True), | ||
sa.Column("description", sa.String(), nullable=True), | ||
sa.Column("name", sa.String(), nullable=False), | ||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), | ||
sa.ForeignKeyConstraint( | ||
["account_id"], | ||
["account.id"], | ||
), | ||
sa.PrimaryKeyConstraint("account_id"), | ||
sa.UniqueConstraint("avatar"), | ||
) | ||
# ### end Alembic commands ### | ||
|
||
|
||
def downgrade() -> None: | ||
# ### commands auto generated by Alembic - please adjust! ### | ||
op.drop_table("profile") | ||
op.drop_table("password_hash") | ||
op.drop_table("account") | ||
# ### end Alembic commands ### |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
"""Exceptions related to account functionality.""" | ||
|
||
from __future__ import annotations | ||
|
||
from src.shared.exceptions import BadRequestException | ||
|
||
|
||
class DuplicateAccountException(BadRequestException): | ||
"""Exception raised when such account already exists.""" | ||
|
||
action = "create account" | ||
|
||
description = "Account already exists." | ||
details: str = "There is another account with same value for one of the unique fields." |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
"""Database models for account.""" | ||
|
||
from __future__ import annotations | ||
|
||
from datetime import datetime | ||
from typing import TYPE_CHECKING | ||
|
||
import bcrypt | ||
from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, select, String | ||
from sqlalchemy.orm import Mapped, mapped_column | ||
|
||
from src.account.exceptions import DuplicateAccountException | ||
from src.shared.database import Base | ||
from src.shared.datetime import utcnow | ||
|
||
|
||
if TYPE_CHECKING: | ||
from sqlalchemy.ext.asyncio import AsyncSession | ||
|
||
from src.auth.fields import Password | ||
from src.auth.schemas import NewAccount | ||
|
||
|
||
class Account(Base): # pylint: disable=too-few-public-methods | ||
"""Account database model.""" | ||
|
||
__tablename__ = "account" | ||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True) # noqa: A003 | ||
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) | ||
email: Mapped[str] = mapped_column(String, nullable=False, unique=True) | ||
login: Mapped[str] = mapped_column(String, nullable=False, unique=True) | ||
updated_at: Mapped[datetime] = mapped_column( | ||
DateTime(timezone=True), default=utcnow, nullable=False, onupdate=utcnow, | ||
) | ||
|
||
@staticmethod | ||
async def create(session: AsyncSession, account_data: NewAccount) -> Account: | ||
"""Create new account object.""" | ||
query = select(Account).where(Account.email == account_data.email) | ||
account_with_same_email = (await session.execute(query)).first() | ||
if account_with_same_email is not None: | ||
raise DuplicateAccountException() | ||
|
||
query = select(Account).where(Account.login == account_data.login) | ||
account_with_same_login = (await session.execute(query)).first() | ||
if account_with_same_login is not None: | ||
raise DuplicateAccountException() | ||
|
||
account = Account(**account_data.dict(exclude={"password"})) | ||
session.add(account) | ||
await session.flush() | ||
return account | ||
|
||
|
||
class PasswordHash(Base): # pylint: disable=too-few-public-methods | ||
"""Password hash database model.""" | ||
|
||
__tablename__ = "password_hash" | ||
|
||
account_id: Mapped[int] = mapped_column(ForeignKey("account.id"), autoincrement=False, primary_key=True) | ||
|
||
updated_at: Mapped[datetime] = mapped_column( | ||
DateTime(timezone=True), default=utcnow, nullable=False, onupdate=utcnow, | ||
) | ||
value: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) | ||
|
||
@staticmethod | ||
async def create(session: AsyncSession, password: Password, account_id: int) -> PasswordHash: | ||
"""Create a password hash object.""" | ||
hash_value = PasswordHash._generate_hash(password) | ||
password_hash = PasswordHash(value=hash_value, account_id=account_id) | ||
session.add(password_hash) | ||
await session.flush() | ||
return password_hash | ||
|
||
@staticmethod | ||
def _generate_hash(password: Password) -> bytes: | ||
"""Generate hash for password.""" | ||
salt = bcrypt.gensalt() | ||
raw_password = password.get_secret_value() | ||
byte_password = raw_password.encode("utf-8") | ||
return bcrypt.hashpw(byte_password, salt) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.