├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── example ├── example │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py └── store │ ├── __init__.py │ ├── admin.py │ ├── choices.py │ ├── fixtures │ └── store.json │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── serializers │ ├── __init__.py │ ├── request.py │ └── response.py │ ├── services.py │ ├── urls.py │ └── views.py ├── manage.py ├── poetry.lock ├── pyproject.toml ├── rest_batteries ├── __init__.py ├── errors_formatter.py ├── exception_handlers.py ├── generics.py ├── mixins.py ├── views.py └── viewsets.py └── tests ├── __init__.py ├── conftest.py ├── factories.py ├── models.py ├── serializers.py ├── settings.py ├── test_generics.py ├── test_views.py ├── test_viewsets.py └── utils.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish to PyPI 3 | 4 | on: 5 | release: 6 | types: 7 | - released 8 | 9 | jobs: 10 | publish: 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | python-version: ['3.10'] 15 | poetry-version: ['1.3.2'] 16 | os: [ ubuntu-latest ] 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - uses: actions/checkout@v3.5.0 21 | - uses: actions/setup-python@v4.5.0 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Run image 25 | uses: abatilo/actions-poetry@v2 26 | with: 27 | poetry-version: ${{ matrix.poetry-version }} 28 | - name: Build and publish 29 | run: | 30 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 31 | poetry publish --build --no-interaction -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | name: Build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}, DRF ${{ matrix.drf-version }}) 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | max-parallel: 5 17 | matrix: 18 | python-version: ['3.8', '3.9', '3.10'] 19 | poetry-version: ['1.3.2'] 20 | django-version: ['3.2.18', '4.1', '4.2'] 21 | drf-version: ['3.12', '3.14'] 22 | exclude: 23 | # not compatible 24 | - django-version: 4.1 25 | drf-version: 3.12 26 | - django-version: 4.2 27 | drf-version: 3.12 28 | 29 | steps: 30 | - uses: actions/checkout@v3.5.0 31 | - uses: actions/setup-python@v4.5.0 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Run image 35 | uses: abatilo/actions-poetry@v2 36 | with: 37 | poetry-version: ${{ matrix.poetry-version }} 38 | - name: Install dependencies 39 | run: poetry install 40 | - name: Upgrade django version 41 | run: | 42 | poetry run pip install "Django==${{ matrix.django-version }}" 43 | - name: Upgrade drf version 44 | run: | 45 | poetry run pip install "djangorestframework==${{ matrix.drf-version }}" 46 | - name: Run code quality checks 47 | run: make check 48 | - name: Run tests 49 | run: make test-cov 50 | - name: Upload coverage to Codecov 51 | uses: codecov/codecov-action@v3 52 | with: 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | fail_ci_if_error: true 55 | verbose: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/pycharm+all,venv,linux,windows,macos,dotenv,database,visualstudiocode,python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all,venv,linux,windows,macos,dotenv,database,visualstudiocode,python 3 | 4 | ### Database ### 5 | *.accdb 6 | *.db 7 | *.dbf 8 | *.mdb 9 | *.pdb 10 | *.sqlite3 11 | 12 | ### dotenv ### 13 | .env 14 | 15 | ### Linux ### 16 | *~ 17 | 18 | # temporary files which can be created if a process still has a handle open of a deleted file 19 | .fuse_hidden* 20 | 21 | # KDE directory preferences 22 | .directory 23 | 24 | # Linux trash folder which might appear on any partition or disk 25 | .Trash-* 26 | 27 | # .nfs files are created when an open file is removed but is still being accessed 28 | .nfs* 29 | 30 | ### macOS ### 31 | # General 32 | .DS_Store 33 | .AppleDouble 34 | .LSOverride 35 | 36 | # Icon must end with two \r 37 | Icon 38 | 39 | # Thumbnails 40 | ._* 41 | 42 | # Files that might appear in the root of a volume 43 | .DocumentRevisions-V100 44 | .fseventsd 45 | .Spotlight-V100 46 | .TemporaryItems 47 | .Trashes 48 | .VolumeIcon.icns 49 | .com.apple.timemachine.donotpresent 50 | 51 | # Directories potentially created on remote AFP share 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | 58 | ### PyCharm+all ### 59 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 60 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 61 | 62 | # User-specific stuff 63 | .idea/**/workspace.xml 64 | .idea/**/tasks.xml 65 | .idea/**/usage.statistics.xml 66 | .idea/**/dictionaries 67 | .idea/**/shelf 68 | 69 | # Generated files 70 | .idea/**/contentModel.xml 71 | 72 | # Sensitive or high-churn files 73 | .idea/**/dataSources/ 74 | .idea/**/dataSources.ids 75 | .idea/**/dataSources.local.xml 76 | .idea/**/sqlDataSources.xml 77 | .idea/**/dynamic.xml 78 | .idea/**/uiDesigner.xml 79 | .idea/**/dbnavigator.xml 80 | 81 | # Gradle 82 | .idea/**/gradle.xml 83 | .idea/**/libraries 84 | 85 | # Gradle and Maven with auto-import 86 | # When using Gradle or Maven with auto-import, you should exclude module files, 87 | # since they will be recreated, and may cause churn. Uncomment if using 88 | # auto-import. 89 | # .idea/artifacts 90 | # .idea/compiler.xml 91 | # .idea/jarRepositories.xml 92 | # .idea/modules.xml 93 | # .idea/*.iml 94 | # .idea/modules 95 | # *.iml 96 | # *.ipr 97 | 98 | # CMake 99 | cmake-build-*/ 100 | 101 | # Mongo Explorer plugin 102 | .idea/**/mongoSettings.xml 103 | 104 | # File-based project format 105 | *.iws 106 | 107 | # IntelliJ 108 | out/ 109 | 110 | # mpeltonen/sbt-idea plugin 111 | .idea_modules/ 112 | 113 | # JIRA plugin 114 | atlassian-ide-plugin.xml 115 | 116 | # Cursive Clojure plugin 117 | .idea/replstate.xml 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+all Patch ### 132 | # Ignores the whole .idea folder and all .iml files 133 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 134 | 135 | .idea/ 136 | 137 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 138 | 139 | *.iml 140 | modules.xml 141 | .idea/misc.xml 142 | *.ipr 143 | 144 | # Sonarlint plugin 145 | .idea/sonarlint 146 | 147 | ### Python ### 148 | # Byte-compiled / optimized / DLL files 149 | __pycache__/ 150 | *.py[cod] 151 | *$py.class 152 | 153 | # C extensions 154 | *.so 155 | 156 | # Distribution / packaging 157 | .Python 158 | build/ 159 | develop-eggs/ 160 | dist/ 161 | downloads/ 162 | eggs/ 163 | .eggs/ 164 | lib/ 165 | lib64/ 166 | parts/ 167 | sdist/ 168 | var/ 169 | wheels/ 170 | pip-wheel-metadata/ 171 | share/python-wheels/ 172 | *.egg-info/ 173 | .installed.cfg 174 | *.egg 175 | MANIFEST 176 | 177 | # PyInstaller 178 | # Usually these files are written by a python script from a template 179 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 180 | *.manifest 181 | *.spec 182 | 183 | # Installer logs 184 | pip-log.txt 185 | pip-delete-this-directory.txt 186 | 187 | # Unit test / coverage reports 188 | htmlcov/ 189 | .tox/ 190 | .nox/ 191 | .coverage 192 | .coverage.* 193 | .cache 194 | nosetests.xml 195 | coverage.xml 196 | *.cover 197 | *.py,cover 198 | .hypothesis/ 199 | .pytest_cache/ 200 | pytestdebug.log 201 | 202 | # Translations 203 | *.mo 204 | *.pot 205 | 206 | # Django stuff: 207 | *.log 208 | local_settings.py 209 | db.sqlite3 210 | db.sqlite3-journal 211 | 212 | # Flask stuff: 213 | instance/ 214 | .webassets-cache 215 | 216 | # Scrapy stuff: 217 | .scrapy 218 | 219 | # Sphinx documentation 220 | docs/_build/ 221 | doc/_build/ 222 | 223 | # PyBuilder 224 | target/ 225 | 226 | # Jupyter Notebook 227 | .ipynb_checkpoints 228 | 229 | # IPython 230 | profile_default/ 231 | ipython_config.py 232 | 233 | # pyenv 234 | .python-version 235 | 236 | # pipenv 237 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 238 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 239 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 240 | # install all needed dependencies. 241 | #Pipfile.lock 242 | 243 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 244 | __pypackages__/ 245 | 246 | # Celery stuff 247 | celerybeat-schedule 248 | celerybeat.pid 249 | 250 | # SageMath parsed files 251 | *.sage.py 252 | 253 | # Environments 254 | .venv 255 | env/ 256 | venv/ 257 | ENV/ 258 | env.bak/ 259 | venv.bak/ 260 | 261 | # Spyder project settings 262 | .spyderproject 263 | .spyproject 264 | 265 | # Rope project settings 266 | .ropeproject 267 | 268 | # mkdocs documentation 269 | /site 270 | 271 | # mypy 272 | .mypy_cache/ 273 | .dmypy.json 274 | dmypy.json 275 | 276 | # Pyre type checker 277 | .pyre/ 278 | 279 | # pytype static type analyzer 280 | .pytype/ 281 | 282 | ### venv ### 283 | # Virtualenv 284 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 285 | [Bb]in 286 | [Ii]nclude 287 | [Ll]ib 288 | [Ll]ib64 289 | [Ll]ocal 290 | [Ss]cripts 291 | pyvenv.cfg 292 | pip-selfcheck.json 293 | 294 | ### VisualStudioCode ### 295 | .vscode/* 296 | !.vscode/settings.json 297 | !.vscode/tasks.json 298 | !.vscode/launch.json 299 | !.vscode/extensions.json 300 | *.code-workspace 301 | 302 | ### VisualStudioCode Patch ### 303 | # Ignore all local history of files 304 | .history 305 | 306 | ### Windows ### 307 | # Windows thumbnail cache files 308 | Thumbs.db 309 | Thumbs.db:encryptable 310 | ehthumbs.db 311 | ehthumbs_vista.db 312 | 313 | # Dump file 314 | *.stackdump 315 | 316 | # Folder config file 317 | [Dd]esktop.ini 318 | 319 | # Recycle Bin used on file shares 320 | $RECYCLE.BIN/ 321 | 322 | # Windows Installer files 323 | *.cab 324 | *.msi 325 | *.msix 326 | *.msm 327 | *.msp 328 | 329 | # Windows shortcuts 330 | *.lnk 331 | 332 | # End of https://www.toptal.com/developers/gitignore/api/pycharm+all,venv,linux,windows,macos,dotenv,database,visualstudiocode,python 333 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 1.4.1 2 | 3 | **Fixed:** 4 | 5 | - Updated main dependencies 6 | 7 | # Version 1.4.0 8 | 9 | **Fixed:** 10 | 11 | - Updated outdated dependencies 12 | 13 | # Version 1.3.0 14 | 15 | **Added:** 16 | 17 | - Added `DjangoValidationErrorTransformMixin` mixin 18 | 19 | **Removed:** 20 | 21 | - Removed `APIErrorsMixin` mixin 22 | 23 | # Version 1.2.2 24 | 25 | **Added:** 26 | 27 | - Split `UpdateModelMixin` into two mixins: `FullUpdateModelMixin` and `PartialUpdateModelMixin` 28 | 29 | # Version 1.2.1 30 | 31 | **Added:** 32 | 33 | - Added `get_field_name` public method to `ErrorsFormatter` class 34 | 35 | # Version 1.2.0 36 | 37 | **Added:** 38 | 39 | - Added action-based permissions for ViewSets 40 | 41 | # Version 1.1.0 42 | 43 | **Added:** 44 | 45 | - Added ability for GenericAPIViews to have two serializers per request/response cycle 46 | 47 | 48 | # Version 1.0.0 49 | 50 | **Added:** 51 | 52 | - Added action-based serializers for ViewSets 53 | - Added ability for ViewSets to have two serializers per request/response cycle 54 | - Added single format for all errors 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Define Impossible 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: example check lint test test-cov 2 | 3 | example: 4 | poetry run python example/manage.py runserver 5 | 6 | check: 7 | poetry run black --check . 8 | poetry run ruff check . 9 | 10 | lint: 11 | poetry run black . 12 | poetry run ruff --fix . 13 | 14 | test: 15 | poetry run pytest 16 | 17 | test-cov: 18 | poetry run pytest --cov=rest_batteries --cov-branch --cov-report=term:skip-covered --cov-report=html 19 | poetry run coverage xml 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Test](https://github.com/defineimpossible/django-rest-batteries/actions/workflows/test.yml/badge.svg)](https://github.com/defineimpossible/django-rest-batteries/actions/workflows/test.yml) 2 | [![Coverage](https://codecov.io/gh/defineimpossible/django-rest-batteries/branch/master/graph/badge.svg)](https://codecov.io/gh/defineimpossible/django-rest-batteries) 3 | 4 | # Django REST Framework Batteries 5 | 6 | Build clean APIs with DRF faster. 7 | 8 | # Overview 9 | 10 | Here's a quick overview of what the library has at the moment: 11 | 12 | - Action-based serializers for ViewSets 13 | - Two serializers per request/response cycle for ViewSets and GenericAPIViews 14 | - Action-based permissions for ViewSets 15 | - Single format for all errors 16 | 17 | # Requirements 18 | 19 | - Python ≥ 3.8 20 | - Django ≥ 3.2 21 | - Django REST Framework ≥ 3.12 22 | 23 | # Installation 24 | 25 | ```bash 26 | $ pip install django-rest-batteries 27 | ``` 28 | 29 | # Usage 30 | 31 | ## Action-based serializers for ViewSets 32 | 33 | Each action can have a separate serializer: 34 | 35 | ```python 36 | from rest_batteries.mixins import RetrieveModelMixin, ListModelMixin 37 | from rest_batteries.viewsets import GenericViewSet 38 | ... 39 | 40 | class OrderViewSet(RetrieveModelMixin, 41 | ListModelMixin, 42 | GenericViewSet): 43 | response_action_serializer_classes = { 44 | 'retrieve': OrderSerializer, 45 | 'list': OrderListSerializer, 46 | } 47 | ``` 48 | 49 | ## Two serializers per request/response cycle 50 | 51 | We found that more often than not we need a separate serializer for handling request payload and a separate serializer for generating response data. 52 | 53 | How to achieve it in ViewSet: 54 | 55 | ```python 56 | from rest_batteries.mixins import CreateModelMixin, ListModelMixin 57 | from rest_batteries.viewsets import GenericViewSet 58 | ... 59 | 60 | class OrderViewSet(CreateModelMixin, 61 | ListModelMixin, 62 | GenericViewSet): 63 | request_action_serializer_classes = { 64 | 'create': OrderCreateSerializer, 65 | } 66 | response_action_serializer_classes = { 67 | 'create': OrderResponseSerializer, 68 | 'list': OrderResponseSerializer, 69 | 'cancel': OrderResponseSerializer, 70 | } 71 | ``` 72 | 73 | How to achieve it in GenericAPIView: 74 | 75 | ```python 76 | from rest_batteries.generics import CreateAPIView 77 | ... 78 | 79 | 80 | class OrderCreateView(CreateAPIView): 81 | request_serializer_class = OrderCreateSerializer 82 | response_serializer_class = OrderResponseSerializer 83 | ``` 84 | 85 | ## Action-based permissions for ViewSets 86 | 87 | Each action can have a separate set of permissions: 88 | 89 | ```python 90 | from rest_batteries.mixins import CreateModelMixin, UpdateModelMixin, ListModelMixin 91 | from rest_batteries.viewsets import GenericViewSet 92 | from rest_framework.permissions import AllowAny, IsAuthenticated 93 | ... 94 | 95 | class OrderViewSet(CreateModelMixin, 96 | UpdateModelMixin, 97 | ListModelMixin, 98 | GenericViewSet): 99 | action_permission_classes = { 100 | 'create': IsAuthenticated, 101 | 'update': [IsAuthenticated, IsOrderOwner], 102 | 'list': AllowAny, 103 | } 104 | ``` 105 | 106 | ## Single format for all errors 107 | 108 | We believe that having a single format for all errors is good practice. This will make the process of displaying and handling errors much simpler for clients that use your APIs. 109 | 110 | Any error always will be a JSON object with a message, code (identifier of the error), and field if the error is specific to a particular field. How your response could look like: 111 | 112 | ```python 113 | { 114 | "errors": [ 115 | { 116 | "message": "Delete or cancel all reservations first.", 117 | "code": "invalid" 118 | }, 119 | { 120 | "message": "Ensure this field has no more than 21 characters.", 121 | "code": "max_length", 122 | "field": "address.work_phone" 123 | }, 124 | { 125 | "message": "This email already exists", 126 | "code": "unique", 127 | "field": "login_email" 128 | } 129 | ] 130 | } 131 | ``` 132 | 133 | You will not have a single format out-of-the-box after installation. You need to add an exception handler to your DRF settings: 134 | 135 | ```python 136 | REST_FRAMEWORK = { 137 | ... 138 | 'EXCEPTION_HANDLER': 'rest_batteries.exception_handlers.errors_formatter_exception_handler', 139 | } 140 | ``` 141 | 142 | # Credits 143 | 144 | - [Django-Styleguide by HackSoftware](https://github.com/HackSoftware/Django-Styleguide) - inspiration 145 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defineimpossible/django-rest-batteries/1c7c4a1cd8fa557c8930601fa296621635c6adf4/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example 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.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', 'example.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = '3ulydh05w+x#puo)6bp36+60+#l6(rjl@0nws3vsb+252ao*e&' 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | ALLOWED_HOSTS = [] 17 | 18 | 19 | # Application definition 20 | 21 | INSTALLED_APPS = [ 22 | 'django.contrib.admin', 23 | 'django.contrib.auth', 24 | 'django.contrib.contenttypes', 25 | 'django.contrib.sessions', 26 | 'django.contrib.messages', 27 | 'django.contrib.staticfiles', 28 | 'rest_framework', 29 | 'debug_toolbar', 30 | 'store', 31 | ] 32 | 33 | MIDDLEWARE = [ 34 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 35 | 'django.middleware.security.SecurityMiddleware', 36 | 'django.contrib.sessions.middleware.SessionMiddleware', 37 | 'django.middleware.common.CommonMiddleware', 38 | 'django.middleware.csrf.CsrfViewMiddleware', 39 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 40 | 'django.contrib.messages.middleware.MessageMiddleware', 41 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 42 | ] 43 | 44 | ROOT_URLCONF = 'example.urls' 45 | 46 | TEMPLATES = [ 47 | { 48 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 49 | 'DIRS': [], 50 | 'APP_DIRS': True, 51 | 'OPTIONS': { 52 | 'context_processors': [ 53 | 'django.template.context_processors.debug', 54 | 'django.template.context_processors.request', 55 | 'django.contrib.auth.context_processors.auth', 56 | 'django.contrib.messages.context_processors.messages', 57 | ], 58 | }, 59 | }, 60 | ] 61 | 62 | WSGI_APPLICATION = 'example.wsgi.application' 63 | 64 | 65 | # Database 66 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 67 | 68 | DATABASES = { 69 | 'default': { 70 | 'ENGINE': 'django.db.backends.sqlite3', 71 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 72 | } 73 | } 74 | 75 | 76 | # Password validation 77 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 78 | 79 | AUTH_PASSWORD_VALIDATORS = [ 80 | { 81 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 82 | }, 83 | { 84 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 85 | }, 86 | { 87 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 88 | }, 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 91 | }, 92 | ] 93 | 94 | 95 | # Internationalization 96 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 97 | 98 | LANGUAGE_CODE = 'en-us' 99 | 100 | TIME_ZONE = 'UTC' 101 | 102 | USE_I18N = True 103 | 104 | USE_L10N = True 105 | 106 | USE_TZ = True 107 | 108 | 109 | # Static files (CSS, JavaScript, Images) 110 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 111 | 112 | STATIC_URL = '/static/' 113 | 114 | 115 | # django-rest-framework 116 | # https://www.django-rest-framework.org/ 117 | 118 | REST_FRAMEWORK = { 119 | 'EXCEPTION_HANDLER': 'rest_batteries.exception_handlers.errors_formatter_exception_handler' 120 | } 121 | 122 | 123 | # django-debug-toolbar 124 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html 125 | 126 | INTERNAL_IPS = [ 127 | 'localhost', 128 | '127.0.0.1', 129 | '0.0.0.0', 130 | ] 131 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/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.conf import settings 17 | from django.contrib import admin 18 | from django.urls import include, path 19 | 20 | urlpatterns = [ 21 | path('admin/', admin.site.urls), 22 | path('', include('store.urls')), 23 | ] 24 | 25 | if settings.DEBUG: 26 | import debug_toolbar 27 | 28 | urlpatterns = [ 29 | path('__debug__/', include(debug_toolbar.urls)), 30 | ] + urlpatterns 31 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example 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.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', 'example.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import os.path 5 | import sys 6 | 7 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 8 | 9 | 10 | def main(): 11 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 12 | try: 13 | from django.core.management import execute_from_command_line 14 | except ImportError as exc: 15 | raise ImportError( 16 | "Couldn't import Django. Are you sure it's installed and " 17 | 'available on your PYTHONPATH environment variable? Did you ' 18 | 'forget to activate a virtual environment?' 19 | ) from exc 20 | execute_from_command_line(sys.argv) 21 | 22 | 23 | if __name__ == '__main__': 24 | main() 25 | -------------------------------------------------------------------------------- /example/store/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defineimpossible/django-rest-batteries/1c7c4a1cd8fa557c8930601fa296621635c6adf4/example/store/__init__.py -------------------------------------------------------------------------------- /example/store/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Order, OrderLine, Product 4 | 5 | 6 | @admin.register(Product) 7 | class ProductAdmin(admin.ModelAdmin): 8 | pass 9 | 10 | 11 | @admin.register(Order) 12 | class OrderAdmin(admin.ModelAdmin): 13 | pass 14 | 15 | 16 | @admin.register(OrderLine) 17 | class OrderLineAdmin(admin.ModelAdmin): 18 | pass 19 | -------------------------------------------------------------------------------- /example/store/choices.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class OrderStatus(models.IntegerChoices): 6 | DRAFT = 0, _('Draft') 7 | APPROVED = 1, _('Approved') 8 | DELIVERED = 2, _('Delivered') 9 | CANCELED = 3, _('Canceled') 10 | -------------------------------------------------------------------------------- /example/store/fixtures/store.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "store.product", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Apple AirPods", 7 | "price": 180 8 | } 9 | }, 10 | { 11 | "model": "store.product", 12 | "pk": 2, 13 | "fields": { 14 | "name": "Kindle Paperwhite", 15 | "price": 85 16 | } 17 | }, 18 | { 19 | "model": "store.product", 20 | "pk": 3, 21 | "fields": { 22 | "name": "Security Camera", 23 | "price": 200 24 | } 25 | }, 26 | 27 | { 28 | "model": "store.order", 29 | "pk": 1, 30 | "fields": { 31 | "status": 0 32 | } 33 | }, 34 | { 35 | "model": "store.orderline", 36 | "pk": 1, 37 | "fields": { 38 | "order": 1, 39 | "product": 1, 40 | "quantity": 2 41 | } 42 | }, 43 | { 44 | "model": "store.orderline", 45 | "pk": 2, 46 | "fields": { 47 | "order": 1, 48 | "product": 2, 49 | "quantity": 1 50 | } 51 | }, 52 | 53 | { 54 | "model": "store.order", 55 | "pk": 2, 56 | "fields": { 57 | "status": 2 58 | } 59 | }, 60 | { 61 | "model": "store.orderline", 62 | "pk": 3, 63 | "fields": { 64 | "order": 2, 65 | "product": 1, 66 | "quantity": 3 67 | } 68 | } 69 | ] 70 | -------------------------------------------------------------------------------- /example/store/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-08-01 05:53 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Order', 16 | fields=[ 17 | ( 18 | 'id', 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name='ID', 24 | ), 25 | ), 26 | ( 27 | 'status', 28 | models.PositiveIntegerField( 29 | choices=[ 30 | (0, 'Draft'), 31 | (1, 'Approved'), 32 | (2, 'Delivered'), 33 | (3, 'Canceled'), 34 | ], 35 | default=0, 36 | ), 37 | ), 38 | ], 39 | ), 40 | migrations.CreateModel( 41 | name='Product', 42 | fields=[ 43 | ( 44 | 'id', 45 | models.AutoField( 46 | auto_created=True, 47 | primary_key=True, 48 | serialize=False, 49 | verbose_name='ID', 50 | ), 51 | ), 52 | ('name', models.CharField(max_length=255)), 53 | ('price', models.DecimalField(decimal_places=2, max_digits=12)), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name='OrderLine', 58 | fields=[ 59 | ( 60 | 'id', 61 | models.AutoField( 62 | auto_created=True, 63 | primary_key=True, 64 | serialize=False, 65 | verbose_name='ID', 66 | ), 67 | ), 68 | ( 69 | 'quantity', 70 | models.IntegerField(validators=[django.core.validators.MinValueValidator(1)]), 71 | ), 72 | ( 73 | 'order', 74 | models.ForeignKey( 75 | on_delete=django.db.models.deletion.CASCADE, 76 | related_name='lines', 77 | to='store.Order', 78 | ), 79 | ), 80 | ( 81 | 'product', 82 | models.ForeignKey( 83 | on_delete=django.db.models.deletion.CASCADE, 84 | related_name='order_lines', 85 | to='store.Product', 86 | ), 87 | ), 88 | ], 89 | options={ 90 | 'unique_together': {('order', 'product')}, 91 | }, 92 | ), 93 | ] 94 | -------------------------------------------------------------------------------- /example/store/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defineimpossible/django-rest-batteries/1c7c4a1cd8fa557c8930601fa296621635c6adf4/example/store/migrations/__init__.py -------------------------------------------------------------------------------- /example/store/models.py: -------------------------------------------------------------------------------- 1 | from django.core.validators import MinValueValidator 2 | from django.db import models 3 | 4 | from .choices import OrderStatus 5 | 6 | 7 | class Product(models.Model): 8 | name = models.CharField(max_length=255) 9 | price = models.DecimalField(max_digits=12, decimal_places=2) 10 | 11 | 12 | class Order(models.Model): 13 | status = models.PositiveIntegerField(choices=OrderStatus.choices, default=OrderStatus.DRAFT) 14 | 15 | @property 16 | def total_price(self): 17 | return sum(line.product.price * line.quantity for line in self.lines.all()) 18 | 19 | 20 | class OrderLine(models.Model): 21 | order = models.ForeignKey('Order', related_name='lines', on_delete=models.CASCADE) 22 | product = models.ForeignKey('Product', related_name='order_lines', on_delete=models.CASCADE) 23 | quantity = models.IntegerField(validators=[MinValueValidator(1)]) 24 | 25 | class Meta: 26 | unique_together = ('order', 'product') 27 | -------------------------------------------------------------------------------- /example/store/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .request import * # noqa 2 | from .response import * # noqa 3 | -------------------------------------------------------------------------------- /example/store/serializers/request.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ..models import Product 4 | 5 | 6 | class OrderLineSerializer(serializers.Serializer): 7 | product_id = serializers.PrimaryKeyRelatedField( 8 | queryset=Product.objects.all(), source='product' 9 | ) 10 | quantity = serializers.IntegerField() 11 | 12 | 13 | class OrderCreateSerializer(serializers.Serializer): 14 | lines = OrderLineSerializer(many=True) 15 | -------------------------------------------------------------------------------- /example/store/serializers/response.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ..models import Order, OrderLine, Product 4 | 5 | 6 | class ProductResponseSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Product 9 | fields = ( 10 | 'id', 11 | 'name', 12 | 'price', 13 | ) 14 | 15 | 16 | class OrderLineResponseSerializer(serializers.ModelSerializer): 17 | product = ProductResponseSerializer() 18 | 19 | class Meta: 20 | model = OrderLine 21 | fields = ( 22 | 'id', 23 | 'product', 24 | 'quantity', 25 | ) 26 | 27 | 28 | class OrderResponseSerializer(serializers.ModelSerializer): 29 | lines = OrderLineResponseSerializer(many=True) 30 | 31 | class Meta: 32 | model = Order 33 | fields = ( 34 | 'id', 35 | 'status', 36 | 'total_price', 37 | 'lines', 38 | ) 39 | -------------------------------------------------------------------------------- /example/store/services.py: -------------------------------------------------------------------------------- 1 | from .choices import OrderStatus 2 | from .models import Order, OrderLine, Product 3 | 4 | 5 | def create_order(*, lines: dict) -> Order: 6 | order = Order.objects.create() 7 | for line in lines: 8 | create_order_line(order=order, **line) 9 | return order 10 | 11 | 12 | def create_order_line(*, order: Order, product: Product, quantity: int) -> OrderLine: 13 | line = OrderLine(order=order, product=product, quantity=quantity) 14 | line.full_clean() 15 | line.save() 16 | return line 17 | 18 | 19 | def cancel_order(*, order: Order) -> Order: 20 | order.status = OrderStatus.CANCELED 21 | order.save(update_fields=['status']) 22 | return order 23 | -------------------------------------------------------------------------------- /example/store/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | from .views import OrderViewSet 4 | 5 | router = routers.DefaultRouter() 6 | router.register(r'orders', OrderViewSet, basename='order') 7 | 8 | urlpatterns = router.urls 9 | -------------------------------------------------------------------------------- /example/store/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.decorators import action 2 | from rest_framework.response import Response 3 | 4 | from rest_batteries.mixins import CreateModelMixin, ListModelMixin 5 | from rest_batteries.viewsets import GenericViewSet 6 | 7 | from .models import Order 8 | from .serializers import OrderCreateSerializer, OrderResponseSerializer 9 | from .services import cancel_order, create_order 10 | 11 | 12 | class OrderViewSet( 13 | CreateModelMixin, 14 | ListModelMixin, 15 | GenericViewSet, 16 | ): 17 | queryset = Order.objects.prefetch_related('lines__product') 18 | request_action_serializer_classes = { 19 | 'create': OrderCreateSerializer, 20 | } 21 | response_action_serializer_classes = { 22 | 'create': OrderResponseSerializer, 23 | 'list': OrderResponseSerializer, 24 | 'cancel': OrderResponseSerializer, 25 | } 26 | 27 | def perform_create(self, serializer): 28 | return create_order(**serializer.validated_data) 29 | 30 | @action(detail=True, methods=['post']) 31 | def cancel(self, _request, *_args, **_kwargs): 32 | order = self.get_object() 33 | order = cancel_order(order=order) 34 | 35 | response_serializer = self.get_response_serializer(order) 36 | return Response(response_serializer.data) 37 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.7.2" 6 | description = "ASGI specs, helper code, and adapters" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, 11 | {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, 12 | ] 13 | 14 | [package.dependencies] 15 | typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} 16 | 17 | [package.extras] 18 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 19 | 20 | [[package]] 21 | name = "black" 22 | version = "23.7.0" 23 | description = "The uncompromising code formatter." 24 | optional = false 25 | python-versions = ">=3.8" 26 | files = [ 27 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, 28 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, 29 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, 30 | {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, 31 | {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, 32 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, 33 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, 34 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, 35 | {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, 36 | {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, 37 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, 38 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, 39 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, 40 | {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, 41 | {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, 42 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, 43 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, 44 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, 45 | {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, 46 | {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, 47 | {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, 48 | {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, 49 | ] 50 | 51 | [package.dependencies] 52 | click = ">=8.0.0" 53 | mypy-extensions = ">=0.4.3" 54 | packaging = ">=22.0" 55 | pathspec = ">=0.9.0" 56 | platformdirs = ">=2" 57 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 58 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 59 | 60 | [package.extras] 61 | colorama = ["colorama (>=0.4.3)"] 62 | d = ["aiohttp (>=3.7.4)"] 63 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 64 | uvloop = ["uvloop (>=0.15.2)"] 65 | 66 | [[package]] 67 | name = "click" 68 | version = "8.1.4" 69 | description = "Composable command line interface toolkit" 70 | optional = false 71 | python-versions = ">=3.7" 72 | files = [ 73 | {file = "click-8.1.4-py3-none-any.whl", hash = "sha256:2739815aaa5d2c986a88f1e9230c55e17f0caad3d958a5e13ad0797c166db9e3"}, 74 | {file = "click-8.1.4.tar.gz", hash = "sha256:b97d0c74955da062a7d4ef92fadb583806a585b2ea81958a81bd72726cbb8e37"}, 75 | ] 76 | 77 | [package.dependencies] 78 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 79 | 80 | [[package]] 81 | name = "colorama" 82 | version = "0.4.6" 83 | description = "Cross-platform colored terminal text." 84 | optional = false 85 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 86 | files = [ 87 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 88 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 89 | ] 90 | 91 | [[package]] 92 | name = "coverage" 93 | version = "7.2.7" 94 | description = "Code coverage measurement for Python" 95 | optional = false 96 | python-versions = ">=3.7" 97 | files = [ 98 | {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, 99 | {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, 100 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, 101 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, 102 | {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, 103 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, 104 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, 105 | {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, 106 | {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, 107 | {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, 108 | {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, 109 | {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, 110 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, 111 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, 112 | {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, 113 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, 114 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, 115 | {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, 116 | {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, 117 | {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, 118 | {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, 119 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, 120 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, 121 | {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, 122 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, 123 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, 124 | {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, 125 | {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, 126 | {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, 127 | {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, 128 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, 129 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, 130 | {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, 131 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, 132 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, 133 | {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, 134 | {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, 135 | {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, 136 | {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, 137 | {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, 138 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, 139 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, 140 | {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, 141 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, 142 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, 143 | {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, 144 | {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, 145 | {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, 146 | {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, 147 | {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, 148 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, 149 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, 150 | {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, 151 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, 152 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, 153 | {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, 154 | {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, 155 | {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, 156 | {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, 157 | {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, 158 | ] 159 | 160 | [package.dependencies] 161 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 162 | 163 | [package.extras] 164 | toml = ["tomli"] 165 | 166 | [[package]] 167 | name = "django" 168 | version = "3.2.20" 169 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 170 | optional = false 171 | python-versions = ">=3.6" 172 | files = [ 173 | {file = "Django-3.2.20-py3-none-any.whl", hash = "sha256:a477ab326ae7d8807dc25c186b951ab8c7648a3a23f9497763c37307a2b5ef87"}, 174 | {file = "Django-3.2.20.tar.gz", hash = "sha256:dec2a116787b8e14962014bf78e120bba454135108e1af9e9b91ade7b2964c40"}, 175 | ] 176 | 177 | [package.dependencies] 178 | asgiref = ">=3.3.2,<4" 179 | pytz = "*" 180 | sqlparse = ">=0.2.2" 181 | 182 | [package.extras] 183 | argon2 = ["argon2-cffi (>=19.1.0)"] 184 | bcrypt = ["bcrypt"] 185 | 186 | [[package]] 187 | name = "django-debug-toolbar" 188 | version = "4.1.0" 189 | description = "A configurable set of panels that display various debug information about the current request/response." 190 | optional = false 191 | python-versions = ">=3.8" 192 | files = [ 193 | {file = "django_debug_toolbar-4.1.0-py3-none-any.whl", hash = "sha256:a0b532ef5d52544fd745d1dcfc0557fa75f6f0d1962a8298bd568427ef2fa436"}, 194 | {file = "django_debug_toolbar-4.1.0.tar.gz", hash = "sha256:f57882e335593cb8e74c2bda9f1116bbb9ca8fc0d81b50a75ace0f83de5173c7"}, 195 | ] 196 | 197 | [package.dependencies] 198 | django = ">=3.2.4" 199 | sqlparse = ">=0.2" 200 | 201 | [[package]] 202 | name = "djangorestframework" 203 | version = "3.14.0" 204 | description = "Web APIs for Django, made easy." 205 | optional = false 206 | python-versions = ">=3.6" 207 | files = [ 208 | {file = "djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08"}, 209 | {file = "djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8"}, 210 | ] 211 | 212 | [package.dependencies] 213 | django = ">=3.0" 214 | pytz = "*" 215 | 216 | [[package]] 217 | name = "exceptiongroup" 218 | version = "1.1.2" 219 | description = "Backport of PEP 654 (exception groups)" 220 | optional = false 221 | python-versions = ">=3.7" 222 | files = [ 223 | {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, 224 | {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, 225 | ] 226 | 227 | [package.extras] 228 | test = ["pytest (>=6)"] 229 | 230 | [[package]] 231 | name = "factory-boy" 232 | version = "3.2.1" 233 | description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." 234 | optional = false 235 | python-versions = ">=3.6" 236 | files = [ 237 | {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, 238 | {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, 239 | ] 240 | 241 | [package.dependencies] 242 | Faker = ">=0.7.0" 243 | 244 | [package.extras] 245 | dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"] 246 | doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] 247 | 248 | [[package]] 249 | name = "faker" 250 | version = "18.13.0" 251 | description = "Faker is a Python package that generates fake data for you." 252 | optional = false 253 | python-versions = ">=3.7" 254 | files = [ 255 | {file = "Faker-18.13.0-py3-none-any.whl", hash = "sha256:801d1a2d71f1fc54d332de2ab19de7452454309937233ea2f7485402882d67b3"}, 256 | {file = "Faker-18.13.0.tar.gz", hash = "sha256:84bcf92bb725dd7341336eea4685df9a364f16f2470c4d29c1d7e6c5fd5a457d"}, 257 | ] 258 | 259 | [package.dependencies] 260 | python-dateutil = ">=2.4" 261 | 262 | [[package]] 263 | name = "iniconfig" 264 | version = "2.0.0" 265 | description = "brain-dead simple config-ini parsing" 266 | optional = false 267 | python-versions = ">=3.7" 268 | files = [ 269 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 270 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 271 | ] 272 | 273 | [[package]] 274 | name = "mypy-extensions" 275 | version = "1.0.0" 276 | description = "Type system extensions for programs checked with the mypy type checker." 277 | optional = false 278 | python-versions = ">=3.5" 279 | files = [ 280 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 281 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 282 | ] 283 | 284 | [[package]] 285 | name = "packaging" 286 | version = "23.1" 287 | description = "Core utilities for Python packages" 288 | optional = false 289 | python-versions = ">=3.7" 290 | files = [ 291 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 292 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 293 | ] 294 | 295 | [[package]] 296 | name = "pathspec" 297 | version = "0.11.1" 298 | description = "Utility library for gitignore style pattern matching of file paths." 299 | optional = false 300 | python-versions = ">=3.7" 301 | files = [ 302 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 303 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 304 | ] 305 | 306 | [[package]] 307 | name = "platformdirs" 308 | version = "3.8.1" 309 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 310 | optional = false 311 | python-versions = ">=3.7" 312 | files = [ 313 | {file = "platformdirs-3.8.1-py3-none-any.whl", hash = "sha256:cec7b889196b9144d088e4c57d9ceef7374f6c39694ad1577a0aab50d27ea28c"}, 314 | {file = "platformdirs-3.8.1.tar.gz", hash = "sha256:f87ca4fcff7d2b0f81c6a748a77973d7af0f4d526f98f308477c3c436c74d528"}, 315 | ] 316 | 317 | [package.extras] 318 | docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 319 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] 320 | 321 | [[package]] 322 | name = "pluggy" 323 | version = "1.2.0" 324 | description = "plugin and hook calling mechanisms for python" 325 | optional = false 326 | python-versions = ">=3.7" 327 | files = [ 328 | {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, 329 | {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, 330 | ] 331 | 332 | [package.extras] 333 | dev = ["pre-commit", "tox"] 334 | testing = ["pytest", "pytest-benchmark"] 335 | 336 | [[package]] 337 | name = "pytest" 338 | version = "7.4.0" 339 | description = "pytest: simple powerful testing with Python" 340 | optional = false 341 | python-versions = ">=3.7" 342 | files = [ 343 | {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, 344 | {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, 345 | ] 346 | 347 | [package.dependencies] 348 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 349 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 350 | iniconfig = "*" 351 | packaging = "*" 352 | pluggy = ">=0.12,<2.0" 353 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 354 | 355 | [package.extras] 356 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 357 | 358 | [[package]] 359 | name = "pytest-cov" 360 | version = "4.1.0" 361 | description = "Pytest plugin for measuring coverage." 362 | optional = false 363 | python-versions = ">=3.7" 364 | files = [ 365 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 366 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 367 | ] 368 | 369 | [package.dependencies] 370 | coverage = {version = ">=5.2.1", extras = ["toml"]} 371 | pytest = ">=4.6" 372 | 373 | [package.extras] 374 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 375 | 376 | [[package]] 377 | name = "pytest-django" 378 | version = "4.5.2" 379 | description = "A Django plugin for pytest." 380 | optional = false 381 | python-versions = ">=3.5" 382 | files = [ 383 | {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, 384 | {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, 385 | ] 386 | 387 | [package.dependencies] 388 | pytest = ">=5.4.0" 389 | 390 | [package.extras] 391 | docs = ["sphinx", "sphinx-rtd-theme"] 392 | testing = ["Django", "django-configurations (>=2.0)"] 393 | 394 | [[package]] 395 | name = "python-dateutil" 396 | version = "2.8.2" 397 | description = "Extensions to the standard Python datetime module" 398 | optional = false 399 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 400 | files = [ 401 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 402 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 403 | ] 404 | 405 | [package.dependencies] 406 | six = ">=1.5" 407 | 408 | [[package]] 409 | name = "pytz" 410 | version = "2023.3" 411 | description = "World timezone definitions, modern and historical" 412 | optional = false 413 | python-versions = "*" 414 | files = [ 415 | {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, 416 | {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, 417 | ] 418 | 419 | [[package]] 420 | name = "ruff" 421 | version = "0.0.277" 422 | description = "An extremely fast Python linter, written in Rust." 423 | optional = false 424 | python-versions = ">=3.7" 425 | files = [ 426 | {file = "ruff-0.0.277-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:3250b24333ef419b7a232080d9724ccc4d2da1dbbe4ce85c4caa2290d83200f8"}, 427 | {file = "ruff-0.0.277-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:3e60605e07482183ba1c1b7237eca827bd6cbd3535fe8a4ede28cbe2a323cb97"}, 428 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baa97c3d7186e5ed4d5d4f6834d759a27e56cf7d5874b98c507335f0ad5aadb"}, 429 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74e4b206cb24f2e98a615f87dbe0bde18105217cbcc8eb785bb05a644855ba50"}, 430 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:479864a3ccd8a6a20a37a6e7577bdc2406868ee80b1e65605478ad3b8eb2ba0b"}, 431 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:468bfb0a7567443cec3d03cf408d6f562b52f30c3c29df19927f1e0e13a40cd7"}, 432 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f32ec416c24542ca2f9cc8c8b65b84560530d338aaf247a4a78e74b99cd476b4"}, 433 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14a7b2f00f149c5a295f188a643ac25226ff8a4d08f7a62b1d4b0a1dc9f9b85c"}, 434 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9879f59f763cc5628aa01c31ad256a0f4dc61a29355c7315b83c2a5aac932b5"}, 435 | {file = "ruff-0.0.277-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f612e0a14b3d145d90eb6ead990064e22f6f27281d847237560b4e10bf2251f3"}, 436 | {file = "ruff-0.0.277-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:323b674c98078be9aaded5b8b51c0d9c424486566fb6ec18439b496ce79e5998"}, 437 | {file = "ruff-0.0.277-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3a43fbe026ca1a2a8c45aa0d600a0116bec4dfa6f8bf0c3b871ecda51ef2b5dd"}, 438 | {file = "ruff-0.0.277-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:734165ea8feb81b0d53e3bf523adc2413fdb76f1264cde99555161dd5a725522"}, 439 | {file = "ruff-0.0.277-py3-none-win32.whl", hash = "sha256:88d0f2afb2e0c26ac1120e7061ddda2a566196ec4007bd66d558f13b374b9efc"}, 440 | {file = "ruff-0.0.277-py3-none-win_amd64.whl", hash = "sha256:6fe81732f788894a00f6ade1fe69e996cc9e485b7c35b0f53fb00284397284b2"}, 441 | {file = "ruff-0.0.277-py3-none-win_arm64.whl", hash = "sha256:2d4444c60f2e705c14cd802b55cd2b561d25bf4311702c463a002392d3116b22"}, 442 | {file = "ruff-0.0.277.tar.gz", hash = "sha256:2dab13cdedbf3af6d4427c07f47143746b6b95d9e4a254ac369a0edb9280a0d2"}, 443 | ] 444 | 445 | [[package]] 446 | name = "six" 447 | version = "1.16.0" 448 | description = "Python 2 and 3 compatibility utilities" 449 | optional = false 450 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 451 | files = [ 452 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 453 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 454 | ] 455 | 456 | [[package]] 457 | name = "sqlparse" 458 | version = "0.4.4" 459 | description = "A non-validating SQL parser." 460 | optional = false 461 | python-versions = ">=3.5" 462 | files = [ 463 | {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, 464 | {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, 465 | ] 466 | 467 | [package.extras] 468 | dev = ["build", "flake8"] 469 | doc = ["sphinx"] 470 | test = ["pytest", "pytest-cov"] 471 | 472 | [[package]] 473 | name = "tomli" 474 | version = "2.0.1" 475 | description = "A lil' TOML parser" 476 | optional = false 477 | python-versions = ">=3.7" 478 | files = [ 479 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 480 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 481 | ] 482 | 483 | [[package]] 484 | name = "typing-extensions" 485 | version = "4.7.1" 486 | description = "Backported and Experimental Type Hints for Python 3.7+" 487 | optional = false 488 | python-versions = ">=3.7" 489 | files = [ 490 | {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, 491 | {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, 492 | ] 493 | 494 | [metadata] 495 | lock-version = "2.0" 496 | python-versions = "^3.8" 497 | content-hash = "fb7ccca80c09d3856e11c9cc89546e7e7b8ae8da7767887db2b5d71cf5f0ef25" 498 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-rest-batteries" 3 | version = "1.4.1" 4 | description = "Build clean APIs with DRF faster" 5 | authors = ["Define Impossible "] 6 | packages = [ 7 | { include = "rest_batteries" } 8 | ] 9 | license = "MIT" 10 | readme = "README.md" 11 | homepage = "https://github.com/defineimpossible/django-rest-batteries" 12 | repository = "https://github.com/defineimpossible/django-rest-batteries" 13 | keywords = [ 14 | "django rest framework", 15 | "drf", 16 | "django", 17 | "batteries", 18 | ] 19 | 20 | [tool.poetry.dependencies] 21 | python = "^3.8" 22 | django = ">=3.2" 23 | djangorestframework = ">=3.12.2" 24 | 25 | [tool.poetry.group.dev.dependencies] 26 | django-debug-toolbar = "^4.1.0" 27 | 28 | [tool.poetry.group.test.dependencies] 29 | pytest = "^7.4.0" 30 | pytest-cov = "^4.1.0" 31 | pytest-django = "^4.5.2" 32 | factory-boy = "^3.2.1" 33 | 34 | [tool.poetry.group.code-quality.dependencies] 35 | black = "^23.7.0" 36 | ruff = "^0.0.277" 37 | 38 | [build-system] 39 | requires = ["poetry>=0.12"] 40 | build-backend = "poetry.masonry.api" 41 | 42 | [tool.black] 43 | line-length = 99 44 | skip-string-normalization = true 45 | 46 | [tool.ruff] 47 | select = ["E", "F", "I"] 48 | 49 | exclude = [ 50 | ".bzr", 51 | ".direnv", 52 | ".eggs", 53 | ".git", 54 | ".hg", 55 | ".mypy_cache", 56 | ".nox", 57 | ".pants.d", 58 | ".ruff_cache", 59 | ".svn", 60 | ".tox", 61 | ".venv", 62 | "__pypackages__", 63 | "_build", 64 | "buck-out", 65 | "build", 66 | "dist", 67 | "node_modules", 68 | "venv", 69 | "migrations" 70 | ] 71 | line-length = 99 72 | 73 | [tool.pytest.ini_options] 74 | # Django configuration: 75 | # https://pytest-django.readthedocs.io/en/latest/ 76 | DJANGO_SETTINGS_MODULE = "tests.settings" 77 | -------------------------------------------------------------------------------- /rest_batteries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defineimpossible/django-rest-batteries/1c7c4a1cd8fa557c8930601fa296621635c6adf4/rest_batteries/__init__.py -------------------------------------------------------------------------------- /rest_batteries/errors_formatter.py: -------------------------------------------------------------------------------- 1 | from rest_framework import exceptions 2 | from rest_framework.settings import api_settings 3 | 4 | 5 | class ErrorsFormatter: 6 | """ 7 | Mostly copied from https://github.com/HackSoftware/Django-Styleguide 8 | TODO: Refactor later 9 | 10 | The current formatter gets invalid serializer errors, 11 | uses DRF standard for code and messaging 12 | and then parses it to the following format: 13 | { 14 | "errors": [ 15 | { 16 | "message": "Error message", 17 | "code": "Some code", 18 | "field": "field_name" 19 | }, 20 | { 21 | "message": "Error message", 22 | "code": "Some code", 23 | "field": "nested.field_name" 24 | }, 25 | ... 26 | ] 27 | } 28 | """ 29 | 30 | FIELD = 'field' 31 | MESSAGE = 'message' 32 | CODE = 'code' 33 | ERRORS = 'errors' 34 | 35 | def __init__(self, exception): 36 | self.exception = exception 37 | 38 | def __call__(self): 39 | if hasattr(self.exception, 'get_full_details'): 40 | formatted_errors = self._get_response_json_from_drf_errors( 41 | serializer_errors=self.exception.get_full_details() 42 | ) 43 | else: 44 | formatted_errors = self._get_response_json_from_error_message( 45 | message=str(self.exception) 46 | ) 47 | 48 | return formatted_errors 49 | 50 | def get_field_name(self, field_name): 51 | """ 52 | Override this method if you want to change a field name returned in the response. 53 | For example, convert snake_case field name to camelCase. 54 | """ 55 | return field_name 56 | 57 | def _get_response_json_from_drf_errors(self, serializer_errors=None): 58 | if serializer_errors is None: 59 | serializer_errors = {} 60 | 61 | if type(serializer_errors) is list: 62 | serializer_errors = {api_settings.NON_FIELD_ERRORS_KEY: serializer_errors} 63 | 64 | list_of_errors = self._get_list_of_errors(errors_dict=serializer_errors) 65 | 66 | response_data = {self.ERRORS: list_of_errors} 67 | 68 | return response_data 69 | 70 | def _get_response_json_from_error_message(self, *, message='', code='error'): 71 | response_data = {self.ERRORS: [{self.MESSAGE: message, self.CODE: code}]} 72 | 73 | return response_data 74 | 75 | def _unpack(self, obj): 76 | if type(obj) is list and len(obj) == 1: 77 | return obj[0] 78 | 79 | return obj 80 | 81 | def _get_list_of_errors(self, field_path='', errors_dict=None): 82 | """ 83 | Error_dict is in the following format: 84 | { 85 | 'field1': { 86 | 'message': 'some message..' 87 | 'code' 'some code...' 88 | }, 89 | 'field2: ...' 90 | } 91 | """ 92 | if errors_dict is None: 93 | return [] 94 | 95 | message_value = errors_dict.get(self.MESSAGE, None) 96 | 97 | # Note: If 'message' is name of a field we don't want to stop the recursion here! 98 | if message_value is not None and (type(message_value) in {str, exceptions.ErrorDetail}): 99 | if field_path: 100 | errors_dict[self.FIELD] = field_path 101 | return [errors_dict] 102 | 103 | errors_list = [] 104 | for key, value in errors_dict.items(): 105 | new_field_path = ( 106 | '{0}.{1}'.format(field_path, self.get_field_name(key)) 107 | if field_path 108 | else self.get_field_name(key) 109 | ) 110 | key_is_non_field_errors = key == api_settings.NON_FIELD_ERRORS_KEY 111 | 112 | if type(value) is list: 113 | current_level_error_list = [] 114 | new_value = value 115 | 116 | for index, error in enumerate(new_value): 117 | # if the type of field_error is list we need to unpack it 118 | field_error = self._unpack(error) 119 | 120 | if self.MESSAGE in field_error: 121 | if not key_is_non_field_errors: 122 | field_error[self.FIELD] = new_field_path 123 | current_level_error_list.append(field_error) 124 | else: 125 | path = '{0}[{1}]'.format(new_field_path, index) 126 | current_level_error_list.extend( 127 | self._get_list_of_errors(field_path=path, errors_dict=field_error) 128 | ) 129 | else: 130 | path = field_path if key_is_non_field_errors else new_field_path 131 | 132 | current_level_error_list = self._get_list_of_errors( 133 | field_path=path, errors_dict=value 134 | ) 135 | 136 | errors_list += current_level_error_list 137 | 138 | return errors_list 139 | -------------------------------------------------------------------------------- /rest_batteries/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.views import exception_handler 2 | 3 | from .errors_formatter import ErrorsFormatter 4 | 5 | 6 | def errors_formatter_exception_handler(exc, context): 7 | response = exception_handler(exc, context) 8 | 9 | # If unexpected error occurs (server error, etc.) 10 | if response is None: 11 | return response 12 | 13 | formatter = ErrorsFormatter(exc) 14 | 15 | response.data = formatter() 16 | 17 | return response 18 | -------------------------------------------------------------------------------- /rest_batteries/generics.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from rest_framework import generics 5 | from rest_framework.serializers import BaseSerializer 6 | 7 | from .mixins import ( 8 | CreateModelMixin, 9 | DestroyModelMixin, 10 | DjangoValidationErrorTransformMixin, 11 | ListModelMixin, 12 | RetrieveModelMixin, 13 | UpdateModelMixin, 14 | ) 15 | 16 | 17 | class GenericAPIView(DjangoValidationErrorTransformMixin, generics.GenericAPIView): 18 | request_serializer_class: Optional[Type[BaseSerializer]] = None 19 | destroy_request_serializer_class: Optional[Type[BaseSerializer]] = None 20 | response_serializer_class: Optional[Type[BaseSerializer]] = None 21 | 22 | def get_request_serializer(self, *args, **kwargs) -> BaseSerializer: 23 | serializer = self.get_request_serializer_or_none(*args, **kwargs) 24 | if serializer is None: 25 | self.raise_request_serializer_error() 26 | return serializer 27 | 28 | def get_request_serializer_or_none(self, *args, **kwargs) -> Optional[BaseSerializer]: 29 | serializer_class = self.get_request_serializer_class_or_none() 30 | if serializer_class is not None: 31 | kwargs.setdefault('context', self.get_request_serializer_context()) 32 | return serializer_class(*args, **kwargs) 33 | 34 | def get_request_serializer_class_or_none(self) -> Optional[Type[BaseSerializer]]: 35 | if self.request.method == 'DELETE': 36 | return self.destroy_request_serializer_class 37 | return self.request_serializer_class 38 | 39 | def get_request_serializer_context(self): 40 | return self.get_serializer_context() 41 | 42 | def raise_request_serializer_error(self): 43 | raise ImproperlyConfigured( 44 | f'{self.__class__.__name__} should properly configure ' 45 | '`request_serializer_class` attribute' 46 | ) 47 | 48 | def get_response_serializer(self, *args, **kwargs) -> BaseSerializer: 49 | serializer = self.get_response_serializer_or_none(*args, **kwargs) 50 | if serializer is None: 51 | self.raise_response_serializer_error() 52 | return serializer 53 | 54 | def get_response_serializer_or_none(self, *args, **kwargs) -> Optional[BaseSerializer]: 55 | serializer_class = self.get_response_serializer_class_or_none() 56 | if serializer_class is not None: 57 | kwargs.setdefault('context', self.get_response_serializer_context()) 58 | return serializer_class(*args, **kwargs) 59 | 60 | def get_response_serializer_class_or_none(self) -> Optional[Type[BaseSerializer]]: 61 | return self.response_serializer_class 62 | 63 | def get_response_serializer_context(self): 64 | return self.get_serializer_context() 65 | 66 | def raise_response_serializer_error(self): 67 | raise ImproperlyConfigured( 68 | f'{self.__class__.__name__} should properly configure ' 69 | '`response_serializer_class` attribute' 70 | ) 71 | 72 | def get_serializer_class(self) -> Type[BaseSerializer]: 73 | response_serializer_class = self.get_response_serializer_class_or_none() 74 | if response_serializer_class is not None: 75 | return response_serializer_class 76 | 77 | if self.serializer_class is not None: 78 | return self.serializer_class 79 | 80 | self.raise_serializer_error() 81 | 82 | def raise_serializer_error(self): 83 | raise ImproperlyConfigured( 84 | f'{self.__class__.__name__} should properly configure one of these attributes: ' 85 | f'`response_serializer_class`, `serializer_class`' 86 | ) 87 | 88 | 89 | # Concrete view classes that provide method handlers 90 | # by composing the mixin classes with the base view. 91 | 92 | 93 | class CreateAPIView(CreateModelMixin, GenericAPIView): 94 | """ 95 | Concrete view for creating a model instance. 96 | """ 97 | 98 | def post(self, request, *args, **kwargs): 99 | return self.create(request, *args, **kwargs) 100 | 101 | 102 | class ListAPIView(ListModelMixin, GenericAPIView): 103 | """ 104 | Concrete view for listing a queryset. 105 | """ 106 | 107 | def get(self, request, *args, **kwargs): 108 | return self.list(request, *args, **kwargs) 109 | 110 | 111 | class RetrieveAPIView(RetrieveModelMixin, GenericAPIView): 112 | """ 113 | Concrete view for retrieving a model instance. 114 | """ 115 | 116 | def get(self, request, *args, **kwargs): 117 | return self.retrieve(request, *args, **kwargs) 118 | 119 | 120 | class DestroyAPIView(DestroyModelMixin, GenericAPIView): 121 | """ 122 | Concrete view for deleting a model instance. 123 | """ 124 | 125 | def delete(self, request, *args, **kwargs): 126 | return self.destroy(request, *args, **kwargs) 127 | 128 | 129 | class UpdateAPIView(UpdateModelMixin, GenericAPIView): 130 | """ 131 | Concrete view for updating a model instance. 132 | """ 133 | 134 | def put(self, request, *args, **kwargs): 135 | return self.update(request, *args, **kwargs) 136 | 137 | def patch(self, request, *args, **kwargs): 138 | return self.partial_update(request, *args, **kwargs) 139 | 140 | 141 | class ListCreateAPIView(ListModelMixin, CreateModelMixin, GenericAPIView): 142 | """ 143 | Concrete view for listing a queryset or creating a model instance. 144 | """ 145 | 146 | def get(self, request, *args, **kwargs): 147 | return self.list(request, *args, **kwargs) 148 | 149 | def post(self, request, *args, **kwargs): 150 | return self.create(request, *args, **kwargs) 151 | 152 | 153 | class RetrieveUpdateAPIView(RetrieveModelMixin, UpdateModelMixin, GenericAPIView): 154 | """ 155 | Concrete view for retrieving, updating a model instance. 156 | """ 157 | 158 | def get(self, request, *args, **kwargs): 159 | return self.retrieve(request, *args, **kwargs) 160 | 161 | def put(self, request, *args, **kwargs): 162 | return self.update(request, *args, **kwargs) 163 | 164 | def patch(self, request, *args, **kwargs): 165 | return self.partial_update(request, *args, **kwargs) 166 | 167 | 168 | class RetrieveDestroyAPIView(RetrieveModelMixin, DestroyModelMixin, GenericAPIView): 169 | """ 170 | Concrete view for retrieving or deleting a model instance. 171 | """ 172 | 173 | def get(self, request, *args, **kwargs): 174 | return self.retrieve(request, *args, **kwargs) 175 | 176 | def delete(self, request, *args, **kwargs): 177 | return self.destroy(request, *args, **kwargs) 178 | 179 | 180 | class RetrieveUpdateDestroyAPIView( 181 | RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericAPIView 182 | ): 183 | """ 184 | Concrete view for retrieving, updating or deleting a model instance. 185 | """ 186 | 187 | def get(self, request, *args, **kwargs): 188 | return self.retrieve(request, *args, **kwargs) 189 | 190 | def put(self, request, *args, **kwargs): 191 | return self.update(request, *args, **kwargs) 192 | 193 | def patch(self, request, *args, **kwargs): 194 | return self.partial_update(request, *args, **kwargs) 195 | 196 | def delete(self, request, *args, **kwargs): 197 | return self.destroy(request, *args, **kwargs) 198 | -------------------------------------------------------------------------------- /rest_batteries/mixins.py: -------------------------------------------------------------------------------- 1 | from django.core import exceptions as django_exceptions 2 | from rest_framework import exceptions as rest_exceptions 3 | from rest_framework import status 4 | from rest_framework.fields import get_error_detail 5 | from rest_framework.response import Response 6 | 7 | 8 | class DjangoValidationErrorTransformMixin: 9 | """ 10 | Transforms Django's ValidationError into REST Framework's ValidationError. 11 | Without this mixin, server responds with 500 status code which is not desired. 12 | """ 13 | 14 | def handle_exception(self, exc): 15 | if isinstance(exc, django_exceptions.ValidationError): 16 | drf_exception = rest_exceptions.ValidationError(get_error_detail(exc)) 17 | return super().handle_exception(drf_exception) 18 | 19 | return super().handle_exception(exc) 20 | 21 | 22 | class CreateModelMixin: 23 | """ 24 | Create a model instance. 25 | """ 26 | 27 | def create(self, request, *_args, **_kwargs): 28 | request_serializer = self.get_request_serializer(data=request.data) 29 | request_serializer.is_valid(raise_exception=True) 30 | 31 | instance = self.perform_create(request_serializer) 32 | 33 | response_serializer = self.get_response_serializer(instance) 34 | return Response(response_serializer.data, status=status.HTTP_201_CREATED) 35 | 36 | def perform_create(self, serializer): 37 | return serializer.save() 38 | 39 | 40 | class RetrieveModelMixin: 41 | """ 42 | Retrieve a model instance. 43 | """ 44 | 45 | def retrieve(self, _request, *_args, **_kwargs): 46 | instance = self.get_object() 47 | serializer = self.get_response_serializer(instance) 48 | return Response(serializer.data) 49 | 50 | 51 | class ListModelMixin: 52 | """ 53 | List a queryset. 54 | """ 55 | 56 | def list(self, _request, *_args, **_kwargs): 57 | queryset = self.filter_queryset(self.get_queryset()) 58 | 59 | page = self.paginate_queryset(queryset) 60 | if page is not None: 61 | serializer = self.get_response_serializer(page, many=True) 62 | return self.get_paginated_response(serializer.data) 63 | 64 | serializer = self.get_response_serializer(queryset, many=True) 65 | return Response(serializer.data) 66 | 67 | 68 | class _UpdateMixin: 69 | def _update(self, request, *_args, **kwargs): 70 | partial = kwargs.pop('partial', False) 71 | instance = self.get_object() 72 | request_serializer = self.get_request_serializer( 73 | instance, data=request.data, partial=partial 74 | ) 75 | request_serializer.is_valid(raise_exception=True) 76 | 77 | if partial: 78 | instance = self.perform_partial_update(instance, request_serializer) 79 | else: 80 | instance = self.perform_update(instance, request_serializer) 81 | 82 | if getattr(instance, '_prefetched_objects_cache', None): 83 | # If 'prefetch_related' has been applied to a queryset, we need to 84 | # forcibly invalidate the prefetch cache on the instance. 85 | instance._prefetched_objects_cache = {} 86 | 87 | response_serializer = self.get_response_serializer(instance) 88 | return Response(response_serializer.data) 89 | 90 | def perform_update(self, instance, serializer): 91 | return serializer.save() 92 | 93 | def perform_partial_update(self, instance, serializer): 94 | return self.perform_update(instance, serializer) 95 | 96 | 97 | class UpdateModelMixin(_UpdateMixin): 98 | """ 99 | Update a model instance. 100 | """ 101 | 102 | def update(self, *args, **kwargs): 103 | return self._update(*args, **kwargs) 104 | 105 | def partial_update(self, *args, **kwargs): 106 | kwargs['partial'] = True 107 | return self.update(*args, **kwargs) 108 | 109 | 110 | class FullUpdateModelMixin(_UpdateMixin): 111 | """ 112 | Fully update a model instance. 113 | """ 114 | 115 | def update(self, *args, **kwargs): 116 | return self._update(*args, **kwargs) 117 | 118 | 119 | class PartialUpdateModelMixin(_UpdateMixin): 120 | """ 121 | Partially update a model instance. 122 | """ 123 | 124 | def partial_update(self, *args, **kwargs): 125 | kwargs['partial'] = True 126 | return self._update(*args, **kwargs) 127 | 128 | 129 | class DestroyModelMixin: 130 | """ 131 | Destroy a model instance. 132 | """ 133 | 134 | def destroy(self, request, *_args, **_kwargs): 135 | instance = self.get_object() 136 | serializer = self.get_request_serializer_or_none(instance, data=request.data) 137 | if serializer is not None: 138 | serializer.is_valid(raise_exception=True) 139 | self.perform_destroy(instance, serializer) 140 | else: 141 | self.perform_destroy(instance) 142 | return Response(status=status.HTTP_204_NO_CONTENT) 143 | 144 | def perform_destroy(self, instance, serializer=None): 145 | instance.delete() 146 | -------------------------------------------------------------------------------- /rest_batteries/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import views 2 | 3 | from .mixins import DjangoValidationErrorTransformMixin 4 | 5 | 6 | class APIView(DjangoValidationErrorTransformMixin, views.APIView): 7 | pass 8 | -------------------------------------------------------------------------------- /rest_batteries/viewsets.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Iterable, Optional, Type, Union 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from rest_framework import viewsets 5 | from rest_framework.permissions import BasePermission 6 | from rest_framework.serializers import BaseSerializer 7 | 8 | from .generics import GenericAPIView 9 | from .mixins import ( 10 | CreateModelMixin, 11 | DestroyModelMixin, 12 | ListModelMixin, 13 | RetrieveModelMixin, 14 | UpdateModelMixin, 15 | ) 16 | 17 | 18 | class GenericViewSet(viewsets.ViewSetMixin, GenericAPIView): 19 | action_permission_classes: Optional[ 20 | Dict[str, Union[Type[BasePermission], Iterable[Type[BasePermission]]]] 21 | ] = None 22 | request_action_serializer_classes: Optional[Dict[str, Type[BaseSerializer]]] = None 23 | response_action_serializer_classes: Optional[Dict[str, Type[BaseSerializer]]] = None 24 | 25 | def get_permission_classes_or_none(self): 26 | if self.action_permission_classes: 27 | permission_classes = self.action_permission_classes.get(self.action) 28 | if permission_classes is None and self.action == 'partial_update': 29 | permission_classes = self.action_permission_classes.get('update') 30 | return permission_classes 31 | 32 | def get_permissions(self): 33 | permissions = super().get_permissions() 34 | 35 | permission_classes = self.get_permission_classes_or_none() 36 | if permission_classes is not None: 37 | if isinstance(permission_classes, Iterable): 38 | for permission_class in permission_classes: 39 | permissions.append(permission_class()) 40 | else: 41 | permissions.append(permission_classes()) 42 | 43 | return permissions 44 | 45 | def get_request_serializer_class_or_none(self) -> Optional[Type[BaseSerializer]]: 46 | serializer_class = None 47 | 48 | if self.request_action_serializer_classes: 49 | serializer_class = self.request_action_serializer_classes.get(self.action) 50 | if serializer_class is None and self.action == 'partial_update': 51 | serializer_class = self.request_action_serializer_classes.get('update') 52 | 53 | if serializer_class is None: 54 | return super().get_request_serializer_class_or_none() 55 | 56 | return serializer_class 57 | 58 | def raise_request_serializer_error(self): 59 | raise ImproperlyConfigured( 60 | f'{self.__class__.__name__} should properly configure ' 61 | '`request_action_serializer_classes` attribute' 62 | ) 63 | 64 | def get_response_serializer_class_or_none(self) -> Optional[Type[BaseSerializer]]: 65 | serializer_class = None 66 | 67 | if self.response_action_serializer_classes: 68 | serializer_class = self.response_action_serializer_classes.get(self.action) 69 | if serializer_class is None and self.action == 'partial_update': 70 | serializer_class = self.response_action_serializer_classes.get('update') 71 | 72 | if serializer_class is None: 73 | return super().get_response_serializer_class_or_none() 74 | 75 | return serializer_class 76 | 77 | def raise_response_serializer_error(self): 78 | raise ImproperlyConfigured( 79 | f'{self.__class__.__name__} should properly configure ' 80 | '`response_action_serializer_classes` attribute' 81 | ) 82 | 83 | def raise_serializer_error(self): 84 | raise ImproperlyConfigured( 85 | f'{self.__class__.__name__} should properly configure one of these attributes: ' 86 | f'`response_action_serializer_classes`, `serializer_class`' 87 | ) 88 | 89 | 90 | class ReadOnlyModelViewSet(RetrieveModelMixin, ListModelMixin, GenericViewSet): 91 | """ 92 | A viewset that provides default `list()` and `retrieve()` actions. 93 | """ 94 | 95 | pass 96 | 97 | 98 | class ModelViewSet( 99 | CreateModelMixin, 100 | RetrieveModelMixin, 101 | ListModelMixin, 102 | UpdateModelMixin, 103 | DestroyModelMixin, 104 | GenericViewSet, 105 | ): 106 | """ 107 | A viewset that provides default `create()`, `retrieve()`, `update()`, 108 | `partial_update()`, `destroy()` and `list()` actions. 109 | """ 110 | 111 | pass 112 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defineimpossible/django-rest-batteries/1c7c4a1cd8fa557c8930601fa296621635c6adf4/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | 4 | from .utils import APIClient 5 | 6 | User = get_user_model() 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def enable_db_access_for_all_tests(db): 11 | pass 12 | 13 | 14 | @pytest.fixture 15 | def api_client() -> APIClient: 16 | return APIClient() 17 | 18 | 19 | @pytest.fixture 20 | def test_user() -> User: 21 | return User.objects.create_user( 22 | username='test-user', email='test-user@example.com', password='password' 23 | ) 24 | 25 | 26 | @pytest.fixture 27 | def test_user_api_client(api_client, test_user) -> APIClient: 28 | api_client.login(test_user) 29 | return api_client 30 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from django.contrib.auth import get_user_model 3 | 4 | from .models import Article, Comment 5 | 6 | User = get_user_model() 7 | 8 | 9 | class ArticleFactory(factory.django.DjangoModelFactory): 10 | title = factory.Sequence(lambda n: f'article-title-{n}') 11 | text = factory.Sequence(lambda n: f'article-text-{n}') 12 | 13 | class Meta: 14 | model = Article 15 | 16 | 17 | class CommentFactory(factory.django.DjangoModelFactory): 18 | article = factory.SubFactory('tests.factories.ArticleFactory') 19 | text = factory.Sequence(lambda n: f'comment-text-{n}') 20 | 21 | class Meta: 22 | model = Comment 23 | 24 | 25 | class UserFactory(factory.django.DjangoModelFactory): 26 | username = factory.Sequence(lambda n: 'username-{n}') 27 | password = factory.Faker( 28 | 'password', 29 | length=10, 30 | special_chars=True, 31 | digits=True, 32 | upper_case=True, 33 | lower_case=True, 34 | ) 35 | email = factory.Faker('email') 36 | 37 | class Meta: 38 | model = User 39 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Article(models.Model): 5 | title = models.CharField(max_length=255) 6 | text = models.TextField() 7 | is_deleted = models.BooleanField(default=False) 8 | 9 | 10 | class Comment(models.Model): 11 | article = models.ForeignKey('Article', on_delete=models.CASCADE, related_name='comments') 12 | text = models.TextField() 13 | -------------------------------------------------------------------------------- /tests/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import serializers 3 | 4 | from .models import Article, Comment 5 | 6 | User = get_user_model() 7 | 8 | 9 | class CommentResponseSerializer(serializers.ModelSerializer): 10 | class Meta: 11 | model = Comment 12 | fields = ( 13 | 'id', 14 | 'text', 15 | ) 16 | 17 | 18 | class ArticleResponseSerializer(serializers.ModelSerializer): 19 | comments = CommentResponseSerializer(many=True) 20 | 21 | class Meta: 22 | model = Article 23 | fields = ( 24 | 'id', 25 | 'title', 26 | 'text', 27 | 'comments', 28 | ) 29 | 30 | 31 | class CommentRequestSerializer(serializers.ModelSerializer): 32 | article_id = serializers.PrimaryKeyRelatedField( 33 | queryset=Article.objects.filter(is_deleted=False), source='article' 34 | ) 35 | 36 | class Meta: 37 | model = Comment 38 | fields = ( 39 | 'article_id', 40 | 'text', 41 | ) 42 | 43 | 44 | class ArticleRequestSerializer(serializers.ModelSerializer): 45 | class Meta: 46 | model = Article 47 | fields = ( 48 | 'title', 49 | 'text', 50 | ) 51 | 52 | 53 | class ArticleDeleteSerializer(serializers.Serializer): 54 | with_comments = serializers.BooleanField(default=False) 55 | 56 | 57 | class UserSerializer(serializers.ModelSerializer): 58 | class Meta: 59 | model = User 60 | fields = ( 61 | 'id', 62 | 'username', 63 | 'email', 64 | ) 65 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for tests. 3 | """ 4 | 5 | import os 6 | 7 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 8 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = '3ulydh05w+x#puo)6bp36+60+#l6(rjl@0nws3vsb+252ao*e&' 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | ALLOWED_HOSTS = [] 17 | 18 | 19 | # Application definition 20 | 21 | INSTALLED_APPS = [ 22 | 'django.contrib.admin', 23 | 'django.contrib.auth', 24 | 'django.contrib.contenttypes', 25 | 'django.contrib.sessions', 26 | 'django.contrib.messages', 27 | 'django.contrib.staticfiles', 28 | 'rest_framework', 29 | 'rest_batteries', 30 | 'tests', 31 | ] 32 | 33 | MIDDLEWARE = [ 34 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 35 | 'django.middleware.security.SecurityMiddleware', 36 | 'django.contrib.sessions.middleware.SessionMiddleware', 37 | 'django.middleware.common.CommonMiddleware', 38 | 'django.middleware.csrf.CsrfViewMiddleware', 39 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 40 | 'django.contrib.messages.middleware.MessageMiddleware', 41 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 42 | ] 43 | 44 | ROOT_URLCONF = 'example.urls' 45 | 46 | TEMPLATES = [ 47 | { 48 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 49 | 'DIRS': [], 50 | 'APP_DIRS': True, 51 | 'OPTIONS': { 52 | 'context_processors': [ 53 | 'django.template.context_processors.debug', 54 | 'django.template.context_processors.request', 55 | 'django.contrib.auth.context_processors.auth', 56 | 'django.contrib.messages.context_processors.messages', 57 | ], 58 | }, 59 | }, 60 | ] 61 | 62 | WSGI_APPLICATION = 'example.wsgi.application' 63 | 64 | 65 | # Database 66 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 67 | 68 | DATABASES = { 69 | 'default': { 70 | 'ENGINE': 'django.db.backends.sqlite3', 71 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 72 | } 73 | } 74 | 75 | 76 | # Password validation 77 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 78 | 79 | AUTH_PASSWORD_VALIDATORS = [ 80 | { 81 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 82 | }, 83 | { 84 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 85 | }, 86 | { 87 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 88 | }, 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 91 | }, 92 | ] 93 | 94 | 95 | # Internationalization 96 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 97 | 98 | LANGUAGE_CODE = 'en-us' 99 | 100 | TIME_ZONE = 'UTC' 101 | 102 | USE_I18N = True 103 | 104 | USE_L10N = True 105 | 106 | USE_TZ = True 107 | 108 | 109 | # Static files (CSS, JavaScript, Images) 110 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 111 | 112 | STATIC_URL = '/static/' 113 | -------------------------------------------------------------------------------- /tests/test_generics.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import path 3 | 4 | from rest_batteries.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView 5 | 6 | from . import factories as f 7 | from .models import Article, Comment 8 | from .serializers import ( 9 | ArticleDeleteSerializer, 10 | ArticleRequestSerializer, 11 | ArticleResponseSerializer, 12 | CommentRequestSerializer, 13 | CommentResponseSerializer, 14 | ) 15 | 16 | 17 | class ArticlesView(ListCreateAPIView): 18 | queryset = Article.objects.all() 19 | request_serializer_class = ArticleRequestSerializer 20 | response_serializer_class = ArticleResponseSerializer 21 | 22 | 23 | class ArticleView(RetrieveUpdateDestroyAPIView): 24 | queryset = Article.objects.all() 25 | request_serializer_class = ArticleRequestSerializer 26 | destroy_request_serializer_class = ArticleDeleteSerializer 27 | response_serializer_class = ArticleResponseSerializer 28 | 29 | def perform_destroy(self, instance, serializer=None): 30 | instance.is_deleted = True 31 | instance.save(update_fields=['is_deleted']) 32 | 33 | if serializer is not None and serializer.validated_data.get('with_comments'): 34 | instance.comments.all().delete() 35 | 36 | 37 | class CommentsView(ListCreateAPIView): 38 | queryset = Comment.objects.all() 39 | request_serializer_class = CommentRequestSerializer 40 | response_serializer_class = CommentResponseSerializer 41 | 42 | 43 | class CommentView(RetrieveUpdateDestroyAPIView): 44 | queryset = Comment.objects.all() 45 | request_serializer_class = CommentRequestSerializer 46 | response_serializer_class = CommentResponseSerializer 47 | 48 | 49 | urlpatterns = [ 50 | path('articles/', ArticlesView.as_view()), 51 | path('articles//', ArticleView.as_view()), 52 | path('comments/', CommentsView.as_view()), 53 | path('comments//', CommentView.as_view()), 54 | ] 55 | 56 | 57 | @pytest.fixture(autouse=True) 58 | def root_urlconf(settings): 59 | settings.ROOT_URLCONF = __name__ 60 | 61 | 62 | class TestArticlesView: 63 | def test_create_article(self, api_client): 64 | title = 'test-article-title' 65 | text = 'test-article-text' 66 | response = api_client.post('/articles/', {'title': title, 'text': text}) 67 | assert response.status_code == 201 68 | assert response.data['title'] == title 69 | assert response.data['text'] == text 70 | 71 | def test_list_articles(self, api_client): 72 | article_1 = f.ArticleFactory.create() 73 | 74 | response = api_client.get('/articles/') 75 | assert response.status_code == 200 76 | assert len(response.data) == 1 77 | assert response.data[0] == ArticleResponseSerializer(article_1).data 78 | 79 | 80 | class TestArticleView: 81 | def test_retrieve_article(self, api_client): 82 | article_1 = f.ArticleFactory.create() 83 | 84 | response = api_client.get(f'/articles/{article_1.id}/') 85 | assert response.status_code == 200 86 | assert response.data == ArticleResponseSerializer(article_1).data 87 | 88 | def test_update_article(self, api_client): 89 | article_1 = f.ArticleFactory.create() 90 | 91 | title = 'test-article-title' 92 | text = 'test-article-text' 93 | response = api_client.put(f'/articles/{article_1.id}/', {'title': title, 'text': text}) 94 | assert response.status_code == 200 95 | assert response.data['id'] == article_1.id 96 | assert response.data['title'] == title 97 | assert response.data['text'] == text 98 | 99 | def test_partial_update_article(self, api_client): 100 | article_1 = f.ArticleFactory.create() 101 | 102 | title = 'test-article-title' 103 | response = api_client.patch(f'/articles/{article_1.id}/', {'title': title}) 104 | assert response.status_code == 200 105 | assert response.data['id'] == article_1.id 106 | assert response.data['title'] == title 107 | 108 | def test_destroy_article(self, api_client): 109 | article_1 = f.ArticleFactory.create() 110 | f.CommentFactory.create(article=article_1) 111 | f.CommentFactory.create(article=article_1) 112 | 113 | response = api_client.delete(f'/articles/{article_1.id}/') 114 | assert response.status_code == 204 115 | assert Article.objects.get(id=article_1.id).is_deleted is True 116 | assert Article.objects.get(id=article_1.id).comments.count() == 2 117 | 118 | def test_destroy_article__when_with_comments(self, api_client): 119 | article_1 = f.ArticleFactory.create() 120 | f.CommentFactory.create(article=article_1) 121 | f.CommentFactory.create(article=article_1) 122 | 123 | response = api_client.delete(f'/articles/{article_1.id}/', {'with_comments': True}) 124 | assert response.status_code == 204 125 | assert Article.objects.get(id=article_1.id).is_deleted is True 126 | assert Article.objects.get(id=article_1.id).comments.count() == 0 127 | 128 | 129 | class TestCommentsView: 130 | def test_create_comment(self, api_client): 131 | article_1 = f.ArticleFactory.create() 132 | 133 | text = 'test-comment-text' 134 | response = api_client.post('/comments/', {'article_id': article_1.id, 'text': text}) 135 | assert response.status_code == 201 136 | assert response.data['text'] == text 137 | 138 | def test_list_comments(self, api_client): 139 | comment_1 = f.CommentFactory.create() 140 | 141 | response = api_client.get('/comments/') 142 | assert response.status_code == 200 143 | assert len(response.data) == 1 144 | assert response.data[0] == CommentResponseSerializer(comment_1).data 145 | 146 | 147 | class TestCommentView: 148 | def test_retrieve_comment(self, api_client): 149 | comment_1 = f.CommentFactory.create() 150 | 151 | response = api_client.get(f'/comments/{comment_1.id}/') 152 | assert response.status_code == 200 153 | assert response.data == CommentResponseSerializer(comment_1).data 154 | 155 | def test_update_comment(self, api_client): 156 | article_1 = f.ArticleFactory.create() 157 | comment_1 = f.CommentFactory.create() 158 | 159 | text = 'test-comment-text' 160 | response = api_client.put( 161 | f'/comments/{comment_1.id}/', {'article_id': article_1.id, 'text': text} 162 | ) 163 | assert response.status_code == 200 164 | assert response.data['id'] == comment_1.id 165 | assert response.data['text'] == text 166 | 167 | def test_partial_update_comment(self, api_client): 168 | comment_1 = f.CommentFactory.create() 169 | 170 | text = 'test-comment-title' 171 | response = api_client.patch(f'/comments/{comment_1.id}/', {'text': text}) 172 | assert response.status_code == 200 173 | assert response.data['id'] == comment_1.id 174 | assert response.data['text'] == text 175 | 176 | def test_destroy_comment(self, api_client): 177 | comment_1 = f.CommentFactory.create() 178 | 179 | response = api_client.delete(f'/comments/{comment_1.id}/') 180 | assert response.status_code == 204 181 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | from django.urls import path 4 | from rest_framework import serializers 5 | from rest_framework.views import exception_handler as drf_exception_handler 6 | 7 | from rest_batteries.errors_formatter import ErrorsFormatter 8 | from rest_batteries.views import APIView 9 | 10 | from .models import Article 11 | 12 | 13 | class APIViewRaisesValueError(APIView): 14 | def post(self, _request, *_args, **_kwargs): 15 | raise ValueError('Value error raised') 16 | 17 | 18 | class APIViewRaisesDjangoValidationError(APIView): 19 | def post(self, _request, *_args, **_kwargs): 20 | raise ValidationError('Django validation error raised') 21 | 22 | 23 | class APIViewRaisesDjangoFieldValidationError(APIView): 24 | def post(self, _request, *_args, **_kwargs): 25 | article = Article(title='t' * 500, text='text') 26 | article.full_clean() 27 | article.save() 28 | 29 | 30 | class APIViewRaisesObjectFieldValidationError(APIView): 31 | def post(self, _request, *_args, **_kwargs): 32 | class ChildSerializer(serializers.Serializer): 33 | text = serializers.CharField() 34 | 35 | class ParentSerializer(serializers.Serializer): 36 | child = ChildSerializer() 37 | 38 | serializer = ParentSerializer(data={'child': {'text': False}}) 39 | serializer.is_valid(raise_exception=True) 40 | 41 | 42 | class APIViewRaisesArrayFieldValidationError(APIView): 43 | def post(self, _request, *_args, **_kwargs): 44 | class ChildSerializer(serializers.Serializer): 45 | text = serializers.CharField() 46 | 47 | class ParentSerializer(serializers.Serializer): 48 | children = ChildSerializer(many=True) 49 | 50 | serializer = ParentSerializer( 51 | data={'children': [{'text': 'comment-text'}, {'text': False}, {'text': False}]} 52 | ) 53 | serializer.is_valid(raise_exception=True) 54 | 55 | 56 | urlpatterns = [ 57 | path('django-validation-error/', APIViewRaisesDjangoValidationError.as_view()), 58 | path( 59 | 'django-field-validation-error/', 60 | APIViewRaisesDjangoFieldValidationError.as_view(), 61 | ), 62 | path( 63 | 'object-field-validation-error/', 64 | APIViewRaisesObjectFieldValidationError.as_view(), 65 | ), 66 | path( 67 | 'array-field-validation-error/', 68 | APIViewRaisesArrayFieldValidationError.as_view(), 69 | ), 70 | ] 71 | 72 | 73 | @pytest.fixture(autouse=True) 74 | def root_urlconf(settings): 75 | settings.ROOT_URLCONF = __name__ 76 | 77 | 78 | @pytest.fixture 79 | def exception_handler(settings): 80 | settings.REST_FRAMEWORK = { 81 | 'EXCEPTION_HANDLER': 'rest_batteries.exception_handlers.errors_formatter_exception_handler' 82 | } 83 | 84 | 85 | @pytest.fixture 86 | def custom_exception_handler(settings): 87 | class CustomErrorsFormatter(ErrorsFormatter): 88 | def get_field_name(self, field_name): 89 | return 'custom_' + field_name 90 | 91 | def _handler(exc, context): 92 | response = drf_exception_handler(exc, context) 93 | 94 | if response is None: 95 | return response 96 | 97 | formatter = CustomErrorsFormatter(exc) 98 | 99 | response.data = formatter() 100 | 101 | return response 102 | 103 | settings.REST_FRAMEWORK = {'EXCEPTION_HANDLER': _handler} 104 | 105 | 106 | class TestAPIViewErrors: 107 | def test_django_validation_error_transforms_into_drf_validation_error(self, api_client): 108 | response = api_client.post('/django-validation-error/') 109 | assert response.status_code == 400 110 | assert response.data == ['Django validation error raised'] 111 | 112 | def test_django_validation_error_transforms_into_drf_validation_error__when_field_error( 113 | self, api_client 114 | ): 115 | response = api_client.post('/django-field-validation-error/') 116 | assert response.status_code == 400 117 | assert response.data == { 118 | 'title': ['Ensure this value has at most 255 characters (it has 500).'] 119 | } 120 | 121 | 122 | @pytest.mark.usefixtures('exception_handler') 123 | class TestAPIViewErrorsFormat: 124 | def test_validation_error(self, api_client): 125 | response = api_client.post('/django-validation-error/') 126 | assert response.status_code == 400 127 | assert response.data == { 128 | 'errors': [{'code': 'invalid', 'message': 'Django validation error raised'}] 129 | } 130 | 131 | def test_field_validation_error(self, api_client): 132 | response = api_client.post('/django-field-validation-error/') 133 | assert response.status_code == 400 134 | assert response.data == { 135 | 'errors': [ 136 | { 137 | 'code': 'max_length', 138 | 'message': 'Ensure this value has at most 255 characters (it has 500).', 139 | 'field': 'title', 140 | } 141 | ] 142 | } 143 | 144 | def test_object_field_validation_error(self, api_client): 145 | response = api_client.post('/object-field-validation-error/') 146 | assert response.status_code == 400 147 | assert response.data == { 148 | 'errors': [ 149 | { 150 | 'code': 'invalid', 151 | 'message': 'Not a valid string.', 152 | 'field': 'child.text', 153 | } 154 | ] 155 | } 156 | 157 | def test_array_field_validation_error(self, api_client): 158 | response = api_client.post('/array-field-validation-error/') 159 | assert response.status_code == 400 160 | assert response.data == { 161 | 'errors': [ 162 | { 163 | 'code': 'invalid', 164 | 'message': 'Not a valid string.', 165 | 'field': 'children[1].text', 166 | }, 167 | { 168 | 'code': 'invalid', 169 | 'message': 'Not a valid string.', 170 | 'field': 'children[2].text', 171 | }, 172 | ] 173 | } 174 | 175 | 176 | @pytest.mark.usefixtures('custom_exception_handler') 177 | class TestAPIViewCustomErrorsFormat: 178 | def test_object_field_validation_error(self, api_client): 179 | response = api_client.post('/object-field-validation-error/') 180 | assert response.status_code == 400 181 | assert response.data == { 182 | 'errors': [ 183 | { 184 | 'code': 'invalid', 185 | 'message': 'Not a valid string.', 186 | 'field': 'custom_child.custom_text', 187 | } 188 | ] 189 | } 190 | 191 | def test_array_field_validation_error(self, api_client): 192 | response = api_client.post('/array-field-validation-error/') 193 | assert response.status_code == 400 194 | assert response.data == { 195 | 'errors': [ 196 | { 197 | 'code': 'invalid', 198 | 'message': 'Not a valid string.', 199 | 'field': 'custom_children[1].custom_text', 200 | }, 201 | { 202 | 'code': 'invalid', 203 | 'message': 'Not a valid string.', 204 | 'field': 'custom_children[2].custom_text', 205 | }, 206 | ] 207 | } 208 | -------------------------------------------------------------------------------- /tests/test_viewsets.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | from rest_framework import routers 4 | from rest_framework.permissions import AllowAny, IsAuthenticated 5 | 6 | from rest_batteries.mixins import ( 7 | CreateModelMixin, 8 | DestroyModelMixin, 9 | FullUpdateModelMixin, 10 | ListModelMixin, 11 | PartialUpdateModelMixin, 12 | RetrieveModelMixin, 13 | ) 14 | from rest_batteries.viewsets import GenericViewSet, ModelViewSet 15 | 16 | from . import factories as f 17 | from .models import Article, Comment 18 | from .serializers import ( 19 | ArticleDeleteSerializer, 20 | ArticleRequestSerializer, 21 | ArticleResponseSerializer, 22 | CommentRequestSerializer, 23 | CommentResponseSerializer, 24 | UserSerializer, 25 | ) 26 | 27 | User = get_user_model() 28 | 29 | 30 | class ArticleViewSet( 31 | CreateModelMixin, 32 | ListModelMixin, 33 | RetrieveModelMixin, 34 | FullUpdateModelMixin, 35 | DestroyModelMixin, 36 | GenericViewSet, 37 | ): 38 | queryset = Article.objects.all() 39 | action_permission_classes = { 40 | 'list': (AllowAny,), 41 | 'retrieve': AllowAny, 42 | 'create': IsAuthenticated, 43 | 'update': IsAuthenticated, 44 | 'destroy': IsAuthenticated, 45 | } 46 | request_action_serializer_classes = { 47 | 'create': ArticleRequestSerializer, 48 | 'update': ArticleRequestSerializer, 49 | 'destroy': ArticleDeleteSerializer, 50 | } 51 | response_action_serializer_classes = { 52 | 'create': ArticleResponseSerializer, 53 | 'retrieve': ArticleResponseSerializer, 54 | 'list': ArticleResponseSerializer, 55 | 'update': ArticleResponseSerializer, 56 | } 57 | 58 | def perform_destroy(self, instance, serializer=None): 59 | instance.is_deleted = True 60 | instance.save(update_fields=['is_deleted']) 61 | 62 | if serializer is not None and serializer.validated_data.get('with_comments'): 63 | instance.comments.all().delete() 64 | 65 | 66 | class CommentViewSet(ModelViewSet): 67 | queryset = Comment.objects.all() 68 | request_action_serializer_classes = { 69 | 'create': CommentRequestSerializer, 70 | 'update': CommentRequestSerializer, 71 | 'partial_update': CommentRequestSerializer, 72 | } 73 | response_action_serializer_classes = { 74 | 'create': CommentResponseSerializer, 75 | 'retrieve': CommentResponseSerializer, 76 | 'list': CommentResponseSerializer, 77 | 'update': CommentResponseSerializer, 78 | 'partial_update': CommentResponseSerializer, 79 | } 80 | 81 | 82 | class UserViewSet(PartialUpdateModelMixin, GenericViewSet): 83 | queryset = User.objects.all() 84 | request_action_serializer_classes = { 85 | 'update': UserSerializer, 86 | } 87 | response_action_serializer_classes = { 88 | 'update': UserSerializer, 89 | } 90 | 91 | 92 | router = routers.SimpleRouter() 93 | router.register(r'articles', ArticleViewSet, basename='article') 94 | router.register(r'comments', CommentViewSet, basename='comment') 95 | router.register(r'users', UserViewSet, basename='user') 96 | 97 | urlpatterns = router.urls 98 | 99 | 100 | @pytest.fixture(autouse=True) 101 | def root_urlconf(settings): 102 | settings.ROOT_URLCONF = __name__ 103 | 104 | 105 | class TestArticleViewSet: 106 | def test_create_article(self, test_user_api_client): 107 | title = 'test-article-title' 108 | text = 'test-article-text' 109 | response = test_user_api_client.post('/articles/', {'title': title, 'text': text}) 110 | assert response.status_code == 201 111 | assert response.data['title'] == title 112 | assert response.data['text'] == text 113 | 114 | def test_create_article__when_not_authenticated(self, api_client): 115 | title = 'test-article-title' 116 | text = 'test-article-text' 117 | response = api_client.post('/articles/', {'title': title, 'text': text}) 118 | assert response.status_code == 403 119 | 120 | def test_retrieve_article(self, api_client): 121 | article_1 = f.ArticleFactory.create() 122 | 123 | response = api_client.get(f'/articles/{article_1.id}/') 124 | assert response.status_code == 200 125 | assert response.data == ArticleResponseSerializer(article_1).data 126 | 127 | def test_list_articles(self, api_client): 128 | article_1 = f.ArticleFactory.create() 129 | 130 | response = api_client.get('/articles/') 131 | assert response.status_code == 200 132 | assert len(response.data) == 1 133 | assert response.data[0] == ArticleResponseSerializer(article_1).data 134 | 135 | def test_update_article(self, test_user_api_client): 136 | article_1 = f.ArticleFactory.create() 137 | 138 | title = 'test-article-title' 139 | text = 'test-article-text' 140 | response = test_user_api_client.put( 141 | f'/articles/{article_1.id}/', {'title': title, 'text': text} 142 | ) 143 | assert response.status_code == 200 144 | assert response.data['id'] == article_1.id 145 | assert response.data['title'] == title 146 | assert response.data['text'] == text 147 | 148 | def test_update_article__when_not_authenticated(self, api_client): 149 | article_1 = f.ArticleFactory.create() 150 | 151 | title = 'test-article-title' 152 | text = 'test-article-text' 153 | response = api_client.put(f'/articles/{article_1.id}/', {'title': title, 'text': text}) 154 | assert response.status_code == 403 155 | 156 | def test_partial_update_article__method_not_allowed(self, test_user_api_client): 157 | article_1 = f.ArticleFactory.create() 158 | 159 | title = 'test-article-title' 160 | response = test_user_api_client.patch(f'/articles/{article_1.id}/', {'title': title}) 161 | assert response.status_code == 405 162 | 163 | def test_destroy_article(self, test_user_api_client): 164 | article_1 = f.ArticleFactory.create() 165 | f.CommentFactory.create(article=article_1) 166 | f.CommentFactory.create(article=article_1) 167 | 168 | response = test_user_api_client.delete(f'/articles/{article_1.id}/') 169 | assert response.status_code == 204 170 | assert Article.objects.get(id=article_1.id).is_deleted is True 171 | assert Article.objects.get(id=article_1.id).comments.count() == 2 172 | 173 | def test_destroy_article__when_with_comments(self, test_user_api_client): 174 | article_1 = f.ArticleFactory.create() 175 | f.CommentFactory.create(article=article_1) 176 | f.CommentFactory.create(article=article_1) 177 | 178 | response = test_user_api_client.delete( 179 | f'/articles/{article_1.id}/', {'with_comments': True} 180 | ) 181 | assert response.status_code == 204 182 | assert Article.objects.get(id=article_1.id).is_deleted is True 183 | assert Article.objects.get(id=article_1.id).comments.count() == 0 184 | 185 | def test_destroy_article__when_not_authenticated(self, api_client): 186 | article_1 = f.ArticleFactory.create() 187 | f.CommentFactory.create(article=article_1) 188 | f.CommentFactory.create(article=article_1) 189 | 190 | response = api_client.delete(f'/articles/{article_1.id}/') 191 | assert response.status_code == 403 192 | 193 | 194 | class TestCommentViewSet: 195 | def test_create_comment(self, api_client): 196 | article_1 = f.ArticleFactory.create() 197 | 198 | text = 'test-comment-text' 199 | response = api_client.post('/comments/', {'article_id': article_1.id, 'text': text}) 200 | assert response.status_code == 201 201 | assert response.data['text'] == text 202 | 203 | def test_retrieve_comment(self, api_client): 204 | comment_1 = f.CommentFactory.create() 205 | 206 | response = api_client.get(f'/comments/{comment_1.id}/') 207 | assert response.status_code == 200 208 | assert response.data == CommentResponseSerializer(comment_1).data 209 | 210 | def test_list_comments(self, api_client): 211 | comment_1 = f.CommentFactory.create() 212 | 213 | response = api_client.get('/comments/') 214 | assert response.status_code == 200 215 | assert len(response.data) == 1 216 | assert response.data[0] == CommentResponseSerializer(comment_1).data 217 | 218 | def test_update_comment(self, api_client): 219 | article_1 = f.ArticleFactory.create() 220 | comment_1 = f.CommentFactory.create() 221 | 222 | text = 'test-comment-text' 223 | response = api_client.put( 224 | f'/comments/{comment_1.id}/', {'article_id': article_1.id, 'text': text} 225 | ) 226 | assert response.status_code == 200 227 | assert response.data['id'] == comment_1.id 228 | assert response.data['text'] == text 229 | 230 | def test_partial_update_comment(self, api_client): 231 | comment_1 = f.CommentFactory.create() 232 | 233 | text = 'test-comment-title' 234 | response = api_client.patch(f'/comments/{comment_1.id}/', {'text': text}) 235 | assert response.status_code == 200 236 | assert response.data['id'] == comment_1.id 237 | assert response.data['text'] == text 238 | 239 | def test_destroy_comment(self, api_client): 240 | comment_1 = f.CommentFactory.create() 241 | 242 | response = api_client.delete(f'/comments/{comment_1.id}/') 243 | assert response.status_code == 204 244 | 245 | 246 | class TestUserViewSet: 247 | def test_partial_update_user(self, api_client): 248 | user_1 = f.UserFactory.create() 249 | 250 | username = 'test-username' 251 | response = api_client.patch(f'/users/{user_1.id}/', {'username': username}) 252 | assert response.status_code == 200 253 | assert response.data['id'] == user_1.id 254 | assert response.data['username'] == username 255 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from rest_framework.test import APIClient as DRFClient 4 | 5 | 6 | class APIClient(DRFClient): 7 | def login(self, user=None, backend='django.contrib.auth.backends.ModelBackend', **credentials): 8 | if user is None: 9 | return super().login(**credentials) 10 | 11 | with mock.patch('django.contrib.auth.authenticate') as authenticate: 12 | user.backend = backend 13 | authenticate.return_value = user 14 | return super().login(**credentials) 15 | --------------------------------------------------------------------------------