├── .editorconfig ├── .envrc ├── .flake8 ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── mypy.ini ├── pyproject.toml ├── renovate.json ├── requirements.txt ├── src ├── api_specification │ └── public_api.yaml ├── hexarch_project │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── test_settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py └── myapp │ ├── application │ ├── adapter │ │ ├── api │ │ │ └── http │ │ │ │ ├── article_vote_view.py │ │ │ │ ├── exceptions_handler.py │ │ │ │ ├── problem_response.py │ │ │ │ └── serializer │ │ │ │ ├── successfully_voted_result_serializer.py │ │ │ │ └── vote_for_article_command_deserializer.py │ │ └── spi │ │ │ └── persistence │ │ │ ├── entity │ │ │ ├── article_vote_entity.py │ │ │ └── voting_user_entity.py │ │ │ ├── exceptions │ │ │ └── voting_user_not_found.py │ │ │ └── repository │ │ │ └── voting_user_repository.py │ ├── domain │ │ └── model │ │ │ ├── identifier │ │ │ ├── article_id.py │ │ │ └── user_id.py │ │ │ ├── karma.py │ │ │ ├── vote.py │ │ │ ├── vote_for_article_result.py │ │ │ └── voting_user.py │ ├── port │ │ ├── api │ │ │ ├── command │ │ │ │ └── vote_for_article_command.py │ │ │ └── vote_for_article_use_case.py │ │ └── spi │ │ │ ├── find_voting_user_port.py │ │ │ └── save_voting_user_port.py │ ├── service │ │ └── article_rating_service.py │ └── util │ │ ├── assert_never.py │ │ └── transactional.py │ ├── apps.py │ ├── dependencies_container.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_populate_demo_user_and_article_data.py │ └── __init__.py │ ├── models.py │ └── urls.py └── tests └── test_myapp ├── application ├── adapter │ ├── api │ │ └── http │ │ │ └── test_article_vote_view.py │ └── spi │ │ └── persistence │ │ └── repository │ │ └── test_voting_user_repository.py ├── conftest.py ├── domain │ └── model │ │ └── test_voting_user.py ├── service │ └── test_article_rating_service.py ├── test_api.py └── util │ └── test_transactional.py ├── make_requests.py └── test_dependencies_container.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | [*.py] 9 | indent_style = space 10 | indent_size = 4 11 | charset = utf-8 12 | 13 | [Makefile] 14 | indent_style = tab 15 | 16 | [ci.yml] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export VIRTUAL_ENV=.venv 2 | layout python 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # ignore = D203 3 | exclude = .git,__pycache__,.venv 4 | max-complexity = 10 5 | max-line-length = 90 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: "Setup Python" 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.13" 19 | 20 | - name: "Install Dependencies" 21 | run: pip install -r requirements.txt 22 | 23 | - name: "Run static analysis" 24 | run: make static-analysis 25 | 26 | - name: "Run tests" 27 | run: make test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit tests / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Zaur Nasibov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MANAGEPY = src/manage.py 2 | 3 | help: 4 | @echo 'Usage:' 5 | @echo ' make migrate' 6 | @echo ' make run' 7 | @echo 'Development: ' 8 | @echo ' make test run unit and integration tests' 9 | @echo ' make flake8 run flake8 style checker' 10 | @echo ' make mypy run mypy static typing checker' 11 | @echo ' make buidl run linters and tests' 12 | 13 | buidl: build 14 | 15 | build: mypy static-analysis test 16 | 17 | static-analysis: flake8 mypy 18 | 19 | flake8: 20 | flake8 21 | 22 | mypy: 23 | mypy --namespace-packages --check-untyped-defs -p myapp -p tests 24 | 25 | test: 26 | pytest 27 | 28 | migrate: 29 | $(MANAGEPY) migrate 30 | 31 | run: migrate 32 | $(MANAGEPY) runserver 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hexagonal-architecture-django 2 | Experiments with Django and Hexagonal Architecture 3 | 4 | ## Project pulse 5 | 6 | I started this project to research building Django apps 7 | following the Hexagonal Architecture and Domain-Driven Design (DDD) principles. 8 | The code has been through numerous iterations and incarnations, as my understanding of 9 | the topics. It took me two and a half years to conclude the project and put down my 10 | experience in a series of articles. 11 | 12 | While the project currently resides in a maintenance state, it's worth noting that 13 | all dependencies continue to be updated automatically via 14 | [Mend Renovate](https://www.mend.io/renovate/) (bless you, people!) 15 | and the presence of an extensive test suite ensures 16 | its technological compatibility. 17 | 18 | ### Articles 19 | 20 | * [Hexagonal architecture of ports and adapters, Dependency injection and Python - Part I (2021.10)](https://zaurnasibov.com/posts/2021/10/30/hexarch_di_python_part_1.html) 21 | * [Hexagonal architecture and Python - Part II: Domain, Application Services, Ports and Adapters (2022.09)](https://zaurnasibov.com/posts/2022/09/18/hexarch_di_python_part_2.html) 22 | * [Hexagonal architecture and Python - Part III: Persistence, Transactions, Exceptions and The Final Assembly (2022.12)](https://zaurnasibov.com/posts/2022/12/31/hexarch_di_python_part_3.html) 23 | * [Hexagonal architecture and Python - Part IV: Lightweight integration tests (2025.05)](https://www.zaurnasibov.com/posts/2025/05/10/hexarch-python-part-4-lightweight-integration-tests.html#hexarch-python-part-4-lightweight-integration-tests) 24 | 25 | Originally, this project was a supporting example for 26 | [Domain-driven design, Hexagonal architecture of ports and adapters, Dependency injection и Python](https://habr.com/ru/post/559560/) 27 | article. 28 | The article was written in Russian language and was published on 31 May 2021 29 | at russian tech-blog platform [Habr](https://habr.com/). 30 | This code is now tagged as ["habr"](https://github.com/BasicWolf/hexagonal-architecture-django/tree/habr). 31 | 32 | ## Finding your way around 33 | 34 | **Installation** 35 | 36 | Requires ``Python 3.10`` or later. CI runs on Python ``3.13``. 37 | 38 | ```shell 39 | python3 -m venv .venv 40 | . .venv/bin/activate 41 | pip install -r requirements.txt 42 | ``` 43 | 44 | **Static type checks (mypy)** 45 | 46 | ```shell 47 | export MYPYPATH=src/ 48 | mypy --namespace-packages -v -p myapp 49 | ``` 50 | 51 | **Run unit tests** 52 | 53 | ```shell 54 | export PYTHONPATH=${PYTHONPATH}:./:src/ 55 | export DJANGO_SETTINGS_MODULE="hexarch_project.test_settings" 56 | pytest 57 | ``` 58 | 59 | **Run application** 60 | 61 | ```shell 62 | ./src/manage.py migrate 63 | ./src/manage.py runserver 64 | ``` 65 | 66 | **Test with cURL** 67 | 68 | With existing user: 69 | ```shell 70 | curl http://localhost:8000/api/article_vote \ 71 | --header "Content-Type: application/json" \ 72 | --request POST \ 73 | --data '{ 74 | "user_id": "e47cec00-c22a-486d-afe6-e76902f211c1", 75 | "article_id": "60ccea0c-0bf2-4726-8ac7-324fa03a74cd", 76 | "vote": "UP" 77 | }' 78 | ``` 79 | 80 | Non-existing user: 81 | ```shell 82 | curl http://localhost:8000/api/article_vote \ 83 | --header "Content-Type: application/json" \ 84 | --request POST \ 85 | --data '{ 86 | "user_id": "efed6f83-49ee-4cbc-bdbd-2b92bf428f2b", 87 | "article_id": "60ccea0c-0bf2-4726-8ac7-324fa03a74cd", 88 | "vote": "UP" 89 | }' 90 | ``` 91 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = 3 | mypy_django_plugin.main, 4 | mypy_drf_plugin.main 5 | mypy_path=src/:tests/ 6 | 7 | [mypy.plugins.django-stubs] 8 | django_settings_module = "hexarch_project.settings" 9 | 10 | [mypy-rest_enumfield.*] 11 | ignore_missing_imports = True 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | DJANGO_SETTINGS_MODULE = "hexarch_project.test_settings" 3 | pythonpath = ["src/"] 4 | testpaths = ["tests"] 5 | python_files = ["test_*.py"] 6 | markers = [ 7 | "integration" 8 | ] 9 | 10 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": true, 3 | "extends": ["config:base"], 4 | "enabledManagers": ["pip_requirements"], 5 | "packageRules": [ 6 | { 7 | "matchManagers": ["pip_requirements"], 8 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 9 | "automerge": true, 10 | "automergeStrategy": "rebase", 11 | "groupName": "Dependencies: minor versions" 12 | }, 13 | { 14 | "matchManagers": ["pip_requirements"], 15 | "matchUpdateTypes": ["major"], 16 | "automerge": true, 17 | "automergeStrategy": "rebase", 18 | "groupName": "Dependencies: major versions" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Prod 2 | django==5.2.1 3 | djangorestframework==3.16.0 4 | django-rest-enumfield==0.2.0 5 | 6 | # Dev 7 | django-stubs==5.2.0 8 | flake8==7.2.0 9 | mypy==1.16.0 10 | pytest==8.3.5 11 | pytest-django==4.11.1 12 | djangorestframework-stubs==3.16.0 13 | types-requests==2.32.0.20250515 14 | -------------------------------------------------------------------------------- /src/api_specification/public_api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | 3 | info: 4 | version: 0.0.1 5 | title: TechBlogging Portal API 6 | description: Hexagonal Architectecture TechBlogging Portal API 7 | 8 | paths: 9 | /article_vote: 10 | post: 11 | summary: Vote for an article. 12 | 13 | requestBody: 14 | required: true 15 | content: 16 | application/json: 17 | schema: 18 | $ref: '#/components/schemas/Vote' 19 | 20 | responses: 21 | '201': 22 | summary: Voted successfully. 23 | '400': 24 | summary: Bad request. There was a domain constraint violation. 25 | '409': 26 | summary: Conflict. User has already voted. 27 | 28 | 29 | components: 30 | schemas: 31 | Vote: 32 | type: object 33 | properties: 34 | user_id: 35 | type: string 36 | format: uuid 37 | article_id: 38 | type: string 39 | format: uuid 40 | vote: 41 | type: string 42 | enum: 43 | - 'up' 44 | - 'down' 45 | required: 46 | - user_id 47 | - article_id 48 | - vote 49 | -------------------------------------------------------------------------------- /src/hexarch_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BasicWolf/hexagonal-architecture-django/0339c16195e9a33d1750e818d7fe1ea137b85083/src/hexarch_project/__init__.py -------------------------------------------------------------------------------- /src/hexarch_project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for src 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/3.1/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', 'src.settings') 15 | 16 | # error: Call to untyped function "get_asgi_application" in typed context 17 | application = get_asgi_application() # type: ignore 18 | -------------------------------------------------------------------------------- /src/hexarch_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for src project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | from typing import List 17 | 18 | BASE_DIR = Path(__file__).resolve().parent.parent 19 | 20 | 21 | # Set to True for settings used for unit / integrational testing 22 | TESTING = False 23 | 24 | 25 | # Quick-start development settings - unsuitable for production 26 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 27 | 28 | # SECURITY WARNING: keep the secret key used in production secret! 29 | SECRET_KEY = 'z_0113l03sdn^6d$0q18#*e_k@o83_6jx0d_nmt7qq4j-0f@vq' 30 | 31 | # SECURITY WARNING: don't run with debug turned on in production! 32 | DEBUG = True 33 | 34 | ALLOWED_HOSTS: List[str] = [] 35 | 36 | REST_FRAMEWORK = { 37 | 'EXCEPTION_HANDLER': 'myapp.application.adapter.api.http.exceptions_handler.exceptions_handler', # noqa 38 | } 39 | 40 | # Application definition 41 | 42 | INSTALLED_APPS = [ 43 | 'django.contrib.admin', 44 | 'django.contrib.auth', 45 | 'django.contrib.contenttypes', 46 | 'django.contrib.sessions', 47 | 'django.contrib.messages', 48 | 'django.contrib.staticfiles', 49 | 'myapp', 50 | ] 51 | 52 | MIDDLEWARE = [ 53 | 'django.middleware.security.SecurityMiddleware', 54 | 'django.contrib.sessions.middleware.SessionMiddleware', 55 | 'django.middleware.common.CommonMiddleware', 56 | 'django.middleware.csrf.CsrfViewMiddleware', 57 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 58 | 'django.contrib.messages.middleware.MessageMiddleware', 59 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 60 | ] 61 | 62 | ROOT_URLCONF = 'hexarch_project.urls' 63 | 64 | TEMPLATES = [ 65 | { 66 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 67 | 'DIRS': [], 68 | 'APP_DIRS': True, 69 | 'OPTIONS': { 70 | 'context_processors': [ 71 | 'django.template.context_processors.debug', 72 | 'django.template.context_processors.request', 73 | 'django.contrib.auth.context_processors.auth', 74 | 'django.contrib.messages.context_processors.messages', 75 | ], 76 | }, 77 | }, 78 | ] 79 | 80 | WSGI_APPLICATION = 'hexarch_project.wsgi.application' 81 | 82 | 83 | # Database 84 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 85 | 86 | DATABASES = { 87 | 'default': { 88 | 'ENGINE': 'django.db.backends.sqlite3', 89 | 'NAME': BASE_DIR / 'db.sqlite3', 90 | } 91 | } 92 | 93 | 94 | # Password validation 95 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 96 | 97 | AUTH_PASSWORD_VALIDATORS = [ 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 103 | }, 104 | { 105 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 106 | }, 107 | { 108 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 109 | }, 110 | ] 111 | 112 | 113 | # Internationalization 114 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 115 | 116 | LANGUAGE_CODE = 'en-us' 117 | 118 | TIME_ZONE = 'UTC' 119 | 120 | USE_I18N = True 121 | 122 | USE_TZ = True 123 | 124 | 125 | # Static files (CSS, JavaScript, Images) 126 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 127 | 128 | STATIC_URL = '/static/' 129 | 130 | LOGGING = { 131 | 'version': 1, 132 | 'disable_existing_loggers': False, 133 | 'handlers': { 134 | 'console': { 135 | 'class': 'logging.StreamHandler', 136 | }, 137 | }, 138 | 'root': { 139 | 'handlers': ['console'], 140 | 'level': 'WARNING', 141 | }, 142 | } 143 | -------------------------------------------------------------------------------- /src/hexarch_project/test_settings.py: -------------------------------------------------------------------------------- 1 | from .settings import * # noqa 2 | 3 | TESTING = True 4 | -------------------------------------------------------------------------------- /src/hexarch_project/urls.py: -------------------------------------------------------------------------------- 1 | """src URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include 18 | 19 | urlpatterns = [ 20 | path('api/', include('myapp.urls')), 21 | path('admin/', admin.site.urls), 22 | ] 23 | -------------------------------------------------------------------------------- /src/hexarch_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for hexarch_project 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/3.1/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', 'hexarch_project.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # type: ignore 3 | 4 | """Django's command-line utility for administrative tasks.""" 5 | 6 | import os 7 | import sys 8 | 9 | 10 | def main(): 11 | """Run administrative tasks.""" 12 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hexarch_project.settings') 13 | try: 14 | from django.core.management import execute_from_command_line 15 | except ImportError as exc: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) from exc 21 | execute_from_command_line(sys.argv) 22 | 23 | 24 | if __name__ == '__main__': 25 | main() 26 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/api/http/article_vote_view.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from rest_framework.request import Request 4 | from rest_framework.response import Response 5 | from rest_framework.views import APIView 6 | 7 | from myapp.application.adapter.api.http.problem_response import problem_response 8 | from myapp.application.adapter.api.http.serializer.successfully_voted_result_serializer import ( # noqa 9 | SuccessfullyVotedResultSerializer 10 | ) 11 | from myapp.application.adapter.api.http.serializer.vote_for_article_command_deserializer import ( # noqa 12 | VoteForArticleCommandDeserializer 13 | ) 14 | from myapp.application.domain.model.vote_for_article_result import ( 15 | AlreadyVotedResult, 16 | InsufficientKarmaResult, 17 | SuccessfullyVotedResult, 18 | VoteForArticleResult 19 | ) 20 | from myapp.application.port.api.command.vote_for_article_command import ( 21 | VoteForArticleCommand 22 | ) 23 | from myapp.application.port.api.vote_for_article_use_case import ( 24 | VoteForArticleUseCase 25 | ) 26 | from myapp.application.util.assert_never import assert_never 27 | 28 | 29 | class ArticleVoteView(APIView): 30 | # default `None` and # `type: ignore` for sake of .as_view() 31 | # which requires passed attributes to be declared on the class level :( 32 | vote_for_article_use_case: VoteForArticleUseCase = None # type: ignore 33 | 34 | def __init__(self, vote_for_article_use_case: VoteForArticleUseCase): 35 | self.vote_for_article_use_case = vote_for_article_use_case 36 | super().__init__() 37 | 38 | def post(self, request: Request) -> Response: 39 | vote_for_article_command = self._read_command(request) 40 | result = self.vote_for_article_use_case.vote_for_article( 41 | vote_for_article_command 42 | ) 43 | return self._build_response(result) 44 | 45 | def _read_command(self, request: Request) -> VoteForArticleCommand: 46 | serializer = VoteForArticleCommandDeserializer(data=request.data) 47 | serializer.is_valid(raise_exception=True) 48 | return serializer.create() 49 | 50 | def _build_response(self, voting_result: VoteForArticleResult) -> Response: 51 | response = None 52 | 53 | match voting_result: 54 | case SuccessfullyVotedResult(): 55 | response_data = SuccessfullyVotedResultSerializer(voting_result).data 56 | response = Response(response_data, status=HTTPStatus.CREATED) 57 | case InsufficientKarmaResult(): 58 | response = problem_response( 59 | title="Cannot vote for an article", 60 | detail=voting_result.to_message(), 61 | status=HTTPStatus.BAD_REQUEST 62 | ) 63 | case AlreadyVotedResult(): 64 | response = problem_response( 65 | title="Cannot vote for an article", 66 | detail=voting_result.to_message(), 67 | status=HTTPStatus.CONFLICT 68 | ) 69 | case _: 70 | assert_never(voting_result) 71 | return response 72 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/api/http/exceptions_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from http import HTTPStatus 3 | 4 | from rest_framework.views import exception_handler 5 | 6 | from myapp.application.adapter.api.http.problem_response import problem_response 7 | from myapp.application.adapter.spi.persistence.exceptions.voting_user_not_found import ( 8 | VotingUserNotFound 9 | ) 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def exceptions_handler(exc, context): 15 | logger.error("Unexpected error occurred: %s", exc) 16 | 17 | # Call REST framework's default exception handler first, 18 | # to get the standard error response. 19 | # NOTE: this is not formatted as Problem response, 20 | # currently left as-is for cleaner demo code. 21 | response = exception_handler(exc, context) 22 | if response is not None: 23 | return response 24 | 25 | if isinstance(exc, VotingUserNotFound): 26 | return problem_response("Error", str(exc), HTTPStatus.NOT_FOUND) 27 | 28 | logger.exception("Unhandled error: %s", exc, exc_info=True) 29 | return problem_response( 30 | "Unknown error", 31 | "Our deepest apologies, an unexpected error occurred " 32 | "and we are already working on it.", 33 | HTTPStatus.INTERNAL_SERVER_ERROR 34 | ) 35 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/api/http/problem_response.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from rest_framework.response import Response 4 | 5 | 6 | def problem_response(title: str, detail: str, status: HTTPStatus) -> Response: 7 | problem_data = { 8 | 'title': title, 9 | 'detail': detail, 10 | 'status': status.value 11 | } 12 | return Response( 13 | problem_data, 14 | status=status.value, 15 | content_type='application/problem+json' 16 | ) 17 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/api/http/serializer/successfully_voted_result_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_enumfield import EnumField 2 | from rest_framework import serializers 3 | 4 | from myapp.application.domain.model.vote import Vote 5 | from myapp.application.domain.model.vote_for_article_result import SuccessfullyVotedResult 6 | 7 | 8 | class SuccessfullyVotedResultSerializer(serializers.Serializer[SuccessfullyVotedResult]): 9 | user_id = serializers.UUIDField() 10 | article_id = serializers.UUIDField() 11 | vote = EnumField(Vote) 12 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/api/http/serializer/vote_for_article_command_deserializer.py: -------------------------------------------------------------------------------- 1 | from rest_enumfield import EnumField 2 | from rest_framework import serializers 3 | 4 | from myapp.application.domain.model.vote import Vote 5 | from myapp.application.port.api.command.vote_for_article_command import ( 6 | VoteForArticleCommand 7 | ) 8 | 9 | 10 | class VoteForArticleCommandDeserializer(serializers.Serializer[VoteForArticleCommand]): 11 | user_id = serializers.UUIDField() 12 | article_id = serializers.UUIDField() 13 | vote = EnumField(Vote) 14 | 15 | # Ignored mypy error: 16 | # Signature of "create" incompatible with supertype "BaseSerializer" 17 | def create(self) -> VoteForArticleCommand: # type: ignore 18 | """ 19 | Create and return a new `VoteForArticleCommand` instance, 20 | given the validated data. 21 | """ 22 | return VoteForArticleCommand(**self.validated_data) 23 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/spi/persistence/entity/article_vote_entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import uuid4 4 | 5 | from django.db import models 6 | 7 | 8 | class ArticleVoteEntity(models.Model): 9 | # the `up` and `down` values are chosen to simplify mapping to/from domain model 10 | # for real applications integer fields might be more suitable. 11 | VOTE_UP = 'up' 12 | VOTE_DOWN = 'down' 13 | 14 | VOTES_CHOICES = [ 15 | (VOTE_UP, 'UP'), 16 | (VOTE_DOWN, 'DOWN') 17 | ] 18 | 19 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 20 | user_id = models.UUIDField() 21 | article_id = models.UUIDField() 22 | vote = models.CharField(max_length=4, choices=VOTES_CHOICES) 23 | 24 | class Meta: 25 | unique_together = [['user_id', 'article_id']] 26 | db_table = 'article_vote' 27 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/spi/persistence/entity/voting_user_entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import uuid4 4 | 5 | from django.db import models 6 | 7 | 8 | class VotingUserEntity(models.Model): 9 | user_id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 10 | karma = models.IntegerField() 11 | 12 | class Meta: 13 | # in a real application this could be a view or a table intended for reads only 14 | # (i.e. think of CQRS). 15 | db_table = 'voting_user' 16 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/spi/persistence/exceptions/voting_user_not_found.py: -------------------------------------------------------------------------------- 1 | from myapp.application.domain.model.identifier.user_id import UserId 2 | 3 | 4 | class VotingUserNotFound(RuntimeError): 5 | def __init__(self, user_id: UserId): 6 | super().__init__(f"User '{user_id}' not found") 7 | -------------------------------------------------------------------------------- /src/myapp/application/adapter/spi/persistence/repository/voting_user_repository.py: -------------------------------------------------------------------------------- 1 | from typing import cast, List 2 | 3 | from myapp.application.adapter.spi.persistence.entity.article_vote_entity import ( 4 | ArticleVoteEntity 5 | ) 6 | from myapp.application.adapter.spi.persistence.entity.voting_user_entity import ( 7 | VotingUserEntity 8 | ) 9 | from myapp.application.adapter.spi.persistence.exceptions.voting_user_not_found import ( 10 | VotingUserNotFound 11 | ) 12 | from myapp.application.domain.model.identifier.article_id import ArticleId 13 | from myapp.application.domain.model.identifier.user_id import UserId 14 | from myapp.application.domain.model.karma import Karma 15 | from myapp.application.domain.model.vote import Vote 16 | from myapp.application.domain.model.voting_user import ArticleVote, VotingUser 17 | from myapp.application.port.spi.find_voting_user_port import FindVotingUserPort 18 | from myapp.application.port.spi.save_voting_user_port import SaveVotingUserPort 19 | 20 | 21 | class VotingUserRepository( 22 | FindVotingUserPort, 23 | SaveVotingUserPort 24 | ): 25 | def find_voting_user(self, article_id: ArticleId, user_id: UserId) -> VotingUser: 26 | voting_user_entity = self._get_voting_user_entity(user_id) 27 | votes_for_articles = self._get_votes_for_article(article_id, user_id) 28 | 29 | return VotingUser( 30 | user_id, 31 | Karma(voting_user_entity.karma), 32 | votes_for_articles 33 | ) 34 | 35 | def _get_voting_user_entity(self, user_id: UserId) -> VotingUserEntity: 36 | try: 37 | return VotingUserEntity.objects.get(user_id=user_id) 38 | except VotingUserEntity.DoesNotExist as e: 39 | raise VotingUserNotFound(user_id) from e 40 | 41 | def _get_votes_for_article( 42 | self, 43 | article_id: ArticleId, 44 | user_id: UserId 45 | ) -> List[ArticleVote]: 46 | article_vote_entity = ArticleVoteEntity.objects.filter( 47 | article_id=article_id, 48 | user_id=user_id 49 | ).first() 50 | 51 | if article_vote_entity is not None: 52 | article_vote = self._article_entity_to_domain_model(article_vote_entity) 53 | return [article_vote] 54 | else: 55 | return [] 56 | 57 | def save_voting_user(self, voting_user: VotingUser) -> VotingUser: 58 | saved_article_vote_entities = self._save_votes_for_articles( 59 | voting_user.votes_for_articles 60 | ) 61 | saved_votes_for_articles = [ 62 | self._article_entity_to_domain_model(article_vote_entity) 63 | for article_vote_entity in saved_article_vote_entities 64 | ] 65 | 66 | return VotingUser( 67 | id=voting_user.id, 68 | karma=voting_user.karma, 69 | votes_for_articles=saved_votes_for_articles 70 | ) 71 | 72 | def _save_votes_for_articles( 73 | self, 74 | votes_for_articles: List[ArticleVote] 75 | ) -> List[ArticleVoteEntity]: 76 | saved_article_vote_entities: List[ArticleVoteEntity] = [] 77 | 78 | for article_vote in votes_for_articles: 79 | article_vote_entity = self._article_vote_to_entity(article_vote) 80 | article_vote_entity.save() 81 | saved_article_vote_entities.append(article_vote_entity) 82 | 83 | return saved_article_vote_entities 84 | 85 | def _article_vote_to_entity(self, article_vote: ArticleVote) -> ArticleVoteEntity: 86 | return ArticleVoteEntity( 87 | article_id=article_vote.article_id, 88 | user_id=article_vote.user_id, 89 | vote=cast(str, article_vote.vote.value) 90 | ) 91 | 92 | def _article_entity_to_domain_model(self, entity: ArticleVoteEntity) -> ArticleVote: 93 | return ArticleVote( 94 | ArticleId(entity.article_id), 95 | UserId(entity.user_id), 96 | Vote(entity.vote) 97 | ) 98 | -------------------------------------------------------------------------------- /src/myapp/application/domain/model/identifier/article_id.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | from uuid import UUID 3 | 4 | ArticleId = NewType('ArticleId', UUID) 5 | -------------------------------------------------------------------------------- /src/myapp/application/domain/model/identifier/user_id.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | from uuid import UUID 3 | 4 | UserId = NewType('UserId', UUID) 5 | -------------------------------------------------------------------------------- /src/myapp/application/domain/model/karma.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | 3 | Karma = NewType('Karma', int) 4 | -------------------------------------------------------------------------------- /src/myapp/application/domain/model/vote.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Vote(Enum): 5 | UP = 'up' 6 | DOWN = 'down' 7 | -------------------------------------------------------------------------------- /src/myapp/application/domain/model/vote_for_article_result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from myapp.application.domain.model.identifier.article_id import ArticleId 6 | from myapp.application.domain.model.identifier.user_id import UserId 7 | from myapp.application.domain.model.vote import Vote 8 | 9 | 10 | class VoteForArticleResult: 11 | def to_message(self) -> str: 12 | raise NotImplementedError() 13 | 14 | 15 | @dataclass 16 | class InsufficientKarmaResult(VoteForArticleResult): 17 | user_id: UserId 18 | 19 | def to_message(self) -> str: 20 | return f"User {self.user_id} does not have enough karma" \ 21 | " to vote for an article" 22 | 23 | 24 | @dataclass 25 | class AlreadyVotedResult(VoteForArticleResult): 26 | article_id: ArticleId 27 | user_id: UserId 28 | 29 | def to_message(self) -> str: 30 | return f"User \"{self.user_id}\" has already voted " \ 31 | f"for article \"{self.article_id}\"" 32 | 33 | 34 | @dataclass 35 | class SuccessfullyVotedResult(VoteForArticleResult): 36 | article_id: ArticleId 37 | user_id: UserId 38 | vote: Vote 39 | -------------------------------------------------------------------------------- /src/myapp/application/domain/model/voting_user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | 5 | from myapp.application.domain.model.identifier.article_id import ArticleId 6 | from myapp.application.domain.model.identifier.user_id import UserId 7 | from myapp.application.domain.model.karma import Karma 8 | from myapp.application.domain.model.vote import Vote 9 | from myapp.application.domain.model.vote_for_article_result import ( 10 | AlreadyVotedResult, 11 | InsufficientKarmaResult, 12 | SuccessfullyVotedResult, 13 | VoteForArticleResult 14 | ) 15 | 16 | MINIMUM_KARMA_REQUIRED_FOR_VOTING = Karma(5) 17 | 18 | 19 | @dataclass 20 | class VotingUser: 21 | id: UserId 22 | karma: Karma 23 | votes_for_articles: list[ArticleVote] = field(default_factory=list) 24 | 25 | def vote_for_article( 26 | self, 27 | article_id: ArticleId, 28 | vote: Vote 29 | ) -> VoteForArticleResult: 30 | if self._user_voted_for_article(article_id): 31 | return AlreadyVotedResult(article_id, self.id) 32 | 33 | if not self._karma_enough_for_voting(): 34 | return InsufficientKarmaResult(user_id=self.id) 35 | 36 | self.votes_for_articles.append( 37 | ArticleVote(article_id, self.id, vote) 38 | ) 39 | 40 | return SuccessfullyVotedResult(article_id, self.id, vote) 41 | 42 | def _karma_enough_for_voting(self): 43 | return self.karma >= MINIMUM_KARMA_REQUIRED_FOR_VOTING 44 | 45 | def _user_voted_for_article(self, article_id: ArticleId) -> bool: 46 | article_ids_for_which_user_voted = ( 47 | article_vote.article_id for article_vote in self.votes_for_articles 48 | ) 49 | return article_id in article_ids_for_which_user_voted 50 | 51 | 52 | @dataclass 53 | class ArticleVote: 54 | article_id: ArticleId 55 | user_id: UserId 56 | vote: Vote 57 | -------------------------------------------------------------------------------- /src/myapp/application/port/api/command/vote_for_article_command.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from myapp.application.domain.model.identifier.article_id import ArticleId 4 | from myapp.application.domain.model.identifier.user_id import UserId 5 | from myapp.application.domain.model.vote import Vote 6 | 7 | 8 | @dataclass 9 | class VoteForArticleCommand: 10 | article_id: ArticleId 11 | user_id: UserId 12 | vote: Vote 13 | -------------------------------------------------------------------------------- /src/myapp/application/port/api/vote_for_article_use_case.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from myapp.application.domain.model.vote_for_article_result import VoteForArticleResult 4 | from myapp.application.port.api.command.vote_for_article_command import ( 5 | VoteForArticleCommand 6 | ) 7 | 8 | 9 | class VoteForArticleUseCase(Protocol): 10 | def vote_for_article(self, command: VoteForArticleCommand) -> VoteForArticleResult: 11 | raise NotImplementedError() 12 | -------------------------------------------------------------------------------- /src/myapp/application/port/spi/find_voting_user_port.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from myapp.application.domain.model.identifier.article_id import ArticleId 4 | from myapp.application.domain.model.identifier.user_id import UserId 5 | from myapp.application.domain.model.voting_user import VotingUser 6 | 7 | 8 | class FindVotingUserPort(Protocol): 9 | def find_voting_user(self, article_id: ArticleId, user_id: UserId) -> VotingUser: 10 | raise NotImplementedError() 11 | -------------------------------------------------------------------------------- /src/myapp/application/port/spi/save_voting_user_port.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from myapp.application.domain.model.voting_user import VotingUser 4 | 5 | 6 | class SaveVotingUserPort(Protocol): 7 | def save_voting_user(self, voting_user: VotingUser) -> VotingUser: 8 | raise NotImplementedError() 9 | -------------------------------------------------------------------------------- /src/myapp/application/service/article_rating_service.py: -------------------------------------------------------------------------------- 1 | from myapp.application.domain.model.vote_for_article_result import ( 2 | SuccessfullyVotedResult, VoteForArticleResult 3 | ) 4 | from myapp.application.port.api.command.vote_for_article_command import ( 5 | VoteForArticleCommand 6 | ) 7 | from myapp.application.port.api.vote_for_article_use_case import ( 8 | VoteForArticleUseCase 9 | ) 10 | from myapp.application.port.spi.find_voting_user_port import FindVotingUserPort 11 | from myapp.application.port.spi.save_voting_user_port import SaveVotingUserPort 12 | from myapp.application.util.transactional import transactional 13 | 14 | 15 | class ArticleRatingService( 16 | VoteForArticleUseCase 17 | ): 18 | _find_voting_user_port: FindVotingUserPort 19 | _save_voting_user_port: SaveVotingUserPort 20 | 21 | def __init__( 22 | self, 23 | find_voting_user_port: FindVotingUserPort, 24 | save_voting_user_port: SaveVotingUserPort 25 | ): 26 | self._find_voting_user_port = find_voting_user_port 27 | self._save_voting_user_port = save_voting_user_port 28 | 29 | @transactional 30 | def vote_for_article(self, command: VoteForArticleCommand) -> VoteForArticleResult: 31 | voting_user = self._find_voting_user_port.find_voting_user( 32 | command.article_id, 33 | command.user_id 34 | ) 35 | 36 | voting_result = voting_user.vote_for_article( 37 | command.article_id, 38 | command.vote 39 | ) 40 | 41 | match voting_result: 42 | case SuccessfullyVotedResult(): 43 | self._save_voting_user_port.save_voting_user(voting_user) 44 | 45 | return voting_result 46 | -------------------------------------------------------------------------------- /src/myapp/application/util/assert_never.py: -------------------------------------------------------------------------------- 1 | from typing import Any, NoReturn 2 | 3 | 4 | def assert_never(v: Any) -> NoReturn: 5 | raise AssertionError(f"Invalid value: {v!r}") 6 | -------------------------------------------------------------------------------- /src/myapp/application/util/transactional.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Callable, ParamSpec, TypeVar 3 | 4 | from django.conf import settings 5 | from django.db import transaction 6 | 7 | T = TypeVar('T') 8 | P = ParamSpec('P') 9 | 10 | 11 | def transactional(f: Callable[P, T]) -> Callable[P, T]: 12 | if settings.TESTING: 13 | return f 14 | else: 15 | @wraps(f) 16 | def inner(*args: P.args, **kwargs: P.kwargs) -> T: 17 | with transaction.atomic(): 18 | return f(*args, **kwargs) 19 | return inner 20 | -------------------------------------------------------------------------------- /src/myapp/apps.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class MyAppConfig(AppConfig): 7 | name = 'myapp' 8 | container: Dict[str, Any] 9 | 10 | def ready(self) -> None: 11 | from myapp.dependencies_container import build_production_dependencies_container 12 | self.container = build_production_dependencies_container() 13 | -------------------------------------------------------------------------------- /src/myapp/dependencies_container.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from myapp.application.adapter.api.http.article_vote_view import ArticleVoteView 4 | from myapp.application.adapter.spi.persistence.repository.voting_user_repository import ( 5 | VotingUserRepository 6 | ) 7 | from myapp.application.service.article_rating_service import ArticleRatingService 8 | 9 | 10 | def build_production_dependencies_container() -> Dict[str, Any]: 11 | voting_user_repository = VotingUserRepository() 12 | 13 | article_rating_service = ArticleRatingService( 14 | find_voting_user_port=voting_user_repository, 15 | save_voting_user_port=voting_user_repository 16 | ) 17 | 18 | article_vote_django_view = ArticleVoteView.as_view( 19 | vote_for_article_use_case=article_rating_service 20 | ) 21 | 22 | return { 23 | 'article_vote_django_view': article_vote_django_view 24 | } 25 | -------------------------------------------------------------------------------- /src/myapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | import uuid 4 | from typing import List, Tuple 5 | 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies: List[Tuple[str, str]] = [] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='ArticleVoteEntity', 18 | fields=[ 19 | ('id', models.UUIDField( 20 | default=uuid.uuid4, 21 | editable=False, 22 | primary_key=True, 23 | serialize=False) 24 | ), 25 | ('user_id', models.UUIDField()), 26 | ('article_id', models.UUIDField()), 27 | ('vote', models.CharField( 28 | max_length=4, 29 | choices=[('up', 'UP'), ('down', 'DOWN')] 30 | )), 31 | ], 32 | options={ 33 | 'db_table': 'article_vote', 34 | 'unique_together': {('user_id', 'article_id')}, 35 | }, 36 | ), 37 | 38 | migrations.CreateModel( 39 | name='VotingUserEntity', 40 | fields=[ 41 | ('user_id', models.UUIDField( 42 | default=uuid.uuid4, 43 | editable=False, 44 | primary_key=True, 45 | serialize=False) 46 | ), 47 | ('karma', models.IntegerField()), 48 | ], 49 | options={ 50 | 'db_table': 'voting_user', 51 | }, 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /src/myapp/migrations/0002_populate_demo_user_and_article_data.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-02 12:15 2 | from uuid import UUID 3 | 4 | from django.db import migrations 5 | 6 | 7 | def populate_demo_user_and_article_data(apps, schema_editor): 8 | VotingUserEntity = apps.get_model('myapp', 'VotingUserEntity') 9 | VotingUserEntity.objects.create( 10 | user_id=UUID('e47cec00-c22a-486d-afe6-e76902f211c1'), 11 | karma=10 12 | ) 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | dependencies = [ 18 | ('myapp', '0001_initial'), 19 | ] 20 | 21 | operations = [ 22 | migrations.RunPython(populate_demo_user_and_article_data) 23 | ] 24 | -------------------------------------------------------------------------------- /src/myapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BasicWolf/hexagonal-architecture-django/0339c16195e9a33d1750e818d7fe1ea137b85083/src/myapp/migrations/__init__.py -------------------------------------------------------------------------------- /src/myapp/models.py: -------------------------------------------------------------------------------- 1 | from myapp.application.adapter.spi.persistence.entity.article_vote_entity import ( 2 | ArticleVoteEntity 3 | ) 4 | from myapp.application.adapter.spi.persistence.entity.voting_user_entity import ( 5 | VotingUserEntity 6 | ) 7 | 8 | # A way to explicitly tell linters that the imported classes are used 9 | __all__ = [ 10 | 'ArticleVoteEntity', 11 | 'VotingUserEntity' 12 | ] 13 | -------------------------------------------------------------------------------- /src/myapp/urls.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from django.apps import apps as django_apps 4 | from django.urls import path 5 | 6 | from myapp.apps import MyAppConfig 7 | 8 | app_config: MyAppConfig = cast( 9 | MyAppConfig, 10 | django_apps.get_containing_app_config('myapp') 11 | ) 12 | article_vote_django_view = app_config.container['article_vote_django_view'] 13 | 14 | urlpatterns = [ 15 | path('article_vote', article_vote_django_view) 16 | ] 17 | -------------------------------------------------------------------------------- /tests/test_myapp/application/adapter/api/http/test_article_vote_view.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from http import HTTPStatus 3 | from uuid import uuid4 4 | 5 | from rest_framework.exceptions import ErrorDetail 6 | from rest_framework.response import Response 7 | from rest_framework.test import APIRequestFactory 8 | 9 | from myapp.application.adapter.api.http.article_vote_view import ArticleVoteView 10 | from myapp.application.domain.model.vote_for_article_result import ( 11 | VoteForArticleResult, 12 | ) 13 | from myapp.application.port.api.command.vote_for_article_command import ( 14 | VoteForArticleCommand 15 | ) 16 | from myapp.application.port.api.vote_for_article_use_case import ( 17 | VoteForArticleUseCase 18 | ) 19 | 20 | 21 | def test__when_making_malformed_request__return_http_bad_request( 22 | post_article_vote_with_missing_data 23 | ): 24 | article_vote_view = ArticleVoteView.as_view( 25 | vote_for_article_use_case=VoteForArticleUseCaseNoopStub() 26 | ) 27 | 28 | response = post_article_vote_with_missing_data(article_vote_view) 29 | 30 | assert response.status_code == HTTPStatus.BAD_REQUEST 31 | assert response.data == { 32 | 'vote': [ 33 | ErrorDetail("This field is required.", code='required') 34 | ] 35 | } 36 | 37 | 38 | @pytest.fixture 39 | def post_article_vote_with_missing_data(arf: APIRequestFactory): 40 | def _post_article_vote_with_missing_data(article_vote_view) -> Response: 41 | return article_vote_view( 42 | arf.post( 43 | '/article_vote', 44 | { 45 | 'user_id': str(uuid4()), 46 | 'article_id': str(uuid4()) 47 | }, 48 | format='json' 49 | ) 50 | ) 51 | return _post_article_vote_with_missing_data 52 | 53 | 54 | class VoteForArticleUseCaseNoopStub(VoteForArticleUseCase): 55 | def vote_for_article(self, command: VoteForArticleCommand) -> VoteForArticleResult: 56 | raise Exception("This should never be reached - we should fail earlier") 57 | -------------------------------------------------------------------------------- /tests/test_myapp/application/adapter/spi/persistence/repository/test_voting_user_repository.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import pytest 4 | from django.db import IntegrityError 5 | 6 | from myapp.application.adapter.spi.persistence.entity.article_vote_entity import ( 7 | ArticleVoteEntity 8 | ) 9 | from myapp.application.adapter.spi.persistence.entity.voting_user_entity import ( 10 | VotingUserEntity 11 | ) 12 | from myapp.application.adapter.spi.persistence.exceptions.voting_user_not_found import ( 13 | VotingUserNotFound 14 | ) 15 | from myapp.application.adapter.spi.persistence.repository.voting_user_repository import ( 16 | VotingUserRepository 17 | ) 18 | from myapp.application.domain.model.identifier.article_id import ArticleId 19 | from myapp.application.domain.model.identifier.user_id import UserId 20 | from myapp.application.domain.model.karma import Karma 21 | from myapp.application.domain.model.vote import Vote 22 | from myapp.application.domain.model.voting_user import ArticleVote, VotingUser 23 | 24 | 25 | @pytest.mark.integration 26 | @pytest.mark.django_db(transaction=True) 27 | def test_find_voting_user_who_has_not_voted( 28 | voting_user_entity: VotingUserEntity, 29 | an_article_id: ArticleId, 30 | voting_user_who_has_not_voted: VotingUser 31 | ): 32 | voting_user_entity.save() 33 | 34 | voting_user = VotingUserRepository().find_voting_user( 35 | an_article_id, 36 | UserId(voting_user_entity.user_id), 37 | ) 38 | 39 | assert voting_user == voting_user_who_has_not_voted 40 | 41 | 42 | @pytest.mark.integration 43 | @pytest.mark.django_db(transaction=True) 44 | def test_find_voting_user_who_has_already_voted( 45 | article_vote_entity: ArticleVoteEntity, 46 | voting_user_entity: VotingUserEntity, 47 | voting_user_who_has_voted: VotingUser 48 | ): 49 | voting_user_entity.save() 50 | article_vote_entity.save() 51 | 52 | voting_user = VotingUserRepository().find_voting_user( 53 | ArticleId(article_vote_entity.article_id), 54 | UserId(voting_user_entity.user_id) 55 | ) 56 | 57 | assert voting_user == voting_user_who_has_voted 58 | 59 | 60 | @pytest.mark.integration 61 | @pytest.mark.django_db 62 | def test_get_non_existing_voting_user_raises_user_not_found(a_user_id, an_article_id): 63 | with pytest.raises(VotingUserNotFound): 64 | VotingUserRepository().find_voting_user(an_article_id, a_user_id) 65 | 66 | 67 | @pytest.mark.integration 68 | @pytest.mark.django_db(transaction=True) 69 | def test_save_voting_user_with_same_vote_raises_integrity_error(voting_user: VotingUser): 70 | with pytest.raises(IntegrityError): 71 | VotingUserRepository().save_voting_user(voting_user) 72 | VotingUserRepository().save_voting_user(voting_user) 73 | 74 | 75 | @pytest.mark.integration 76 | @pytest.mark.django_db(transaction=True) 77 | def test_article_vote_saved(voting_user: VotingUser, article_vote: ArticleVote): 78 | VotingUserRepository().save_voting_user(voting_user) 79 | 80 | article_vote_entity = ArticleVoteEntity.objects.get( 81 | article_id=voting_user.votes_for_articles[0].article_id, 82 | user_id=voting_user.id 83 | ) 84 | assert article_vote_entity.article_id == article_vote.article_id 85 | assert article_vote_entity.user_id == voting_user.id 86 | assert article_vote_entity.vote == str(article_vote.vote.value) 87 | 88 | 89 | @pytest.fixture(scope='module') 90 | def voting_user_entity() -> VotingUserEntity: 91 | return VotingUserEntity( 92 | user_id=UUID('06aee517-0000-0000-0000-000000000000'), 93 | karma=100 94 | ) 95 | 96 | 97 | @pytest.fixture(scope='module') 98 | def article_vote_entity() -> ArticleVoteEntity: 99 | return ArticleVoteEntity( 100 | article_id=UUID('171f2557-0000-0000-0000-000000000000'), 101 | user_id=UUID('06aee517-0000-0000-0000-000000000000'), 102 | vote=ArticleVoteEntity.VOTE_UP 103 | ) 104 | 105 | 106 | @pytest.fixture(scope='module') 107 | def voting_user_who_has_not_voted() -> VotingUser: 108 | return VotingUser( 109 | UserId(UUID('06aee517-0000-0000-0000-000000000000')), 110 | Karma(100), 111 | [] 112 | ) 113 | 114 | 115 | @pytest.fixture(scope='module') 116 | def voting_user_who_has_voted() -> VotingUser: 117 | return VotingUser( 118 | UserId(UUID('06aee517-0000-0000-0000-000000000000')), 119 | Karma(100), 120 | [ 121 | ArticleVote( 122 | ArticleId(UUID('171f2557-0000-0000-0000-000000000000')), 123 | UserId(UUID('06aee517-0000-0000-0000-000000000000')), 124 | Vote.UP 125 | ) 126 | ] 127 | ) 128 | 129 | 130 | @pytest.fixture(scope='module') 131 | def voting_user(article_vote: ArticleVote) -> VotingUser: 132 | return VotingUser( 133 | UserId(UUID('5e3f29f9-0000-0000-0000-000000000000')), 134 | Karma(10), 135 | [article_vote] 136 | ) 137 | 138 | 139 | @pytest.fixture(scope='module') 140 | def article_vote() -> ArticleVote: 141 | return ArticleVote( 142 | ArticleId(UUID('c313a2b3-0000-0000-0000-000000000000')), 143 | UserId(UUID('5e3f29f9-0000-0000-0000-000000000000')), 144 | Vote.UP 145 | ) 146 | -------------------------------------------------------------------------------- /tests/test_myapp/application/conftest.py: -------------------------------------------------------------------------------- 1 | import random 2 | from uuid import uuid4 3 | 4 | import pytest 5 | from rest_framework.test import APIRequestFactory 6 | 7 | from myapp.application.domain.model.identifier.article_id import ArticleId 8 | from myapp.application.domain.model.identifier.user_id import UserId 9 | from myapp.application.domain.model.vote import Vote 10 | 11 | 12 | @pytest.fixture 13 | def an_article_id() -> ArticleId: 14 | return ArticleId(uuid4()) 15 | 16 | 17 | @pytest.fixture 18 | def a_user_id() -> UserId: 19 | return UserId(uuid4()) 20 | 21 | 22 | @pytest.fixture 23 | def a_vote() -> Vote: 24 | return random.choice(list(Vote)) 25 | 26 | 27 | @pytest.fixture 28 | def arf(): 29 | return APIRequestFactory() 30 | -------------------------------------------------------------------------------- /tests/test_myapp/application/domain/model/test_voting_user.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import pytest 4 | 5 | from myapp.application.domain.model.identifier.article_id import ArticleId 6 | from myapp.application.domain.model.identifier.user_id import UserId 7 | from myapp.application.domain.model.karma import Karma 8 | from myapp.application.domain.model.vote import Vote 9 | from myapp.application.domain.model.vote_for_article_result import ( 10 | AlreadyVotedResult, InsufficientKarmaResult, 11 | SuccessfullyVotedResult 12 | ) 13 | from myapp.application.domain.model.voting_user import ArticleVote, VotingUser 14 | 15 | 16 | def test_vote_for_article_twice_returns_already_voted_result( 17 | voting_user_who_has_voted: VotingUser, 18 | article_id_for_which_user_has_voted: ArticleId, 19 | a_vote: Vote, 20 | expected_already_voted_result: AlreadyVotedResult 21 | ): 22 | voting_result = voting_user_who_has_voted.vote_for_article( 23 | article_id_for_which_user_has_voted, 24 | a_vote 25 | ) 26 | assert voting_result == expected_already_voted_result 27 | 28 | 29 | def test_vote_for_article_returns_successfully_voted_result( 30 | voting_user_who_has_not_voted: VotingUser, 31 | article_id_for_which_user_has_voted: ArticleId, 32 | expected_successfully_voted_result: SuccessfullyVotedResult 33 | ): 34 | voting_result = voting_user_who_has_not_voted.vote_for_article( 35 | article_id_for_which_user_has_voted, 36 | Vote.DOWN 37 | ) 38 | 39 | assert voting_result == expected_successfully_voted_result 40 | 41 | 42 | def test_cannot_vote_for_article_with_insufficient_karma( 43 | voting_user_with_insufficient_karma_for_voting: VotingUser, 44 | an_article_id: ArticleId, 45 | a_vote: Vote, 46 | expected_insufficient_karma_result: InsufficientKarmaResult 47 | ): 48 | voting_result = voting_user_with_insufficient_karma_for_voting.vote_for_article( 49 | an_article_id, 50 | a_vote 51 | ) 52 | 53 | assert voting_result == expected_insufficient_karma_result 54 | 55 | 56 | @pytest.fixture(scope='module') 57 | def article_id_for_which_user_has_voted() -> ArticleId: 58 | return ArticleId(UUID('4df32c92-0000-0000-0000-000000000000')) 59 | 60 | 61 | @pytest.fixture(scope='module') 62 | def voting_user_who_has_voted() -> VotingUser: 63 | return VotingUser( 64 | UserId(UUID('7ebd50e7-0000-0000-0000-000000000000')), 65 | Karma(10), 66 | [ 67 | ArticleVote( 68 | ArticleId(UUID('4df32c92-0000-0000-0000-000000000000')), 69 | UserId(UUID('7ebd50e7-0000-0000-0000-000000000000')), 70 | Vote.DOWN 71 | ) 72 | ] 73 | ) 74 | 75 | 76 | @pytest.fixture(scope='module') 77 | def expected_already_voted_result() -> AlreadyVotedResult: 78 | return AlreadyVotedResult( 79 | ArticleId(UUID('4df32c92-0000-0000-0000-000000000000')), 80 | UserId(UUID('7ebd50e7-0000-0000-0000-000000000000')) 81 | ) 82 | 83 | 84 | @pytest.fixture(scope='module') 85 | def voting_user_who_has_not_voted() -> VotingUser: 86 | return VotingUser( 87 | UserId(UUID('c23ec6da-0000-0000-0000-000000000000')), 88 | Karma(10), 89 | [] 90 | ) 91 | 92 | 93 | @pytest.fixture(scope='module') 94 | def expected_successfully_voted_result() -> SuccessfullyVotedResult: 95 | return SuccessfullyVotedResult( 96 | ArticleId(UUID('4df32c92-0000-0000-0000-000000000000')), 97 | UserId(UUID('c23ec6da-0000-0000-0000-000000000000')), 98 | Vote.DOWN 99 | ) 100 | 101 | 102 | @pytest.fixture(scope='module') 103 | def voting_user_with_insufficient_karma_for_voting() -> VotingUser: 104 | return VotingUser( 105 | UserId(UUID('d826bff6-0000-0000-0000-000000000000')), 106 | Karma(4), 107 | [] 108 | ) 109 | 110 | 111 | @pytest.fixture(scope='module') 112 | def expected_insufficient_karma_result() -> InsufficientKarmaResult: 113 | return InsufficientKarmaResult( 114 | UserId(UUID('d826bff6-0000-0000-0000-000000000000')) 115 | ) 116 | -------------------------------------------------------------------------------- /tests/test_myapp/application/service/test_article_rating_service.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID 3 | 4 | import pytest 5 | 6 | from myapp.application.domain.model.identifier.article_id import ArticleId 7 | from myapp.application.domain.model.identifier.user_id import UserId 8 | from myapp.application.domain.model.karma import Karma 9 | from myapp.application.domain.model.vote import Vote 10 | from myapp.application.domain.model.vote_for_article_result import SuccessfullyVotedResult 11 | from myapp.application.domain.model.voting_user import ArticleVote, VotingUser 12 | from myapp.application.port.api.command.vote_for_article_command import ( 13 | VoteForArticleCommand 14 | ) 15 | from myapp.application.port.spi.find_voting_user_port import FindVotingUserPort 16 | from myapp.application.port.spi.save_voting_user_port import SaveVotingUserPort 17 | from myapp.application.service.article_rating_service import ArticleRatingService 18 | 19 | 20 | class TestArticleRatingService: 21 | def test_successfully_voted_for_article( 22 | self, 23 | vote_for_article_command: VoteForArticleCommand, 24 | successfully_voted_result: SuccessfullyVotedResult 25 | ): 26 | article_rating_service = build_article_rating_service() 27 | 28 | vote_for_article_result = article_rating_service.vote_for_article( 29 | vote_for_article_command 30 | ) 31 | 32 | assert vote_for_article_result == successfully_voted_result 33 | 34 | def test_voting_user_saved( 35 | self, 36 | vote_for_article_command: VoteForArticleCommand, 37 | saved_voting_user: VotingUser 38 | ): 39 | save_voting_user_port_mock = SaveVotingUserPortMock() 40 | article_rating_service = build_article_rating_service( 41 | save_voting_user_port=save_voting_user_port_mock 42 | ) 43 | 44 | article_rating_service.vote_for_article(vote_for_article_command) 45 | 46 | assert save_voting_user_port_mock.saved_voting_user == saved_voting_user 47 | 48 | 49 | class FindVotingUserPortStub(FindVotingUserPort): 50 | def find_voting_user(self, article_id: ArticleId, user_id: UserId) -> VotingUser: 51 | return VotingUser(user_id, Karma(10)) 52 | 53 | 54 | class SaveVotingUserPortMock(SaveVotingUserPort): 55 | def __init__(self): 56 | self.saved_voting_user: Optional[VotingUser] = None 57 | 58 | def save_voting_user(self, voting_user: VotingUser) -> VotingUser: 59 | self.saved_voting_user = voting_user 60 | return voting_user 61 | 62 | 63 | @pytest.fixture(scope='module') 64 | def vote_for_article_command() -> VoteForArticleCommand: 65 | return VoteForArticleCommand( 66 | ArticleId(UUID('c77fc6c4-0000-0000-0000-000000000000')), 67 | UserId(UUID('bd971243-0000-0000-0000-000000000000')), 68 | Vote.UP 69 | ) 70 | 71 | 72 | @pytest.fixture(scope='module') 73 | def saved_article_vote() -> ArticleVote: 74 | return ArticleVote( 75 | ArticleId(UUID('c77fc6c4-0000-0000-0000-000000000000')), 76 | UserId(UUID('bd971243-0000-0000-0000-000000000000')), 77 | Vote.UP 78 | ) 79 | 80 | 81 | @pytest.fixture(scope='module') 82 | def saved_voting_user() -> VotingUser: 83 | return VotingUser( 84 | UserId(UUID('bd971243-0000-0000-0000-000000000000')), 85 | Karma(10), 86 | [ 87 | ArticleVote( 88 | ArticleId(UUID('c77fc6c4-0000-0000-0000-000000000000')), 89 | UserId(UUID('bd971243-0000-0000-0000-000000000000')), 90 | Vote.UP 91 | ) 92 | ] 93 | ) 94 | 95 | 96 | @pytest.fixture(scope='module') 97 | def successfully_voted_result() -> SuccessfullyVotedResult: 98 | return SuccessfullyVotedResult( 99 | ArticleId(UUID('c77fc6c4-0000-0000-0000-000000000000')), 100 | UserId(UUID('bd971243-0000-0000-0000-000000000000')), 101 | Vote.UP 102 | ) 103 | 104 | 105 | def build_article_rating_service( 106 | find_voting_user_port: FindVotingUserPort = FindVotingUserPortStub(), 107 | save_voting_user_port: SaveVotingUserPort = SaveVotingUserPortMock() 108 | ): 109 | return ArticleRatingService( 110 | find_voting_user_port, 111 | save_voting_user_port 112 | ) 113 | -------------------------------------------------------------------------------- /tests/test_myapp/application/test_api.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Optional, Self 3 | from unittest.mock import patch 4 | from uuid import UUID, uuid4 5 | 6 | import pytest 7 | from rest_framework.response import Response 8 | from rest_framework.test import APIRequestFactory 9 | 10 | from myapp.application.adapter.spi.persistence.entity.article_vote_entity import ArticleVoteEntity # noqa: E501 11 | from myapp.application.adapter.spi.persistence.entity.voting_user_entity import VotingUserEntity # noqa: E501 12 | from myapp.dependencies_container import build_production_dependencies_container 13 | 14 | 15 | class TestWhenUserSuccessfullyVotesForExistingArticle: 16 | @pytest.fixture(autouse=True) 17 | def arrange( 18 | self, 19 | given_a_user_who_can_vote, 20 | given_no_existing_article_votes, 21 | mock_persisting_article_vote, 22 | post_article_vote 23 | ): 24 | given_a_user_who_can_vote( 25 | UUID('9af8961e-0000-0000-0000-000000000000') 26 | ) 27 | given_no_existing_article_votes() 28 | self.persisting_article_vote_spy = mock_persisting_article_vote() 29 | self.post_article_vote = post_article_vote 30 | 31 | def act(self) -> Response: 32 | return self.post_article_vote( 33 | article_id='3f577757-0000-0000-0000-000000000000', 34 | user_id='9af8961e-0000-0000-0000-000000000000', 35 | vote='down' 36 | ) 37 | 38 | def test_system_returns_http_created_with_vote_details(self): 39 | response = self.act() 40 | assert response.status_code == HTTPStatus.CREATED 41 | assert response.data == { 42 | 'article_id': '3f577757-0000-0000-0000-000000000000', 43 | 'user_id': '9af8961e-0000-0000-0000-000000000000', 44 | 'vote': 'DOWN' 45 | } 46 | 47 | def test_system_persists_the_vote_in_the_database(self): 48 | self.act() 49 | 50 | entity = self.persisting_article_vote_spy.saved_article_voted_entity 51 | assert entity.article_id == UUID('3f577757-0000-0000-0000-000000000000') 52 | assert entity.user_id == UUID('9af8961e-0000-0000-0000-000000000000') 53 | assert entity.vote == 'down' 54 | 55 | 56 | def test_when_user_with_insufficient_karma__votes_for_article__system_returns_http_bad_request( # noqa: E501 57 | given_user_who_cannot_vote, 58 | given_no_existing_article_votes, 59 | post_article_vote 60 | ): 61 | given_no_existing_article_votes() 62 | given_user_who_cannot_vote( 63 | user_id=UUID('2e8a5b4e-0000-0000-0000-000000000000') 64 | ) 65 | 66 | response: Response = post_article_vote( 67 | user_id='2e8a5b4e-0000-0000-0000-000000000000' 68 | ) 69 | 70 | assert response.status_code == HTTPStatus.BAD_REQUEST 71 | assert response.data == { 72 | 'status': 400, 73 | 'detail': "User 2e8a5b4e-0000-0000-0000-000000000000 does not have enough " 74 | "karma to vote for an article", 75 | 'title': "Cannot vote for an article" 76 | } 77 | 78 | 79 | def test_when_user_votes_twice_for_the_same_article__system_returns_http_conflict( 80 | given_a_user_who_can_vote, 81 | given_an_article_vote, 82 | post_article_vote 83 | ): 84 | given_a_user_who_can_vote( 85 | user_id=UUID('a3854820-0000-0000-0000-000000000000') 86 | ) 87 | given_an_article_vote( 88 | user_id=UUID('a3854820-0000-0000-0000-000000000000'), 89 | article_id=UUID('dd494bd6-0000-0000-0000-000000000000') 90 | ) 91 | 92 | response: Response = post_article_vote( 93 | article_id='dd494bd6-0000-0000-0000-000000000000', 94 | user_id='a3854820-0000-0000-0000-000000000000', 95 | vote='up' 96 | ) 97 | 98 | assert response.status_code == HTTPStatus.CONFLICT 99 | assert response.data == { 100 | 'status': 409, 101 | 'detail': "User \"a3854820-0000-0000-0000-000000000000\" has already voted" 102 | " for article \"dd494bd6-0000-0000-0000-000000000000\"", 103 | 'title': "Cannot vote for an article" 104 | } 105 | 106 | 107 | def test_when_voting__as_non_existing_user__system_returns_http_not_found_with_error_details( # noqa: E501 108 | given_no_existing_users, 109 | post_article_vote 110 | ): 111 | given_no_existing_users() 112 | response = post_article_vote( 113 | user_id='a3853333-0000-0000-0000-000000000000' 114 | ) 115 | assert response.status_code == HTTPStatus.NOT_FOUND 116 | assert response.data == { 117 | 'detail': "User 'a3853333-0000-0000-0000-000000000000' not found", 118 | 'status': 404, 119 | 'title': 'Error' 120 | } 121 | 122 | 123 | @pytest.fixture 124 | def given_a_user_who_can_vote(given_voting_user): 125 | def _given_a_user_who_can_vote(user_id: UUID): 126 | return given_voting_user(user_id, karma=10) 127 | return _given_a_user_who_can_vote 128 | 129 | 130 | @pytest.fixture 131 | def given_user_who_cannot_vote(given_voting_user): 132 | def _given_user_who_cannot_vote(user_id: UUID): 133 | return given_voting_user(user_id, karma=0) 134 | return _given_user_who_cannot_vote 135 | 136 | 137 | @pytest.fixture 138 | def given_voting_user(): 139 | original_voting_user_entity_manager = VotingUserEntity.objects 140 | 141 | def _given_voting_user( 142 | user_id: UUID = uuid4(), 143 | karma: int = 10 144 | ): 145 | VotingUserEntity.objects = VotingUserEntityObjectManagerMock( 146 | VotingUserEntity( 147 | user_id=user_id, 148 | karma=karma 149 | ) 150 | ) # type: ignore 151 | yield _given_voting_user 152 | 153 | VotingUserEntity.objects = original_voting_user_entity_manager 154 | 155 | 156 | @pytest.fixture 157 | def given_no_existing_users(): 158 | original_voting_user_entity_manager = VotingUserEntity.objects 159 | 160 | def _given_no_existing_user(): 161 | VotingUserEntity.objects = VotingUserEntityObjectManagerMock(None) # type: ignore 162 | yield _given_no_existing_user 163 | 164 | VotingUserEntity.objects = original_voting_user_entity_manager 165 | 166 | 167 | @pytest.fixture 168 | def given_no_existing_article_votes(): 169 | original_article_vote_entity_manager = ArticleVoteEntity.objects 170 | 171 | def _given_no_existing_article_votes(): 172 | ArticleVoteEntity.objects = ArticleVoteEntityManagerMock(None) # type: ignore 173 | yield _given_no_existing_article_votes 174 | 175 | ArticleVoteEntity.objects = original_article_vote_entity_manager 176 | 177 | 178 | @pytest.fixture 179 | def given_an_article_vote(): 180 | original_article_vote_entity_manager = ArticleVoteEntity.objects 181 | 182 | def _given_an_article_vote( 183 | article_id: UUID = uuid4(), 184 | user_id: UUID = uuid4(), 185 | vote: str = 'down' 186 | ): 187 | ArticleVoteEntity.objects = ArticleVoteEntityManagerMock( 188 | ArticleVoteEntity( 189 | article_id=article_id, 190 | user_id=user_id, 191 | vote=vote 192 | ) 193 | ) # type: ignore 194 | yield _given_an_article_vote 195 | 196 | ArticleVoteEntity.objects = original_article_vote_entity_manager 197 | 198 | 199 | @pytest.fixture 200 | def mock_persisting_article_vote(): 201 | spy = SaveArticleVoteEntitySpy() 202 | 203 | with patch.object(ArticleVoteEntity, 'save', autospec=True) as save_mock: # noqa: E501 204 | def _mock_persisting_article_vote() -> SaveArticleVoteEntitySpy: 205 | save_mock.side_effect = spy.save_article_vote_entity_mock 206 | return spy 207 | yield _mock_persisting_article_vote 208 | 209 | 210 | @pytest.fixture 211 | def post_article_vote( 212 | article_vote_view, 213 | ): 214 | def _post_article_vote( 215 | article_id: str = str(uuid4()), 216 | user_id: str = str(uuid4()), 217 | vote: str = 'DOWN' 218 | ) -> Response: 219 | return article_vote_view( 220 | APIRequestFactory().post( 221 | '/article_vote', 222 | { 223 | 'article_id': article_id, 224 | 'user_id': user_id, 225 | 'vote': vote 226 | }, 227 | format='json' 228 | ) 229 | ) 230 | return _post_article_vote 231 | 232 | 233 | @pytest.fixture 234 | def article_vote_view(production_dependencies_container): 235 | return production_dependencies_container['article_vote_django_view'] 236 | 237 | 238 | @pytest.fixture(scope='module') 239 | def production_dependencies_container(): 240 | return build_production_dependencies_container() 241 | 242 | 243 | class VotingUserEntityObjectManagerMock: 244 | stub: VotingUserEntity | None = None 245 | 246 | def __init__(self, stub: VotingUserEntity): 247 | super().__init__() 248 | self.stub = stub 249 | 250 | def get(self, *_args, **_kwargs) -> VotingUserEntity: 251 | if self.stub is None: 252 | raise VotingUserEntity.DoesNotExist() 253 | return self.stub 254 | 255 | 256 | class ArticleVoteEntityManagerMock: 257 | stub: Optional[ArticleVoteEntity] = None 258 | 259 | def __init__(self, stub: Optional[ArticleVoteEntity]): 260 | super().__init__() 261 | self.stub = stub 262 | 263 | def filter(self, *_args, **_kwargs) -> Self: 264 | return self 265 | 266 | def first(self) -> ArticleVoteEntity | None: 267 | return self.stub 268 | 269 | 270 | class SaveArticleVoteEntitySpy: 271 | saved_article_voted_entity: Optional[ArticleVoteEntity] = None 272 | 273 | def save_article_vote_entity_mock(self, entity, *_args, **_kwargs): 274 | self.saved_article_voted_entity = entity 275 | -------------------------------------------------------------------------------- /tests/test_myapp/application/util/test_transactional.py: -------------------------------------------------------------------------------- 1 | from myapp.application.util.transactional import transactional 2 | 3 | 4 | def test_does_not_decorate_when_testing(settings): 5 | settings.TESTING = True 6 | 7 | def dummy(): pass 8 | transactional_dummy = transactional(dummy) 9 | 10 | assert dummy is transactional_dummy 11 | 12 | 13 | def test_decorates_when_running_production(settings): 14 | settings.TESTING = False 15 | 16 | def dummy(): pass 17 | transactional_dummy = transactional(dummy) 18 | 19 | assert dummy is not transactional_dummy 20 | -------------------------------------------------------------------------------- /tests/test_myapp/make_requests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | response = requests.post('http://localhost:8000/article_vote', json={ 4 | 'id': 1, 5 | 'name': 'Jessa' 6 | }) 7 | print(response.content) 8 | -------------------------------------------------------------------------------- /tests/test_myapp/test_dependencies_container.py: -------------------------------------------------------------------------------- 1 | from myapp.dependencies_container import build_production_dependencies_container 2 | 3 | 4 | def test_build_production_ioc_container(): 5 | build_production_dependencies_container() 6 | --------------------------------------------------------------------------------