Skip to content

Commit

Permalink
⭐ Upload Avatar
Browse files Browse the repository at this point in the history
  • Loading branch information
jmillandev committed Feb 16, 2024
1 parent 11b5692 commit 9fb7b5d
Show file tree
Hide file tree
Showing 25 changed files with 257 additions and 4 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,7 @@ cython_debug/

# Visual Studio Code
.vscode/

# Storage
files/*
!files/.gitkeep
14 changes: 13 additions & 1 deletion apps/planner/backend/users/controllers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Annotated

from fastapi import Depends
from fastapi import Depends, File
from kink import di

from apps.planner.backend.shared.auth import oauth2_scheme
Expand All @@ -10,6 +10,7 @@
from src.planner.users.application.find.query import FindUserQuery
from src.planner.users.application.find.responses import UserResponse
from src.planner.users.application.register.command import RegisterUserCommand
from src.planner.users.application.update_avatar.command import UpdateUserAvatarCommand


async def sign_up(
Expand All @@ -31,3 +32,14 @@ async def find(
auth_token = await query_bus.ask(FindAuthTokenQuery(access_token=access_token))
find_user_query = FindUserQuery(id=id, user_id=auth_token.user_id) # type: ignore[union-attr]
return await query_bus.ask(find_user_query) # type: ignore[return-value]

async def update_avatar(
id: str,
avatar: Annotated[bytes | None, File()],
access_token: Annotated[str, Depends(oauth2_scheme)],
command_bus: Annotated[CommandBus, Depends(lambda: di[CommandBus])],
query_bus: Annotated[QueryBus, Depends(lambda: di[QueryBus])]
):
auth_token = await query_bus.ask(FindAuthTokenQuery(access_token=access_token))
command = UpdateUserAvatarCommand(id=id, avatar=avatar, user_id=auth_token.user_id)
await command_bus.dispatch(command)
9 changes: 8 additions & 1 deletion apps/planner/backend/users/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter, status

from apps.planner.backend.users.controllers import find, sign_up
from apps.planner.backend.users.controllers import find, sign_up, update_avatar
from src.planner.users.application.find.responses import UserResponse

router = APIRouter()
Expand All @@ -20,3 +20,10 @@
endpoint=find,
status_code=status.HTTP_200_OK,
)

router.add_api_route(
"/v1/users/{id}/avatar",
methods=["PUT"],
endpoint=update_avatar,
status_code=status.HTTP_200_OK,
)
Empty file added files/.gitkeep
Empty file.
3 changes: 3 additions & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ pydantic==2.5.3

# Dependecy Injector
kink==0.7.0

# Content-Types
python-magic~=0.4.27
36 changes: 36 additions & 0 deletions src/planner/shared/domain/value_objects/file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from src.planner.shared.domain.value_objects.base import ValueObject
from typing import Any, Self

from uuid import uuid4
from src.planner.users.domain.storage import UserFileStorage
from src.planner.users.domain.mime_guesser import MimeGuesser
from kink import inject

@inject
class FileValueObject(ValueObject):

BASE_TYPE = bytes
SUBPATH = str

def __init__(self, value: Any, filename: str, storage: UserFileStorage) -> None:
"""
Args:
value (str): Filename
content (bytes, optional): FileContent
"""
self._filename = filename
self._storage = storage
super().__init__(value)

@property
def filename(self)-> str:
return self._filename

@classmethod
@inject
def make(cls, file, mime_guesser: MimeGuesser) -> Self:
filename = f"{uuid4()}{mime_guesser.extension(file)}"
return cls(file, filename)

async def push(self) -> None:
await self._storage.push(self.value, self.SUBPATH + self.filename)
3 changes: 3 additions & 0 deletions src/planner/shared/infrastructure/bus/command/hardcoded.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
from src.planner.users.application.register.command_handler import (
RegisterUserCommandHandler,
)
from src.planner.users.application.update_avatar.command import UpdateUserAvatarCommand
from src.planner.users.application.update_avatar.command_handler import UpdateUserAvatarCommandHandler


@inject(alias=CommandBus)
Expand All @@ -39,6 +41,7 @@ class HardcodedCommandBus:
AddExpenseMovementCommand: AddExpenseMovementCommandHandler,
AddIncomeMovementCommand: AddIncomeMovementCommandHandler,
AddTransferMovementCommand: AddTransferMovementCommandHandler,
UpdateUserAvatarCommand: UpdateUserAvatarCommandHandler,
}

async def dispatch(self, command: Command) -> None:
Expand Down
Empty file.
30 changes: 30 additions & 0 deletions src/planner/users/application/update_avatar/avatar_updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from kink import inject

from src.planner.shared.domain.users import UserId
from src.planner.users.domain.repository import UserRepository
from src.planner.users.domain.value_objects import UserAvatar
from src.shared.domain.bus.event.event_bus import EventBus
from src.planner.users.application.find.finder import UserFinder


@inject(use_factory=True)
class UserAvatarUpdater:
def __init__(self,
repository: UserRepository,
event_bus: EventBus,
user_finder: UserFinder
):
self._repository = repository
self._event_bus = event_bus
self._user_finder = user_finder

async def __call__(
self,
id: UserId,
current_user_id: UserId,
avatar: UserAvatar,
) -> None:
user = await self._user_finder(id, current_user_id)
await user.update_avatar(avatar)
await self._repository.save(user)
await self._event_bus.publish(*user.pull_domain_events())
10 changes: 10 additions & 0 deletions src/planner/users/application/update_avatar/command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from dataclasses import dataclass

from src.planner.shared.domain.bus.command import Command


@dataclass(frozen=True)
class UpdateUserAvatarCommand(Command):
id: str
avatar: bytes
user_id: str
26 changes: 26 additions & 0 deletions src/planner/users/application/update_avatar/command_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from kink import inject

from src.planner.shared.domain.users import UserId
from src.planner.users.domain.value_objects import (
UserAvatar,
UserLastName,
UserName,
UserPassword,
UserPronoun,
)

from .command import UpdateUserAvatarCommand
from .avatar_updater import UserAvatarUpdater


@inject
class UpdateUserAvatarCommandHandler:
def __init__(self, use_case: UserAvatarUpdater) -> None:
self.use_case = use_case

async def __call__(self, command: UpdateUserAvatarCommand) -> None:
await self.use_case(
id=UserId(command.id),
avatar=UserAvatar.make(command.avatar),
current_user_id=UserId(command.user_id)
)
13 changes: 13 additions & 0 deletions src/planner/users/domain/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
UserName,
UserPassword,
UserPronoun,
UserAvatar
)


Expand Down Expand Up @@ -59,3 +60,15 @@ def register(
)
)
return user

async def update_avatar(self, avatar: UserAvatar) -> None:
# TODO: Add UserAvatarUpdated
# self._record_event(
# UserAvatarUpdated.make(
# self.id.primitive,
# new_avatar=avatar.url,
# old_avatar=self.avatar.url
# )
# )
self.avatar = avatar
await self.avatar.push()
7 changes: 7 additions & 0 deletions src/planner/users/domain/mime_guesser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from typing import Protocol, runtime_checkable


@runtime_checkable
class MimeGuesser(Protocol):
def extension(self, file: bytes) -> str:
...
2 changes: 1 addition & 1 deletion src/planner/users/domain/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

@runtime_checkable
class UserRepository(Protocol):
async def create(self, user: User) -> None:
async def save(self, user: User) -> None:
...

async def search(self, user_id: UserId) -> Optional[User]:
Expand Down
13 changes: 13 additions & 0 deletions src/planner/users/domain/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import Protocol, runtime_checkable


@runtime_checkable
class UserFileStorage(Protocol):
async def push(self, file: bytes, path: str) -> None:
...

async def pull(self, path: str) -> None:
...

def url(self, path: str) -> str:
...
1 change: 1 addition & 0 deletions src/planner/users/domain/value_objects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
from .password import UserPassword # noqa: F401
from .pronoun import UserPronoun # noqa: F401
from .updated_at import UserUpdatedAt # noqa: F401
from .avatar import UserAvatar # noqa: F401
6 changes: 6 additions & 0 deletions src/planner/users/domain/value_objects/avatar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from src.planner.shared.domain.value_objects.file import FileValueObject


class UserAvatar(FileValueObject):
NAME = "avatar"
SUBPATH = "users/avatars/"
Empty file.
10 changes: 10 additions & 0 deletions src/planner/users/infrastructure/mime_guessers/magic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from magic import from_buffer
from mimetypes import guess_extension
from src.planner.users.domain.mime_guesser import MimeGuesser
from kink import inject

@inject(use_factory=True, alias=MimeGuesser)
class MagicMimeGuesser:
def extension(self, file: bytes) -> str:
content_type = from_buffer(file, mime=True)
return guess_extension(content_type)
3 changes: 2 additions & 1 deletion src/planner/users/infrastructure/repositories/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
SqlAlchemyCreateMixin,
SqlAlchemyFindMixin,
SqlAlchemyRepository,
SqlAlchemySaveMixin
)
from src.planner.users.domain.entity import User
from src.planner.users.domain.repository import UserRepository
Expand Down Expand Up @@ -36,7 +37,7 @@ class SqlAlchemyUser(Base):

@inject(alias=UserRepository, use_factory=True)
class SqlAlchemyUserRepository(
SqlAlchemyRepository, SqlAlchemyCreateMixin, SqlAlchemyFindMixin
SqlAlchemyRepository, SqlAlchemyCreateMixin, SqlAlchemyFindMixin, SqlAlchemySaveMixin
):
model_class = SqlAlchemyUser
entity_class = User
Expand Down
Empty file.
21 changes: 21 additions & 0 deletions src/planner/users/infrastructure/storages/local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from src.planner.shared.domain.value_objects.file import FileValueObject
from kink import inject
from src.planner.users.domain.storage import UserFileStorage
from pathlib import Path

@inject(use_factory=True, alias=UserFileStorage)
class LocalUserFileStorage:

def __init__(self, basedir: str = 'files') -> None:
self._basedir = basedir


async def push(self, file: bytes, path: str) -> None:
Path(f"{self._basedir}/{path}").parent.mkdir(parents=True, exist_ok=True)
with open(f"{self._basedir}/{path}", "wb") as f:
f.write(file)

async def pull(self, path: str) -> bytes:
with open(f"{self._basedir}/{path}", "rb") as f:
response = f.read()
return response
2 changes: 2 additions & 0 deletions src/shared/infrastructure/dependency_injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
from src.shared.infrastructure.bus.event.in_memory.event_bus import ( # noqa: F401
InMemoryEventBus,
)
from src.planner.users.infrastructure.storages.local import LocalUserFileStorage # noqa: F401
from src.planner.users.infrastructure.mime_guessers.magic import MagicMimeGuesser # noqa: F401


def search_subscribers() -> Set[type[DomainEventSubscriber]]:
Expand Down
48 changes: 48 additions & 0 deletions tests/apps/planner/users/test_controllers/test_update_avatar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import pytest
from faker import Faker
from fastapi import status
from httpx import AsyncClient
from kink import di
from sqlalchemy.ext.asyncio import AsyncSession

from apps.planner.backend.config import settings
from src.planner.users.domain.repository import UserRepository
from tests.apps.planner.shared.auth import AuthAsUser
from tests.src.planner.users.factories import UserFactory

fake = Faker()

pytestmark = pytest.mark.anyio


class TestUpdateUserAvatarController:
def setup_method(self):
self._user = UserFactory.build()
self._url = f"{settings.API_PREFIX}/v1/users/{self._user.id}/avatar"

async def test_success(
self, client: AsyncClient, sqlalchemy_sessionmaker: type[AsyncSession]
) -> None:
await di[UserRepository].create(self._user) # type: ignore[type-abstract]

response = await client.put(
self._url,
auth=AuthAsUser(self._user.id),
files={"avatar": ("sample.jpg", open('tests/fixtures/files/sample.jpg', "rb"), "image/jpeg")}
)

assert response.status_code == status.HTTP_200_OK, response.text
assert response.json() == None

async def test_should_return_unauthorized_missing_token(
self, client: AsyncClient, sqlalchemy_sessionmaker: type[AsyncSession]
) -> None:
response = await client.put(self._url)

assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text

json_response = response.json()
assert len(json_response["detail"]) == 1
error_response = json_response["detail"][0]
assert error_response["msg"] == "Is required"
assert error_response["source"] == "access_token"
Binary file added tests/fixtures/files/sample.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 9fb7b5d

Please sign in to comment.