From 52f30727296d40e9aca5b13432675f9735d3a318 Mon Sep 17 00:00:00 2001 From: doas root Date: Thu, 20 Nov 2025 01:37:19 +0300 Subject: [PATCH] feat(): profiles --- .../adapters/data_gateways/profile.py | 20 +++++ .../adapters/data_gateways/tables.py | 19 +++- .../auth_identity/interactors/sign_up.py | 13 +-- .../application/user/entity.py | 11 +-- .../application/user/profile/__init__.py | 0 .../application/user/profile/data_gateway.py | 11 +++ .../application/user/profile/entity.py | 44 ++++++++++ .../user/profile/interactors/__init__.py | 0 .../user/profile/interactors/get_profile.py | 39 +++++++++ .../user/profile/interactors/patch_profile.py | 70 +++++++++++++++ .../migrations/versions/b5fa4f3e95c5_.py | 52 +++++++++++ src/template_project/web_api/entry_point.py | 3 +- .../web_api/identity_provider.py | 6 +- .../web_api/ioc/data_gateway.py | 2 + src/template_project/web_api/ioc/idp.py | 4 +- .../web_api/ioc/interactor.py | 4 + .../web_api/routes/profile.py | 87 +++++++++++++++++++ 17 files changed, 361 insertions(+), 24 deletions(-) create mode 100644 src/template_project/adapters/data_gateways/profile.py create mode 100644 src/template_project/application/user/profile/__init__.py create mode 100644 src/template_project/application/user/profile/data_gateway.py create mode 100644 src/template_project/application/user/profile/entity.py create mode 100644 src/template_project/application/user/profile/interactors/__init__.py create mode 100644 src/template_project/application/user/profile/interactors/get_profile.py create mode 100644 src/template_project/application/user/profile/interactors/patch_profile.py create mode 100644 src/template_project/migrations/versions/b5fa4f3e95c5_.py create mode 100644 src/template_project/web_api/routes/profile.py diff --git a/src/template_project/adapters/data_gateways/profile.py b/src/template_project/adapters/data_gateways/profile.py new file mode 100644 index 0000000..26b1c8d --- /dev/null +++ b/src/template_project/adapters/data_gateways/profile.py @@ -0,0 +1,20 @@ +from typing import override + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from template_project.adapters.data_gateways.tables import profile_table +from template_project.application.user.entity import UserId +from template_project.application.user.profile.data_gateway import ProfileDataGateway +from template_project.application.user.profile.entity import Profile + + +class DefaultProfileDataGateway(ProfileDataGateway): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @override + async def load_by_user_id(self, user_id: UserId) -> Profile | None: + statement = select(Profile).where(profile_table.c.user_id == user_id) + result = await self._session.execute(statement) + return result.scalar_one_or_none() diff --git a/src/template_project/adapters/data_gateways/tables.py b/src/template_project/adapters/data_gateways/tables.py index 433627e..af14b73 100644 --- a/src/template_project/adapters/data_gateways/tables.py +++ b/src/template_project/adapters/data_gateways/tables.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import registry from template_project.application.access_token.entity import AccessToken from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod from template_project.application.user.entity import User +from template_project.application.user.profile.entity import Profile meta_data = MetaData() mapper_registry = registry() @@ -23,8 +24,6 @@ user_table = Table( "users", meta_data, Column("id", UUID, primary_key=True), - Column("email", String, unique=True, nullable=True), - Column("hashed_password", String, nullable=True), Column("deleted_at", DateTime(timezone=True)), Column("created_at", DateTime(timezone=True), nullable=False), ) @@ -52,6 +51,22 @@ auth_identity_table = Table( UniqueConstraint("method", "identifier", name="uq_auth_method_identifier"), ) +profile_table = Table( + "profiles", + meta_data, + Column("id", UUID, primary_key=True), + Column("user_id", UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True), + Column("email", String, nullable=True), + Column("display_name", String, nullable=True), + Column("first_name", String, nullable=True), + Column("last_name", String, nullable=True), + Column("avatar_url", String, nullable=True), + Column("phone", String, nullable=True), + Column("deleted_at", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False), +) + mapper_registry.map_imperatively(User, user_table) mapper_registry.map_imperatively(AccessToken, access_token_table) mapper_registry.map_imperatively(AuthIdentity, auth_identity_table) +mapper_registry.map_imperatively(Profile, profile_table) diff --git a/src/template_project/application/auth_identity/interactors/sign_up.py b/src/template_project/application/auth_identity/interactors/sign_up.py index 17267f5..495069f 100644 --- a/src/template_project/application/auth_identity/interactors/sign_up.py +++ b/src/template_project/application/auth_identity/interactors/sign_up.py @@ -14,6 +14,7 @@ from template_project.application.common.oauth.yandex import ( from template_project.application.common.unit_of_work import UnitOfWork from template_project.application.user.entity import User, UserId from template_project.application.user.password_utils import PasswordHasher +from template_project.application.user.profile.entity import Profile @to_data_structure @@ -46,10 +47,7 @@ class SignUpInteractor: hashed_password = self.password_hasher.hash(password) - user = User.factory( - email=email, - hashed_password=hashed_password, - ) + user = User.factory() auth_identity = AuthIdentity.factory( user_id=user.id, @@ -61,7 +59,9 @@ class SignUpInteractor: access_token = self.access_token_factory.execute(user.id) crypted_access_token = self.access_token_cryptographer.crypto(access_token.id) - for entity in (user, auth_identity, access_token): + profile = Profile.factory(user_id=user.id, email=email) + + for entity in (user, auth_identity, access_token, profile): await self.unit_of_work.add(entity) await self.unit_of_work.commit() @@ -101,8 +101,11 @@ class SignUpInteractor: secret_key=None, ) + profile = Profile.factory(user_id=user_id) + await self.unit_of_work.add(user) await self.unit_of_work.add(auth_identity) + await self.unit_of_work.add(profile) access_token = self.access_token_factory.execute(user_id) crypted_access_token = self.access_token_cryptographer.crypto(access_token.id) diff --git a/src/template_project/application/user/entity.py b/src/template_project/application/user/entity.py index e4ccab4..71b41c6 100644 --- a/src/template_project/application/user/entity.py +++ b/src/template_project/application/user/entity.py @@ -11,18 +11,9 @@ UserId = NewType("UserId", UUID) @to_entity class User(Entity[UserId]): - email: str | None - hashed_password: str | None - @classmethod - def factory( - cls, - email: str | None = None, - hashed_password: str | None = None, - ) -> Self: + def factory(cls) -> Self: return cls( id=UserId(uuid7()), - email=email, - hashed_password=hashed_password, created_at=datetime.now(tz=UTC), ) diff --git a/src/template_project/application/user/profile/__init__.py b/src/template_project/application/user/profile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/user/profile/data_gateway.py b/src/template_project/application/user/profile/data_gateway.py new file mode 100644 index 0000000..8cce877 --- /dev/null +++ b/src/template_project/application/user/profile/data_gateway.py @@ -0,0 +1,11 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.user.entity import UserId +from template_project.application.user.profile.entity import Profile + + +class ProfileDataGateway(Protocol): + @abstractmethod + async def load_by_user_id(self, user_id: UserId) -> Profile | None: + raise NotImplementedError diff --git a/src/template_project/application/user/profile/entity.py b/src/template_project/application/user/profile/entity.py new file mode 100644 index 0000000..b893430 --- /dev/null +++ b/src/template_project/application/user/profile/entity.py @@ -0,0 +1,44 @@ +from datetime import UTC, datetime +from typing import NewType, Self +from uuid import UUID + +from uuid_utils.compat import uuid7 + +from template_project.application.common.entity import Entity, to_entity +from template_project.application.user.entity import UserId + +ProfileId = NewType("ProfileId", UUID) + + +@to_entity +class Profile(Entity[ProfileId]): + user_id: UserId + email: str | None + display_name: str | None + first_name: str | None + last_name: str | None + avatar_url: str | None + phone: str | None + + @classmethod + def factory( + cls, + user_id: UserId, + email: str | None = None, + display_name: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + avatar_url: str | None = None, + phone: str | None = None, + ) -> Self: + return cls( + id=ProfileId(uuid7()), + user_id=user_id, + email=email, + display_name=display_name, + first_name=first_name, + last_name=last_name, + avatar_url=avatar_url, + phone=phone, + created_at=datetime.now(tz=UTC), + ) diff --git a/src/template_project/application/user/profile/interactors/__init__.py b/src/template_project/application/user/profile/interactors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/user/profile/interactors/get_profile.py b/src/template_project/application/user/profile/interactors/get_profile.py new file mode 100644 index 0000000..c7ca097 --- /dev/null +++ b/src/template_project/application/user/profile/interactors/get_profile.py @@ -0,0 +1,39 @@ +from template_project.application.common.data_structure import to_data_structure +from template_project.application.common.identity_provider import IdentityProvider +from template_project.application.common.interactor import to_interactor +from template_project.application.user.entity import UserId +from template_project.application.user.profile.data_gateway import ProfileDataGateway + + +@to_data_structure +class GetProfileResponse: + id: UserId + email: str | None + display_name: str | None + first_name: str | None + last_name: str | None + avatar_url: str | None + phone: str | None + + +@to_interactor +class GetProfileInteractor: + identity_provider: IdentityProvider + profile_data_gateway: ProfileDataGateway + + async def execute(self) -> GetProfileResponse | None: + current_user = await self.identity_provider.get_current_user() + profile = await self.profile_data_gateway.load_by_user_id(current_user.id) + + if not profile: + return None + + return GetProfileResponse( + id=profile.user_id, + email=profile.email, + display_name=profile.display_name, + first_name=profile.first_name, + last_name=profile.last_name, + avatar_url=profile.avatar_url, + phone=profile.phone, + ) diff --git a/src/template_project/application/user/profile/interactors/patch_profile.py b/src/template_project/application/user/profile/interactors/patch_profile.py new file mode 100644 index 0000000..008d029 --- /dev/null +++ b/src/template_project/application/user/profile/interactors/patch_profile.py @@ -0,0 +1,70 @@ +from template_project.application.common.data_structure import to_data_structure +from template_project.application.common.identity_provider import IdentityProvider +from template_project.application.common.interactor import to_interactor +from template_project.application.common.unit_of_work import UnitOfWork +from template_project.application.user.entity import UserId +from template_project.application.user.profile.data_gateway import ProfileDataGateway +from template_project.application.user.profile.entity import Profile + + +@to_data_structure +class PatchProfileRequest: + email: str | None = None + display_name: str | None = None + first_name: str | None = None + last_name: str | None = None + avatar_url: str | None = None + phone: str | None = None + + +@to_data_structure +class PatchProfileResponse: + id: UserId + email: str | None + display_name: str | None + first_name: str | None + last_name: str | None + avatar_url: str | None + phone: str | None + + +@to_interactor +class PatchProfileInteractor: + identity_provider: IdentityProvider + profile_data_gateway: ProfileDataGateway + unit_of_work: UnitOfWork + + async def execute(self, request: PatchProfileRequest) -> PatchProfileResponse: + current_user = await self.identity_provider.get_current_user() + profile = await self.profile_data_gateway.load_by_user_id(current_user.id) + + if profile: + for attr in ("email", "display_name", "first_name", "last_name", "avatar_url", "phone"): + value = getattr(request, attr) + if value is not None: + setattr(profile, attr, value) + + await self.unit_of_work.add(profile) + else: + profile = Profile.factory( + user_id=current_user.id, + email=request.email, + display_name=request.display_name, + first_name=request.first_name, + last_name=request.last_name, + avatar_url=request.avatar_url, + phone=request.phone, + ) + await self.unit_of_work.add(profile) + + await self.unit_of_work.commit() + + return PatchProfileResponse( + id=profile.user_id, + email=profile.email, + display_name=profile.display_name, + first_name=profile.first_name, + last_name=profile.last_name, + avatar_url=profile.avatar_url, + phone=profile.phone, + ) diff --git a/src/template_project/migrations/versions/b5fa4f3e95c5_.py b/src/template_project/migrations/versions/b5fa4f3e95c5_.py new file mode 100644 index 0000000..d6aabb6 --- /dev/null +++ b/src/template_project/migrations/versions/b5fa4f3e95c5_.py @@ -0,0 +1,52 @@ +"""empty message + +Revision ID: b5fa4f3e95c5 +Revises: f29dccaa6356 +Create Date: 2025-11-20 01:13:13.877236 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b5fa4f3e95c5' +down_revision: Union[str, Sequence[str], None] = 'f29dccaa6356' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('profiles', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('email', sa.String(), nullable=True), + sa.Column('display_name', sa.String(), nullable=True), + sa.Column('first_name', sa.String(), nullable=True), + sa.Column('last_name', sa.String(), nullable=True), + sa.Column('avatar_url', sa.String(), nullable=True), + sa.Column('phone', sa.String(), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + op.drop_constraint(op.f('users_email_key'), 'users', type_='unique') + op.drop_column('users', 'hashed_password') + op.drop_column('users', 'email') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('email', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('hashed_password', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.create_unique_constraint(op.f('users_email_key'), 'users', ['email'], postgresql_nulls_not_distinct=False) + op.drop_table('profiles') + # ### end Alembic commands ### diff --git a/src/template_project/web_api/entry_point.py b/src/template_project/web_api/entry_point.py index 3b24519..7e5ad3b 100644 --- a/src/template_project/web_api/entry_point.py +++ b/src/template_project/web_api/entry_point.py @@ -16,7 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware from template_project.web_api.configuration import load_configuration from template_project.web_api.ioc.make import make_ioc -from template_project.web_api.routes import auth, healthcheck +from template_project.web_api.routes import auth, healthcheck, profile LOG_CONFIG: Final = { "version": 1, @@ -65,6 +65,7 @@ def make_asgi_application( ) app.include_router(auth.router) app.include_router(healthcheck.router) + app.include_router(profile.router) setup_dishka(container=ioc, app=app) diff --git a/src/template_project/web_api/identity_provider.py b/src/template_project/web_api/identity_provider.py index 3829a0b..c39965f 100644 --- a/src/template_project/web_api/identity_provider.py +++ b/src/template_project/web_api/identity_provider.py @@ -32,9 +32,7 @@ class WebApiIdentityProvider(IdentityProvider): @override async def get_current_user(self) -> User: - auth_tokn = self._request.headers[AUTH_HEADER] - - access_token_id = self._parse_token(auth_tokn) + access_token_id = self._parse_token() if access_token_id is None: raise UserUnauthorizedError @@ -54,7 +52,7 @@ class WebApiIdentityProvider(IdentityProvider): return user - def _parse_token(self, token: str) -> AccessTokenId | None: + def _parse_token(self) -> AccessTokenId | None: authorization_header = self._request.headers.get(AUTH_HEADER) if authorization_header is None: diff --git a/src/template_project/web_api/ioc/data_gateway.py b/src/template_project/web_api/ioc/data_gateway.py index f5d2724..9724c7b 100644 --- a/src/template_project/web_api/ioc/data_gateway.py +++ b/src/template_project/web_api/ioc/data_gateway.py @@ -2,6 +2,7 @@ from dishka import BaseScope, Provider, Scope, WithParents, provide, provide_all from template_project.adapters.data_gateways.access_token import DefaultAccessTokenDataGateway from template_project.adapters.data_gateways.auth_identity import DefaultAuthIdentityDataGateway +from template_project.adapters.data_gateways.profile import DefaultProfileDataGateway from template_project.adapters.data_gateways.user import DefaultUserDataGateway from template_project.adapters.unit_of_work import DefaultUnitOfWork @@ -14,4 +15,5 @@ class DataGatewayProvider(Provider): WithParents[DefaultUserDataGateway], WithParents[DefaultAccessTokenDataGateway], WithParents[DefaultAuthIdentityDataGateway], + WithParents[DefaultProfileDataGateway], ) diff --git a/src/template_project/web_api/ioc/idp.py b/src/template_project/web_api/ioc/idp.py index c4cb989..7b5e5f7 100644 --- a/src/template_project/web_api/ioc/idp.py +++ b/src/template_project/web_api/ioc/idp.py @@ -1,4 +1,4 @@ -from dishka import BaseScope, Provider, Scope, provide +from dishka import BaseScope, Provider, Scope, WithParents, provide from template_project.web_api.identity_provider import WebApiIdentityProvider @@ -6,4 +6,4 @@ from template_project.web_api.identity_provider import WebApiIdentityProvider class IdPProvider(Provider): scope: BaseScope | None = Scope.REQUEST - web_api = provide(WebApiIdentityProvider) + web_api = provide(WithParents[WebApiIdentityProvider]) diff --git a/src/template_project/web_api/ioc/interactor.py b/src/template_project/web_api/ioc/interactor.py index 81a92fe..f8c2037 100644 --- a/src/template_project/web_api/ioc/interactor.py +++ b/src/template_project/web_api/ioc/interactor.py @@ -2,6 +2,8 @@ from dishka import BaseScope, Provider, Scope, provide_all from template_project.application.auth_identity.interactors.sign_in import SignInInteractor from template_project.application.auth_identity.interactors.sign_up import SignUpInteractor +from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor +from template_project.application.user.profile.interactors.patch_profile import PatchProfileInteractor class InteractorProvider(Provider): @@ -10,4 +12,6 @@ class InteractorProvider(Provider): interactors = provide_all( SignInInteractor, SignUpInteractor, + GetProfileInteractor, + PatchProfileInteractor, ) diff --git a/src/template_project/web_api/routes/profile.py b/src/template_project/web_api/routes/profile.py new file mode 100644 index 0000000..5c4adc7 --- /dev/null +++ b/src/template_project/web_api/routes/profile.py @@ -0,0 +1,87 @@ +from dishka import FromDishka +from dishka.integrations.fastapi import DishkaRoute +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer +from pydantic import BaseModel + +from template_project.application.user.entity import UserId +from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor +from template_project.application.user.profile.interactors.patch_profile import ( + PatchProfileInteractor, + PatchProfileRequest, +) + +security = HTTPBearer() +router = APIRouter(route_class=DishkaRoute, dependencies=[Depends(security)]) + + +class GetProfileResponse(BaseModel): + id: UserId + email: str | None + display_name: str | None + first_name: str | None + last_name: str | None + avatar_url: str | None + phone: str | None + + +@router.get("/profile") +async def get_profile( + interactor: FromDishka[GetProfileInteractor], +) -> GetProfileResponse: + response = await interactor.execute() + if not response: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") + return GetProfileResponse( + id=response.id, + email=response.email, + display_name=response.display_name, + first_name=response.first_name, + last_name=response.last_name, + avatar_url=response.avatar_url, + phone=response.phone, + ) + + +class PatchProfileRequestModel(BaseModel): + email: str | None = None + display_name: str | None = None + first_name: str | None = None + last_name: str | None = None + avatar_url: str | None = None + phone: str | None = None + + +class PatchProfileResponse(BaseModel): + id: UserId + email: str | None + display_name: str | None + first_name: str | None + last_name: str | None + avatar_url: str | None + phone: str | None + + +@router.patch("/profile") +async def patch_profile( + request: PatchProfileRequestModel, + interactor: FromDishka[PatchProfileInteractor], +) -> PatchProfileResponse: + patch_request = PatchProfileRequest( + email=request.email, + display_name=request.display_name, + first_name=request.first_name, + last_name=request.last_name, + avatar_url=request.avatar_url, + phone=request.phone, + ) + response = await interactor.execute(patch_request) + return PatchProfileResponse( + id=response.id, + email=response.email, + display_name=response.display_name, + first_name=response.first_name, + last_name=response.last_name, + avatar_url=response.avatar_url, + phone=response.phone, + )