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 Nov 4, 2023
1 parent 045eace commit 383994f
Show file tree
Hide file tree
Showing 19 changed files with 445 additions and 5 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 password profile
Revision ID: 577aab14d99c
Revises: 8f2f6221d1de
Create Date: 2023-08-12 10:20:29.332462+00:00
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "577aab14d99c"
down_revision = "8f2f6221d1de"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"accounts",
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_hashes",
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"],
["accounts.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"profiles",
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"],
["accounts.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("profiles")
op.drop_table("password_hashes")
op.drop_table("accounts")
# ### 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.

2 changes: 2 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 All @@ -44,6 +45,7 @@ optional = true
black = "23.3.0"
darglint = "1.8.1" # Checks whether a docstring's description matches the actual function/method implementation
dlint = "0.14.0" # Tool for encouraging best coding practices and helping ensure Python code is secure.
faker = "19.2.0" # Tool to create mock data (Replace it by factory-boy in further updates, because factory-boy has faker as depenadancy)
flake8 = "6.0.0"
flake8-aaa = "0.14.0" # Lints tests against the Arrange Act Assert pattern.
flake8-absolute-import = "1.0.0.1" # Plugin to require absolute imports.
Expand Down
82 changes: 82 additions & 0 deletions src/account/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""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__ = "accounts"

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."""
accounts_with_same_email = await session.execute(select(Account).filter_by(email=account_data.email))
accounts_with_same_login = await session.execute(select(Account).filter_by(login=account_data.login))
if accounts_with_same_email.all():
raise DuplicateEntityException(
action="create Account",
details="Provided email address already exists.",
)

if accounts_with_same_login.all():
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_hashes"

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)

account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))

@staticmethod
async def create(session: AsyncSession, password: Password, account_id: int) -> None:
"""Create a password hash object."""
password_hash = PasswordHash.create_password_hash(password)
obj = PasswordHash(value=password_hash, account_id=account_id)
session.add(obj)
await session.flush()

@staticmethod
def create_password_hash(password: Password) -> bytes:
"""Generate hash for password."""
salt = bcrypt.gensalt()
byte_password = str(password).encode("utf-8")
return bcrypt.hashpw(byte_password, salt)
12 changes: 12 additions & 0 deletions src/account/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,15 @@ class AccountEmail(Schema):
"""Account email."""

email: Email
updated_at: datetime

class Config: # noqa: D106
orm_mode = True


class PassHash(Schema):
"""Password hash model."""

algorithm: str
value: str
salt: str
29 changes: 29 additions & 0 deletions src/auth/controllers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""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(session: AsyncSession, request_data: NewAccountWithProfile) -> schemas.AccountWithProfile:
"""Create a new account with profile."""
account = await Account.create(session, request_data.account)
profile_data = request_data.profile
password = request_data.account.password
profile = await Profile.create(session, profile_data, account_id=account.id)
await PasswordHash.create(session, password, account_id=account.id)
return schemas.AccountWithProfile(
account=account_schemas.Account.from_orm(account),
profile=profile_schemas.Profile.from_orm(profile),
)
11 changes: 7 additions & 4 deletions src/auth/routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""Auth related endpoints."""

from __future__ import annotations

from typing import Annotated
Expand All @@ -8,10 +7,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 +49,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 +65,10 @@ async def create_account(
},
),
],
) -> None:
session: AsyncSession = Depends(get_session),
) -> schemas.AccountWithProfile:
"""Create a new account with profile."""
return await controllers.create_account(session, new_account)


@router.post(
Expand Down
39 changes: 39 additions & 0 deletions src/profile/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Database models for profile."""
from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING

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

from src.shared.database import Base


if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession

from src.auth.schemas import _NewProfile


class Profile(Base): # pylint: disable=too-few-public-methods
"""Profile database model."""

__tablename__ = "profiles"

id: Mapped[int] = mapped_column(Integer, primary_key=True) # noqa: A003
avatar: Mapped[str | None] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
description: Mapped[String | None] = mapped_column(String)
name: Mapped[str] = mapped_column(String, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())

account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))

@staticmethod
async def create(session: AsyncSession, profile_data: _NewProfile, account_id: int) -> Profile:
"""Create new profile object."""
profile = Profile(**profile_data.dict(), account_id=account_id)
session.add(profile)
await session.flush()
return profile
3 changes: 3 additions & 0 deletions src/profile/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class Profile(Schema):
description: Description | None
name: Name

class Config: # noqa: D106
orm_mode = True


class ProfileUpdate(Schema):
"""Data for Profile update."""
Expand Down
1 change: 1 addition & 0 deletions src/shared/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ async def get_session() -> AsyncIterator[AsyncSession]: # pragma: no cover
await session.rollback()
raise
finally:
await session.commit()
await session.close()

0 comments on commit 383994f

Please sign in to comment.