├── src ├── shared │ ├── __init__.py │ ├── infra │ │ ├── __init__.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ ├── asgi.py │ │ │ ├── wsgi.py │ │ │ └── settings.py │ │ ├── repository │ │ │ ├── __init__.py │ │ │ ├── rdb.py │ │ │ └── mapper.py │ │ └── authentication.py │ ├── domain │ │ ├── __init__.py │ │ ├── exception.py │ │ └── entity.py │ └── presentation │ │ ├── __init__.py │ │ └── rest │ │ ├── __init__.py │ │ ├── containers.py │ │ ├── response.py │ │ └── api.py ├── tests │ ├── __init__.py │ ├── functional │ │ ├── __init__.py │ │ ├── test_health_check_api.py │ │ ├── test_user_api.py │ │ └── test_todo_api.py │ ├── integration │ │ ├── __init__.py │ │ ├── test_user.py │ │ └── test_todo.py │ └── conftest.py ├── todo │ ├── __init__.py │ ├── domain │ │ ├── __init__.py │ │ ├── exception.py │ │ └── entity.py │ ├── infra │ │ ├── __init__.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ ├── admin.py │ │ │ └── apps.py │ │ └── database │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ │ ├── repository │ │ │ ├── __init__.py │ │ │ ├── mapper.py │ │ │ └── rdb.py │ │ │ └── models.py │ ├── application │ │ ├── __init__.py │ │ └── use_case │ │ │ ├── __init__.py │ │ │ ├── query.py │ │ │ └── command.py │ └── presentation │ │ ├── __init__.py │ │ └── rest │ │ ├── __init__.py │ │ ├── request.py │ │ ├── containers.py │ │ ├── response.py │ │ └── api.py ├── user │ ├── __init__.py │ ├── domain │ │ ├── __init__.py │ │ ├── exception.py │ │ └── entity.py │ ├── infra │ │ ├── __init__.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ ├── admin.py │ │ │ └── apps.py │ │ └── database │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ │ ├── repository │ │ │ ├── __init__.py │ │ │ ├── mapper.py │ │ │ └── rdb.py │ │ │ └── models.py │ ├── application │ │ ├── __init__.py │ │ ├── query.py │ │ └── command.py │ └── presentation │ │ ├── __init__.py │ │ └── rest │ │ ├── __init__.py │ │ ├── request.py │ │ ├── containers.py │ │ ├── response.py │ │ └── api.py ├── pytest.ini └── manage.py ├── pyproject.toml ├── .pre-commit-config.yaml ├── requirements.txt ├── README.md └── .gitignore /src/shared/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shared/infra/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/infra/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/infra/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shared/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/application/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/infra/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/application/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/infra/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shared/infra/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shared/presentation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/infra/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/presentation/rest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/infra/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/presentation/rest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shared/infra/repository/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shared/presentation/rest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/application/use_case/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/infra/database/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/infra/database/repository/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/infra/database/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/user/infra/database/repository/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/todo/infra/django/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /src/user/infra/django/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /src/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = shared.infra.django.settings 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | # Same as Black. 3 | line-length = 120 4 | indent-width = 4 5 | 6 | # Enable the isort rules. 7 | extend-select = ["I"] -------------------------------------------------------------------------------- /src/user/presentation/rest/request.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class PostUserCredentialsRequestBody(Schema): 5 | email: str 6 | password: str 7 | -------------------------------------------------------------------------------- /src/shared/presentation/rest/containers.py: -------------------------------------------------------------------------------- 1 | from shared.infra.authentication import AuthenticationService 2 | 3 | auth_service: AuthenticationService = AuthenticationService() 4 | -------------------------------------------------------------------------------- /src/tests/functional/test_health_check_api.py: -------------------------------------------------------------------------------- 1 | def test_health_check(api_client): 2 | response = api_client.get("/api/health-check/") 3 | assert response.status_code == 200 4 | -------------------------------------------------------------------------------- /src/todo/domain/exception.py: -------------------------------------------------------------------------------- 1 | from shared.domain.exception import BaseMsgException 2 | 3 | 4 | class ToDoNotFoundException(BaseMsgException): 5 | message = "ToDo Not Found" 6 | -------------------------------------------------------------------------------- /src/user/domain/exception.py: -------------------------------------------------------------------------------- 1 | from shared.domain.exception import BaseMsgException 2 | 3 | 4 | class UserNotFoundException(BaseMsgException): 5 | message = "User Not Found" 6 | -------------------------------------------------------------------------------- /src/todo/infra/django/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TodoConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "todo" 7 | -------------------------------------------------------------------------------- /src/user/infra/django/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "user" 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.1.11 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format 11 | -------------------------------------------------------------------------------- /src/user/infra/database/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class User(models.Model): 5 | email = models.EmailField(unique=True) 6 | password = models.CharField(max_length=200, blank=False, null=False) 7 | 8 | class Meta: 9 | app_label = "user" 10 | db_table = "user" 11 | -------------------------------------------------------------------------------- /src/user/application/query.py: -------------------------------------------------------------------------------- 1 | from user.infra.database.repository.rdb import UserRDBRepository 2 | 3 | 4 | class UserQuery: 5 | def __init__(self, user_repo: UserRDBRepository): 6 | self.user_repo = user_repo 7 | 8 | def get_user(self, user_id: int): 9 | return self.user_repo.get_user_by_id(user_id=user_id) 10 | -------------------------------------------------------------------------------- /src/todo/presentation/rest/request.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from ninja import Schema 4 | 5 | 6 | class PostToDoRequestBody(Schema): 7 | contents: str 8 | due_datetime: datetime | None = None 9 | 10 | 11 | class PatchToDoRequestBody(Schema): 12 | contents: str | None 13 | due_datetime: datetime | None = None 14 | -------------------------------------------------------------------------------- /src/shared/domain/exception.py: -------------------------------------------------------------------------------- 1 | class BaseMsgException(Exception): 2 | message: str 3 | 4 | def __str__(self): 5 | return self.message 6 | 7 | 8 | class JWTKeyParsingException(BaseMsgException): 9 | message: str = "Invalid JWT Key Error" 10 | 11 | 12 | class NotAuthorizedException(BaseMsgException): 13 | message = "Not Authorized" 14 | -------------------------------------------------------------------------------- /src/todo/presentation/rest/containers.py: -------------------------------------------------------------------------------- 1 | from todo.application.use_case.command import ToDoCommand 2 | from todo.application.use_case.query import ToDoQuery 3 | from todo.infra.database.repository.rdb import ToDoRDBRepository 4 | 5 | todo_repo: ToDoRDBRepository = ToDoRDBRepository() 6 | 7 | todo_query: ToDoQuery = ToDoQuery(todo_repo=todo_repo) 8 | todo_command: ToDoCommand = ToDoCommand(todo_repo=todo_repo) 9 | -------------------------------------------------------------------------------- /src/todo/infra/database/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from user.infra.database.models import User 3 | 4 | 5 | class ToDo(models.Model): 6 | contents = models.CharField(blank=False, max_length=200) 7 | due_datetime = models.DateTimeField(null=True) 8 | user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="todos") 9 | 10 | class Meta: 11 | db_table = "todo" 12 | -------------------------------------------------------------------------------- /src/user/domain/entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from shared.domain.entity import Entity 6 | 7 | 8 | @dataclass(eq=False) 9 | class User(Entity): 10 | email: str 11 | password: str 12 | 13 | @classmethod 14 | def new(cls, email: str, hashed_password: str) -> User: 15 | return cls(email=email, password=hashed_password) 16 | -------------------------------------------------------------------------------- /src/user/presentation/rest/containers.py: -------------------------------------------------------------------------------- 1 | from shared.presentation.rest.containers import auth_service 2 | 3 | from user.application.command import UserCommand 4 | from user.application.query import UserQuery 5 | from user.infra.database.repository.rdb import UserRDBRepository 6 | 7 | user_repo = UserRDBRepository() 8 | 9 | user_query = UserQuery(user_repo=user_repo) 10 | 11 | user_command = UserCommand( 12 | auth_service=auth_service, 13 | user_repo=user_repo, 14 | ) 15 | -------------------------------------------------------------------------------- /src/shared/domain/entity.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, TypeVar 3 | 4 | 5 | @dataclass(kw_only=True) 6 | class Entity: 7 | id: int | None = None 8 | 9 | def __eq__(self, other: Any) -> bool: 10 | if isinstance(other, type(self)): 11 | return self.id == other.id 12 | return False 13 | 14 | def __hash__(self): 15 | return hash(self.id) 16 | 17 | 18 | EntityType = TypeVar("EntityType", bound=Entity) 19 | -------------------------------------------------------------------------------- /src/shared/infra/repository/rdb.py: -------------------------------------------------------------------------------- 1 | from shared.domain.entity import EntityType 2 | from shared.infra.repository.mapper import DjangoModelType, ModelMapperInterface 3 | 4 | 5 | class RDBRepository: 6 | model_mapper: ModelMapperInterface 7 | 8 | def save(self, entity: EntityType) -> EntityType: 9 | instance: DjangoModelType = self.model_mapper.to_instance(entity=entity) 10 | instance.save() 11 | return self.model_mapper.to_entity(instance=instance) 12 | -------------------------------------------------------------------------------- /src/shared/presentation/rest/response.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | 3 | from ninja import Schema 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def response(results: dict | list) -> dict: 9 | return {"results": results} 10 | 11 | 12 | class ObjectResponse(Schema, Generic[T]): 13 | results: T 14 | 15 | 16 | def error_response(msg: str) -> dict: 17 | return {"results": {"message": msg}} 18 | 19 | 20 | class ErrorMessageResponse(Schema): 21 | message: str 22 | -------------------------------------------------------------------------------- /src/shared/infra/django/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for shared project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shared.infra.django.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /src/shared/infra/django/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for shared project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shared.infra.django.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /src/todo/application/use_case/query.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from todo.domain.entity import ToDo 4 | from todo.infra.database.repository.rdb import ToDoRDBRepository 5 | 6 | 7 | class ToDoQuery: 8 | def __init__(self, todo_repo: ToDoRDBRepository): 9 | self.todo_repo = todo_repo 10 | 11 | def get_todo_of_user(self, user_id: int, todo_id: int) -> ToDo: 12 | return self.todo_repo.get_todo_of_user(user_id=user_id, todo_id=todo_id) 13 | 14 | def get_todos_of_user(self, user_id: int) -> List[ToDo]: 15 | return self.todo_repo.get_todos_of_user(user_id=user_id) 16 | -------------------------------------------------------------------------------- /src/user/presentation/rest/response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ninja import Schema 4 | 5 | from user.domain.entity import User 6 | 7 | 8 | class UserSchema(Schema): 9 | id: int 10 | email: str 11 | 12 | 13 | class UserResponse(Schema): 14 | user: UserSchema 15 | 16 | @classmethod 17 | def build(cls, user: User) -> dict: 18 | return cls(user=UserSchema(id=user.id, email=user.email)).model_dump() 19 | 20 | 21 | class TokenResponse(Schema): 22 | token: str 23 | 24 | @classmethod 25 | def build(cls, token: str) -> dict: 26 | return cls(token=token).model_dump() 27 | -------------------------------------------------------------------------------- /src/user/infra/database/repository/mapper.py: -------------------------------------------------------------------------------- 1 | from shared.infra.repository.mapper import ModelMapperInterface 2 | 3 | from user.domain.entity import User as UserEntity 4 | from user.infra.database.models import User as UserModel 5 | 6 | 7 | class UserMapper(ModelMapperInterface): 8 | def to_entity(self, instance: UserModel) -> UserEntity: 9 | return UserEntity( 10 | id=instance.id, 11 | email=instance.email, 12 | password=instance.password, 13 | ) 14 | 15 | def to_instance(self, entity: UserEntity) -> UserModel: 16 | return UserModel( 17 | id=entity.id, 18 | email=entity.email, 19 | password=entity.password, 20 | ) 21 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | annotated-types==0.6.0 2 | asgiref==3.7.2 3 | bcrypt==4.1.2 4 | cfgv==3.4.0 5 | contextlib2==21.6.0 6 | distlib==0.3.8 7 | Django==5.0 8 | django-ninja==1.1.0 9 | exceptiongroup==1.2.0 10 | filelock==3.13.1 11 | identify==2.5.33 12 | iniconfig==2.0.0 13 | nodeenv==1.8.0 14 | packaging==23.2 15 | platformdirs==4.1.0 16 | pluggy==1.3.0 17 | pre-commit==3.6.0 18 | pydantic==2.5.3 19 | pydantic_core==2.14.6 20 | PyJWT==2.8.0 21 | pytest==7.4.3 22 | pytest-django==4.7.0 23 | pytest-mock==3.12.0 24 | python-dateutil==2.8.2 25 | pytz==2023.3.post1 26 | PyYAML==6.0.1 27 | ruff==0.1.9 28 | schema==0.7.5 29 | six==1.16.0 30 | sqlparse==0.4.4 31 | time-machine==2.13.0 32 | tomli==2.0.1 33 | typing_extensions==4.9.0 34 | tzdata==2023.3 35 | virtualenv==20.25.0 36 | -------------------------------------------------------------------------------- /src/user/infra/database/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0 on 2023-12-23 13:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="User", 14 | fields=[ 15 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 16 | ("email", models.EmailField(max_length=254, unique=True)), 17 | ("password", models.CharField(max_length=200)), 18 | ], 19 | options={ 20 | "db_table": "user", 21 | }, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shared.infra.django.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /src/shared/presentation/rest/api.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.http import HttpRequest 3 | from django.urls import path 4 | from ninja import NinjaAPI 5 | from todo.presentation.rest.api import router as todo_router 6 | from user.presentation.rest.api import router as user_router 7 | 8 | api = NinjaAPI( 9 | title="Django-DDD(Pessimistic Way)", 10 | description="This is a demo API with dynamic OpenAPI info section.", 11 | ) 12 | 13 | 14 | @api.get("health-check/") 15 | def health_check(request: HttpRequest): 16 | return {"status": "ok"} 17 | 18 | 19 | api.add_router("todos/", todo_router) 20 | api.add_router("users/", user_router) 21 | 22 | 23 | urlpatterns = [ 24 | path("admin/", admin.site.urls), 25 | path("api/", api.urls), 26 | ] 27 | -------------------------------------------------------------------------------- /src/todo/domain/entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | 6 | from shared.domain.entity import Entity 7 | from user.domain.entity import User 8 | 9 | 10 | @dataclass(eq=False) 11 | class ToDo(Entity): 12 | contents: str 13 | due_datetime: datetime | None 14 | user: User 15 | 16 | @classmethod 17 | def new(cls, user: User, contents: str, due_datetime: datetime | None) -> ToDo: 18 | return cls(user=user, contents=contents, due_datetime=due_datetime) 19 | 20 | def update_contents(self, contents: str) -> None: 21 | self.contents = contents 22 | 23 | def update_due_datetime(self, due_datetime: datetime) -> None: 24 | self.due_datetime = due_datetime 25 | -------------------------------------------------------------------------------- /src/shared/infra/repository/mapper.py: -------------------------------------------------------------------------------- 1 | from typing import List, TypeVar 2 | 3 | from django.db.models import Model 4 | 5 | from shared.domain.entity import EntityType 6 | 7 | DjangoModelType = TypeVar("DjangoModelType", bound=Model) 8 | 9 | 10 | class ModelMapperInterface: 11 | def to_entity(self, instance: DjangoModelType) -> EntityType: 12 | raise NotImplementedError 13 | 14 | def to_instance(self, entity: EntityType) -> DjangoModelType: 15 | raise NotImplementedError 16 | 17 | def to_entity_list(self, instances: List[DjangoModelType]) -> List[EntityType]: 18 | return [self.to_entity(instance=i) for i in instances] 19 | 20 | def to_instance_list(self, entities: List[EntityType]) -> List[DjangoModelType]: 21 | return [self.to_instance(entity=e) for e in entities] 22 | -------------------------------------------------------------------------------- /src/tests/integration/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from user.domain.entity import User 3 | from user.domain.exception import UserNotFoundException 4 | from user.presentation.rest.containers import user_repo 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_create_user(): 9 | # given 10 | user: User = User.new(email="email", hashed_password="secure-pw") 11 | 12 | # when 13 | user: User = user_repo.save(entity=user) 14 | 15 | # then 16 | user_repo.get_user_by_id(user_id=user.id) 17 | 18 | 19 | @pytest.mark.django_db 20 | def test_delete_user(): 21 | # given 22 | user: User = User.new(email="email", hashed_password="secure-pw") 23 | user: User = user_repo.save(entity=user) 24 | 25 | # when 26 | user_repo.delete_user_by_id(user_id=user.id) 27 | 28 | # then 29 | with pytest.raises(UserNotFoundException): 30 | user_repo.get_user_by_id(user_id=user.id) 31 | -------------------------------------------------------------------------------- /src/todo/presentation/rest/response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import List 5 | 6 | from ninja import Schema 7 | 8 | from todo.domain.entity import ToDo 9 | 10 | 11 | class ToDoSchema(Schema): 12 | id: int 13 | contents: str 14 | due_datetime: datetime | None = None 15 | 16 | 17 | class ToDoResponse(Schema): 18 | todo: ToDoSchema 19 | 20 | @classmethod 21 | def build(cls, todo: ToDo) -> dict: 22 | return cls(todo=ToDoSchema(id=todo.id, contents=todo.contents, due_datetime=todo.due_datetime)).model_dump() 23 | 24 | 25 | class ListToDoResponse(Schema): 26 | todos: List[ToDoSchema] 27 | 28 | @classmethod 29 | def build(cls, todos: List[ToDo]) -> dict: 30 | return cls( 31 | todos=[ToDoSchema(id=todo.id, contents=todo.contents, due_datetime=todo.due_datetime) for todo in todos] 32 | ).model_dump() 33 | -------------------------------------------------------------------------------- /src/todo/infra/database/repository/mapper.py: -------------------------------------------------------------------------------- 1 | from shared.infra.repository.mapper import ModelMapperInterface 2 | from user.infra.database.repository.mapper import UserMapper 3 | 4 | from todo.domain.entity import ToDo as ToDoEntity 5 | from todo.infra.database.models import ToDo as ToDoModel 6 | 7 | 8 | class ToDoMapper(ModelMapperInterface): 9 | def __init__(self, user_mapper: UserMapper = UserMapper()): 10 | self.user_mapper = user_mapper 11 | 12 | def to_entity(self, instance: ToDoModel) -> ToDoEntity: 13 | return ToDoEntity( 14 | id=instance.id, 15 | contents=instance.contents, 16 | due_datetime=instance.due_datetime, 17 | user=self.user_mapper.to_entity(instance=instance.user), 18 | ) 19 | 20 | def to_instance(self, entity: ToDoEntity) -> ToDoModel: 21 | return ToDoModel( 22 | id=entity.id, 23 | contents=entity.contents, 24 | due_datetime=entity.due_datetime, 25 | user=self.user_mapper.to_instance(entity=entity.user), 26 | ) 27 | -------------------------------------------------------------------------------- /src/todo/application/use_case/command.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from user.domain.entity import User 4 | 5 | from todo.domain.entity import ToDo 6 | from todo.infra.database.repository.rdb import ToDoRDBRepository 7 | 8 | 9 | class ToDoCommand: 10 | def __init__(self, todo_repo: ToDoRDBRepository): 11 | self.todo_repo = todo_repo 12 | 13 | def create_todo(self, user: User, contents: str, due_datetime: datetime) -> ToDo: 14 | todo: ToDo = ToDo.new(user=user, contents=contents, due_datetime=due_datetime) 15 | return self.todo_repo.save(entity=todo) 16 | 17 | def update_todo(self, todo: ToDo, contents: str | None, due_datetime: datetime | None) -> ToDo: 18 | if contents: 19 | todo.update_contents(contents=contents) 20 | 21 | if due_datetime: 22 | todo.update_due_datetime(due_datetime=due_datetime) 23 | 24 | return self.todo_repo.save(entity=todo) 25 | 26 | def delete_todo_of_user(self, user_id: int, todo_id: int) -> None: 27 | self.todo_repo.delete_todo_of_user(user_id=user_id, todo_id=todo_id) 28 | -------------------------------------------------------------------------------- /src/todo/infra/database/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0 on 2023-12-23 13:54 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [ 11 | ("user", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="ToDo", 17 | fields=[ 18 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 19 | ("contents", models.CharField(max_length=200)), 20 | ("due_datetime", models.DateTimeField(null=True)), 21 | ( 22 | "user", 23 | models.ForeignKey( 24 | on_delete=django.db.models.deletion.CASCADE, related_name="todos", to="user.user" 25 | ), 26 | ), 27 | ], 28 | options={ 29 | "db_table": "todo", 30 | }, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test.client import Client 3 | 4 | 5 | class APIClient(Client): 6 | def post( 7 | self, 8 | path, 9 | data=None, 10 | content_type="application/json", 11 | follow=False, 12 | secure=False, 13 | *, 14 | headers=None, 15 | **extra, 16 | ): 17 | return super().post( 18 | path=path, 19 | data=data, 20 | content_type=content_type, 21 | follow=follow, 22 | secure=secure, 23 | headers=headers, 24 | **extra, 25 | ) 26 | 27 | def patch( 28 | self, 29 | path, 30 | data="", 31 | content_type="application/json", 32 | follow=False, 33 | secure=False, 34 | *, 35 | headers=None, 36 | **extra, 37 | ): 38 | return super().patch( 39 | path=path, 40 | data=data, 41 | content_type=content_type, 42 | follow=follow, 43 | secure=secure, 44 | headers=headers, 45 | **extra, 46 | ) 47 | 48 | 49 | @pytest.fixture(scope="session") 50 | def api_client(): 51 | return APIClient() 52 | -------------------------------------------------------------------------------- /src/user/infra/database/repository/rdb.py: -------------------------------------------------------------------------------- 1 | from shared.infra.repository.rdb import RDBRepository 2 | 3 | from user.domain.entity import User as UserEntity 4 | from user.domain.exception import UserNotFoundException 5 | from user.infra.database.models import User as UserModel 6 | from user.infra.database.repository.mapper import UserMapper 7 | 8 | 9 | class UserRDBRepository(RDBRepository): 10 | def __init__(self, model_mapper: UserMapper = UserMapper()): 11 | self.model_mapper = model_mapper 12 | 13 | def get_user_by_id(self, user_id: int) -> UserEntity: 14 | try: 15 | return self.model_mapper.to_entity(instance=UserModel.objects.get(id=user_id)) 16 | except UserModel.DoesNotExist: 17 | raise UserNotFoundException 18 | 19 | def get_user_by_email(self, email: str) -> UserEntity: 20 | try: 21 | return self.model_mapper.to_entity(instance=UserModel.objects.get(email=email)) 22 | except UserModel.DoesNotExist: 23 | raise UserNotFoundException 24 | 25 | @staticmethod 26 | def delete_user_by_id(user_id: int) -> None: 27 | try: 28 | UserModel.objects.get(id=user_id).delete() 29 | except UserModel.DoesNotExist: 30 | raise UserNotFoundException 31 | -------------------------------------------------------------------------------- /src/todo/infra/database/repository/rdb.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from shared.infra.repository.rdb import RDBRepository 4 | 5 | from todo.domain.entity import ToDo as ToDoEntity 6 | from todo.domain.exception import ToDoNotFoundException 7 | from todo.infra.database.models import ToDo as ToDoModel 8 | from todo.infra.database.repository.mapper import ToDoMapper 9 | 10 | 11 | class ToDoRDBRepository(RDBRepository): 12 | def __init__(self, model_mapper: ToDoMapper = ToDoMapper()): 13 | self.model_mapper = model_mapper 14 | 15 | def get_todo_of_user(self, user_id: int, todo_id: int) -> ToDoEntity: 16 | try: 17 | return self.model_mapper.to_entity(instance=ToDoModel.objects.get(id=todo_id, user_id=user_id)) 18 | except ToDoModel.DoesNotExist: 19 | raise ToDoNotFoundException 20 | 21 | def get_todos_of_user(self, user_id: int) -> List[ToDoEntity]: 22 | return self.model_mapper.to_entity_list(instances=ToDoModel.objects.filter(user_id=user_id)) 23 | 24 | @staticmethod 25 | def delete_todo_of_user(user_id: int, todo_id: int) -> None: 26 | try: 27 | ToDoModel.objects.get(id=todo_id, user_id=user_id).delete() 28 | except ToDoModel.DoesNotExist: 29 | raise ToDoNotFoundException 30 | -------------------------------------------------------------------------------- /src/user/application/command.py: -------------------------------------------------------------------------------- 1 | from shared.domain.exception import NotAuthorizedException 2 | from shared.infra.authentication import AuthenticationService 3 | 4 | from user.domain.entity import User 5 | from user.domain.exception import UserNotFoundException 6 | from user.infra.database.repository.rdb import UserRDBRepository 7 | 8 | 9 | class UserCommand: 10 | def __init__( 11 | self, 12 | auth_service: AuthenticationService, 13 | user_repo: UserRDBRepository, 14 | ): 15 | self.auth_service = auth_service 16 | self.user_repo = user_repo 17 | 18 | def sign_up_user(self, email: str, plain_password: str) -> User: 19 | hashed_password: str = self.auth_service.hash_password(plain_password=plain_password) 20 | user: User = User.new(email=email, hashed_password=hashed_password) 21 | return self.user_repo.save(entity=user) 22 | 23 | def log_in_user(self, email: str, plain_password: str) -> str: 24 | try: 25 | user: User = self.user_repo.get_user_by_email(email=email) 26 | except UserNotFoundException: 27 | raise NotAuthorizedException 28 | 29 | if not self.auth_service.verify_password(plain_password=plain_password, hashed_password=user.password): 30 | raise NotAuthorizedException 31 | 32 | return self.auth_service.create_jwt(user=user) 33 | 34 | def delete_user_by_id(self, user_id: int) -> None: 35 | self.user_repo.delete_user_by_id(user_id=user_id) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django-DDD 2 | 3 | Apply Domain-driven Design to Django without Django Rest Framework. 4 | This could be an overkill for using Django, but it's going to be an interesting experiment. 5 | 6 | ## Requirements 7 | - django 8 | - django-ninja 9 | 10 | ## Points to Note 11 | - Most project hierarchies are similar to [python-ddd project](https://github.com/qu3vipon/python-ddd). 12 | - The whole project is not dependent on the database using the imperative mapping and repository pattern. 13 | - [Imperative Mapping](src/shared/infra/repository/mapper.py) 14 | - [Repository Pattern](src/todo/infra/database/repository/rdb.py) 15 | - The built-in features(Admin, ORM, etc.) of Django can be used as they are. 16 | 17 | ### Project Structure 18 | ``` 19 | src 20 | ├── shared 21 | │ ├── domain 22 | │ └── infra 23 | │ ├── django 24 | │ └── repository 25 | │ ├── mapper 26 | │ └── rdb 27 | ├── todo 28 | │ ├── application 29 | │ │ └── use_case 30 | │ │ ├── query 31 | │ │ └── command 32 | │ ├── domain 33 | │ │ ├── entity 34 | │ │ └── exception 35 | │ ├── infra 36 | │ │ └── database 37 | │ │ ├── migrations 38 | │ │ ├── models 39 | │ │ └── repository 40 | │ │ ├── mapper 41 | │ │ └── rdb 42 | │ └── presentation 43 | │ └── rest 44 | │ ├── api 45 | │ ├── containers 46 | │ ├── request 47 | │ └── response 48 | ├── user 49 | └── tests 50 | ``` 51 | 52 | ## Opinion 53 | DRF has the advantage of being able to create the web applications quickly, but it is inherently too dependent on the database. I want to take advantage of Django's built-in features like Admin, ORM, etc. But I have sometimes suffered from DRF because of its inflexible design. 54 | 55 | I found Django-Ninja instead, and it has everything I need. 56 | 57 | But I still think DRF is a very good framework, and I highly value DRF's contribution to the Python web community. 58 | -------------------------------------------------------------------------------- /src/user/presentation/rest/api.py: -------------------------------------------------------------------------------- 1 | from ninja import Router 2 | from shared.domain.exception import JWTKeyParsingException 3 | from shared.infra.authentication import auth_bearer 4 | from shared.presentation.rest.containers import auth_service 5 | from shared.presentation.rest.response import ErrorMessageResponse, ObjectResponse, error_response, response 6 | 7 | from user.domain.entity import User 8 | from user.domain.exception import UserNotFoundException 9 | from user.presentation.rest.containers import user_command 10 | from user.presentation.rest.request import PostUserCredentialsRequestBody 11 | from user.presentation.rest.response import TokenResponse, UserResponse 12 | 13 | router = Router(tags=["users"]) 14 | 15 | 16 | @router.post("", response={201: ObjectResponse[UserResponse]}) 17 | def sign_up_user_handler(request, body: PostUserCredentialsRequestBody): 18 | user: User = user_command.sign_up_user(email=body.email, plain_password=body.password) 19 | return 201, response(UserResponse.build(user=user)) 20 | 21 | 22 | @router.delete( 23 | "/me", 24 | auth=auth_bearer, 25 | response={ 26 | 204: None, 27 | 401: ObjectResponse[ErrorMessageResponse], 28 | 404: ObjectResponse[ErrorMessageResponse], 29 | }, 30 | ) 31 | def delete_user_me_handler(request): 32 | try: 33 | user_id: int = auth_service.get_user_id_from_token(token=request.auth) 34 | except JWTKeyParsingException as e: 35 | return 401, error_response(str(e)) 36 | 37 | try: 38 | user_command.delete_user_by_id(user_id=user_id) 39 | except UserNotFoundException as e: 40 | return 404, error_response(str(e)) 41 | return 204, None 42 | 43 | 44 | @router.post("/log-in", response={200: ObjectResponse[TokenResponse]}) 45 | def log_in_user_handler(request, body: PostUserCredentialsRequestBody): 46 | jwt_token: str = user_command.log_in_user(email=body.email, plain_password=body.password) 47 | return 200, response(TokenResponse.build(token=jwt_token)) 48 | -------------------------------------------------------------------------------- /src/shared/infra/authentication.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict 2 | 3 | import bcrypt 4 | import jwt 5 | from django.http import HttpRequest 6 | from ninja.security import HttpBearer 7 | from user.domain.entity import User 8 | 9 | from shared.domain.exception import JWTKeyParsingException 10 | from shared.infra.django import settings 11 | 12 | 13 | class AuthBearer(HttpBearer): 14 | def authenticate(self, request: HttpRequest, token: str) -> str: 15 | return token 16 | 17 | 18 | auth_bearer = AuthBearer() 19 | 20 | 21 | class AuthenticationService: 22 | SECRET_KEY: ClassVar[str] = settings.SECRET_KEY 23 | ALGORITHM: ClassVar[str] = "HS256" 24 | USER_ID_KEY: ClassVar[str] = "user_id" 25 | HASH_ENCODING: ClassVar[str] = "UTF-8" 26 | 27 | # hash 28 | def hash_password(self, plain_password: str) -> str: 29 | hashed_password: bytes = bcrypt.hashpw(plain_password.encode(self.HASH_ENCODING), salt=bcrypt.gensalt()) 30 | return hashed_password.decode(self.HASH_ENCODING) 31 | 32 | def verify_password(self, plain_password: str, hashed_password: str) -> bool: 33 | return bcrypt.checkpw(plain_password.encode(self.HASH_ENCODING), hashed_password.encode(self.HASH_ENCODING)) 34 | 35 | # JWT 36 | def create_jwt(self, user: User) -> str: 37 | payload: Dict[str, Any] = {self.USER_ID_KEY: user.id} 38 | return self._encode_jwt(payload=payload) 39 | 40 | def _encode_jwt(self, payload: Dict[str, Any]) -> str: 41 | return jwt.encode(payload=payload, key=self.SECRET_KEY, algorithm=self.ALGORITHM) 42 | 43 | def get_user_id_from_token(self, token: str) -> int: 44 | payload: Dict[str, int] = self._decode_jwt(token=token) 45 | user_id: int = payload.get(self.USER_ID_KEY, 0) 46 | # todo: check expiration 47 | if not user_id: 48 | raise JWTKeyParsingException 49 | return user_id 50 | 51 | def _decode_jwt(self, token: str) -> Dict[str, Any]: 52 | return jwt.decode(jwt=token, key=self.SECRET_KEY, algorithms=self.ALGORITHM) 53 | -------------------------------------------------------------------------------- /src/tests/functional/test_user_api.py: -------------------------------------------------------------------------------- 1 | from schema import Schema 2 | from user.domain.entity import User 3 | from user.presentation.rest.containers import user_command 4 | 5 | 6 | class TestUser: 7 | # payload(user_id: 1) 8 | jwt_token: str = ( 9 | "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxfQ.VXxfcKEMlBdcasrjitwvAuZxzjCg2kWMPTwLd2E3Ofk" 10 | ) 11 | 12 | def test_sign_up_user(self, api_client, mocker): 13 | # given 14 | user = User(id=1, email="email", password="hashed") 15 | sign_up_user = mocker.patch.object(user_command, "sign_up_user", return_value=user) 16 | 17 | # when 18 | response = api_client.post("/api/users/", data={"email": "email", "password": "plain"}) 19 | 20 | # then 21 | sign_up_user.assert_called_once_with(email="email", plain_password="plain") 22 | assert response.status_code == 201 23 | assert Schema( 24 | { 25 | "results": { 26 | "user": { 27 | "id": 1, 28 | "email": "email", 29 | } 30 | } 31 | } 32 | ).is_valid(response.json()) 33 | 34 | def test_log_in_user(self, api_client, mocker): 35 | # given 36 | log_in_user = mocker.patch.object(user_command, "log_in_user", return_value="jwt_token") 37 | 38 | # when 39 | response = api_client.post("/api/users/log-in", data={"email": "email", "password": "plain"}) 40 | 41 | # then 42 | log_in_user.assert_called_once_with(email="email", plain_password="plain") 43 | assert response.status_code == 200 44 | assert Schema({"results": {"token": str}}).is_valid(response.json()) 45 | 46 | def test_delete_user(self, api_client, mocker): 47 | # given 48 | delete_user = mocker.patch.object(user_command, "delete_user_by_id", return_value=None) 49 | 50 | # when 51 | response = api_client.delete("/api/users/me", headers={"Authorization": self.jwt_token}) 52 | 53 | # then 54 | assert response.status_code == 204 55 | delete_user.assert_called_once_with(user_id=1) 56 | -------------------------------------------------------------------------------- /src/tests/integration/test_todo.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from todo.domain.entity import ToDo 5 | from todo.domain.exception import ToDoNotFoundException 6 | from todo.presentation.rest.containers import todo_repo 7 | from user.domain.entity import User 8 | from user.presentation.rest.containers import user_repo 9 | from zoneinfo import ZoneInfo 10 | 11 | 12 | @pytest.mark.django_db 13 | def test_create_todo(): 14 | # given 15 | user: User = User.new(email="email", hashed_password="secure-pw") 16 | user: User = user_repo.save(entity=user) 17 | 18 | due_datetime: datetime = datetime(2024, 1, 1, tzinfo=ZoneInfo("UTC")) 19 | todo: ToDo = ToDo.new(contents="contents", due_datetime=due_datetime, user=user) 20 | 21 | # when 22 | todo: ToDo = todo_repo.save(entity=todo) 23 | 24 | # then 25 | todo_repo.get_todo_of_user(user_id=user.id, todo_id=todo.id) 26 | 27 | 28 | @pytest.mark.django_db 29 | def test_filter_todos(): 30 | # given 31 | user: User = User.new(email="email", hashed_password="secure-pw") 32 | user: User = user_repo.save(entity=user) 33 | 34 | due_datetime: datetime = datetime(2024, 1, 1, tzinfo=ZoneInfo("UTC")) 35 | 36 | todo_1: ToDo = ToDo.new(contents="contents-1", due_datetime=due_datetime, user=user) 37 | todo_2: ToDo = ToDo.new(contents="contents-2", due_datetime=due_datetime, user=user) 38 | 39 | # when 40 | todo_1: ToDo = todo_repo.save(entity=todo_1) 41 | todo_2: ToDo = todo_repo.save(entity=todo_2) 42 | 43 | # then 44 | ret = todo_repo.get_todos_of_user(user_id=user.id) 45 | assert ret == [todo_1, todo_2] 46 | 47 | 48 | @pytest.mark.django_db 49 | def test_delete_todo(): 50 | # given 51 | user: User = User.new(email="email", hashed_password="secure-pw") 52 | user: User = user_repo.save(entity=user) 53 | 54 | due_datetime: datetime = datetime(2024, 1, 1, tzinfo=ZoneInfo("UTC")) 55 | todo: ToDo = ToDo.new(contents="contents", due_datetime=due_datetime, user=user) 56 | todo: ToDo = todo_repo.save(entity=todo) 57 | 58 | # when 59 | todo_repo.delete_todo_of_user(user_id=user.id, todo_id=todo.id) 60 | 61 | # then 62 | with pytest.raises(ToDoNotFoundException): 63 | todo_repo.get_todo_of_user(user_id=user.id, todo_id=todo.id) 64 | -------------------------------------------------------------------------------- /src/shared/infra/django/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for shared project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.0. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.0/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-f4k^j4uq0y()$o^+*+5n!1#+_63ibusg8^20zzx4ht-3=r)bpm" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | # Application definition 31 | 32 | INSTALLED_APPS = [ 33 | "django.contrib.admin", 34 | "django.contrib.auth", 35 | "django.contrib.contenttypes", 36 | "django.contrib.sessions", 37 | "django.contrib.messages", 38 | "django.contrib.staticfiles", 39 | "todo.infra.django.apps.TodoConfig", 40 | "user.infra.django.apps.UserConfig", 41 | ] 42 | 43 | MIGRATION_MODULES = { 44 | "todo": "todo.infra.database.migrations", 45 | "user": "user.infra.database.migrations", 46 | } 47 | 48 | MIDDLEWARE = [ 49 | "django.middleware.security.SecurityMiddleware", 50 | "django.contrib.sessions.middleware.SessionMiddleware", 51 | "django.middleware.common.CommonMiddleware", 52 | "django.middleware.csrf.CsrfViewMiddleware", 53 | "django.contrib.auth.middleware.AuthenticationMiddleware", 54 | "django.contrib.messages.middleware.MessageMiddleware", 55 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 56 | ] 57 | 58 | ROOT_URLCONF = "shared.presentation.rest.api" 59 | 60 | TEMPLATES = [ 61 | { 62 | "BACKEND": "django.template.backends.django.DjangoTemplates", 63 | "DIRS": [], 64 | "APP_DIRS": True, 65 | "OPTIONS": { 66 | "context_processors": [ 67 | "django.template.context_processors.debug", 68 | "django.template.context_processors.request", 69 | "django.contrib.auth.context_processors.auth", 70 | "django.contrib.messages.context_processors.messages", 71 | ], 72 | }, 73 | }, 74 | ] 75 | 76 | WSGI_APPLICATION = "shared.infra.django.wsgi.application" 77 | 78 | 79 | # Database 80 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases 81 | 82 | DATABASES = { 83 | "default": { 84 | "ENGINE": "django.db.backends.sqlite3", 85 | "NAME": BASE_DIR / "db.sqlite3", 86 | } 87 | } 88 | 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | { 95 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 99 | }, 100 | { 101 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 102 | }, 103 | { 104 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 105 | }, 106 | ] 107 | 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/5.0/topics/i18n/ 111 | 112 | LANGUAGE_CODE = "en-us" 113 | 114 | TIME_ZONE = "UTC" 115 | 116 | USE_I18N = True 117 | 118 | USE_TZ = True 119 | 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 123 | 124 | STATIC_URL = "static/" 125 | 126 | # Default primary key field type 127 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 128 | 129 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 130 | -------------------------------------------------------------------------------- /src/todo/presentation/rest/api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from ninja import Router 4 | from shared.domain.exception import JWTKeyParsingException 5 | from shared.infra.authentication import auth_bearer 6 | from shared.presentation.rest.containers import auth_service 7 | from shared.presentation.rest.response import ( 8 | ErrorMessageResponse, 9 | ObjectResponse, 10 | error_response, 11 | response, 12 | ) 13 | from user.domain.entity import User 14 | from user.domain.exception import UserNotFoundException 15 | from user.presentation.rest.containers import user_query 16 | 17 | from todo.domain.entity import ToDo 18 | from todo.domain.exception import ToDoNotFoundException 19 | from todo.presentation.rest.containers import todo_command, todo_query 20 | from todo.presentation.rest.request import PatchToDoRequestBody, PostToDoRequestBody 21 | from todo.presentation.rest.response import ListToDoResponse, ToDoResponse 22 | 23 | router = Router(tags=["todos"], auth=auth_bearer) 24 | 25 | 26 | @router.get( 27 | "/{todo_id}", 28 | response={ 29 | 200: ObjectResponse[ToDoResponse], 30 | 401: ObjectResponse[ErrorMessageResponse], 31 | 404: ObjectResponse[ErrorMessageResponse], 32 | }, 33 | ) 34 | def get_todo_handler(request, todo_id: int): 35 | try: 36 | user_id: int = auth_service.get_user_id_from_token(token=request.auth) 37 | except JWTKeyParsingException as e: 38 | return 401, error_response(str(e)) 39 | 40 | try: 41 | todo: ToDo = todo_query.get_todo_of_user(user_id=user_id, todo_id=todo_id) 42 | except ToDoNotFoundException as e: 43 | return 404, error_response(str(e)) 44 | 45 | return 200, response(ToDoResponse.build(todo=todo)) 46 | 47 | 48 | @router.get( 49 | "", 50 | response={ 51 | 200: ObjectResponse[ListToDoResponse], 52 | 401: ObjectResponse[ErrorMessageResponse], 53 | }, 54 | ) 55 | def get_todo_list_handler(request): 56 | try: 57 | user_id: int = auth_service.get_user_id_from_token(token=request.auth) 58 | except JWTKeyParsingException as e: 59 | return 401, error_response(str(e)) 60 | 61 | todos: List[ToDo] = todo_query.get_todos_of_user(user_id=user_id) 62 | return 200, response(ListToDoResponse.build(todos=todos)) 63 | 64 | 65 | @router.post( 66 | "", 67 | response={ 68 | 201: ObjectResponse[ToDoResponse], 69 | 401: ObjectResponse[ErrorMessageResponse], 70 | 404: ObjectResponse[ErrorMessageResponse], 71 | }, 72 | ) 73 | def post_todos_handler(request, body: PostToDoRequestBody): 74 | try: 75 | user_id: int = auth_service.get_user_id_from_token(token=request.auth) 76 | except JWTKeyParsingException as e: 77 | return 401, error_response(str(e)) 78 | 79 | try: 80 | user: User = user_query.get_user(user_id=user_id) 81 | except UserNotFoundException as e: 82 | return 404, error_response(str(e)) 83 | 84 | todo: ToDo = todo_command.create_todo(user=user, contents=body.contents, due_datetime=body.due_datetime) 85 | return 201, response(ToDoResponse.build(todo=todo)) 86 | 87 | 88 | @router.patch( 89 | "/{todo_id}", 90 | response={ 91 | 200: ObjectResponse[ToDoResponse], 92 | 401: ObjectResponse[ErrorMessageResponse], 93 | 404: ObjectResponse[ErrorMessageResponse], 94 | }, 95 | ) 96 | def patch_todos_handler(request, todo_id: int, body: PatchToDoRequestBody): 97 | try: 98 | user_id: int = auth_service.get_user_id_from_token(token=request.auth) 99 | except JWTKeyParsingException as e: 100 | return 401, error_response(str(e)) 101 | 102 | try: 103 | todo: ToDo = todo_query.get_todo_of_user(user_id=user_id, todo_id=todo_id) 104 | except ToDoNotFoundException as e: 105 | return 404, error_response(str(e)) 106 | 107 | todo: ToDo = todo_command.update_todo(todo=todo, contents=body.contents, due_datetime=body.due_datetime) 108 | return 200, response(ToDoResponse.build(todo=todo)) 109 | 110 | 111 | @router.delete( 112 | "/{todo_id}", 113 | response={ 114 | 204: None, 115 | 401: ObjectResponse[ErrorMessageResponse], 116 | 404: ObjectResponse[ErrorMessageResponse], 117 | }, 118 | ) 119 | def delete_todos_handler(request, todo_id: int): 120 | try: 121 | user_id: int = auth_service.get_user_id_from_token(token=request.auth) 122 | except JWTKeyParsingException as e: 123 | return 404, error_response(str(e)) 124 | 125 | try: 126 | todo_command.delete_todo_of_user(user_id=user_id, todo_id=todo_id) 127 | except ToDoNotFoundException as e: 128 | return 404, error_response(str(e)) 129 | 130 | return 204, None 131 | -------------------------------------------------------------------------------- /src/tests/functional/test_todo_api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from schema import Schema 4 | from todo.domain.entity import ToDo 5 | from todo.presentation.rest.containers import todo_command, todo_query 6 | from user.domain.entity import User 7 | from user.presentation.rest.containers import user_query 8 | 9 | 10 | class TestTodo: 11 | # payload(user_id: 1) 12 | headers = { 13 | "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxfQ.VXxfcKEMlBdcasrjitwvAuZxzjCg2kWMPTwLd2E3Ofk" 14 | } 15 | 16 | # Success 17 | def test_get_todo(self, api_client, mocker): 18 | # given 19 | user: User = User(id=1, email="email", password="secure-pw") 20 | todo = ToDo(id=1, contents="workout", due_datetime=datetime(2024, 1, 1), user=user) 21 | 22 | # when 23 | get_todo_of_user = mocker.patch.object(todo_query, "get_todo_of_user", return_value=todo) 24 | response = api_client.get("/api/todos/1", headers=self.headers) 25 | 26 | # then 27 | assert response.status_code == 200 28 | assert Schema( 29 | { 30 | "results": { 31 | "todo": { 32 | "id": 1, 33 | "contents": "workout", 34 | "due_datetime": str, 35 | } 36 | } 37 | } 38 | ).validate(response.json()) 39 | 40 | get_todo_of_user.assert_called_once_with(user_id=1, todo_id=1) 41 | 42 | def test_get_todo_list(self, api_client, mocker): 43 | # given 44 | user: User = User(id=1, email="email", password="secure-pw") 45 | todo = ToDo(id=1, contents="workout", due_datetime=datetime(2024, 1, 1), user=user) 46 | 47 | # when 48 | mocker.patch.object(todo_query, "get_todos_of_user", return_value=[todo]) 49 | response = api_client.get("/api/todos/", headers=self.headers) 50 | 51 | # then 52 | assert response.status_code == 200 53 | assert Schema( 54 | { 55 | "results": { 56 | "todos": [ 57 | { 58 | "id": 1, 59 | "contents": "workout", 60 | "due_datetime": str, 61 | } 62 | ] 63 | } 64 | } 65 | ).validate(response.json()) 66 | 67 | def test_post_todos(self, api_client, mocker): 68 | # given 69 | user: User = User(id=1, email="email", password="secure-pw") 70 | todo = ToDo(id=1, contents="workout", due_datetime=datetime(2024, 1, 1), user=user) 71 | 72 | # when 73 | get_user = mocker.patch.object(user_query, "get_user", return_value=user) 74 | create_todo = mocker.patch.object(todo_command, "create_todo", return_value=todo) 75 | 76 | response = api_client.post( 77 | path="/api/todos/", 78 | data={"contents": "workout", "due_datetime": "2024-01-01T00:00:00"}, 79 | headers=self.headers, 80 | ) 81 | # then 82 | assert response.status_code == 201 83 | assert Schema( 84 | { 85 | "results": { 86 | "todo": { 87 | "id": 1, 88 | "contents": "workout", 89 | "due_datetime": str, 90 | } 91 | } 92 | } 93 | ).validate(response.json()) 94 | 95 | get_user.assert_called_once_with(user_id=1) 96 | create_todo.assert_called_once_with(user=user, contents="workout", due_datetime=datetime(2024, 1, 1)) 97 | 98 | def test_patch_todos(self, api_client, mocker): 99 | before_update, after_update = "workout", "read" 100 | 101 | # given 102 | user: User = User(id=1, email="email", password="secure-pw") 103 | todo = ToDo(id=1, contents=before_update, due_datetime=datetime(2024, 1, 1), user=user) 104 | todo_updated = ToDo(id=1, contents=after_update, due_datetime=datetime(2024, 1, 1), user=user) 105 | 106 | # when 107 | get_todo_of_user = mocker.patch.object(todo_query, "get_todo_of_user", return_value=todo) 108 | update_todo = mocker.patch.object(todo_command, "update_todo", return_value=todo_updated) 109 | 110 | response = api_client.patch(path="/api/todos/1", data={"contents": after_update}, headers=self.headers) 111 | 112 | # then 113 | assert response.status_code == 200 114 | assert Schema( 115 | { 116 | "results": { 117 | "todo": { 118 | "id": 1, 119 | "contents": after_update, 120 | "due_datetime": str, 121 | } 122 | } 123 | } 124 | ).validate(response.json()) 125 | 126 | get_todo_of_user.assert_called_once_with(user_id=1, todo_id=1) 127 | update_todo.assert_called_once_with(todo=todo, contents=after_update, due_datetime=None) 128 | 129 | def test_delete_todos(self, api_client, mocker): 130 | # given 131 | user: User = User(id=1, email="email", password="secure-pw") 132 | todo = ToDo(id=1, contents="workout", due_datetime=datetime(2024, 1, 1), user=user) 133 | 134 | # when 135 | delete_todo_of_user = mocker.patch.object(todo_command, "delete_todo_of_user", return_value=todo) 136 | response = api_client.delete("/api/todos/1", headers=self.headers) 137 | 138 | # then 139 | assert response.status_code == 204 140 | delete_todo_of_user.assert_called_once_with(user_id=1, todo_id=1) 141 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,pycharm,python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,pycharm,python 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### macOS Patch ### 49 | # iCloud generated files 50 | *.icloud 51 | 52 | ### PyCharm ### 53 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 54 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 55 | 56 | # User-specific stuff 57 | .idea/**/workspace.xml 58 | .idea/**/tasks.xml 59 | .idea/**/usage.statistics.xml 60 | .idea/**/dictionaries 61 | .idea/**/shelf 62 | 63 | # AWS User-specific 64 | .idea/**/aws.xml 65 | 66 | # Generated files 67 | .idea/**/contentModel.xml 68 | 69 | # Sensitive or high-churn files 70 | .idea/**/dataSources/ 71 | .idea/**/dataSources.ids 72 | .idea/**/dataSources.local.xml 73 | .idea/**/sqlDataSources.xml 74 | .idea/**/dynamic.xml 75 | .idea/**/uiDesigner.xml 76 | .idea/**/dbnavigator.xml 77 | 78 | # Gradle 79 | .idea/**/gradle.xml 80 | .idea/**/libraries 81 | 82 | # Gradle and Maven with auto-import 83 | # When using Gradle or Maven with auto-import, you should exclude module files, 84 | # since they will be recreated, and may cause churn. Uncomment if using 85 | # auto-import. 86 | # .idea/artifacts 87 | # .idea/compiler.xml 88 | # .idea/jarRepositories.xml 89 | # .idea/modules.xml 90 | # .idea/*.iml 91 | # .idea/modules 92 | # *.iml 93 | # *.ipr 94 | 95 | # CMake 96 | cmake-build-*/ 97 | 98 | # Mongo Explorer plugin 99 | .idea/**/mongoSettings.xml 100 | 101 | # File-based project format 102 | *.iws 103 | 104 | # IntelliJ 105 | out/ 106 | 107 | # mpeltonen/sbt-idea plugin 108 | .idea_modules/ 109 | 110 | # JIRA plugin 111 | atlassian-ide-plugin.xml 112 | 113 | # Cursive Clojure plugin 114 | .idea/replstate.xml 115 | 116 | # SonarLint plugin 117 | .idea/sonarlint/ 118 | 119 | # Crashlytics plugin (for Android Studio and IntelliJ) 120 | com_crashlytics_export_strings.xml 121 | crashlytics.properties 122 | crashlytics-build.properties 123 | fabric.properties 124 | 125 | # Editor-based Rest Client 126 | .idea/httpRequests 127 | 128 | # Android studio 3.1+ serialized cache file 129 | .idea/caches/build_file_checksums.ser 130 | 131 | ### PyCharm Patch ### 132 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 133 | 134 | # *.iml 135 | # modules.xml 136 | # .idea/misc.xml 137 | # *.ipr 138 | 139 | # Sonarlint plugin 140 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 141 | .idea/**/sonarlint/ 142 | 143 | # SonarQube Plugin 144 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 145 | .idea/**/sonarIssues.xml 146 | 147 | # Markdown Navigator plugin 148 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 149 | .idea/**/markdown-navigator.xml 150 | .idea/**/markdown-navigator-enh.xml 151 | .idea/**/markdown-navigator/ 152 | 153 | # Cache file creation bug 154 | # See https://youtrack.jetbrains.com/issue/JBR-2257 155 | .idea/$CACHE_FILE$ 156 | 157 | # CodeStream plugin 158 | # https://plugins.jetbrains.com/plugin/12206-codestream 159 | .idea/codestream.xml 160 | 161 | # Azure Toolkit for IntelliJ plugin 162 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 163 | .idea/**/azureSettings.xml 164 | 165 | ### Python ### 166 | # Byte-compiled / optimized / DLL files 167 | __pycache__/ 168 | *.py[cod] 169 | *$py.class 170 | 171 | # C extensions 172 | *.so 173 | 174 | # Distribution / packaging 175 | .Python 176 | build/ 177 | develop-eggs/ 178 | dist/ 179 | downloads/ 180 | eggs/ 181 | .eggs/ 182 | lib/ 183 | lib64/ 184 | parts/ 185 | sdist/ 186 | var/ 187 | wheels/ 188 | share/python-wheels/ 189 | *.egg-info/ 190 | .installed.cfg 191 | *.egg 192 | MANIFEST 193 | 194 | # PyInstaller 195 | # Usually these files are written by a python script from a template 196 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 197 | *.manifest 198 | *.spec 199 | 200 | # Installer logs 201 | pip-log.txt 202 | pip-delete-this-directory.txt 203 | 204 | # Unit test / coverage reports 205 | htmlcov/ 206 | .tox/ 207 | .nox/ 208 | .coverage 209 | .coverage.* 210 | .cache 211 | nosetests.xml 212 | coverage.xml 213 | *.cover 214 | *.py,cover 215 | .hypothesis/ 216 | .pytest_cache/ 217 | cover/ 218 | 219 | # Translations 220 | *.mo 221 | *.pot 222 | 223 | # Django stuff: 224 | *.log 225 | local_settings.py 226 | db.sqlite3 227 | db.sqlite3-journal 228 | 229 | # Flask stuff: 230 | instance/ 231 | .webassets-cache 232 | 233 | # Scrapy stuff: 234 | .scrapy 235 | 236 | # Sphinx documentation 237 | docs/_build/ 238 | 239 | # PyBuilder 240 | .pybuilder/ 241 | target/ 242 | 243 | # Jupyter Notebook 244 | .ipynb_checkpoints 245 | 246 | # IPython 247 | profile_default/ 248 | ipython_config.py 249 | 250 | # pyenv 251 | # For a library or package, you might want to ignore these files since the code is 252 | # intended to run in multiple environments; otherwise, check them in: 253 | .python-version 254 | 255 | # pipenv 256 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 257 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 258 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 259 | # install all needed dependencies. 260 | #Pipfile.lock 261 | 262 | # poetry 263 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 264 | # This is especially recommended for binary packages to ensure reproducibility, and is more 265 | # commonly ignored for libraries. 266 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 267 | #poetry.lock 268 | 269 | # pdm 270 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 271 | #pdm.lock 272 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 273 | # in version control. 274 | # https://pdm.fming.dev/#use-with-ide 275 | .pdm.toml 276 | 277 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 278 | __pypackages__/ 279 | 280 | # Celery stuff 281 | celerybeat-schedule 282 | celerybeat.pid 283 | 284 | # SageMath parsed files 285 | *.sage.py 286 | 287 | # Environments 288 | .env 289 | .venv 290 | env/ 291 | venv/ 292 | ENV/ 293 | env.bak/ 294 | venv.bak/ 295 | 296 | # Spyder project settings 297 | .spyderproject 298 | .spyproject 299 | 300 | # Rope project settings 301 | .ropeproject 302 | 303 | # mkdocs documentation 304 | /site 305 | 306 | # mypy 307 | .mypy_cache/ 308 | .dmypy.json 309 | dmypy.json 310 | 311 | # Pyre type checker 312 | .pyre/ 313 | 314 | # pytype static type analyzer 315 | .pytype/ 316 | 317 | # Cython debug symbols 318 | cython_debug/ 319 | 320 | # PyCharm 321 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 322 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 323 | # and can be added to the global gitignore or merged into this file. For a more nuclear 324 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 325 | .idea/ 326 | 327 | ### Python Patch ### 328 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 329 | poetry.toml 330 | 331 | # ruff 332 | .ruff_cache/ 333 | 334 | # LSP config files 335 | pyrightconfig.json 336 | 337 | ### Windows ### 338 | # Windows thumbnail cache files 339 | Thumbs.db 340 | Thumbs.db:encryptable 341 | ehthumbs.db 342 | ehthumbs_vista.db 343 | 344 | # Dump file 345 | *.stackdump 346 | 347 | # Folder config file 348 | [Dd]esktop.ini 349 | 350 | # Recycle Bin used on file shares 351 | $RECYCLE.BIN/ 352 | 353 | # Windows Installer files 354 | *.cab 355 | *.msi 356 | *.msix 357 | *.msm 358 | *.msp 359 | 360 | # Windows shortcuts 361 | *.lnk 362 | 363 | # End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,pycharm,python 364 | --------------------------------------------------------------------------------