Skip to content

Commit

Permalink
Реализовать эндпоинт регистрации #88
Browse files Browse the repository at this point in the history
Добавлены модели базы данных - Акканут, Профиль, Хэш пароля и базовый
функционал по созданию сущностей.
  • Loading branch information
Alex Goryev authored and birthdaysgift committed Oct 7, 2023
1 parent 9179c0f commit 865f345
Show file tree
Hide file tree
Showing 20 changed files with 451 additions and 8 deletions.
1 change: 1 addition & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ disable =
no-self-argument,
nonexistent-operator,
nonlocal-without-binding,
not-callable,
not-in-loop,
notimplemented-raised,
pointless-statement,
Expand Down
2 changes: 2 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ script_location = ./migrations

# absolute paths to the packages with models definitions
models_packages =
src.account.models,
src.profile.models,

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""add account, profile and password_hash tables
Revision ID: e5b03d5c76f9
Revises: 8f2f6221d1de
Create Date: 2023-09-13 17:47:54.104460+00:00
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "e5b03d5c76f9"
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(), server_default=sa.text("now()"), nullable=False),
sa.Column("email", sa.String(), nullable=False),
sa.Column("login", sa.String(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
sa.UniqueConstraint("login"),
)
op.create_table(
"password_hash",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("value", sa.LargeBinary(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["account.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"profile",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("avatar", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["account.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### 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 ###
52 changes: 51 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ optional = true

alembic = {extras = ["tz"], version = "1.10.4"}
asyncpg = "0.27.0" # asynchronous postgresql driver used by sqlalchemy
bcrypt = "4.0.1" # modern password hashing library
fastapi = {extras = ["all"], version = "0.95.0" } # we need "all" at least for uvicorn
minio = "7.1.14"
overrides = "7.3.1"
Expand Down Expand Up @@ -57,6 +58,7 @@ flake8-mutable = "1.2.0" # Extension for mutable default arguments.
flake8-rst-docstrings = "0.3.0" # Validate Python docstrings as reStructuredText (сRST)
flake8-spellcheck = "0.28.0" # Spellcheck variables, classnames, comments, docstrings etc.
mypy = "1.2.0"
psycopg2-binary = "2.9.7"
pyenchant = "3.2.2" # needed for pylint's spellchecker
pylint = "2.17.2"
pylint-per-file-ignores = "1.2.0" # A pylint plugin to ignore error codes per file.
Expand All @@ -72,6 +74,7 @@ optional = true

[tool.poetry.group.test.dependencies]

faker = "19.2.0" # Tool to create mock data (Replace it by factory-boy in further updates, because factory-boy has faker as depenadancy)
pytest = "7.3.0"
pytest-cov = "4.0.0"
pytest-env = "0.8.1" # needed to set up default $WLSS_ENV for tests execution
Expand Down
89 changes: 89 additions & 0 deletions src/account/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Database models for account."""

from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING

import bcrypt
from sqlalchemy import DateTime, ForeignKey, func, Integer, LargeBinary, select, String
from sqlalchemy.orm import Mapped, mapped_column

from src.shared.database import Base
from src.shared.exceptions import DuplicateEntityException


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, nullable=False, server_default=func.now())
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, nullable=False, server_default=func.now())

@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 DuplicateEntityException(
action="create Account",
details="Provided email address already exists.",
)

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 DuplicateEntityException(
action="create Account",
details="Provided login already exists.",
)

account = Account(**account_data.dict(exclude={"password"}))
session.add(account)
await session.flush()
return account


class PasswordHash(Base):
"""Password hash database model."""

__tablename__ = "password_hash"

id: Mapped[int] = mapped_column(Integer, primary_key=True) # noqa: A003

created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
value: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)

# TODO: add uniqueness constraint since one account cannot have more than one password hash
# and check other kind of constraints for other models
account_id: Mapped[int] = mapped_column(ForeignKey("account.id"))

@staticmethod # TODO: we cannot use this create method due to Password is a field and cannot be used outside of the schema
async def create(session: AsyncSession, password: Password, account_id: int) -> None:
"""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()
byte_password = password.get_secret_value().encode("utf-8")
return bcrypt.hashpw(byte_password, salt)
6 changes: 6 additions & 0 deletions src/account/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class Account(Schema):
email: Email
login: Login

class Config: # noqa: D106
orm_mode = True


class AccountId(Schema):
"""Account Id."""
Expand All @@ -36,3 +39,6 @@ class AccountEmail(Schema):
"""Account email."""

email: Email
# TODO: do we really need to add it in this task?
# if not, check other places where update_at was added and remove them as well
updated_at: datetime
28 changes: 28 additions & 0 deletions src/auth/controllers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Controllers functions for account entity."""

from __future__ import annotations

from typing import TYPE_CHECKING

from src.account import schemas as account_schemas
from src.account.models import Account, PasswordHash
from src.auth import schemas
from src.profile import schemas as profile_schemas
from src.profile.models import Profile


if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession

from src.auth.schemas import NewAccountWithProfile


async def create_account(request_data: NewAccountWithProfile, session: AsyncSession) -> schemas.AccountWithProfile:
"""Create a new account with profile."""
account = await Account.create(session, request_data.account)
profile = await Profile.create(session, request_data.profile, account_id=account.id)
await PasswordHash.create(session, request_data.account.password, account_id=account.id)
return schemas.AccountWithProfile(
account=account_schemas.Account.from_orm(account),
profile=profile_schemas.Profile.from_orm(profile),
)
10 changes: 7 additions & 3 deletions src/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
from fastapi import APIRouter, Body, Depends, Path, status
from fastapi.security import HTTPAuthorizationCredentials
from pydantic import PositiveInt
from sqlalchemy.ext.asyncio import AsyncSession

from src.auth import schemas, swagger
from src.auth import controllers, schemas, swagger
from src.auth.security import get_token
from src.shared import swagger as shared_swagger
from src.shared.database import get_session


router = APIRouter(tags=["auth"])
Expand Down Expand Up @@ -48,7 +50,7 @@
summary="Sign Up - create a new account.",
)
async def create_account(
new_account: Annotated[ # noqa: ARG001
new_account: Annotated[
schemas.NewAccountWithProfile,
Body(
example={
Expand All @@ -64,8 +66,10 @@ async def create_account(
},
),
],
) -> None:
session: AsyncSession = Depends(get_session),
) -> schemas.AccountWithProfile:
"""Create a new account with profile."""
return await controllers.create_account(new_account, session)


@router.post(
Expand Down
8 changes: 4 additions & 4 deletions src/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ class NotAuthenticatedResponse(HTTPError): # noqa: N818
"""Schema for 401 UNAUTHORIZED response."""


class _NewAccount(Schema):
class NewAccount(Schema):
"""Account data which is going to be created during sign up process."""

email: Email
login: Login
password: Password


class _NewProfile(Schema):
class NewProfile(Schema):
"""Profile data for an account which is going to be created during sign up process."""

name: Name
Expand All @@ -40,8 +40,8 @@ class _NewProfile(Schema):
class NewAccountWithProfile(Schema):
"""Account and corresponding profile data which are going to be created during sign up process."""

account: _NewAccount
profile: _NewProfile
account: NewAccount
profile: NewProfile


class AccountWithProfile(Schema):
Expand Down

0 comments on commit 865f345

Please sign in to comment.