├── .bumpversion.cfg ├── .coveragerc ├── .editorconfig ├── .github └── workflows │ ├── publish.yml │ ├── test.yml │ └── test_full.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .pylintrc ├── CONTRIBUTORS.txt ├── LICENSE ├── Makefile ├── README.md ├── docs ├── Makefile ├── __init__.py ├── conf.py ├── howto.rst ├── images │ ├── admin_apis_list.png │ ├── auto_api_demo_2.png │ └── user_admin_api.png ├── index.rst └── make.bat ├── easy ├── __init__.py ├── admin.py ├── apps.py ├── conf │ ├── __init__.py │ └── settings.py ├── controller │ ├── __init__.py │ ├── auto_api.py │ ├── base.py │ ├── meta.py │ └── meta_conf.py ├── decorators.py ├── domain │ ├── __init__.py │ ├── base.py │ ├── meta.py │ └── orm.py ├── exception.py ├── main.py ├── permissions │ ├── __init__.py │ ├── adminsite.py │ ├── base.py │ └── superuser.py ├── renderer │ ├── __init__.py │ └── json.py ├── response.py ├── services │ ├── __init__.py │ ├── base.py │ ├── crud.py │ └── permission.py ├── testing │ ├── __init__.py │ └── client.py └── utils.py ├── manage.py ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── config ├── __init__.py └── settings.py ├── conftest.py ├── easy_app ├── __init__.py ├── apis.py ├── apps.py ├── auth.py ├── controllers.py ├── domain.py ├── factories.py ├── models.py ├── schema.py ├── services.py └── urls.py ├── test_api_base_response.py ├── test_async_api_permissions.py ├── test_async_auto_crud_apis.py ├── test_async_other_apis.py ├── test_auto_api_creation.py ├── test_doc_decorator.py ├── test_exceptions.py └── test_settings.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.39 3 | commit = True 4 | tag = True 5 | parse = (?P\d+)\.(?P\d+)\.(?P\d+) 6 | serialize = 7 | {major}.{feat}.{patch} 8 | 9 | [bumpversion:file:easy/__init__.py] 10 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | def __repr__ 5 | def __str__ 6 | if self.debug: 7 | if settings.DEBUG 8 | raise AssertionError 9 | raise NotImplementedError 10 | if 0: 11 | if __name__ == .__main__.: 12 | class .*\bProtocol\): 13 | @(abc\.)?abstractmethod 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,css,scss,json,yml,xml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | 25 | [nginx.conf] 26 | indent_style = space 27 | indent_size = 2 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.9 17 | - name: Install Flit 18 | run: pip install flit 19 | - name: Install Dependencies 20 | run: flit install --symlink 21 | - name: Publish 22 | env: 23 | FLIT_USERNAME: ${{ secrets.FLIT_USERNAME }} 24 | FLIT_PASSWORD: ${{ secrets.FLIT_PASSWORD }} 25 | run: flit publish 26 | # - name: Deploy Documentation 27 | # run: make doc-deploy 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [assigned, opened, synchronize, reopened] 7 | 8 | jobs: 9 | test_coverage: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: 3.8 18 | - name: Install Flit 19 | run: pip install flit 20 | - name: Install Dependencies 21 | run: flit install --symlink 22 | - name: Test 23 | run: pytest --cov=easy --cov-report=xml tests 24 | - name: Coverage 25 | uses: codecov/codecov-action@v3 26 | -------------------------------------------------------------------------------- /.github/workflows/test_full.yml: -------------------------------------------------------------------------------- 1 | name: Full Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [assigned, opened, synchronize, reopened] 7 | 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 15 | django-version: ['>3.1', '<3.2', '<3.3', '<4.3'] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install core 24 | run: pip install "Django${{ matrix.django-version }}" pydantic 25 | - name: Install tests 26 | run: pip install pytest pytest-asyncio pytest-django django django-ninja-extra factory-boy django-ninja-jwt 27 | - name: Test 28 | run: pytest 29 | codestyle: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - name: Set up Python 35 | uses: actions/setup-python@v4 36 | with: 37 | python-version: 3.9 38 | - name: Install Flit 39 | run: pip install flit 40 | - name: Install Dependencies 41 | run: flit install --symlink 42 | - name: Black 43 | run: black --check easy tests 44 | - name: isort 45 | run: isort --check easy tests 46 | - name: Flake8 47 | run: flake8 easy tests 48 | - name: mypy 49 | run: mypy easy 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | ### Node template 133 | # Logs 134 | logs 135 | *.log 136 | npm-debug.log* 137 | yarn-debug.log* 138 | yarn-error.log* 139 | 140 | # Runtime data 141 | pids 142 | *.pid 143 | *.seed 144 | *.pid.lock 145 | 146 | # Directory for instrumented libs generated by jscoverage/JSCover 147 | lib-cov 148 | 149 | # Coverage directory used by tools like istanbul 150 | coverage 151 | 152 | # nyc test coverage 153 | .nyc_output 154 | 155 | # Bower dependency directory (https://bower.io/) 156 | bower_components 157 | 158 | # node-waf configuration 159 | .lock-wscript 160 | 161 | # Compiled binary addons (http://nodejs.org/api/addons.html) 162 | build/Release 163 | 164 | # Dependency directories 165 | node_modules/ 166 | jspm_packages/ 167 | 168 | # Typescript v1 declaration files 169 | typings/ 170 | 171 | # Optional npm cache directory 172 | .npm 173 | 174 | # Optional eslint cache 175 | .eslintcache 176 | 177 | # Optional REPL history 178 | .node_repl_history 179 | 180 | # Output of 'npm pack' 181 | *.tgz 182 | 183 | # Yarn Integrity file 184 | .yarn-integrity 185 | 186 | 187 | ### Linux template 188 | *~ 189 | 190 | # temporary files which can be created if a process still has a handle open of a deleted file 191 | .fuse_hidden* 192 | 193 | # KDE directory preferences 194 | .directory 195 | 196 | # Linux trash folder which might appear on any partition or disk 197 | .Trash-* 198 | 199 | # .nfs files are created when an open file is removed but is still being accessed 200 | .nfs* 201 | 202 | 203 | ### VisualStudioCode template 204 | .vscode/* 205 | !.vscode/settings.json 206 | !.vscode/tasks.json 207 | !.vscode/launch.json 208 | !.vscode/extensions.json 209 | *.code-workspace 210 | 211 | # Local History for Visual Studio Code 212 | .history/ 213 | 214 | 215 | # Provided default Pycharm Run/Debug Configurations should be tracked by git 216 | # In case of local modifications made by Pycharm, use update-index command 217 | # for each changed file, like this: 218 | # git update-index --assume-unchanged .idea/easy.iml 219 | ### JetBrains template 220 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 221 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 222 | 223 | # User-specific stuff: 224 | .idea/**/workspace.xml 225 | .idea/**/tasks.xml 226 | .idea/dictionaries 227 | 228 | # Sensitive or high-churn files: 229 | .idea/**/dataSources/ 230 | .idea/**/dataSources.ids 231 | .idea/**/dataSources.xml 232 | .idea/**/dataSources.local.xml 233 | .idea/**/sqlDataSources.xml 234 | .idea/**/dynamic.xml 235 | .idea/**/uiDesigner.xml 236 | 237 | # Gradle: 238 | .idea/**/gradle.xml 239 | .idea/**/libraries 240 | 241 | # CMake 242 | cmake-build-debug/ 243 | 244 | # Mongo Explorer plugin: 245 | .idea/**/mongoSettings.xml 246 | 247 | ## File-based project format: 248 | *.iws 249 | 250 | ## Plugin-specific files: 251 | 252 | # IntelliJ 253 | out/ 254 | 255 | # mpeltonen/sbt-idea plugin 256 | .idea_modules/ 257 | 258 | # JIRA plugin 259 | atlassian-ide-plugin.xml 260 | 261 | # Cursive Clojure plugin 262 | .idea/replstate.xml 263 | 264 | # Crashlytics plugin (for Android Studio and IntelliJ) 265 | com_crashlytics_export_strings.xml 266 | crashlytics.properties 267 | crashlytics-build.properties 268 | fabric.properties 269 | 270 | 271 | 272 | ### Windows template 273 | # Windows thumbnail cache files 274 | Thumbs.db 275 | ehthumbs.db 276 | ehthumbs_vista.db 277 | 278 | # Dump file 279 | *.stackdump 280 | 281 | # Folder config file 282 | Desktop.ini 283 | 284 | # Recycle Bin used on file shares 285 | $RECYCLE.BIN/ 286 | 287 | # Windows Installer files 288 | *.cab 289 | *.msi 290 | *.msm 291 | *.msp 292 | 293 | # Windows shortcuts 294 | *.lnk 295 | 296 | 297 | ### macOS template 298 | # General 299 | *.DS_Store 300 | .AppleDouble 301 | .LSOverride 302 | 303 | # Icon must end with two \r 304 | Icon 305 | 306 | # Thumbnails 307 | ._* 308 | 309 | # Files that might appear in the root of a volume 310 | .DocumentRevisions-V100 311 | .fseventsd 312 | .Spotlight-V100 313 | .TemporaryItems 314 | .Trashes 315 | .VolumeIcon.icns 316 | .com.apple.timemachine.donotpresent 317 | 318 | # Directories potentially created on remote AFP share 319 | .AppleDB 320 | .AppleDesktop 321 | Network Trash Folder 322 | Temporary Items 323 | .apdisk 324 | 325 | 326 | ### SublimeText template 327 | # Cache files for Sublime Text 328 | *.tmlanguage.cache 329 | *.tmPreferences.cache 330 | *.stTheme.cache 331 | 332 | # Workspace files are user-specific 333 | *.sublime-workspace 334 | 335 | # Project files should be checked into the repository, unless a significant 336 | # proportion of contributors will probably not be using Sublime Text 337 | # *.sublime-project 338 | 339 | # SFTP configuration file 340 | sftp-config.json 341 | 342 | # Package control specific files 343 | Package Control.last-run 344 | Package Control.ca-list 345 | Package Control.ca-bundle 346 | Package Control.system-ca-bundle 347 | Package Control.cache/ 348 | Package Control.ca-certs/ 349 | Package Control.merged-ca-bundle 350 | Package Control.user-ca-bundle 351 | oscrypto-ca-bundle.crt 352 | bh_unicode_properties.cache 353 | 354 | # Sublime-github package stores a github token in this file 355 | # https://packagecontrol.io/packages/sublime-github 356 | GitHub.sublime-settings 357 | 358 | 359 | ### Vim template 360 | # Swap 361 | [._]*.s[a-v][a-z] 362 | [._]*.sw[a-p] 363 | [._]s[a-v][a-z] 364 | [._]sw[a-p] 365 | 366 | # Session 367 | Session.vim 368 | 369 | # Temporary 370 | .netrwhist 371 | 372 | # Auto-generated tag files 373 | tags 374 | 375 | ### Project template 376 | easy/media/ 377 | 378 | .pytest_cache/ 379 | .ipython/ 380 | 381 | 382 | .envs/* 383 | !.envs/.local/ 384 | /.idea/inspectionProfiles/profiles_settings.xml 385 | /.idea/inspectionProfiles/Project_Default.xml 386 | /.idea/.gitignore 387 | /.idea/django-easy-api.iml 388 | /.idea/misc.xml 389 | /.idea/modules.xml 390 | /.idea/vcs.xml 391 | /.idea/django-api-framework.iml 392 | /.idea/shelf/ 393 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | combine_as_imports = true 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-merge-conflict 6 | 7 | - repo: https://github.com/asottile/yesqa 8 | rev: v1.3.0 9 | hooks: 10 | - id: yesqa 11 | 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v4.3.0 14 | hooks: 15 | - id: check-yaml 16 | - id: end-of-file-fixer 17 | exclude: '.bumpversion.cfg' 18 | - id: trailing-whitespace 19 | exclude: '.bumpversion.cfg' 20 | - id: requirements-txt-fixer 21 | - id: detect-private-key 22 | - id: detect-aws-credentials 23 | args: [--allow-missing-credentials] 24 | 25 | - repo: https://github.com/psf/black 26 | rev: 23.10.1 27 | hooks: 28 | - id: black 29 | 30 | - repo: https://github.com/PyCQA/isort 31 | rev: 5.10.1 32 | hooks: 33 | - id: isort 34 | 35 | - repo: https://github.com/PyCQA/flake8 36 | rev: 5.0.4 37 | hooks: 38 | - id: flake8 39 | args: ["--config=setup.cfg"] 40 | additional_dependencies: [flake8-isort] 41 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins=pylint_django, pylint_celery 3 | 4 | [FORMAT] 5 | max-line-length=88 6 | 7 | [MESSAGES CONTROL] 8 | disable=missing-docstring,invalid-name 9 | 10 | [DESIGN] 11 | max-parents=13 12 | 13 | [TYPECHECK] 14 | generated-members=REQUEST,acl_users,aq_parent,"[a-zA-Z]+_set{1,2}",save,delete 15 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | freemindcore 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 freemindcore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help docs 2 | .DEFAULT_GOAL := help 3 | 4 | help: 5 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | clean: ## Removing cached python compiled files 8 | find . -name \*pyc | xargs rm -rfv 9 | find . -name \*pyo | xargs rm -fv 10 | find . -name \*~ | xargs rm -fv 11 | find . -name __pycache__ | xargs rm -rfv 12 | 13 | install: ## Install dependencies 14 | pip install flit 15 | make clean 16 | flit install --deps develop --symlink 17 | pre-commit install 18 | 19 | lint: ## Run code linters 20 | autoflake --remove-all-unused-imports --remove-unused-variables --ignore-init-module-imports -r easy tests 21 | black --check easy tests 22 | isort --check easy tests 23 | flake8 24 | mypy easy 25 | 26 | fmt format: ## Run code formatters 27 | autoflake --in-place --remove-all-unused-imports --remove-unused-variables --ignore-init-module-imports -r easy tests 28 | isort easy tests 29 | black easy tests 30 | 31 | test: ## Run tests 32 | pytest -s -vv 33 | 34 | test-cov: ## Run tests with coverage 35 | pytest --cov=easy --cov-report term 36 | 37 | test-cov-full: ## Run tests with coverage term-missing 38 | pytest --cov=easy --cov-report term-missing tests 39 | 40 | doc-deploy: ## Run Deploy Documentation 41 | make clean 42 | mkdocs gh-deploy --force 43 | 44 | bump: 45 | bumpversion patch 46 | 47 | bump-feat: 48 | bumpversion feat 49 | 50 | bump-major: 51 | bumpversion major 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/django-api-framework.svg)](https://badge.fury.io/py/django-api-framework) 2 | [![PyPI version](https://img.shields.io/pypi/v/django-api-framework.svg)](https://pypi.python.org/pypi/django-api-framework) 3 | 4 | ![Test](https://github.com/freemindcore/django-api-framework/actions/workflows/test_full.yml/badge.svg) 5 | [![Codecov](https://img.shields.io/codecov/c/gh/freemindcore/django-api-framework)](https://codecov.io/gh/freemindcore/django-api-framework) 6 | [![Downloads](https://pepy.tech/badge/django-api-framework/month)](https://pepy.tech/project/django-api-framework) 7 | 8 | [![PyPI version](https://img.shields.io/pypi/pyversions/django-api-framework.svg)](https://pypi.python.org/pypi/django-api-framework) 9 | [![PyPI version](https://img.shields.io/pypi/djversions/django-api-framework.svg)](https://pypi.python.org/pypi/django-api-framework) 10 | 11 | 12 | # Easy CRUD API Framework 13 | 14 | - Zero code for a full CRUD API: Automatic and configurable, inspired by [NextJs-Crud](https://github.com/nestjsx/crud). 15 | - Async CRUD API with Django RBAC security protection 16 | - Prefetch and retrieve all m2m fields if configured 17 | - Recursively retrieve all FK/OneToOne fields if configured 18 | - Excluding fields you do not want, or define a list of sensitive fields of your choice 19 | - Pure class based [Django-Ninja](https://github.com/vitalik/django-ninja) APIs: thanks to [Django-Ninja-Extra](https://github.com/eadwinCode/django-ninja-extra) 20 | - Domain/Service/Controller Base Structure: for better code organization. 21 | - Base Permission/Response/Exception Classes: and some handy features to help your API coding easier. 22 | 23 | ``` 24 | Django-Ninja features: 25 | 26 | Easy: Designed to be easy to use and intuitive. 27 | FAST execution: Very high performance thanks to Pydantic and async support. 28 | Fast to code: Type hints and automatic docs lets you focus only on business logic. 29 | Standards-based: Based on the open standards for APIs: OpenAPI (previously known as Swagger) and JSON Schema. 30 | Django friendly: (obviously) has good integration with the Django core and ORM. 31 | 32 | Plus Extra: 33 | Class Based: Design your APIs in a class based fashion. 34 | Permissions: Protect endpoint(s) at ease with defined permissions and authorizations at route level or controller level. 35 | Dependency Injection: Controller classes supports dependency injection with python Injector or django_injector. Giving you the ability to inject API dependable services to APIController class and utilizing them where needed 36 | ``` 37 | 38 | ## Install 39 | `pip install django-api-framework` 40 | 41 | Then add "easy" to your django INSTALLED_APPS: 42 | 43 | ``` 44 | [ 45 | ..., 46 | "easy", 47 | ..., 48 | ] 49 | ``` 50 | 51 | ## Usage 52 | ### Get all your Django app CRUD APIs up and running in < 1 min 53 | In your Django project next to urls.py create new apis.py file: 54 | ``` 55 | from easy.main import EasyAPI 56 | 57 | api_admin_v1 = EasyAPI( 58 | urls_namespace="admin_api", 59 | version="v1.0.0", 60 | ) 61 | 62 | # Automatic Admin API generation 63 | api_admin_v1.auto_create_admin_controllers() 64 | ``` 65 | Go to urls.py and add the following: 66 | ``` 67 | from django.urls import path 68 | from .apis import api_admin_v1 69 | 70 | urlpatterns = [ 71 | path("admin/", admin.site.urls), 72 | path("api_admin/v1/", api_admin_v1.urls), # <---------- ! 73 | ] 74 | ``` 75 | Now go to http://127.0.0.1:8000/api_admin/v1/docs 76 | 77 | You will see the automatic interactive API documentation (provided by Swagger UI). 78 | ![Auto generated APIs List](https://github.com/freemindcore/django-api-framework/blob/fae8209a8d08c55daf75ac3a4619fe62b8ef3af6/docs/images/admin_apis_list.png) 79 | 80 | 81 | ### Boilerplate Django project 82 | A boilerplate Django project for quickly getting started, and get production ready easy-apis with 100% test coverage UP and running: 83 | https://github.com/freemindcore/django-easy-api 84 | 85 | ![Auto generated APIs - Users](https://github.com/freemindcore/django-api-framework/blob/9aa26e92b6fd79f4d9db422ec450fe62d4cd97b9/docs/images/user_admin_api.png) 86 | 87 | 88 | ## Thanks to your help 89 | **_If you find this project useful, please give your stars to support this open-source project. :) Thank you !_** 90 | 91 | 92 | 93 | 94 | 95 | ## Advanced Usage 96 | If `CRUD_API_ENABLED_ALL_APPS` is set to True (default), all app models CRUD apis will be generated. 97 | Apps in the `CRUD_API_EXCLUDE_APPS` list, will always be excluded. 98 | 99 | If `CRUD_API_ENABLED_ALL_APPS` is set to False, only apps in the `CRUD_API_INCLUDE_APPS` list will have CRUD apis generated. 100 | 101 | Also, configuration is possible for each model, via APIMeta class: 102 | - `generate_crud`: whether to create crud api, default to True 103 | - `model_exclude`: fields to be excluded in Schema 104 | - `model_fields`: fields to be included in Schema, default to `"__all__"` 105 | - `model_join`: prefetch and retrieve all m2m fields, default to False 106 | - `model_recursive`: recursively retrieve FK/OneToOne fields, default to False 107 | - `sensitive_fields`: fields to be ignored 108 | 109 | Example: 110 | ``` 111 | class Category(TestBaseModel): 112 | title = models.CharField(max_length=100) 113 | status = models.PositiveSmallIntegerField(default=1, null=True) 114 | 115 | class APIMeta: 116 | generate_crud = True 117 | model_fields = ["field_1", "field_2",] # if not configured default to "__all__" 118 | model_join = True 119 | model_recursive = True 120 | sensitive_fields = ["password", "sensitive_info"] 121 | ``` 122 | 123 | ### Adding CRUD APIs to a specific API Controller 124 | By inheriting `CrudAPIController` class, CRUD APIs can be added to any API controller. 125 | Configuration is available via `APIMeta` inner class in your Controller, same as the above `APIMeta` inner class defined in your Django models. 126 | 127 | Example: 128 | 129 | ``` 130 | @api_controller("event_api", permissions=[AdminSitePermission]) 131 | class EventAPIController(CrudAPIController): 132 | def __init__(self, service: EventService): 133 | super().__init__(service) 134 | 135 | class APIMeta: 136 | model = Event # django model 137 | generate_crud = True # whether to create crud api, default to True 138 | model_fields = ["field_1", "field_2",] # if not configured default to "__all__" 139 | model_join = True 140 | model_recursive = True 141 | sensitive_fields = ["password", "sensitive_info"] 142 | 143 | ``` 144 | Please check tests/demo_app for more examples. 145 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = ./_build 10 | APP = /app 11 | 12 | .PHONY: help livehtml apidocs Makefile 13 | 14 | # Put it first so that "make" without argument is like "make help". 15 | help: 16 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c . 17 | 18 | # Build, watch and serve docs with live reload 19 | livehtml: 20 | sphinx-autobuild -b html --host 0.0.0.0 --port 9000 --watch $(APP) -c . $(SOURCEDIR) $(BUILDDIR)/html 21 | 22 | # Outputs rst files from django application code 23 | apidocs: 24 | sphinx-apidoc -o $(SOURCEDIR)/api $(APP) 25 | 26 | # Catch-all target: route all unknown targets to Sphinx using the new 27 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 28 | %: Makefile 29 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c . 30 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- 1 | # Included so that Django's startproject comment runs against the docs directory 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | 13 | import os 14 | import sys 15 | import django 16 | 17 | if os.getenv("READTHEDOCS", default=False) == "True": 18 | sys.path.insert(0, os.path.abspath("..")) 19 | os.environ["DJANGO_READ_DOT_ENV_FILE"] = "True" 20 | os.environ["USE_DOCKER"] = "no" 21 | else: 22 | sys.path.insert(0, os.path.abspath("/app")) 23 | os.environ["DATABASE_URL"] = "sqlite:///readthedocs.db" 24 | os.environ["CELERY_BROKER_URL"] = os.getenv("REDIS_URL", "redis://redis:6379") 25 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") 26 | django.setup() 27 | 28 | # -- Project information ----------------------------------------------------- 29 | 30 | project = "django-api-framework" 31 | copyright = """2022, Freemindcore""" 32 | author = "Freemindcore@icloud.com" 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | "sphinx.ext.autodoc", 42 | "sphinx.ext.napoleon", 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | # templates_path = ["_templates"] 47 | 48 | # List of patterns, relative to source directory, that match files and 49 | # directories to ignore when looking for source files. 50 | # This pattern also affects html_static_path and html_extra_path. 51 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | 55 | # The theme to use for HTML and HTML Help pages. See the documentation for 56 | # a list of builtin themes. 57 | # 58 | html_theme = "alabaster" 59 | 60 | # Add any paths that contain custom static files (such as style sheets) here, 61 | # relative to this directory. They are copied after the builtin static files, 62 | # so a file named "default.css" will overwrite the builtin "default.css". 63 | # html_static_path = ["_static"] 64 | -------------------------------------------------------------------------------- /docs/howto.rst: -------------------------------------------------------------------------------- 1 | How To - Project Documentation 2 | ====================================================================== 3 | 4 | Get Started 5 | ---------------------------------------------------------------------- 6 | 7 | Documentation can be written as rst files in `easy/docs`. 8 | 9 | 10 | To build and serve docs, use the commands:: 11 | 12 | docker-compose -f local.yml up docs 13 | 14 | 15 | 16 | Changes to files in `docs/_source` will be picked up and reloaded automatically. 17 | 18 | `Sphinx `_ is the tool used to build documentation. 19 | 20 | Docstrings to Documentation 21 | ---------------------------------------------------------------------- 22 | 23 | The sphinx extension `apidoc `_ is used to automatically document code using signatures and docstrings. 24 | 25 | Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon `_ extension for details. 26 | 27 | For an in-use example, see the `page source <_sources/users.rst.txt>`_ for :ref:`users`. 28 | 29 | To compile all docstrings automatically into documentation source files, use the command: 30 | :: 31 | 32 | make apidocs 33 | 34 | 35 | This can be done in the docker container: 36 | :: 37 | 38 | docker run --rm docs make apidocs 39 | -------------------------------------------------------------------------------- /docs/images/admin_apis_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/docs/images/admin_apis_list.png -------------------------------------------------------------------------------- /docs/images/auto_api_demo_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/docs/images/auto_api_demo_2.png -------------------------------------------------------------------------------- /docs/images/user_admin_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/docs/images/user_admin_api.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-rest-api documentation master file, created by 2 | sphinx-quickstart. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-rest-api's documentation! 7 | ====================================================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | howto 14 | pycharm/configuration 15 | users 16 | 17 | 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | 8 | if "%SPHINXBUILD%" == "" ( 9 | set SPHINXBUILD=sphinx-build -c . 10 | ) 11 | set SOURCEDIR=_source 12 | set BUILDDIR=_build 13 | set APP=..\easy 14 | 15 | if "%1" == "" goto help 16 | 17 | %SPHINXBUILD% >NUL 2>NUL 18 | if errorlevel 9009 ( 19 | echo. 20 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 21 | echo.installed, then set the SPHINXBUILD environment variable to point 22 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 23 | echo.may add the Sphinx directory to PATH. 24 | echo. 25 | echo.Install sphinx-autobuild for live serving. 26 | echo.If you don't have Sphinx installed, grab it from 27 | echo.http://sphinx-doc.org/ 28 | exit /b 1 29 | ) 30 | 31 | %SPHINXBUILD% -b %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 32 | goto end 33 | 34 | :livehtml 35 | sphinx-autobuild -b html --open-browser -p 9000 --watch %APP% -c . %SOURCEDIR% %BUILDDIR%/html 36 | GOTO :EOF 37 | 38 | :apidocs 39 | sphinx-apidoc -o %SOURCEDIR%/api %APP% 40 | GOTO :EOF 41 | 42 | :help 43 | %SPHINXBUILD% -b help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 44 | 45 | :end 46 | popd 47 | -------------------------------------------------------------------------------- /easy/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Easy API - Easy and Fast Django REST framework based on Django-ninja-extra""" 2 | 3 | __version__ = "0.2.0" 4 | 5 | from easy.main import EasyAPI 6 | 7 | __all__ = [ 8 | "EasyAPI", 9 | ] 10 | -------------------------------------------------------------------------------- /easy/admin.py: -------------------------------------------------------------------------------- 1 | # from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /easy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = "easy" 6 | verbose_name = "Django-Easy-API" 7 | -------------------------------------------------------------------------------- /easy/conf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/easy/conf/__init__.py -------------------------------------------------------------------------------- /easy/conf/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.conf import settings as django_settings 4 | from django.test.signals import setting_changed 5 | 6 | # AUTO ADMIN API settings 7 | # If not all 8 | CRUD_API_ENABLED_ALL_APPS = getattr(django_settings, "CRUD_API_ENABLED_ALL_APPS", True) 9 | # Only generate for included apps 10 | CRUD_API_INCLUDE_APPS = getattr(django_settings, "CRUD_API_INCLUDE_APPS", []) 11 | # Exclude apps always got excluded 12 | CRUD_API_EXCLUDE_APPS = getattr(django_settings, "CRUD_API_EXCLUDE_APPS", []) 13 | 14 | 15 | def reload_settings(*args: Any, **kwargs: Any) -> None: # pragma: no cover 16 | global settings 17 | 18 | setting, value = kwargs["setting"], kwargs["value"] 19 | globals()[setting] = value 20 | 21 | 22 | setting_changed.connect(reload_settings) # pragma: no cover 23 | -------------------------------------------------------------------------------- /easy/controller/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/easy/controller/__init__.py -------------------------------------------------------------------------------- /easy/controller/auto_api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Type, Union 3 | 4 | from django.db import models 5 | from ninja_extra import ControllerBase, api_controller 6 | from ninja_extra.permissions import BasePermission 7 | 8 | from easy.controller.base import CrudAPIController 9 | from easy.controller.meta_conf import ( 10 | GENERATE_CRUD_ATTR, 11 | MODEL_EXCLUDE_ATTR, 12 | MODEL_FIELDS_ATTR, 13 | MODEL_JOIN_ATTR, 14 | MODEL_RECURSIVE_ATTR, 15 | SENSITIVE_FIELDS_ATTR, 16 | ModelOptions, 17 | ) 18 | from easy.permissions import AdminSitePermission, BaseApiPermission 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def create_api_controller( 24 | model: models.Model, 25 | app_name: str, 26 | permission_class: Type[BasePermission] = BaseApiPermission, 27 | controller_name_prefix: Optional[str] = None, 28 | ) -> Union[Type[ControllerBase], Type]: 29 | """Create APIController class dynamically, with specified permission class""" 30 | model_name = model.__name__ # type:ignore 31 | 32 | model_opts: ModelOptions = ModelOptions.get_model_options( 33 | getattr(model, "APIMeta", None) 34 | ) 35 | 36 | APIMeta = type( 37 | "APIMeta", 38 | (object,), 39 | { 40 | "model": model, 41 | GENERATE_CRUD_ATTR: model_opts.generate_crud, 42 | MODEL_EXCLUDE_ATTR: model_opts.model_exclude, 43 | MODEL_FIELDS_ATTR: model_opts.model_fields, 44 | MODEL_RECURSIVE_ATTR: model_opts.model_recursive, 45 | MODEL_JOIN_ATTR: model_opts.model_join, 46 | SENSITIVE_FIELDS_ATTR: model_opts.model_fields, 47 | }, 48 | ) 49 | 50 | class_name = f"{model_name}{controller_name_prefix}APIController" 51 | 52 | auto_cls = type.__new__( 53 | type, 54 | class_name, 55 | (CrudAPIController,), 56 | { 57 | "APIMeta": APIMeta, 58 | }, 59 | ) 60 | 61 | return api_controller( 62 | f"/{app_name}/{model_name.lower()}", 63 | tags=[f"{model_name} {controller_name_prefix}API"], 64 | permissions=[permission_class], 65 | )(auto_cls) 66 | 67 | 68 | def create_admin_controller( 69 | model: models.Model, app_name: str 70 | ) -> Union[Type[ControllerBase], Type]: 71 | """Create AdminAPI class dynamically, permission class set to AdminSitePermission""" 72 | return create_api_controller( 73 | model=model, 74 | app_name=app_name, 75 | permission_class=AdminSitePermission, 76 | controller_name_prefix="Admin", 77 | ) 78 | -------------------------------------------------------------------------------- /easy/controller/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from easy.controller.meta import CrudAPIMetaclass 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class CrudAPIController(metaclass=CrudAPIMetaclass): 9 | """ 10 | Base APIController for auto creating CRUD APIs, configurable via APIMeta class 11 | APIs auto generated: 12 | Creat 13 | PUT /{id} - Create a single Object 14 | 15 | Read 16 | GET /{id} - Retrieve a single Object 17 | GET / - Retrieve multiple Object, paginated, support filtering 18 | 19 | Update 20 | PATCH /{id} - Update a single Object 21 | 22 | Delete 23 | DELETE /{id} - Delete a single Object 24 | 25 | Configuration: 26 | model: django model 27 | generate_crud: whether to create crud api, default to True 28 | model_exclude: fields to be excluded in Schema, it will ignore model_fields 29 | model_fields: fields to be included in Schema, default to "__all__" 30 | model_join: prefetch and retrieve all m2m fields, default to False 31 | model_recursive: recursively retrieve FK/OneToOne fields, default to False 32 | sensitive_fields: fields to be ignored 33 | 34 | Example: 35 | class APIMeta 36 | model = Event 37 | generate_crud = False 38 | model_exclude = ["field1", "field2"] 39 | model_fields = ["field1", "field2"] 40 | model_join = False 41 | model_recursive = True 42 | sensitive_fields = ["token", "money"] 43 | """ 44 | 45 | ... 46 | -------------------------------------------------------------------------------- /easy/controller/meta.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import re 4 | import uuid 5 | from abc import ABC, ABCMeta 6 | from collections import ChainMap 7 | from typing import Any, List, Match, Optional, Tuple, Type 8 | 9 | from django.http import HttpRequest 10 | from ninja import ModelSchema 11 | from ninja_extra import ControllerBase, http_delete, http_get, http_patch, http_put 12 | from ninja_extra.exceptions import ValidationError 13 | from ninja_extra.pagination import paginate 14 | 15 | from easy.controller.meta_conf import MODEL_FIELDS_ATTR_DEFAULT, ModelOptions 16 | from easy.domain.meta import CrudModel 17 | from easy.response import BaseAPIResponse 18 | from easy.services import BaseService 19 | from easy.utils import copy_func 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class CrudAPI(CrudModel, ABC): 25 | # Never add type note to service, it will cause injection error 26 | def __init__(self, service=None): # type: ignore 27 | # Critical to set __Meta 28 | self.service = service 29 | 30 | _model_opts: ModelOptions = ModelOptions.get_model_options(self.APIMeta) 31 | if self.model and _model_opts: 32 | ModelOptions.set_model_meta(self.model, _model_opts) 33 | 34 | if not service: 35 | self.service = BaseService(model=self.model) 36 | super().__init__(model=self.model) 37 | 38 | 39 | class CrudAPIMetaclass(ABCMeta): 40 | def __new__(mcs, name: str, bases: Tuple[Type[Any], ...], attrs: dict) -> Any: 41 | # Get configs from APIMeta 42 | attrs_meta = attrs.get("APIMeta", None) 43 | model_opts: ModelOptions = ModelOptions.get_model_options(attrs_meta) 44 | 45 | # Get all attrs from parents excluding private ones 46 | def is_private_attrs(attr_name: str) -> Optional[Match[str]]: 47 | return re.match(r"^__[^\d\W]\w*\Z__$", attr_name, re.UNICODE) 48 | 49 | parent_attrs = ChainMap( 50 | *[attrs] 51 | + [ 52 | {k: v for (k, v) in vars(base).items() if not (is_private_attrs(k))} 53 | for base in bases 54 | ] 55 | ) 56 | base_cls_attrs: dict = {} 57 | base_cls_attrs.update(parent_attrs) 58 | 59 | # Define Controller APIs for auto generation 60 | async def get_obj(self, request: HttpRequest, id: int) -> Any: # type: ignore 61 | """ 62 | GET /{id} 63 | Retrieve a single Object 64 | """ 65 | try: 66 | qs = await self.service.get_obj(id) 67 | except Exception as e: # pragma: no cover 68 | logger.error(f"Get Error - {e}", exc_info=True) 69 | return BaseAPIResponse(str(e), message="Get Failed", code=500) 70 | if qs: 71 | return qs 72 | else: 73 | return BaseAPIResponse(message="Not Found", code=404) 74 | 75 | async def del_obj(self, request: HttpRequest, id: int) -> Any: # type: ignore 76 | """ 77 | DELETE /{id} 78 | Delete a single Object 79 | """ 80 | if await self.service.del_obj(id): 81 | return BaseAPIResponse("Deleted.", code=204) 82 | else: 83 | return BaseAPIResponse("Not Found.", code=404) 84 | 85 | @paginate 86 | async def get_objs(self, request: HttpRequest, filters: Optional[str] = None) -> Any: # type: ignore 87 | """ 88 | GET /?filters={filters_dict} 89 | Retrieve multiple Object (optional: django filters) 90 | """ 91 | if filters: 92 | try: 93 | _filters = json.loads(filters) 94 | except Exception as exc: # pragma: no cover 95 | raise ValidationError( 96 | detail=f"Bad filter, please check carefully. {exc}", 97 | code=402, 98 | ) 99 | return await self.service.get_objs(**_filters) 100 | return await self.service.get_objs() 101 | 102 | if model_opts.generate_crud and model_opts.model: 103 | base_cls_attrs.update( 104 | { 105 | "get_obj": http_get("/{id}", summary="Get a single object")( 106 | copy_func(get_obj) # type: ignore 107 | ), 108 | "del_obj": http_delete("/{id}", summary="Delete a single object")( 109 | copy_func(del_obj) # type: ignore 110 | ), 111 | "get_objs": http_get("/", summary="Get multiple objects")( 112 | copy_func(get_objs) # type: ignore 113 | ), 114 | } 115 | ) 116 | 117 | class DataSchema(ModelSchema): 118 | class Config: 119 | model = model_opts.model 120 | model_exclude: List = [] 121 | if model_opts.model_exclude: 122 | model_exclude.extend(model_opts.model_exclude) 123 | # Remove pk(id) from Create/Update Schema 124 | model_exclude.extend([model._meta.pk.name]) # type: ignore 125 | else: 126 | if model_opts.model_fields == MODEL_FIELDS_ATTR_DEFAULT: 127 | # Remove pk(id) from Create/Update Schema 128 | model_exclude.extend([model._meta.pk.name]) # type: ignore 129 | else: 130 | model_fields = ( 131 | model_opts.model_fields 132 | if model_opts.model_fields 133 | else MODEL_FIELDS_ATTR_DEFAULT 134 | ) 135 | 136 | async def add_obj( # type: ignore 137 | self, request: HttpRequest, data: DataSchema 138 | ) -> Any: 139 | """ 140 | PUT / 141 | Create a single Object 142 | """ 143 | obj_id = await self.service.add_obj(**data.dict()) 144 | if obj_id: 145 | return BaseAPIResponse({"id": obj_id}, code=201, message="Created.") 146 | else: 147 | return BaseAPIResponse( 148 | code=204, message="Add failed." 149 | ) # pragma: no cover 150 | 151 | async def patch_obj( # type: ignore 152 | self, request: HttpRequest, id: int, data: DataSchema 153 | ) -> Any: 154 | """ 155 | PATCH /{id} 156 | Update a single object 157 | """ 158 | if await self.service.patch_obj(id=id, payload=data.dict()): 159 | return BaseAPIResponse(message="Updated.") 160 | else: 161 | return BaseAPIResponse(code=400, message="Updated Failed") 162 | 163 | DataSchema.__name__ = ( 164 | f"{model_opts.model.__name__}__AutoSchema({str(uuid.uuid4())[:4]})" 165 | ) 166 | 167 | base_cls_attrs.update( 168 | { 169 | "patch_obj": http_patch("/{id}", summary="Patch a single object")( 170 | copy_func(patch_obj) # type: ignore 171 | ), 172 | "add_obj": http_put("/", summary="Create")( 173 | copy_func(add_obj) # type: ignore 174 | ), 175 | } 176 | ) 177 | 178 | new_cls: Type = super().__new__( 179 | mcs, 180 | name, 181 | ( 182 | ControllerBase, 183 | CrudAPI, 184 | ), 185 | base_cls_attrs, 186 | ) 187 | 188 | if model_opts.model: 189 | ModelOptions.set_model_meta(model_opts.model, model_opts) 190 | setattr(new_cls, "model", model_opts.model) 191 | 192 | return new_cls 193 | -------------------------------------------------------------------------------- /easy/controller/meta_conf.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Type, Union 2 | 3 | from django.db import models 4 | 5 | META_ATTRIBUTE_NAME: str = "_easy_api_meta_" 6 | 7 | GENERATE_CRUD_ATTR: str = "generate_crud" 8 | GENERATE_CRUD_ATTR_DEFAULT = True 9 | 10 | MODEL_EXCLUDE_ATTR: str = "model_exclude" 11 | MODEL_EXCLUDE_ATTR_DEFAULT: List = [] 12 | 13 | MODEL_FIELDS_ATTR: str = "model_fields" 14 | MODEL_FIELDS_ATTR_DEFAULT: str = "__all__" 15 | 16 | MODEL_RECURSIVE_ATTR: str = "model_recursive" 17 | MODEL_RECURSIVE_ATTR_DEFAULT: bool = False 18 | 19 | MODEL_JOIN_ATTR: str = "model_join" 20 | MODEL_JOIN_ATTR_DEFAULT: bool = False 21 | 22 | SENSITIVE_FIELDS_ATTR: str = "sensitive_fields" 23 | SENSITIVE_FIELDS_ATTR_DEFAULT: List = ["password", "token"] 24 | 25 | 26 | class ModelOptions: 27 | def __init__(self, options: Optional[object] = None): 28 | """ 29 | Configuration reader 30 | """ 31 | self.model: Optional[Type[models.Model]] = getattr(options, "model", None) 32 | self.generate_crud: Optional[Union[bool]] = getattr( 33 | options, GENERATE_CRUD_ATTR, GENERATE_CRUD_ATTR_DEFAULT 34 | ) 35 | self.model_exclude: Union[Union[str], List[Any]] = getattr( 36 | options, MODEL_EXCLUDE_ATTR, MODEL_EXCLUDE_ATTR_DEFAULT 37 | ) 38 | self.model_fields: Union[Union[str], List[Any]] = getattr( 39 | options, MODEL_FIELDS_ATTR, MODEL_FIELDS_ATTR_DEFAULT 40 | ) 41 | self.model_join: Optional[Union[bool]] = getattr( 42 | options, MODEL_JOIN_ATTR, MODEL_JOIN_ATTR_DEFAULT 43 | ) 44 | self.model_recursive: Optional[Union[bool]] = getattr( 45 | options, MODEL_RECURSIVE_ATTR, MODEL_RECURSIVE_ATTR_DEFAULT 46 | ) 47 | self.sensitive_fields: Optional[Union[str, List[str]]] = getattr( 48 | options, SENSITIVE_FIELDS_ATTR, list(SENSITIVE_FIELDS_ATTR_DEFAULT) 49 | ) 50 | 51 | @classmethod 52 | def get_model_options(cls, meta: Optional[Any]) -> "ModelOptions": 53 | return ModelOptions(meta) 54 | 55 | @classmethod 56 | def set_model_meta( 57 | cls, model: Type[models.Model], model_opts: "ModelOptions" 58 | ) -> None: 59 | setattr( 60 | model, 61 | META_ATTRIBUTE_NAME, 62 | { 63 | GENERATE_CRUD_ATTR: model_opts.generate_crud, 64 | MODEL_EXCLUDE_ATTR: model_opts.model_exclude, 65 | MODEL_FIELDS_ATTR: model_opts.model_fields, 66 | MODEL_RECURSIVE_ATTR: model_opts.model_recursive, 67 | MODEL_JOIN_ATTR: model_opts.model_join, 68 | SENSITIVE_FIELDS_ATTR: model_opts.sensitive_fields, 69 | }, 70 | ) 71 | 72 | 73 | class ModelMetaConfig(object): 74 | @staticmethod 75 | def get_configuration(obj: models.Model, _name: str, default: Any = None) -> Any: 76 | _value = default if default else None 77 | if hasattr(obj, META_ATTRIBUTE_NAME): 78 | _value = getattr(obj, META_ATTRIBUTE_NAME).get(_name, _value) 79 | return _value 80 | 81 | def get_model_recursive(self, obj: models.Model) -> bool: 82 | model_recursive: bool = self.get_configuration( 83 | obj, MODEL_RECURSIVE_ATTR, default=MODEL_RECURSIVE_ATTR_DEFAULT 84 | ) 85 | return model_recursive 86 | 87 | def get_model_join(self, obj: models.Model) -> bool: 88 | model_join: bool = self.get_configuration( 89 | obj, MODEL_JOIN_ATTR, default=MODEL_JOIN_ATTR_DEFAULT 90 | ) 91 | return model_join 92 | 93 | def get_model_fields_list(self, obj: models.Model) -> List[Any]: 94 | model_fields: List = self.get_configuration( 95 | obj, MODEL_FIELDS_ATTR, default=MODEL_FIELDS_ATTR_DEFAULT 96 | ) 97 | return model_fields 98 | 99 | def get_model_exclude_list(self, obj: models.Model) -> List[Any]: 100 | exclude_list: List = self.get_configuration( 101 | obj, MODEL_EXCLUDE_ATTR, default=MODEL_EXCLUDE_ATTR_DEFAULT 102 | ) 103 | return exclude_list 104 | 105 | def get_sensitive_list(self, obj: models.Model) -> List[Any]: 106 | sensitive_list: List = self.get_configuration( 107 | obj, SENSITIVE_FIELDS_ATTR, default=SENSITIVE_FIELDS_ATTR_DEFAULT 108 | ) 109 | return sensitive_list 110 | 111 | def get_final_excluded_list(self, obj: models.Model) -> List[Any]: 112 | total_excluded_list = [] 113 | sensitive_list: List = list(SENSITIVE_FIELDS_ATTR_DEFAULT) 114 | excluded_list = [] 115 | 116 | sensitive_fields = self.get_sensitive_list(obj) 117 | if sensitive_fields: 118 | sensitive_list.extend(sensitive_fields) 119 | sensitive_list = list(set(sensitive_list)) 120 | 121 | excluded_fields = self.get_model_exclude_list(obj) 122 | if excluded_fields: 123 | excluded_list.extend(excluded_fields) 124 | excluded_list = list(set(excluded_list)) 125 | 126 | total_excluded_list.extend(sensitive_list) 127 | total_excluded_list.extend(excluded_list) 128 | return list(set(total_excluded_list)) 129 | 130 | def show_field(self, obj: models.Model, field_name: str) -> bool: 131 | model_exclude_list = self.get_model_exclude_list(obj) 132 | if model_exclude_list: 133 | if field_name in self.get_final_excluded_list(obj): 134 | return False 135 | else: 136 | if field_name in self.get_final_excluded_list(obj): 137 | return False 138 | model_fields_list = self.get_model_fields_list(obj) 139 | if model_fields_list != MODEL_FIELDS_ATTR_DEFAULT: 140 | if field_name not in model_fields_list: 141 | return False 142 | return True 143 | -------------------------------------------------------------------------------- /easy/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from types import FunctionType 3 | from typing import Any, Callable, Optional 4 | from urllib.parse import urlparse 5 | 6 | from django.conf import settings 7 | from django.contrib.auth import REDIRECT_FIELD_NAME 8 | from django.http import HttpRequest 9 | from django.shortcuts import resolve_url 10 | 11 | 12 | def request_passes_test( 13 | test_func: Callable[[Any], Any], 14 | login_url: Optional[str] = None, 15 | redirect_field_name: str = REDIRECT_FIELD_NAME, 16 | ) -> Callable[[FunctionType], Callable[[HttpRequest, Any], Any]]: 17 | """ 18 | Decorator for views that checks that the request passes the given test, 19 | redirecting to the log-in page if necessary. The test should be a callable 20 | that takes the user object and returns True if the user passes. 21 | """ 22 | 23 | def decorator(view_func: FunctionType) -> Callable[[HttpRequest, Any], Any]: 24 | @wraps(view_func) 25 | def _wrapped_view(request: HttpRequest, *args: Any, **kwargs: Any) -> Any: 26 | if test_func(request): 27 | return view_func(request, *args, **kwargs) 28 | path = request.build_absolute_uri() 29 | resolved_login_url = resolve_url(login_url or settings.LOGIN_URL) 30 | # If the login url is the same scheme and net location then just 31 | # use the path as the "next" url. 32 | login_scheme, login_netloc = urlparse(resolved_login_url)[:2] 33 | current_scheme, current_netloc = urlparse(path)[:2] 34 | if (not login_scheme or login_scheme == current_scheme) and ( 35 | not login_netloc or login_netloc == current_netloc 36 | ): 37 | path = request.get_full_path() 38 | from django.contrib.auth.views import redirect_to_login 39 | 40 | return redirect_to_login(path, resolved_login_url, redirect_field_name) 41 | 42 | return _wrapped_view 43 | 44 | return decorator 45 | 46 | 47 | def docs_permission_required( 48 | view_func: Optional[FunctionType] = None, 49 | redirect_field_name: str = REDIRECT_FIELD_NAME, 50 | login_url: str = "admin:login", 51 | ) -> Any: 52 | """ 53 | Decorator for views that checks that the user is logged in and is a staff 54 | member, redirecting to the login page if necessary. 55 | """ 56 | actual_decorator = request_passes_test( 57 | lambda r: (r.user.is_active and r.user.is_staff), 58 | login_url=login_url, 59 | redirect_field_name=redirect_field_name, 60 | ) 61 | if view_func: 62 | return actual_decorator(view_func) 63 | return actual_decorator # pragma: no cover 64 | -------------------------------------------------------------------------------- /easy/domain/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseDomain 2 | from .meta import CrudModel 3 | 4 | __all__ = [ 5 | "BaseDomain", 6 | "CrudModel", 7 | ] 8 | -------------------------------------------------------------------------------- /easy/domain/base.py: -------------------------------------------------------------------------------- 1 | from easy.domain.orm import DjangoOrmModel 2 | 3 | 4 | class BaseDomain(DjangoOrmModel): 5 | pass 6 | -------------------------------------------------------------------------------- /easy/domain/meta.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Any, Dict, Optional 3 | 4 | 5 | class CrudModel(object): 6 | APIMeta: Dict = {} 7 | 8 | def __init__(self, model: Any): 9 | self.model = model 10 | 11 | @abstractmethod 12 | def crud_add_obj(self, **payload: Dict) -> Any: 13 | raise NotImplementedError 14 | 15 | @abstractmethod 16 | def crud_del_obj(self, pk: int) -> bool: 17 | raise NotImplementedError 18 | 19 | @abstractmethod 20 | def crud_update_obj(self, pk: int, payload: Dict) -> bool: 21 | raise NotImplementedError 22 | 23 | @abstractmethod 24 | def crud_get_obj(self, pk: int) -> Any: 25 | raise NotImplementedError 26 | 27 | @abstractmethod 28 | def crud_get_objs_all(self, maximum: Optional[int] = None, **filters: Any) -> Any: 29 | raise NotImplementedError 30 | 31 | @abstractmethod 32 | def crud_filter(self, **kwargs: Any) -> Any: 33 | raise NotImplementedError 34 | 35 | @abstractmethod 36 | def crud_filter_exclude(self, **kwargs: Any) -> Any: 37 | raise NotImplementedError 38 | -------------------------------------------------------------------------------- /easy/domain/orm.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict, List, Optional, Tuple, Type 3 | 4 | from django.db import models, transaction 5 | from ninja_extra.shortcuts import get_object_or_none 6 | 7 | from easy.controller.meta_conf import ModelMetaConfig 8 | from easy.domain.meta import CrudModel 9 | from easy.exception import BaseAPIException 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class DjangoOrmModel(CrudModel): 15 | def __init__(self, model: Optional[Type[models.Model]] = None) -> None: 16 | self.model = model 17 | if self.model: 18 | config = ModelMetaConfig() 19 | exclude_list = config.get_final_excluded_list(self.model()) 20 | self.m2m_fields_list: List = list( 21 | _field 22 | for _field in self.model._meta.get_fields(include_hidden=True) 23 | if ( 24 | isinstance(_field, models.ManyToManyField) 25 | and ((_field not in exclude_list) if exclude_list else True) 26 | ) 27 | ) 28 | super().__init__(self.model) 29 | 30 | def _separate_payload(self, payload: Dict) -> Tuple[Dict, Dict]: 31 | m2m_fields = {} 32 | local_fields = {} 33 | for _field in payload.keys(): 34 | model_field = self.model._meta.get_field(_field) 35 | if model_field in self.m2m_fields_list: 36 | m2m_fields.update({_field: payload[_field]}) 37 | else: 38 | # Handling FK fields ( append _id in the end) 39 | if self.model._meta.get_field(_field).is_relation: 40 | if not f"{_field}".endswith("_id"): 41 | local_fields.update({f"{_field}_id": payload[_field]}) 42 | else: 43 | local_fields.update({_field: payload[_field]}) 44 | return local_fields, m2m_fields 45 | 46 | @staticmethod 47 | def _crud_set_m2m_obj(obj: models.Model, m2m_fields: Dict) -> None: 48 | if obj and m2m_fields: 49 | for _field, _value in m2m_fields.items(): 50 | if _value and isinstance(_value, List): 51 | m2m_f = getattr(obj, _field) 52 | m2m_f.set(_value) 53 | 54 | # Define BASE CRUD 55 | @transaction.atomic() 56 | def crud_add_obj(self, **payload: Dict) -> Any: 57 | local_f_payload, m2m_f_payload = self._separate_payload(payload) 58 | 59 | try: 60 | # Create obj with local_fields payload 61 | obj = self.model.objects.create(**local_f_payload) 62 | # Save obj with m2m_fields payload 63 | self._crud_set_m2m_obj(obj, m2m_f_payload) 64 | except Exception as e: # pragma: no cover 65 | raise BaseAPIException(f"Create Error - {e}") 66 | if obj: 67 | return obj.id 68 | 69 | def crud_del_obj(self, pk: int) -> bool: 70 | obj = get_object_or_none(self.model, pk=pk) 71 | if obj: 72 | self.model.objects.filter(pk=pk).delete() 73 | return True 74 | else: 75 | return False 76 | 77 | @transaction.atomic() 78 | def crud_update_obj(self, pk: int, payload: Dict) -> bool: 79 | local_fields, m2m_fields = self._separate_payload(payload) 80 | if not self.model.objects.filter(pk=pk).exists(): 81 | return False 82 | try: 83 | obj, _ = self.model.objects.update_or_create(pk=pk, defaults=local_fields) 84 | self._crud_set_m2m_obj(obj, m2m_fields) 85 | except Exception as e: # pragma: no cover 86 | raise BaseAPIException(f"Update Error - {e}") 87 | return bool(obj) 88 | 89 | def crud_get_obj(self, pk: int) -> Any: 90 | if self.m2m_fields_list: 91 | qs = self.model.objects.filter(pk=pk).prefetch_related( 92 | self.m2m_fields_list[0].name 93 | ) 94 | for f in self.m2m_fields_list[1:]: 95 | qs = qs.prefetch_related(f.name) 96 | else: 97 | qs = self.model.objects.filter(pk=pk) 98 | if qs: 99 | return qs.first() 100 | 101 | def crud_get_objs_all(self, maximum: Optional[int] = None, **filters: Any) -> Any: 102 | """ 103 | CRUD: get multiple objects, with django orm filters support 104 | Args: 105 | maximum: {int} 106 | filters: {"field_name__lte", 1} 107 | Returns: qs 108 | 109 | """ 110 | qs = None 111 | if filters: 112 | try: 113 | qs = self.model.objects.filter(**filters) 114 | except Exception as e: # pragma: no cover 115 | logger.error(e) 116 | elif maximum: 117 | qs = self.model.objects.all()[:maximum] 118 | else: 119 | qs = self.model.objects.all() 120 | # If there are 2m2_fields 121 | if self.m2m_fields_list and qs: 122 | qs = qs.prefetch_related(self.m2m_fields_list[0].name) 123 | for f in self.m2m_fields_list[1:]: 124 | qs = qs.prefetch_related(f.name) 125 | return qs 126 | 127 | def crud_filter(self, **kwargs: Any) -> Any: 128 | return self.model.objects.filter(**kwargs) # pragma: no cover 129 | 130 | def crud_filter_exclude(self, **kwargs: Any) -> Any: 131 | return self.model.objects.all().exclude(**kwargs) 132 | 133 | 134 | class DjangoSerializer(ModelMetaConfig): 135 | @staticmethod 136 | def is_model_instance(data: Any) -> bool: 137 | return isinstance(data, models.Model) 138 | 139 | @staticmethod 140 | def is_queryset(data: Any) -> bool: 141 | return isinstance(data, models.query.QuerySet) 142 | 143 | @staticmethod 144 | def is_one_relationship(data: Any) -> bool: 145 | return isinstance(data, models.ForeignKey) or isinstance( 146 | data, models.OneToOneRel 147 | ) 148 | 149 | @staticmethod 150 | def is_many_relationship(data: Any) -> bool: 151 | return ( 152 | isinstance(data, models.ManyToManyRel) 153 | or isinstance(data, models.ManyToManyField) 154 | or isinstance(data, models.ManyToOneRel) 155 | ) 156 | 157 | @staticmethod 158 | def is_paginated(data: Any) -> bool: 159 | return isinstance(data, dict) and isinstance( 160 | data.get("items", None), models.query.QuerySet 161 | ) 162 | 163 | def serialize_model_instance( 164 | self, obj: models.Model, referrers: Any = tuple() 165 | ) -> Dict[Any, Any]: 166 | """Serializes Django model instance to dictionary""" 167 | out = {} 168 | for field in obj._meta.get_fields(): 169 | if self.show_field(obj, field.name): 170 | if self.is_one_relationship(field): 171 | out.update( 172 | self.serialize_foreign_key(obj, field, referrers + (obj,)) 173 | ) 174 | 175 | elif self.is_many_relationship(field): 176 | out.update( 177 | self.serialize_many_relationship(obj, referrers + (obj,)) 178 | ) 179 | 180 | else: 181 | out.update(self.serialize_value_field(obj, field)) 182 | return out 183 | 184 | def serialize_queryset( 185 | self, data: models.query.QuerySet, referrers: Tuple[Any, ...] = tuple() 186 | ) -> List[Dict[Any, Any]]: 187 | """Serializes Django Queryset to dictionary""" 188 | return [self.serialize_model_instance(obj, referrers) for obj in data] 189 | 190 | def serialize_foreign_key( 191 | self, obj: models.Model, field: Any, referrers: Any = tuple() 192 | ) -> Dict[Any, Any]: 193 | """Serializes foreign key field of Django model instance""" 194 | try: 195 | if not hasattr(obj, field.name): 196 | return {field.name: None} # pragma: no cover 197 | related_instance = getattr(obj, field.name) 198 | if related_instance is None: 199 | return {field.name: None} 200 | if related_instance in referrers: 201 | return {} # pragma: no cover 202 | field_value = getattr(related_instance, "pk") 203 | except Exception as exc: # pragma: no cover 204 | logger.error(f"serialize_foreign_key error - {obj}", exc_info=exc) 205 | return {field.name: None} 206 | 207 | if self.get_model_recursive(obj): 208 | return { 209 | field.name: self.serialize_model_instance(related_instance, referrers) 210 | } 211 | return {field.name: field_value} 212 | 213 | def serialize_many_relationship( 214 | self, obj: models.Model, referrers: Any = tuple() 215 | ) -> Dict[Any, Any]: 216 | """ 217 | Serializes many relationship (ManyToMany, ManyToOne) of Django model instance 218 | """ 219 | if not hasattr(obj, "_prefetched_objects_cache"): 220 | return {} 221 | out = {} 222 | try: 223 | for k, v in obj._prefetched_objects_cache.items(): 224 | field_name = k if hasattr(obj, k) else k + "_set" 225 | if v: 226 | if self.get_model_join(obj): 227 | out[field_name] = self.serialize_queryset(v, referrers + (obj,)) 228 | else: 229 | out[field_name] = [o.pk for o in v] 230 | else: 231 | out[field_name] = [] 232 | except Exception as exc: # pragma: no cover 233 | logger.error(f"serialize_many_relationship error - {obj}", exc_info=exc) 234 | return out 235 | 236 | def serialize_value_field(self, obj: models.Model, field: Any) -> Dict[Any, Any]: 237 | """ 238 | Serializes regular 'jsonable' field (Char, Int, etc.) of Django model instance 239 | """ 240 | return {field.name: getattr(obj, field.name)} 241 | 242 | def serialize_data(self, data: Any) -> Any: 243 | out = data 244 | # Queryset 245 | if self.is_queryset(data): 246 | out = self.serialize_queryset(data) 247 | # Model 248 | elif self.is_model_instance(data): 249 | out = self.serialize_model_instance(data) 250 | # Add limit_off pagination support 251 | elif self.is_paginated(data): 252 | out = self.serialize_queryset(data.get("items")) 253 | return out 254 | 255 | 256 | django_serializer = DjangoSerializer() 257 | -------------------------------------------------------------------------------- /easy/exception.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from ninja_extra import status 3 | from ninja_extra.exceptions import APIException 4 | 5 | 6 | class BaseAPIException(APIException): 7 | """ 8 | API Exception 9 | """ 10 | 11 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR 12 | default_detail = _( 13 | "There is an unexpected error, please try again later, if the problem " 14 | "persists, please contact customer support team for further support." 15 | ) 16 | 17 | 18 | class APIAuthException(BaseAPIException): 19 | """ 20 | API Auth Exception 21 | """ 22 | 23 | status_code = status.HTTP_401_UNAUTHORIZED 24 | default_detail = _("Unauthorized") 25 | -------------------------------------------------------------------------------- /easy/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from importlib import import_module 3 | from typing import Any, Callable, Optional, Sequence, Union 4 | 5 | from django.conf import settings 6 | from django.http import HttpRequest, HttpResponse 7 | from django.utils.module_loading import module_has_submodule 8 | from ninja.constants import NOT_SET, NOT_SET_TYPE 9 | from ninja.parser import Parser 10 | from ninja.renderers import BaseRenderer 11 | from ninja.types import TCallable 12 | from ninja_extra import NinjaExtraAPI 13 | 14 | from easy.controller.auto_api import create_admin_controller 15 | from easy.domain.orm import django_serializer 16 | from easy.renderer.json import EasyJSONRenderer 17 | from easy.response import BaseAPIResponse 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class EasyAPI(NinjaExtraAPI): 23 | """ 24 | EasyAPI, extensions: 25 | -Add 2 init params 26 | Easy_extra: bool = True, 27 | Can serialize queryset or model, and support pagination 28 | Easy_output: bool = True, 29 | If True, will be encapsulated in BaseAPIResponse 30 | -renderer, default to EasyJSONRenderer 31 | -Auto generate AdminAPIs, it will read the following settings: 32 | CRUD_API_ENABLED_ALL_APPS 33 | CRUD_API_EXCLUDE_APPS 34 | CRUD_API_INCLUDE_APPS 35 | """ 36 | 37 | def __init__( 38 | self, 39 | *, 40 | title: str = "Easy API", 41 | version: str = "1.0.0", 42 | description: str = "", 43 | openapi_url: Optional[str] = "/openapi.json", 44 | docs_url: Optional[str] = "/docs", 45 | docs_decorator: Optional[Callable[[TCallable], TCallable]] = None, 46 | urls_namespace: Optional[str] = None, 47 | csrf: bool = False, 48 | auth: Union[ 49 | Sequence[Callable[..., Any]], Callable[..., Any], NOT_SET_TYPE, None 50 | ] = NOT_SET, 51 | renderer: Optional[BaseRenderer] = EasyJSONRenderer(), 52 | parser: Optional[Parser] = None, 53 | app_name: str = "ninja", 54 | easy_extra: bool = True, 55 | easy_output: bool = True, 56 | ) -> None: 57 | super(NinjaExtraAPI, self).__init__( 58 | title=title, 59 | version=version, 60 | description=description, 61 | openapi_url=openapi_url, 62 | docs_url=docs_url, 63 | docs_decorator=docs_decorator, 64 | urls_namespace=urls_namespace, 65 | csrf=csrf, 66 | auth=auth, 67 | renderer=renderer, 68 | parser=parser, 69 | ) 70 | self.docs_decorator = docs_decorator 71 | self.app_name = app_name 72 | self.easy_extra = easy_extra 73 | self.easy_output = easy_output 74 | 75 | def auto_create_admin_controllers(self, version: str = None) -> None: 76 | for app_module in self.get_installed_apps(): 77 | # If not all 78 | if not settings.CRUD_API_ENABLED_ALL_APPS: # type:ignore 79 | # Only generate for this included apps 80 | if settings.CRUD_API_INCLUDE_APPS is not None: # type:ignore 81 | if ( 82 | app_module.name 83 | not in settings.CRUD_API_INCLUDE_APPS # type:ignore 84 | ): 85 | continue 86 | 87 | # Exclude list 88 | if app_module.name in settings.CRUD_API_EXCLUDE_APPS: # type:ignore 89 | continue 90 | 91 | try: 92 | app_module_ = import_module(app_module.name) 93 | final = [] 94 | if module_has_submodule(app_module_, "models"): 95 | # Auto generate AdminAPI 96 | for model in app_module.get_models(): 97 | final.append( 98 | create_admin_controller( 99 | model, app_module.name.split(".")[1] 100 | ) 101 | ) 102 | self.register_controllers(*final) 103 | except ImportError as ex: # pragma: no cover 104 | raise ex 105 | 106 | @staticmethod 107 | def get_installed_apps() -> list: 108 | from django.apps import apps 109 | 110 | return [ 111 | v 112 | for k, v in apps.app_configs.items() 113 | if not v.name.startswith("django.") and (not v.name == "easy.api") 114 | ] 115 | 116 | def create_response( 117 | self, 118 | request: HttpRequest, 119 | data: Any, 120 | *, 121 | status: int = None, 122 | temporal_response: HttpResponse = None, 123 | ) -> HttpResponse: 124 | if self.easy_extra: 125 | try: 126 | data = django_serializer.serialize_data(data) 127 | except Exception as e: # pragma: no cover 128 | logger.error(f"Creat Response Error - {e}", exc_info=True) 129 | return BaseAPIResponse(str(e), code=500) 130 | 131 | if self.easy_output: 132 | if temporal_response: 133 | status = temporal_response.status_code 134 | assert status 135 | 136 | _temp = BaseAPIResponse( 137 | data, status=status, content_type=self.get_content_type() 138 | ) 139 | 140 | if temporal_response: 141 | response = temporal_response 142 | response.content = _temp.content 143 | else: 144 | response = _temp 145 | 146 | else: 147 | response = super().create_response( 148 | request, 149 | data, 150 | status=status, 151 | temporal_response=temporal_response, 152 | ) 153 | return response 154 | 155 | def create_temporal_response(self, request: HttpRequest) -> HttpResponse: 156 | if self.easy_output: 157 | return BaseAPIResponse("", content_type=self.get_content_type()) 158 | else: 159 | return super().create_temporal_response(request) 160 | -------------------------------------------------------------------------------- /easy/permissions/__init__.py: -------------------------------------------------------------------------------- 1 | from ninja_extra.permissions import ( 2 | AllowAny, 3 | IsAdminUser, 4 | IsAuthenticated, 5 | IsAuthenticatedOrReadOnly, 6 | ) 7 | 8 | from .adminsite import AdminSitePermission 9 | from .base import BaseApiPermission 10 | from .superuser import IsSuperUser 11 | 12 | __all__ = [ 13 | "AllowAny", 14 | "AdminSitePermission", 15 | "BaseApiPermission", 16 | "IsAuthenticated", 17 | "IsAuthenticatedOrReadOnly", 18 | "IsAdminUser", 19 | "IsSuperUser", 20 | ] 21 | -------------------------------------------------------------------------------- /easy/permissions/adminsite.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, cast 2 | 3 | from django.db import models 4 | from django.http import HttpRequest 5 | from ninja_extra.permissions import IsAdminUser 6 | 7 | if TYPE_CHECKING: 8 | from ninja_extra.controllers.base import ControllerBase # pragma: no cover 9 | 10 | 11 | class AdminSitePermission(IsAdminUser): 12 | """ 13 | Only staff users with the right permission can modify objects. 14 | """ 15 | 16 | def has_permission( 17 | self, request: HttpRequest, controller: "ControllerBase" 18 | ) -> bool: 19 | """ 20 | Return `True` if permission is granted, `False` otherwise. 21 | """ 22 | user = request.user or request.auth # type: ignore 23 | has_perm: bool = False 24 | model: models.Model = cast(models.Model, getattr(controller, "model", None)) 25 | if model: 26 | app: str = model._meta.app_label 27 | model_name = model._meta.model_name 28 | if request.method in ("GET", "OPTIONS"): 29 | has_perm = user.has_perm(f"{app}.view_{model_name}") # type: ignore 30 | 31 | elif request.method in ("PUT", "POST"): 32 | has_perm = user.has_perm(f"{app}.add_{model_name}") # type: ignore 33 | 34 | elif request.method in ("PUT", "PATCH", "POST"): 35 | has_perm = user.has_perm(f"{app}.change_{model_name}") # type: ignore 36 | 37 | elif request.method in ("DELETE",): 38 | has_perm = user.has_perm(f"{app}.delete_{model_name}") # type: ignore 39 | 40 | if user.is_superuser: # type: ignore 41 | has_perm = True 42 | 43 | return bool( 44 | user 45 | and user.is_authenticated 46 | and user.is_active 47 | and has_perm 48 | and super().has_permission(request, controller) 49 | ) 50 | -------------------------------------------------------------------------------- /easy/permissions/base.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | from django.http import HttpRequest 4 | from ninja_extra import permissions 5 | 6 | if TYPE_CHECKING: 7 | from ninja_extra.controllers.base import ControllerBase # pragma: no cover 8 | 9 | 10 | class BaseApiPermission(permissions.BasePermission): 11 | """ 12 | Base permission class that all Permission Class should inherit from. 13 | This will call service.check_permission for extra check. 14 | """ 15 | 16 | def has_permission( 17 | self, request: HttpRequest, controller: "ControllerBase" 18 | ) -> bool: 19 | """ 20 | Return `True` if permission is granted, `False` otherwise. 21 | """ 22 | has_perm: bool = True 23 | if hasattr(controller, "service"): 24 | has_perm = controller.service.check_permission(request, controller) 25 | return has_perm 26 | 27 | def has_object_permission( 28 | self, request: HttpRequest, controller: "ControllerBase", obj: Any 29 | ) -> bool: 30 | """ 31 | Return `True` if permission is granted, `False` otherwise. 32 | """ 33 | has_perm: bool = True 34 | if hasattr(controller, "service"): 35 | has_perm = controller.service.check_object_permission( 36 | request, controller, obj 37 | ) 38 | return has_perm 39 | -------------------------------------------------------------------------------- /easy/permissions/superuser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django.http import HttpRequest 4 | 5 | from .base import BaseApiPermission 6 | 7 | if TYPE_CHECKING: 8 | from ninja_extra.controllers.base import ControllerBase # pragma: no cover 9 | 10 | 11 | class IsSuperUser(BaseApiPermission): 12 | """ 13 | Allows access only to super user. 14 | """ 15 | 16 | def has_permission( 17 | self, request: HttpRequest, controller: "ControllerBase" 18 | ) -> bool: 19 | """ 20 | Return `True` if permission is granted, `False` otherwise. 21 | """ 22 | user = request.user or request.auth # type: ignore 23 | return bool(user and user.is_authenticated and user.is_superuser) # type: ignore 24 | -------------------------------------------------------------------------------- /easy/renderer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/easy/renderer/__init__.py -------------------------------------------------------------------------------- /easy/renderer/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Type 3 | 4 | from django.db.models.fields.files import FieldFile, ImageFieldFile 5 | from ninja.renderers import JSONRenderer 6 | from ninja.responses import NinjaJSONEncoder 7 | 8 | 9 | class EasyJSONEncoder(NinjaJSONEncoder): 10 | def default(self, o: Any) -> Any: 11 | if isinstance(o, ImageFieldFile) or isinstance(o, FieldFile): 12 | try: 13 | return o.path 14 | except NotImplementedError: 15 | return o.url or o.name # pragma: no cover 16 | except ValueError: 17 | return "" 18 | 19 | return super().default(o) 20 | 21 | 22 | class EasyJSONRenderer(JSONRenderer): 23 | encoder_class: Type[json.JSONEncoder] = EasyJSONEncoder 24 | -------------------------------------------------------------------------------- /easy/response.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict, List, Union 3 | 4 | from django.db.models import QuerySet 5 | from django.http.response import JsonResponse 6 | 7 | from easy.renderer.json import EasyJSONEncoder 8 | 9 | CODE_SUCCESS = 0 10 | SUCCESS_MESSAGE = "success" 11 | 12 | 13 | class BaseAPIResponse(JsonResponse): 14 | """ 15 | Base for all API responses 16 | """ 17 | 18 | def __init__( 19 | self, 20 | data: Union[Dict, str, bool, List[Any], QuerySet] = None, 21 | code: int = None, 22 | message: str = None, 23 | **kwargs: Any 24 | ): 25 | if code: 26 | message = message or str(code) 27 | else: 28 | message = message or SUCCESS_MESSAGE 29 | code = CODE_SUCCESS 30 | 31 | _data: Union[Dict, str] = { 32 | "code": code, 33 | "message": message, 34 | "data": data if data is not None else {}, 35 | } 36 | 37 | super().__init__(data=_data, encoder=EasyJSONEncoder, **kwargs) 38 | 39 | @property 40 | def json_data(self) -> Any: 41 | """ 42 | Get json data 43 | """ 44 | return json.loads(self.content) 45 | 46 | def update_content(self, data: Dict) -> None: 47 | """ 48 | Update content with new data 49 | """ 50 | self.content = json.dumps(data) 51 | -------------------------------------------------------------------------------- /easy/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseService 2 | 3 | __all__ = ["BaseService"] 4 | -------------------------------------------------------------------------------- /easy/services/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Type 3 | 4 | from django.db import models 5 | 6 | from easy.services.crud import CrudService 7 | from easy.services.permission import PermissionService 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class BaseService(CrudService, PermissionService): 13 | def __init__(self, model: Optional[Type[models.Model]] = None): 14 | self.model = model 15 | super().__init__(model=self.model) 16 | -------------------------------------------------------------------------------- /easy/services/crud.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Optional, Type 3 | 4 | from asgiref.sync import sync_to_async 5 | from django.db import models 6 | 7 | from easy.domain.orm import DjangoOrmModel 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class CrudService(DjangoOrmModel): 13 | def __init__(self, model: Optional[Type[models.Model]] = None): 14 | super().__init__(model) 15 | self.model = model 16 | 17 | async def get_obj(self, id: int) -> Any: 18 | return await sync_to_async(self.crud_get_obj)(id) 19 | 20 | async def get_objs(self, **filters: Any) -> Any: 21 | return await sync_to_async(self.crud_get_objs_all)(**filters) 22 | 23 | async def patch_obj(self, id: int, payload: Any) -> Any: 24 | return await sync_to_async(self.crud_update_obj)(id, payload) 25 | 26 | async def del_obj(self, id: int) -> Any: 27 | return await sync_to_async(self.crud_del_obj)(id) 28 | 29 | async def add_obj(self, **payload: Any) -> Any: 30 | return await sync_to_async(self.crud_add_obj)(**payload) 31 | 32 | async def filter_objs(self, **payload: Any) -> Any: 33 | return await sync_to_async(self.crud_filter)(**payload) # pragma: no cover 34 | 35 | async def filter_exclude_objs(self, **payload: Any) -> Any: 36 | return await sync_to_async(self.crud_filter_exclude)(**payload) 37 | 38 | # async def bulk_create_objs(self): 39 | # ... 40 | # 41 | # async def recover_obj(self): 42 | # ... 43 | -------------------------------------------------------------------------------- /easy/services/permission.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING, Any 3 | 4 | from django.http import HttpRequest 5 | 6 | if TYPE_CHECKING: 7 | from ninja_extra.controllers.base import ControllerBase # pragma: no cover 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class PermissionService(object): 14 | """Base permission service for extra customization needs""" 15 | 16 | def check_permission( 17 | self, request: HttpRequest, controller: "ControllerBase" 18 | ) -> bool: 19 | """ 20 | Return `True` if permission is granted, `False` otherwise. 21 | """ 22 | return True 23 | 24 | def check_object_permission( 25 | self, request: HttpRequest, controller: "ControllerBase", obj: Any 26 | ) -> bool: 27 | """ 28 | Return `True` if permission is granted, `False` otherwise. 29 | """ 30 | return True 31 | -------------------------------------------------------------------------------- /easy/testing/__init__.py: -------------------------------------------------------------------------------- 1 | from easy.testing.client import EasyTestClient 2 | 3 | __all__ = ["EasyTestClient"] 4 | -------------------------------------------------------------------------------- /easy/testing/client.py: -------------------------------------------------------------------------------- 1 | from json import dumps as json_dumps 2 | from typing import Any, Callable, Dict, List, Optional, Sequence, Type, Union, cast 3 | from unittest.mock import Mock 4 | from urllib.parse import urlencode 5 | 6 | from ninja import NinjaAPI, Router 7 | from ninja.constants import NOT_SET, NOT_SET_TYPE 8 | from ninja.testing.client import NinjaClientBase, NinjaResponse 9 | from ninja_extra import ControllerBase 10 | 11 | from easy.main import EasyAPI 12 | 13 | 14 | class EasyAPIClientBase(NinjaClientBase): 15 | def __init__( 16 | self, 17 | router_or_app: Union[EasyAPI, Router, Type[ControllerBase]], 18 | auth: Union[ 19 | Sequence[Callable[..., Any]], Callable[..., Any], NOT_SET_TYPE, None 20 | ] = NOT_SET, 21 | api_cls: Union[Type[EasyAPI], Type] = EasyAPI, 22 | ) -> None: 23 | if hasattr(router_or_app, "get_api_controller"): 24 | api = api_cls(auth=auth) 25 | controller_ninja_api_controller = router_or_app.get_api_controller() 26 | assert controller_ninja_api_controller 27 | controller_ninja_api_controller.set_api_instance(api) 28 | self._urls_cache = list(controller_ninja_api_controller.urls_paths("")) 29 | router_or_app = api 30 | super().__init__(cast(Union[NinjaAPI, Router], router_or_app)) 31 | 32 | def request( 33 | self, 34 | method: str, 35 | path: str, 36 | data: Optional[Dict] = None, 37 | json: Any = None, 38 | **request_params: Any, 39 | ) -> "NinjaResponse": 40 | if json is not None: 41 | request_params["body"] = json_dumps(json) 42 | if "query" in request_params and isinstance(request_params["query"], dict): 43 | query = request_params.pop("query") 44 | url_encode = urlencode(query) 45 | path = f"{path}?{url_encode}" 46 | func, request, kwargs = self._resolve(method, path, data, request_params) # type: ignore 47 | return self._call(func, request, kwargs) # type: ignore 48 | 49 | @property 50 | def urls(self) -> List: 51 | if not hasattr(self, "_urls_cache"): 52 | self._urls_cache: List 53 | if isinstance(self.router_or_app, EasyAPI): 54 | self._urls_cache = self.router_or_app.urls[0] # pragma: no cover 55 | else: 56 | api = EasyAPI() 57 | self.router_or_app.set_api_instance(api) # type: ignore 58 | self._urls_cache = list(self.router_or_app.urls_paths("")) # type: ignore 59 | return self._urls_cache 60 | 61 | 62 | class EasyTestClient(EasyAPIClientBase): 63 | async def _call(self, func: Callable, request: Mock, kwargs: Dict) -> NinjaResponse: 64 | return NinjaResponse(await func(request, **kwargs)) 65 | -------------------------------------------------------------------------------- /easy/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import types 3 | 4 | 5 | def copy_func(f): # type: ignore 6 | n = types.FunctionType( 7 | f.__code__, 8 | f.__globals__, 9 | name=f.__name__, 10 | argdefs=f.__defaults__, 11 | closure=f.__closure__, 12 | ) 13 | n = functools.update_wrapper(n, f) 14 | n.__kwdefaults__ = f.__kwdefaults__ 15 | return n 16 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.config.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError: 11 | # The above import may fail for some other reason. Ensure that the 12 | # issue is really that Django is missing to avoid masking other 13 | # exceptions on Python 2. 14 | try: 15 | import django # noqa 16 | except ImportError: 17 | raise ImportError( 18 | "Couldn't import Django. Are you sure it's installed and " 19 | "available on your PYTHONPATH environment variable? Did you " 20 | "forget to activate a virtual environment?" 21 | ) 22 | 23 | raise 24 | 25 | # This allows easy placement of apps within the interior 26 | # tests directory. 27 | current_path = Path(__file__).parent.resolve() 28 | sys.path.append(str(current_path) / "tests") 29 | 30 | execute_from_command_line(sys.argv) 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "easy" 7 | dist-name = "django-api-framework" 8 | author = "Freemind Core" 9 | author-email = "freemindcore@icloud.com" 10 | home-page = "https://github.com/freemindcore/django-api-framework" 11 | classifiers = [ 12 | "Intended Audience :: Information Technology", 13 | "Intended Audience :: System Administrators", 14 | "Operating System :: OS Independent", 15 | "Topic :: Internet", 16 | "Topic :: Software Development :: Libraries :: Application Frameworks", 17 | "Topic :: Software Development :: Libraries :: Python Modules", 18 | "Topic :: Software Development :: Libraries", 19 | "Topic :: Software Development", 20 | "Typing :: Typed", 21 | "Environment :: Web Environment", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.6", 26 | "Programming Language :: Python :: 3.7", 27 | "Programming Language :: Python :: 3.8", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Framework :: Django", 33 | "Framework :: Django :: 3.1", 34 | "Framework :: Django :: 3.2", 35 | "Framework :: Django :: 4.0", 36 | "Framework :: Django :: 4.1", 37 | "Framework :: Django :: 4.2", 38 | "Framework :: AsyncIO", 39 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 40 | "Topic :: Internet :: WWW/HTTP", 41 | ] 42 | 43 | requires = [ 44 | "Django >= 3.1", 45 | "django-ninja-extra >= 0.16.0", 46 | ] 47 | description-file = "README.md" 48 | requires-python = ">=3.6" 49 | 50 | 51 | [tool.flit.metadata.urls] 52 | Documentation = "https://github.com/freemindcore/django-api-framework" 53 | 54 | [tool.flit.metadata.requires-extra] 55 | test = [ 56 | "pytest", 57 | "pytest-cov", 58 | "pytest-django", 59 | "pytest-asyncio", 60 | "black==23.10.1", 61 | "mypy==1.6.1", 62 | "isort", 63 | "injector>= 0.19.0", 64 | "flake8", 65 | "django-stubs", 66 | "factory-boy==3.2.1", 67 | "django_coverage_plugin", 68 | "django-ninja-extra >= 0.20.0", 69 | "django-ninja-jwt>=5.2.9", 70 | "Django >= 3.1", 71 | ] 72 | dev = [ 73 | "autoflake", 74 | "pre_commit", 75 | "bumpversion==0.6.0", 76 | ] 77 | doc = [ 78 | "mkdocs >=1.1.2,<2.0.0", 79 | "mkdocs-material >=7.1.9,<8.0.0", 80 | "mdx-include >=1.4.1,<2.0.0", 81 | "mkdocs-markdownextradata-plugin >=0.1.7,<0.3.0", 82 | "markdown-include" 83 | ] 84 | 85 | [tool.pytest.ini_options] 86 | DJANGO_SETTINGS_MODULE = "tests.config.settings" 87 | asyncio_mode = "auto" 88 | addopts = "--nomigrations" 89 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = C,E,F,W,B,B950 4 | # it's not a bug that we aren't using all of hacking, ignore: 5 | # F812: list comprehension redefines ... 6 | # H101: Use TODO(NAME) 7 | # H202: assertRaises Exception too broad 8 | # H233: Python 3.x incompatible use of print operator 9 | # H301: one import per line 10 | # H306: imports not in alphabetical order (time, os) 11 | # H401: docstring should not start with a space 12 | # H403: multi line docstrings should end on a new line 13 | # H404: multi line docstring should start without a leading new line 14 | # H405: multi line docstring summary not separated with an empty line 15 | # H501: Do not use self.__dict__ for string formatting 16 | # W503: line break before binary operator 17 | # E231: missing whitespace after ',' 18 | ignore = D203,F812,H101,H202,H233,H301,H306,H401,H403,H404,H405,H501,W503,E231 19 | # C901: is too complex 20 | extend-ignore = 21 | E203, 22 | E501, 23 | C901 24 | per-file-ignores = 25 | # F401: imported but unused 26 | # F403: import * used; unable to detect undefined names 27 | __init__.py: F401,F403 28 | # F405: XXX may be undefined, or defined from star imports 29 | api_ci.py: F405 30 | api_dev.py: F405 31 | api_stag.py: F405 32 | api_test.py: F405 33 | prod.py: F405 34 | local.py: F405, E121 35 | local-default.py: F405 36 | 37 | exclude = 38 | .tox, 39 | */migrations/*, 40 | */static/CACHE/*, 41 | docs, 42 | node_modules, 43 | venv, 44 | scripts/*, 45 | misc/local/*, 46 | # No need to traverse our git directory 47 | .git, 48 | # There's no value in checking cache directories 49 | __pycache__, 50 | # The conf file is mostly autogenerated, ignore it 51 | docs/source/conf.py, 52 | # The old directory contains Flake8 2.0 53 | old, 54 | # This contains our built documentation 55 | build, 56 | # This contains builds of flake8 that we don't want to check 57 | dist 58 | max-complexity = 10 59 | 60 | [pycodestyle] 61 | max-line-length = 88 62 | exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv 63 | 64 | [mypy] 65 | python_version = 3.11 66 | ignore_missing_imports = True 67 | warn_unused_configs = True 68 | plugins = mypy_django_plugin.main 69 | 70 | show_column_numbers = True 71 | 72 | follow_imports = normal 73 | 74 | # be strict 75 | disallow_untyped_calls = True 76 | warn_return_any = True 77 | strict_optional = True 78 | warn_no_return = True 79 | warn_redundant_casts = True 80 | warn_unused_ignores = True 81 | 82 | disallow_untyped_defs = True 83 | check_untyped_defs = True 84 | no_implicit_reexport = True 85 | no_implicit_optional = False 86 | 87 | [mypy.plugins.django-stubs] 88 | django_settings_module = "tests.easy_app" 89 | 90 | [mypy-*.migrations.*] 91 | # Django migrations should not produce any errors: 92 | ignore_errors = True 93 | 94 | [mypy-tests.*] 95 | ignore_errors = True 96 | 97 | [coverage:run] 98 | include = easy/* 99 | omit = *migrations*, *tests* 100 | plugins = 101 | django_coverage_plugin 102 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/tests/__init__.py -------------------------------------------------------------------------------- /tests/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/tests/config/__init__.py -------------------------------------------------------------------------------- /tests/config/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | With these settings, tests run faster. 3 | """ 4 | 5 | # Admin API auto-generation settings 6 | CRUD_API_ENABLED_ALL_APPS = True 7 | CRUD_API_INCLUDE_APPS = [] 8 | CRUD_API_EXCLUDE_APPS = [] 9 | 10 | # Django Settings 11 | INSTALLED_APPS = ( 12 | "django.contrib.admin", 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.sites", 17 | "django.contrib.staticfiles", 18 | "ninja_extra", 19 | "tests.easy_app", 20 | "easy", 21 | ) 22 | 23 | MIDDLEWARE = ( 24 | "django.middleware.security.SecurityMiddleware", 25 | "django.contrib.sessions.middleware.SessionMiddleware", 26 | "django.middleware.common.CommonMiddleware", 27 | "django.middleware.csrf.CsrfViewMiddleware", 28 | "django.contrib.auth.middleware.AuthenticationMiddleware", 29 | "django.contrib.messages.middleware.MessageMiddleware", 30 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 31 | ) 32 | 33 | USE_I18N = True 34 | USE_TZ = True 35 | LANGUAGE_CODE = "en-us" 36 | 37 | STATIC_URL = "/static/" 38 | ROOT_URLCONF = "tests.easy_app.urls" 39 | 40 | AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) 41 | 42 | # DB 43 | DATABASES = { 44 | "default": { 45 | "ENGINE": "django.db.backends.sqlite3", 46 | "NAME": ":memory:", 47 | # "TEST": { 48 | # # this gets you in-memory sqlite for faster testing 49 | # "ENGINE": "django.db.backends.sqlite3", 50 | # }, 51 | } 52 | } 53 | 54 | # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner 55 | TEST_RUNNER = "django.test.runner.DiscoverRunner" 56 | 57 | # CACHES 58 | # ------------------------------------------------------------------------------ 59 | # https://docs.djangoproject.com/en/dev/ref/settings/#caches 60 | CACHES = { 61 | "default": { 62 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 63 | "LOCATION": "", 64 | } 65 | } 66 | 67 | # PASSWORDS 68 | # ------------------------------------------------------------------------------ 69 | # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers 70 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 71 | 72 | # TEMPLATES 73 | # ------------------------------------------------------------------------------ 74 | TEMPLATES = [ 75 | { 76 | "BACKEND": "django.template.backends.django.DjangoTemplates", 77 | "DIRS": [], 78 | "APP_DIRS": True, 79 | "OPTIONS": { 80 | "context_processors": [ 81 | "django.template.context_processors.debug", 82 | "django.template.context_processors.request", 83 | "django.contrib.auth.context_processors.auth", 84 | "django.contrib.messages.context_processors.messages", 85 | ], 86 | }, 87 | }, 88 | ] 89 | 90 | # EMAIL 91 | # ------------------------------------------------------------------------------ 92 | # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend 93 | EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" 94 | 95 | 96 | # Security 97 | ALLOWED_HOSTS = ["*"] 98 | SECRET_KEY = "not very secret in tests" 99 | 100 | # Debug 101 | DEBUG_PROPAGATE_EXCEPTIONS = True 102 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Callable, Type, Union 3 | 4 | import pytest 5 | from django.contrib.auth import get_user_model 6 | from ninja_extra import ControllerBase, Router 7 | 8 | from easy import EasyAPI 9 | from easy.testing import EasyTestClient 10 | 11 | from .easy_app.auth import JWTAuthAsync, jwt_auth_async 12 | from .easy_app.factories import UserFactory 13 | 14 | User = get_user_model() 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def media_storage(settings, tmpdir): 19 | settings.MEDIA_ROOT = tmpdir.strpath 20 | 21 | 22 | @pytest.fixture 23 | def user(db) -> User: 24 | return UserFactory() 25 | 26 | 27 | @pytest.fixture 28 | def easy_api_client(user) -> Callable: 29 | orig_func = copy.deepcopy(JWTAuthAsync.__call__) 30 | 31 | def create_client( 32 | api: Union[EasyAPI, Router, Type[ControllerBase]], 33 | api_user=None, # type: ignore 34 | is_staff: bool = False, 35 | is_superuser: bool = False, 36 | has_perm: bool = False, 37 | ) -> "EasyTestClient": 38 | if api_user is None: 39 | api_user = user 40 | setattr(api_user, "is_staff", is_staff) 41 | setattr(api_user, "is_superuser", is_superuser) 42 | 43 | def mock_has_perm_true(*args, **kwargs): 44 | return True 45 | 46 | def mock_has_perm_false(*args, **kwargs): 47 | return False 48 | 49 | async def mock_func(self, request): 50 | setattr(request, "user", api_user) 51 | return True 52 | 53 | setattr(JWTAuthAsync, "__call__", mock_func) 54 | 55 | if is_superuser: 56 | setattr(api_user, "is_staff", True) 57 | if has_perm: 58 | setattr(api_user, "has_perm", mock_has_perm_true) 59 | else: 60 | setattr(api_user, "has_perm", mock_has_perm_false) 61 | client = EasyTestClient(api, auth=jwt_auth_async) 62 | return client 63 | 64 | yield create_client 65 | setattr(JWTAuthAsync, "__call__", orig_func) 66 | -------------------------------------------------------------------------------- /tests/easy_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freemindcore/django-api-framework/f4fd0ff27775a87a912544280c9ae8fc0c6ea26f/tests/easy_app/__init__.py -------------------------------------------------------------------------------- /tests/easy_app/apis.py: -------------------------------------------------------------------------------- 1 | from easy.main import EasyAPI 2 | 3 | from .auth import jwt_auth_async 4 | from .controllers import ( 5 | AutoGenCrudAPIController, 6 | EasyCrudAPIController, 7 | PermissionAPIController, 8 | ) 9 | 10 | api_unittest = EasyAPI(auth=jwt_auth_async) 11 | api_unittest.register_controllers( 12 | EasyCrudAPIController, 13 | PermissionAPIController, 14 | AutoGenCrudAPIController, 15 | ) 16 | -------------------------------------------------------------------------------- /tests/easy_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestsConfig(AppConfig): 5 | name = "tests.easy_app" 6 | -------------------------------------------------------------------------------- /tests/easy_app/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from typing import Any 4 | 5 | from asgiref.sync import sync_to_async 6 | from django.contrib.auth import get_user_model 7 | from django.http import HttpRequest 8 | from ninja_jwt.authentication import JWTAuth 9 | 10 | logger = logging.getLogger(__name__) 11 | user_model = get_user_model() 12 | 13 | 14 | class JWTAuthAsync(JWTAuth, ABC): 15 | async def authenticate(self, request: HttpRequest, token: str) -> Any: 16 | return await sync_to_async(super().authenticate)(request, token) 17 | 18 | 19 | jwt_auth = JWTAuth() 20 | jwt_auth_async = JWTAuthAsync() 21 | -------------------------------------------------------------------------------- /tests/easy_app/controllers.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from asgiref.sync import sync_to_async 4 | from ninja_extra import api_controller, http_get, paginate 5 | 6 | from easy.controller.base import CrudAPIController 7 | from easy.permissions import ( 8 | AdminSitePermission, 9 | BaseApiPermission, 10 | IsAdminUser, 11 | IsAuthenticated, 12 | IsSuperUser, 13 | ) 14 | from easy.response import BaseAPIResponse 15 | 16 | from .models import Client, Event 17 | from .schema import EventSchema 18 | from .services import EventService 19 | 20 | 21 | @api_controller("unittest", permissions=[BaseApiPermission]) 22 | class AutoGenCrudAPIController(CrudAPIController): 23 | """ 24 | For unit testings of the following auto generated APIs: 25 | get/create/patch/delete 26 | """ 27 | 28 | def __init__(self, service: EventService): 29 | super().__init__(service) 30 | 31 | class APIMeta: 32 | model = Event 33 | model_join = True 34 | 35 | 36 | @api_controller("unittest", permissions=[BaseApiPermission]) 37 | class RecursiveAPIController(CrudAPIController): 38 | """ 39 | For unit testings of recursive configuration 40 | """ 41 | 42 | def __init__(self, service: EventService): 43 | super().__init__(service) 44 | 45 | class APIMeta: 46 | model = Event 47 | model_fields = "__all__" 48 | model_join = True 49 | model_recursive = True 50 | 51 | 52 | @api_controller("unittest", permissions=[BaseApiPermission]) 53 | class InheritedRecursiveAPIController(AutoGenCrudAPIController): 54 | """ 55 | For unit testings of inherited recursive configuration 56 | """ 57 | 58 | def __init__(self, service: EventService): 59 | super().__init__(service) 60 | 61 | class APIMeta: 62 | model = Event 63 | model_fields = "__all__" 64 | model_join = True 65 | model_recursive = True 66 | 67 | 68 | @api_controller("unittest", permissions=[BaseApiPermission]) 69 | class AutoGenCrudNoJoinAPIController(CrudAPIController): 70 | """ 71 | For unit testings of mo model_join and sensitive_fields configuration 72 | """ 73 | 74 | def __init__(self, service: EventService): 75 | super().__init__(service) 76 | 77 | class APIMeta: 78 | model = Event 79 | model_fields = "__all__" 80 | model_join = False 81 | model_recursive = True 82 | sensitive_fields = ["password", "sensitive_info"] 83 | 84 | 85 | @api_controller("unittest", permissions=[BaseApiPermission]) 86 | class AutoGenCrudSomeFieldsAPIController(CrudAPIController): 87 | """ 88 | For unit testings of the no-m2m-fields model 89 | """ 90 | 91 | class APIMeta: 92 | model = Client 93 | model_fields = [ 94 | "key", 95 | "name", 96 | ] 97 | 98 | 99 | @api_controller("unittest") 100 | class EasyCrudAPIController(CrudAPIController): 101 | """ 102 | For unit testings of demo APIs 103 | """ 104 | 105 | def __init__(self, service: EventService): 106 | super().__init__(service) 107 | 108 | class APIMeta: 109 | model = Event 110 | model_exclude = [ 111 | "category", 112 | ] 113 | 114 | @http_get("/base_response/") 115 | async def generate_base_response(self, request): 116 | return BaseAPIResponse({"data": "This is a BaseAPIResponse."}) 117 | 118 | @http_get("/qs_paginated/", auth=None) 119 | @paginate 120 | async def qs_paginated(self, request): 121 | return await self.service.get_event_objs_demo() 122 | 123 | @http_get("/qs_list/", response=List[EventSchema]) 124 | async def get_objs_list_with_filter_exclude(self, request): 125 | return await sync_to_async(list)( 126 | await self.service.filter_exclude_objs( 127 | title__endswith="qs_list", 128 | ) 129 | ) 130 | 131 | @http_get( 132 | "/qs/", 133 | ) 134 | async def list_events(self): 135 | qs = await sync_to_async(self.service.crud_get_objs_all)(maximum=10) 136 | await sync_to_async(list)(qs) 137 | if qs: 138 | return qs 139 | return BaseAPIResponse() 140 | 141 | 142 | @api_controller("unittest") 143 | class PermissionAPIController(CrudAPIController): 144 | """ 145 | For unit testings of permissions class 146 | """ 147 | 148 | def __init__(self, service: EventService): 149 | super().__init__(service) 150 | self.service = service 151 | 152 | class APIMeta: 153 | model = Event 154 | 155 | @http_get("/must_be_authenticated/", permissions=[IsAuthenticated]) 156 | async def must_be_authenticated(self, word: str): 157 | return await self.service.get_identity_demo(word) 158 | 159 | @http_get("/must_be_admin_user/", permissions=[IsAdminUser]) 160 | async def must_be_admin_user(self, word: str): 161 | return await self.service.get_identity_demo(word) 162 | 163 | @http_get("/must_be_super_user/", permissions=[IsSuperUser]) 164 | async def must_be_super_user(self, word: str): 165 | return await self.service.get_identity_demo(word) 166 | 167 | @http_get("/test_perm_only_super/", permissions=[BaseApiPermission]) 168 | async def test_perm_only_super(self, request): 169 | event_id = await self.service.add_obj(title="test_event_title") 170 | # return await self.service.get_obj(id=note.id) 171 | return await sync_to_async(self.get_object_or_none)(Event, id=event_id) 172 | 173 | @http_get("/test_perm/", permissions=[BaseApiPermission]) 174 | async def test_perm(self, request, word: str): 175 | return await self.service.get_identity_demo(word) 176 | 177 | @http_get("/test_perm_admin_site/", permissions=[AdminSitePermission]) 178 | async def test_perm_admin_site(self, request, word: str): 179 | return await self.service.get_identity_demo(word) 180 | 181 | 182 | @api_controller("unittest", permissions=[AdminSitePermission]) 183 | class AdminSitePermissionAPIController(CrudAPIController): 184 | """ 185 | For unit testings of AdminSite permissions class 186 | """ 187 | 188 | def __init__(self, service: EventService): 189 | super().__init__(service) 190 | self.service = service 191 | 192 | class APIMeta: 193 | model = Event 194 | 195 | 196 | @api_controller("unittest", permissions=[AdminSitePermission]) 197 | class NoCrudAPIController(CrudAPIController): 198 | """ 199 | For unit testings of no crud configuration 200 | """ 201 | 202 | def __init__(self, service: EventService): 203 | super().__init__(service) 204 | self.service = service 205 | 206 | class APIMeta: 207 | model = Event 208 | generate_crud = False 209 | 210 | 211 | @api_controller("unittest", permissions=[AdminSitePermission]) 212 | class NoCrudInheritedAPIController(AdminSitePermissionAPIController): 213 | """ 214 | For unit testings of no crud configuration (Inherited Class) 215 | """ 216 | 217 | def __init__(self, service: EventService): 218 | super().__init__(service) 219 | self.service = service 220 | 221 | class APIMeta: 222 | model = Event 223 | generate_crud = False 224 | model_exclude = [ 225 | "start_date", 226 | ] 227 | -------------------------------------------------------------------------------- /tests/easy_app/domain.py: -------------------------------------------------------------------------------- 1 | from easy.domain import BaseDomain 2 | 3 | from .models import Event 4 | 5 | 6 | class EventDomain(BaseDomain): 7 | pass 8 | 9 | 10 | class EventBiz(EventDomain): 11 | def __init__(self, model=Event): 12 | self.model = model 13 | super(EventBiz, self).__init__(self.model) 14 | -------------------------------------------------------------------------------- /tests/easy_app/factories.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from factory import Faker 3 | from factory.django import DjangoModelFactory 4 | 5 | 6 | class UserFactory(DjangoModelFactory): 7 | username = Faker("user_name") 8 | email = Faker("email") 9 | 10 | class Meta: 11 | model = get_user_model() 12 | django_get_or_create = ["username"] 13 | -------------------------------------------------------------------------------- /tests/easy_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TestBaseModel(models.Model): 5 | class Meta: 6 | abstract = True 7 | 8 | 9 | class Category(TestBaseModel): 10 | title = models.CharField(max_length=100) 11 | status = models.PositiveSmallIntegerField(default=1, null=True) 12 | 13 | class APIMeta: 14 | generate_crud = False 15 | 16 | 17 | class Client(TestBaseModel): 18 | key = models.CharField(max_length=20, unique=True) 19 | name = models.CharField(max_length=50, null=True) 20 | category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True) 21 | password = models.CharField(max_length=30, null=True) 22 | 23 | 24 | class Type(TestBaseModel): 25 | name = models.CharField(max_length=50, null=True) 26 | status = models.PositiveSmallIntegerField(default=1, null=True) 27 | 28 | 29 | class Event(TestBaseModel): 30 | title = models.CharField(max_length=100) 31 | category = models.OneToOneField( 32 | Category, null=True, blank=True, on_delete=models.SET_NULL 33 | ) 34 | start_date = models.DateField( 35 | null=True, 36 | ) 37 | end_date = models.DateField( 38 | null=True, 39 | ) 40 | photo = models.ImageField(upload_to="client/photo", null=True) 41 | 42 | owner = models.ManyToManyField(to=Client, related_name="events", null=True) 43 | 44 | lead_owner = models.ManyToManyField(to=Client, related_name="lead_owner", null=True) 45 | 46 | type = models.ForeignKey(Type, on_delete=models.CASCADE, null=True) 47 | 48 | sensitive_info = models.CharField(max_length=100, null=True) 49 | 50 | def __str__(self): 51 | return self.title 52 | -------------------------------------------------------------------------------- /tests/easy_app/schema.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from ninja import Schema 4 | 5 | 6 | class EventSchema(Schema): 7 | title: str 8 | start_date: date 9 | end_date: date 10 | 11 | class Config: 12 | orm_mode = True 13 | 14 | 15 | class EventSchemaOut(Schema): 16 | id: int 17 | -------------------------------------------------------------------------------- /tests/easy_app/services.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | from django.http import HttpRequest 4 | 5 | from .domain import EventBiz 6 | 7 | if TYPE_CHECKING: 8 | from ninja_extra.controllers.base import ControllerBase # pragma: no cover 9 | 10 | from easy.services import BaseService 11 | 12 | 13 | class EventService(BaseService): 14 | def __init__(self, biz=EventBiz()): 15 | super().__init__(biz.model) 16 | 17 | @staticmethod 18 | async def prepare_create_event_data(data): 19 | """Helper func for unit testing""" 20 | object_data = data.copy() 21 | object_data.update(title=f"{object_data['title']}_create") 22 | return object_data 23 | 24 | async def get_event_objs_demo(self): 25 | """Demo API for unit testing""" 26 | return await self.get_objs() 27 | 28 | async def get_identity_demo(self, word): 29 | """Demo API for unit testing""" 30 | return dict(says=word) 31 | 32 | def check_permission( 33 | self, request: HttpRequest, controller: "ControllerBase" 34 | ) -> bool: 35 | """ 36 | Overwrite parent check_permission 37 | """ 38 | user = request.user 39 | return bool( 40 | user and user.is_authenticated and user.is_active 41 | ) and super().check_permission(request, controller) 42 | 43 | def check_object_permission( 44 | self, request: HttpRequest, controller: "ControllerBase", obj: Any 45 | ) -> bool: 46 | """ 47 | Only superuser is granted access 48 | """ 49 | return bool(request.user.is_superuser) and super().check_object_permission( 50 | request, controller, obj 51 | ) 52 | -------------------------------------------------------------------------------- /tests/easy_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from .apis import api_unittest 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("api/", api_unittest.urls), 9 | ] 10 | -------------------------------------------------------------------------------- /tests/test_api_base_response.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from easy.response import BaseAPIResponse 6 | 7 | 8 | def test_base_api_result_base(): 9 | assert BaseAPIResponse("").json_data["data"] == "" 10 | 11 | assert BaseAPIResponse("1").json_data["data"] == "1" 12 | 13 | assert BaseAPIResponse("0").json_data["data"] == "0" 14 | assert BaseAPIResponse().json_data["data"] == {} 15 | assert BaseAPIResponse([]).json_data["data"] == [] 16 | assert BaseAPIResponse(True).json_data["data"] is True 17 | assert BaseAPIResponse(False).json_data["data"] is False 18 | assert BaseAPIResponse([1, 2, 3]).json_data["data"] == [1, 2, 3] 19 | 20 | 21 | def test_base_api_result_dict(): 22 | assert BaseAPIResponse({"a": 1, "b": 2}).json_data["data"] == { 23 | "a": 1, 24 | "b": 2, 25 | } 26 | 27 | assert (BaseAPIResponse({"code": 2, "im": 14})).json_data["data"]["im"] == 14 28 | assert (BaseAPIResponse({"code": 2, "im": 14})).json_data["data"]["code"] == 2 29 | 30 | 31 | def test_base_api_result_message(): 32 | assert ( 33 | BaseAPIResponse(code=-1, message="error test").json_data["message"] 34 | == "error test" 35 | ) 36 | assert BaseAPIResponse().json_data["message"] 37 | 38 | 39 | def test_base_api_edit(): 40 | orig_resp = BaseAPIResponse( 41 | {"item_id": 2, "im": 14}, 42 | code=0, 43 | ) 44 | 45 | with pytest.raises(KeyError): 46 | print(orig_resp.json_data["detail"]) 47 | 48 | data = orig_resp.json_data 49 | 50 | data["detail"] = "Edited!!!" 51 | orig_resp.content = json.dumps(data) 52 | 53 | assert orig_resp.json_data["detail"] == "Edited!!!" 54 | 55 | data = orig_resp.json_data 56 | assert data["code"] == 0 57 | 58 | data["code"] = 401 59 | orig_resp.update_content(data) 60 | assert orig_resp.json_data["code"] == 401 61 | assert orig_resp.json_data["data"]["im"] == 14 62 | 63 | data["data"]["im"] = 8888 64 | orig_resp.update_content(data) 65 | assert orig_resp.json_data["data"]["im"] == 8888 66 | -------------------------------------------------------------------------------- /tests/test_async_api_permissions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import django 4 | import pytest 5 | from asgiref.sync import sync_to_async 6 | 7 | from .easy_app.controllers import ( 8 | AdminSitePermissionAPIController, 9 | AutoGenCrudAPIController, 10 | PermissionAPIController, 11 | ) 12 | from .easy_app.models import Client, Event, Type 13 | from .test_async_other_apis import dummy_data 14 | 15 | 16 | @pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher") 17 | @pytest.mark.django_db 18 | class TestPermissionController: 19 | async def test_demo(self, easy_api_client): 20 | client = easy_api_client(PermissionAPIController) 21 | 22 | response = await client.get( 23 | "/must_be_authenticated/?word=authenticated", 24 | content_type="application/json", 25 | ) 26 | assert response.status_code == 200 27 | assert response.json().get("data")["says"] == "authenticated" 28 | 29 | client = easy_api_client(PermissionAPIController) 30 | response = await client.get( 31 | "/must_be_admin_user/?word=admin", 32 | ) 33 | assert response.status_code == 403 34 | with pytest.raises(KeyError): 35 | assert response.json().get("data")["says"] == "admin" 36 | 37 | client = easy_api_client(PermissionAPIController, is_staff=True) 38 | response = await client.get( 39 | "/must_be_admin_user/?word=admin", 40 | ) 41 | assert response.status_code == 200 42 | assert response.json().get("data")["says"] == "admin" 43 | 44 | client = easy_api_client(PermissionAPIController) 45 | response = await client.get( 46 | "/must_be_super_user/?word=superuser", 47 | ) 48 | assert response.status_code == 403 49 | with pytest.raises(KeyError): 50 | assert response.json().get("data")["says"] == "superuser" 51 | 52 | client = easy_api_client(PermissionAPIController, is_superuser=True) 53 | response = await client.get( 54 | "/must_be_super_user/?word=superuser", 55 | ) 56 | assert response.status_code == 200 57 | assert response.json().get("data")["says"] == "superuser" 58 | 59 | async def test_perm(self, transactional_db, easy_api_client): 60 | client = easy_api_client(PermissionAPIController) 61 | response = await client.get("/test_perm/", query=dict(word="normal")) 62 | assert response.status_code == 200 63 | assert response.json().get("data")["says"] == "normal" 64 | client = easy_api_client(PermissionAPIController, is_staff=True) 65 | response = await client.get("/test_perm/", query=dict(word="staff")) 66 | assert response.status_code == 200 67 | assert response.json().get("data")["says"] == "staff" 68 | 69 | async def test_perm_only_super(self, transactional_db, easy_api_client): 70 | client = easy_api_client(PermissionAPIController) 71 | response = await client.get("/test_perm_only_super/") 72 | assert response.status_code == 403 73 | assert response.json().get("data") == { 74 | "detail": "You do not have permission to perform this action." 75 | } 76 | 77 | client = easy_api_client(PermissionAPIController) 78 | response = await client.get("/test_perm_only_super/") 79 | assert response.status_code == 403 80 | assert response.json().get("data") == { 81 | "detail": "You do not have permission to perform this action." 82 | } 83 | 84 | client = easy_api_client(PermissionAPIController, is_superuser=True) 85 | response = await client.get("/test_perm_only_super/") 86 | assert response.status_code == 200 87 | assert response.json().get("data")["title"] == "test_event_title" 88 | 89 | async def test_perm_admin_site(self, transactional_db, easy_api_client): 90 | # None-admin users 91 | client = easy_api_client(PermissionAPIController) 92 | response = await client.get( 93 | "/test_perm_admin_site/", query=dict(word="non-admin") 94 | ) 95 | assert response.status_code == 403 96 | assert response.json().get("data") == { 97 | "detail": "You do not have permission to perform this action." 98 | } 99 | 100 | # Staff users 101 | client = easy_api_client(PermissionAPIController, is_staff=True, has_perm=True) 102 | response = await client.get("/test_perm_admin_site/", query=dict(word="staff")) 103 | assert response.status_code == 200 104 | assert response.json()["data"]["says"] == "staff" 105 | 106 | 107 | @pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher") 108 | @pytest.mark.django_db 109 | class TestAdminSitePermissionController: 110 | async def test_perm_auto_apis_delete(self, transactional_db, easy_api_client): 111 | client = easy_api_client(AdminSitePermissionAPIController) 112 | # Test delete 113 | object_data = dummy_data.copy() 114 | object_data.update(title=f"{object_data['title']}_get") 115 | event = await sync_to_async(Event.objects.create)(**object_data) 116 | response = await client.get( 117 | f"/{event.id}", 118 | ) 119 | assert response.status_code == 403 120 | 121 | response = await client.delete( 122 | f"/{event.id}", 123 | ) 124 | assert response.status_code == 403 125 | assert response.json().get("data") == { 126 | "detail": "You do not have permission to perform this action." 127 | } 128 | 129 | # Super users 130 | client = easy_api_client(AutoGenCrudAPIController, is_superuser=True) 131 | response = await client.delete( 132 | f"/{event.id}", 133 | ) 134 | 135 | assert response.status_code == 200 136 | 137 | response = await client.get( 138 | f"/{event.id}", 139 | ) 140 | assert response.status_code == 200 141 | assert response.json().get("code") == 404 142 | 143 | async def test_perm_auto_apis_patch(self, transactional_db, easy_api_client): 144 | client = easy_api_client(AdminSitePermissionAPIController) 145 | 146 | object_data = dummy_data.copy() 147 | event = await sync_to_async(Event.objects.create)(**object_data) 148 | 149 | response = await client.get( 150 | f"/{event.id}", 151 | ) 152 | assert response.status_code == 403 153 | assert response.json().get("data") == { 154 | "detail": "You do not have permission to perform this action." 155 | } 156 | 157 | # Staff users 158 | client = easy_api_client(AutoGenCrudAPIController, is_staff=True) 159 | response = await client.get( 160 | f"/{event.id}", 161 | ) 162 | assert response.json().get("data")["title"] == f"{object_data['title']}" 163 | 164 | client_g = await sync_to_async(Client.objects.create)( 165 | name="Client G for Unit Testings", key="G" 166 | ) 167 | 168 | client_h = await sync_to_async(Client.objects.create)( 169 | name="Client H for Unit Testings", key="H" 170 | ) 171 | 172 | new_data = dict( 173 | id=event.id, 174 | title=f"{object_data['title']}_patch", 175 | start_date=str((datetime.now() + timedelta(days=10)).date()), 176 | end_date=str((datetime.now() + timedelta(days=20)).date()), 177 | owner=[client_g.id, client_h.id], 178 | ) 179 | 180 | client = easy_api_client(AdminSitePermissionAPIController) 181 | response = await client.patch( 182 | f"/{event.id}", json=new_data, content_type="application/json" 183 | ) 184 | 185 | assert response.status_code == 403 186 | assert response.json().get("data") == { 187 | "detail": "You do not have permission to perform this action." 188 | } 189 | 190 | # Super users 191 | client = easy_api_client(AutoGenCrudAPIController, is_superuser=True) 192 | response = await client.patch( 193 | f"/{event.id}", json=new_data, content_type="application/json" 194 | ) 195 | assert response.json().get("message") == "Updated." 196 | 197 | response = await client.get( 198 | f"/{event.id}", 199 | ) 200 | assert response.status_code == 200 201 | assert response.json().get("data")["title"] == "AsyncAPIEvent_patch" 202 | assert response.json().get("data")["start_date"] == str( 203 | (datetime.now() + timedelta(days=10)).date() 204 | ) 205 | assert response.json().get("data")["end_date"] == str( 206 | (datetime.now() + timedelta(days=20)).date() 207 | ) 208 | 209 | async def test_perm_auto_apis_add(self, transactional_db, easy_api_client): 210 | client = easy_api_client(AdminSitePermissionAPIController) 211 | type = await sync_to_async(Type.objects.create)(name="TypeForCreating") 212 | 213 | object_data = dummy_data.copy() 214 | object_data.update(title=f"{object_data['title']}_create") 215 | object_data.update(type_id=type.id) 216 | 217 | response = await client.put( 218 | "/", json=object_data, content_type="application/json" 219 | ) 220 | assert response.status_code == 403 221 | 222 | client = easy_api_client(AdminSitePermissionAPIController, is_superuser=True) 223 | 224 | response = await client.put( 225 | "/", json=object_data, content_type="application/json" 226 | ) 227 | assert response.status_code == 200 228 | -------------------------------------------------------------------------------- /tests/test_async_auto_crud_apis.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | 4 | import django 5 | import pytest 6 | from asgiref.sync import sync_to_async 7 | 8 | from .easy_app.controllers import ( 9 | AutoGenCrudAPIController, 10 | AutoGenCrudNoJoinAPIController, 11 | AutoGenCrudSomeFieldsAPIController, 12 | EventSchema, 13 | InheritedRecursiveAPIController, 14 | NoCrudAPIController, 15 | NoCrudInheritedAPIController, 16 | RecursiveAPIController, 17 | ) 18 | from .easy_app.models import Category, Client, Event, Type 19 | 20 | dummy_data = dict( 21 | title="AsyncAdminAPIEvent", 22 | start_date=str(datetime.now().date()), 23 | end_date=str((datetime.now() + timedelta(days=5)).date()), 24 | ) 25 | 26 | 27 | @pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher") 28 | @pytest.mark.django_db 29 | class TestAutoCrudAdminAPI: 30 | async def test_crud_generate_or_not(self, transactional_db, easy_api_client): 31 | client = easy_api_client(NoCrudAPIController) 32 | 33 | object_data = dummy_data.copy() 34 | object_data.update(title=f"{object_data['title']}_get") 35 | 36 | event = await sync_to_async(Event.objects.create)(**object_data) 37 | 38 | with pytest.raises(Exception): 39 | await client.get( 40 | f"/{event.id}", 41 | ) 42 | 43 | client = easy_api_client(NoCrudInheritedAPIController, is_superuser=True) 44 | 45 | object_data = dummy_data.copy() 46 | object_data.update(title=f"{object_data['title']}_get") 47 | 48 | event = await sync_to_async(Event.objects.create)(**object_data) 49 | 50 | response = await client.get( 51 | f"/{event.id}", 52 | ) 53 | assert response.status_code == 200 54 | with pytest.raises(Exception): 55 | print(response.json()["data"]["start_date"]) 56 | 57 | async def test_crud_default_get_all(self, transactional_db, easy_api_client): 58 | client = easy_api_client(AutoGenCrudAPIController) 59 | 60 | object_data = dummy_data.copy() 61 | object_data.update(title=f"{object_data['title']}_get_all") 62 | 63 | event = await sync_to_async(Event.objects.create)(**object_data) 64 | type = await sync_to_async(Type.objects.create)(name="Type") 65 | category = await sync_to_async(Category.objects.create)( 66 | title="Category for Unit Testings" 67 | ) 68 | client_a = await sync_to_async(Client.objects.create)( 69 | name="Client A for Unit Testings", key="A" 70 | ) 71 | client_b = await sync_to_async(Client.objects.create)( 72 | name="Client B for Unit Testings", key="B" 73 | ) 74 | event.category = category 75 | event.type = type 76 | await sync_to_async(event.save)() 77 | await sync_to_async(event.owner.set)([client_a, client_b]) 78 | 79 | response = await client.get( 80 | "/", query=dict(maximum=100, filters=json.dumps(dict(id__gte=1))) 81 | ) 82 | assert response.status_code == 200 83 | 84 | data = response.json().get("data") 85 | assert data[0]["title"] == "AsyncAdminAPIEvent_get_all" 86 | assert data[0]["type"] == type.id 87 | 88 | event_schema = json.loads(EventSchema.from_orm(event).json()) 89 | assert event_schema["start_date"] == data[0]["start_date"] 90 | 91 | # Recursive = True 92 | client = easy_api_client(RecursiveAPIController) 93 | response = await client.get( 94 | "/", query=dict(maximum=100, filters=json.dumps(dict(id__gte=1))) 95 | ) 96 | assert response.status_code == 200 97 | 98 | data = response.json().get("data") 99 | assert data[0]["title"] == "AsyncAdminAPIEvent_get_all" 100 | 101 | assert data[0]["type"]["id"] == type.id 102 | assert data[0]["category"]["status"] == 1 103 | 104 | # Recursive = True, inherited class 105 | client = easy_api_client(InheritedRecursiveAPIController) 106 | response = await client.get( 107 | "/", query=dict(maximum=100, filters=json.dumps(dict(id__gte=1))) 108 | ) 109 | assert response.status_code == 200 110 | 111 | data = response.json().get("data") 112 | assert data[0]["title"] == "AsyncAdminAPIEvent_get_all" 113 | 114 | assert data[0]["type"]["id"] == type.id 115 | assert data[0]["category"]["status"] == 1 116 | 117 | # Back to AutoGenCrudAPIController 118 | client = easy_api_client(AutoGenCrudAPIController) 119 | 120 | response = await client.get( 121 | "/", 122 | ) 123 | assert response.status_code == 200 124 | 125 | data = response.json().get("data") 126 | assert data[0]["title"] == "AsyncAdminAPIEvent_get_all" 127 | 128 | event_schema = json.loads(EventSchema.from_orm(event).json()) 129 | assert event_schema["start_date"] == data[0]["start_date"] 130 | 131 | response = await client.get("/") 132 | assert response.status_code == 200 133 | 134 | data = response.json().get("data") 135 | assert data[0]["title"] == "AsyncAdminAPIEvent_get_all" 136 | 137 | event_schema = json.loads(EventSchema.from_orm(event).json()) 138 | assert event_schema["start_date"] == data[0]["start_date"] 139 | 140 | async def test_crud_default_get_delete(self, transactional_db, easy_api_client): 141 | client = easy_api_client(AutoGenCrudAPIController) 142 | 143 | object_data = dummy_data.copy() 144 | object_data.update(title=f"{object_data['title']}_get") 145 | 146 | event = await sync_to_async(Event.objects.create)(**object_data) 147 | 148 | response = await client.get( 149 | f"/{event.id}", 150 | ) 151 | assert response.status_code == 200 152 | 153 | data = response.json().get("data") 154 | assert data["title"] == "AsyncAdminAPIEvent_get" 155 | 156 | event_schema = json.loads(EventSchema.from_orm(event).json()) 157 | assert event_schema["end_date"] == data["end_date"] 158 | 159 | await client.delete( 160 | f"/{event.id}", 161 | ) 162 | 163 | response = await client.get( 164 | f"/{event.id}", 165 | ) 166 | assert response.status_code == 200 167 | assert response.json().get("code") == 404 168 | 169 | response = await client.delete("/20000") 170 | assert response.status_code == 200 171 | assert response.json().get("data") == "Not Found." 172 | 173 | async def test_crud_default_create(self, transactional_db, easy_api_client): 174 | client = easy_api_client(AutoGenCrudAPIController) 175 | 176 | client_c = await sync_to_async(Client.objects.create)( 177 | name="Client C for Unit Testings", key="C" 178 | ) 179 | 180 | client_d = await sync_to_async(Client.objects.create)( 181 | name="Client D for Unit Testings", key="D" 182 | ) 183 | 184 | type = await sync_to_async(Type.objects.create)(name="TypeForCreating") 185 | 186 | object_data = dummy_data.copy() 187 | object_data.update(title=f"{object_data['title']}_create") 188 | object_data.update(owner=[client_c.id, client_d.id]) 189 | object_data.update(lead_owner=[client_d.id]) 190 | object_data.update(type_id=type.id) 191 | 192 | response = await client.put( 193 | "/", json=object_data, content_type="application/json" 194 | ) 195 | assert response.status_code == 200 196 | assert response.json().get("code") == 201 197 | assert response.json().get("message") == "Created." 198 | 199 | event_id = response.json().get("data")["id"] 200 | 201 | response = await client.get( 202 | f"/{event_id}", 203 | ) 204 | 205 | assert response.status_code == 200 206 | assert response.json().get("data")["title"] == "AsyncAdminAPIEvent_create" 207 | 208 | async def test_crud_default_create_some_fields( 209 | self, transactional_db, easy_api_client 210 | ): 211 | client = easy_api_client(AutoGenCrudSomeFieldsAPIController) 212 | 213 | category = await sync_to_async(Category.objects.create)( 214 | title="Category for Unit Testings" 215 | ) 216 | 217 | client_type = await sync_to_async(Client.objects.create)( 218 | name="Client for Unit Testings", 219 | key="Type", 220 | category=category, 221 | password="DUMMY_PASSWORD", 222 | ) 223 | 224 | response = await client.get( 225 | f"/{client_type.id}", 226 | ) 227 | 228 | assert response.status_code == 200 229 | assert response.json()["data"]["key"] == "Type" 230 | with pytest.raises(KeyError): 231 | print(response.json()["data"]["password"]) 232 | with pytest.raises(KeyError): 233 | print(response.json()["data"]["category"]) 234 | 235 | async def test_crud_default_patch(self, transactional_db, easy_api_client): 236 | client = easy_api_client(AutoGenCrudAPIController) 237 | 238 | object_data = dummy_data.copy() 239 | event = await sync_to_async(Event.objects.create)(**object_data) 240 | 241 | response = await client.get( 242 | f"/{event.pk}", 243 | ) 244 | assert response.status_code == 200 245 | assert response.json().get("data")["title"] == f"{object_data['title']}" 246 | 247 | client_e = await sync_to_async(Client.objects.create)( 248 | name="Client E for Unit Testings", key="E" 249 | ) 250 | 251 | client_f = await sync_to_async(Client.objects.create)( 252 | name="Client F for Unit Testings", key="F" 253 | ) 254 | 255 | category = await sync_to_async(Category.objects.create)( 256 | title="Category for Unit Testings", status=2 257 | ) 258 | 259 | new_data = dict( 260 | id=event.pk, 261 | title=f"{object_data['title']}_patch", 262 | start_date=str((datetime.now() + timedelta(days=10)).date()), 263 | end_date=str((datetime.now() + timedelta(days=20)).date()), 264 | owner=[client_e.pk, client_f.pk], 265 | category=category.pk, 266 | ) 267 | 268 | response = await client.patch( 269 | "/20000", json=new_data, content_type="application/json" 270 | ) 271 | 272 | assert response.status_code == 200 273 | assert response.json()["code"] == 400 274 | 275 | response = await client.patch( 276 | f"/{event.pk}", json=new_data, content_type="application/json" 277 | ) 278 | 279 | assert response.status_code == 200 280 | assert response.json().get("message") == "Updated." 281 | 282 | response = await client.get( 283 | f"/{event.pk}", 284 | ) 285 | assert response.status_code == 200 286 | data = response.json().get("data") 287 | 288 | assert len(data["owner"]) == 2 289 | assert len(data["lead_owner"]) == 0 290 | assert data["owner"][0]["name"] == "Client E for Unit Testings" 291 | assert data["owner"][1]["name"] == "Client F for Unit Testings" 292 | 293 | assert data["title"] == "AsyncAdminAPIEvent_patch" 294 | assert data["start_date"] == str((datetime.now() + timedelta(days=10)).date()) 295 | assert data["end_date"] == str((datetime.now() + timedelta(days=20)).date()) 296 | 297 | client = easy_api_client(AutoGenCrudNoJoinAPIController) 298 | 299 | # No auto join 300 | response = await client.get( 301 | f"/{event.pk}", 302 | ) 303 | assert response.status_code == 200 304 | data = response.json().get("data") 305 | assert data["owner"] == [8, 9] 306 | -------------------------------------------------------------------------------- /tests/test_async_other_apis.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | 4 | import django 5 | import pytest 6 | from asgiref.sync import sync_to_async 7 | 8 | from .easy_app.controllers import EasyCrudAPIController 9 | from .easy_app.models import Event, Type 10 | from .easy_app.schema import EventSchema 11 | from .easy_app.services import EventService 12 | 13 | dummy_data = dict( 14 | title="AsyncAPIEvent", 15 | start_date=str(datetime.now().date()), 16 | end_date=str((datetime.now() + timedelta(days=5)).date()), 17 | ) 18 | 19 | 20 | @pytest.mark.skipif(django.VERSION < (3, 1), reason="requires django 3.1 or higher") 21 | @pytest.mark.django_db 22 | class TestEasyCrudAPIController: 23 | async def test_crud_apis(self, transactional_db, easy_api_client): 24 | client = easy_api_client(EasyCrudAPIController) 25 | 26 | object_data = await EventService.prepare_create_event_data(dummy_data) 27 | 28 | response = await client.put( 29 | "/", json=object_data, content_type="application/json" 30 | ) 31 | assert response.status_code == 200 32 | 33 | assert response.json().get("code") == 201 34 | event_id = response.json().get("data")["id"] 35 | 36 | response = await client.get( 37 | f"/{event_id}", 38 | ) 39 | assert response.status_code == 200 40 | assert response.json().get("data")["title"] == "AsyncAPIEvent_create" 41 | 42 | response = await client.get("/") 43 | assert response.status_code == 200 44 | assert response.json().get("data")[0]["title"] == "AsyncAPIEvent_create" 45 | 46 | async def test_base_response(self, transactional_db, easy_api_client): 47 | client = easy_api_client(EasyCrudAPIController) 48 | 49 | response = await client.get( 50 | "/base_response/", 51 | ) 52 | assert response.status_code == 200 53 | assert response.json().get("data")["data"] == "This is a BaseAPIResponse." 54 | 55 | async def test_qs_paginated(self, transactional_db, easy_api_client): 56 | client = easy_api_client(EasyCrudAPIController) 57 | 58 | object_data = await EventService.prepare_create_event_data(dummy_data) 59 | 60 | response = await client.put( 61 | "/", json=object_data, content_type="application/json" 62 | ) 63 | assert response.status_code == 200 64 | assert response.json().get("code") == 201 65 | event_id = response.json().get("data")["id"] 66 | response = await client.get( 67 | "/qs_paginated/", 68 | ) 69 | assert response.status_code == 200 70 | assert response.json().get("data")[0]["id"] == event_id 71 | 72 | async def test_qs_list(self, transactional_db, easy_api_client): 73 | client = easy_api_client(EasyCrudAPIController) 74 | 75 | for i in range(4): 76 | type = await sync_to_async(Type.objects.create)(name=f"Test-Type-{i}") 77 | object_data = await EventService.prepare_create_event_data(dummy_data) 78 | object_data.update( 79 | title=f"{object_data['title']}_qs_list_{i}", type=type.id 80 | ) 81 | await client.put("/", json=object_data) 82 | 83 | type = await sync_to_async(Type.objects.create)(name="Test-Type-88") 84 | object_data = await EventService.prepare_create_event_data(dummy_data) 85 | object_data.update(title=f"{object_data['title']}_qs_list_88", type=type) 86 | event = await sync_to_async(Event.objects.create)(**object_data) 87 | 88 | response = await client.get( 89 | "/qs_list/", 90 | ) 91 | assert response.status_code == 200 92 | 93 | data = response.json().get("data") 94 | 95 | assert data[0]["title"] == "AsyncAPIEvent_create_qs_list_0" 96 | 97 | event_schema = json.loads(EventSchema.from_orm(event).json()) 98 | assert ( 99 | event_schema["title"] 100 | == data[4]["title"] 101 | == "AsyncAPIEvent_create_qs_list_88" 102 | ) 103 | 104 | async def test_qs_(self, transactional_db, easy_api_client): 105 | client = easy_api_client(EasyCrudAPIController) 106 | 107 | for i in range(4): 108 | type = await sync_to_async(Type.objects.create)(name=f"Test-Type-{i}") 109 | object_data = await EventService.prepare_create_event_data(dummy_data) 110 | object_data.update(title=f"{object_data['title']}_qs_{i}", type=type.id) 111 | await client.put("/", json=object_data) 112 | 113 | response = await client.get( 114 | "/qs/", 115 | ) 116 | assert response.status_code == 200 117 | 118 | assert response.json().get("data")[0]["title"] == "AsyncAPIEvent_create_qs_0" 119 | -------------------------------------------------------------------------------- /tests/test_auto_api_creation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from ninja_extra.operation import AsyncOperation 3 | 4 | from easy import EasyAPI 5 | 6 | api_admin_v1 = EasyAPI() 7 | api_admin_v1.auto_create_admin_controllers() 8 | 9 | path_names = [] 10 | controllers = [] 11 | controller_names = [] 12 | 13 | for path, rtr in api_admin_v1._routers: 14 | path_names.append(path) 15 | controllers.append(rtr) 16 | controller_names.append(str(rtr)) 17 | for path_ops in rtr.path_operations.values(): 18 | for op in path_ops.operations: 19 | assert isinstance(op, AsyncOperation) 20 | assert op.api is api_admin_v1 21 | 22 | 23 | def test_auto_generate_admin_api(): 24 | assert len(api_admin_v1._routers) == 5 # default + 3 models 25 | assert "/easy_app/category" in path_names 26 | assert "/easy_app/client" in path_names 27 | assert "/easy_app/event" in path_names 28 | assert "/easy_app/type" in path_names 29 | 30 | assert "CategoryAdminAPIController" in controller_names 31 | assert "EventAdminAPIController" in controller_names 32 | assert "ClientAdminAPIController" in controller_names 33 | assert "TypeAdminAPIController" in controller_names 34 | 35 | 36 | async def test_auto_apis(transactional_db, user, easy_api_client): 37 | for controller_class in controllers: 38 | if str(controller_class).endswith("ClientAdminAPIController"): 39 | client = easy_api_client(controller_class, api_user=user, has_perm=True) 40 | response = await client.get("/", data={}, json={}, user=user) 41 | assert response.status_code == 403 42 | 43 | client = easy_api_client( 44 | controller_class, api_user=user, has_perm=True, is_staff=True 45 | ) 46 | response = await client.get("/", data={}, json={}, user=user) 47 | assert response.status_code == 200 48 | assert response.json()["data"] == [] 49 | 50 | client = easy_api_client( 51 | controller_class, api_user=user, has_perm=True, is_staff=True 52 | ) 53 | response = await client.delete("/20000") 54 | assert response.status_code == 403 55 | 56 | client = easy_api_client( 57 | controller_class, api_user=user, has_perm=True, is_staff=True 58 | ) 59 | response = await client.delete("/20000", data={}, json={}, user=user) 60 | assert response.status_code == 200 61 | assert response.json()["code"] == 404 62 | elif str(controller_class).endswith("CategoryAdminAPIController"): 63 | client = easy_api_client(controller_class, api_user=user, has_perm=True) 64 | with pytest.raises(Exception): 65 | await client.get("/", data={}, json={}, user=user) 66 | 67 | 68 | async def test_auto_generation_settings(settings): 69 | settings.CRUD_API_EXCLUDE_APPS = ["tests.easy_app"] 70 | api_admin_v2 = EasyAPI() 71 | api_admin_v2.auto_create_admin_controllers() 72 | assert len(api_admin_v2._routers) == 1 73 | 74 | settings.CRUD_API_ENABLED_ALL_APPS = False 75 | settings.CRUD_API_EXCLUDE_APPS = [] 76 | settings.CRUD_API_INCLUDE_APPS = ["tests.none_existing_app"] 77 | api_admin_v3 = EasyAPI() 78 | api_admin_v3.auto_create_admin_controllers() 79 | assert len(api_admin_v3._routers) == 1 80 | -------------------------------------------------------------------------------- /tests/test_doc_decorator.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from ninja.openapi.urls import get_openapi_urls 4 | 5 | from easy import EasyAPI 6 | from easy.decorators import docs_permission_required 7 | 8 | 9 | def test_docs_decorator_staff_members(): 10 | api = EasyAPI(docs_decorator=docs_permission_required) 11 | paths = get_openapi_urls(api) 12 | 13 | assert len(paths) == 2 14 | for ptrn in paths: 15 | request = Mock(user=Mock(is_staff=True)) 16 | result = ptrn.callback(request) 17 | assert result.status_code == 200 18 | 19 | request = Mock(user=Mock(is_staff=False)) 20 | request.build_absolute_uri = lambda: "http://example.com" 21 | result = ptrn.callback(request) 22 | assert result.status_code == 302 23 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from django.test import RequestFactory 2 | from django.utils.translation import gettext_lazy as _ 3 | from ninja_extra import exceptions 4 | 5 | from easy import EasyAPI, testing 6 | from easy.exception import APIAuthException, BaseAPIException 7 | 8 | api = EasyAPI(urls_namespace="exception") 9 | 10 | 11 | @api.get("/list_exception") 12 | async def list_exception(request): 13 | raise BaseAPIException( 14 | [ 15 | "some error 1", 16 | "some error 2", 17 | ] 18 | ) 19 | 20 | 21 | @api.get("/list_exception_full_detail") 22 | async def list_exception_full(request): 23 | exception = BaseAPIException( 24 | [ 25 | "some error 1", 26 | "some error 2", 27 | ] 28 | ) 29 | return exception.get_full_details() 30 | 31 | 32 | @api.get("/dict_exception") 33 | async def dict_exception(request): 34 | raise BaseAPIException(dict(error="error 1")) 35 | 36 | 37 | @api.get("/dict_exception_full_detail") 38 | async def dict_exception_full_detail(request): 39 | exception = BaseAPIException(dict(error="error 1")) 40 | return exception.get_full_details() 41 | 42 | 43 | @api.get("/dict_exception_code_detail") 44 | async def dict_exception_code_detail(request): 45 | exception = BaseAPIException(dict(error="error 1")) 46 | return exception.get_codes() 47 | 48 | 49 | @api.get("/list_exception_code_detail") 50 | async def list_exception_code_detail(request): 51 | exception = BaseAPIException(["some error"]) 52 | return exception.get_codes() 53 | 54 | 55 | client = testing.EasyTestClient(api) 56 | 57 | 58 | class TestException: 59 | async def test_get_error_details(self): 60 | example = "string" 61 | lazy_example = _(example) 62 | 63 | assert exceptions._get_error_details(lazy_example) == example 64 | 65 | assert isinstance( 66 | exceptions._get_error_details(lazy_example), exceptions.ErrorDetail 67 | ) 68 | 69 | assert ( 70 | exceptions._get_error_details({"nested": lazy_example})["nested"] == example 71 | ) 72 | 73 | assert isinstance( 74 | exceptions._get_error_details({"nested": lazy_example})["nested"], 75 | exceptions.ErrorDetail, 76 | ) 77 | 78 | assert exceptions._get_error_details([[lazy_example]])[0][0] == example 79 | 80 | assert isinstance( 81 | exceptions._get_error_details([[lazy_example]])[0][0], 82 | exceptions.ErrorDetail, 83 | ) 84 | 85 | 86 | class TestErrorDetail: 87 | async def test_eq(self): 88 | assert exceptions.ErrorDetail("msg") == exceptions.ErrorDetail("msg") 89 | assert exceptions.ErrorDetail("msg", "code") == exceptions.ErrorDetail( 90 | "msg", code="code" 91 | ) 92 | 93 | assert exceptions.ErrorDetail("msg") == "msg" 94 | assert exceptions.ErrorDetail("msg", "code") == "msg" 95 | 96 | async def test_ne(self): 97 | assert exceptions.ErrorDetail("msg1") != exceptions.ErrorDetail("msg2") 98 | assert exceptions.ErrorDetail("msg") != exceptions.ErrorDetail( 99 | "msg", code="invalid" 100 | ) 101 | 102 | assert exceptions.ErrorDetail("msg1") != "msg2" 103 | assert exceptions.ErrorDetail("msg1", "code") != "msg2" 104 | 105 | async def test_repr(self): 106 | assert repr( 107 | exceptions.ErrorDetail("msg1") 108 | ) == "ErrorDetail(string={!r}, code=None)".format("msg1") 109 | assert repr( 110 | exceptions.ErrorDetail("msg1", "code") 111 | ) == "ErrorDetail(string={!r}, code={!r})".format("msg1", "code") 112 | 113 | async def test_str(self): 114 | assert str(exceptions.ErrorDetail("msg1")) == "msg1" 115 | assert str(exceptions.ErrorDetail("msg1", "code")) == "msg1" 116 | 117 | async def test_hash(self): 118 | assert hash(exceptions.ErrorDetail("msg")) == hash("msg") 119 | assert hash(exceptions.ErrorDetail("msg", "code")) == hash("msg") 120 | 121 | 122 | async def test_server_error(): 123 | request = RequestFactory().get("/") 124 | response = exceptions.server_error(request) 125 | assert response.status_code == 500 126 | assert response["content-type"] == "application/json" 127 | 128 | 129 | async def test_bad_request(): 130 | request = RequestFactory().get("/") 131 | exception = Exception("Something went wrong — Not used") 132 | response = exceptions.bad_request(request, exception) 133 | assert response.status_code == 400 134 | assert response["content-type"] == "application/json" 135 | 136 | 137 | async def test_exception_with_list_details(): 138 | res = await client.get("list_exception") 139 | assert res.status_code == 500 140 | assert res.json()["data"] == { 141 | "detail": "[ErrorDetail(string='some error 1', code='error'), " 142 | "ErrorDetail(string='some error 2', code='error')]", 143 | } 144 | 145 | 146 | async def test_exception_with_list_full_details(): 147 | res = await client.get("list_exception_full_detail") 148 | assert res.status_code == 200 149 | assert res.json()["data"] == [ 150 | {"message": "some error 1", "code": "error"}, 151 | {"message": "some error 2", "code": "error"}, 152 | ] 153 | 154 | 155 | async def test_exception_with_dict_details(): 156 | res = await client.get("dict_exception") 157 | assert res.status_code == 500 158 | assert res.json()["data"] == { 159 | "detail": "{'error': ErrorDetail(string='error 1', code='error')}" 160 | } 161 | 162 | 163 | async def test_exception_with_full_details(): 164 | res = await client.get("dict_exception_full_detail") 165 | assert res.status_code == 200 166 | assert res.json()["data"] == {"error": {"message": "error 1", "code": "error"}} 167 | 168 | 169 | async def test_exception_dict_exception_code_detail(): 170 | res = await client.get("dict_exception_code_detail") 171 | assert res.status_code == 200 172 | assert res.json()["data"] == {"error": "error"} 173 | 174 | 175 | async def test_exception_with_list_exception_code_detail(): 176 | res = await client.get("list_exception_code_detail") 177 | assert res.status_code == 200 178 | assert res.json()["data"] == ["error"] 179 | 180 | 181 | def test_validation_error(): 182 | exception = exceptions.ValidationError() 183 | assert exception.detail == [ 184 | exceptions.ErrorDetail( 185 | string=exceptions.ValidationError.default_detail, 186 | code=exceptions.ValidationError.default_code, 187 | ) 188 | ] 189 | assert exception.get_codes() == [exceptions.ValidationError.default_code] 190 | 191 | exception = exceptions.ValidationError(["errors"]) 192 | assert exception.detail == ["errors"] 193 | 194 | 195 | def test_method_not_allowed_error(): 196 | exception = exceptions.MethodNotAllowed("get") 197 | assert exception.detail == exceptions.MethodNotAllowed.default_detail.format( 198 | method="get" 199 | ) 200 | assert exception.get_codes() == exceptions.MethodNotAllowed.default_code 201 | 202 | exception = exceptions.MethodNotAllowed("get", ["errors"]) 203 | assert exception.detail == ["errors"] 204 | 205 | 206 | async def test_method_not_allowed_accepted_error(): 207 | exception = exceptions.NotAcceptable() 208 | assert exception.detail == exceptions.NotAcceptable.default_detail 209 | assert exception.get_codes() == exceptions.NotAcceptable.default_code 210 | 211 | exception = exceptions.NotAcceptable(["errors"]) 212 | assert exception.detail == ["errors"] 213 | 214 | 215 | async def test_unsupported_media_type_allowed_error(): 216 | exception = exceptions.UnsupportedMediaType("whatever/type") 217 | assert exception.detail == exceptions.UnsupportedMediaType.default_detail.format( 218 | media_type="whatever/type" 219 | ) 220 | assert exception.get_codes() == exceptions.UnsupportedMediaType.default_code 221 | 222 | exception = exceptions.UnsupportedMediaType("whatever/type", ["errors"]) 223 | assert exception.detail == ["errors"] 224 | 225 | 226 | async def test_api_exception_code(): 227 | exception = APIAuthException("Unexpected error") 228 | assert exception.detail == "Unexpected error" 229 | assert exception.status_code == 401 230 | 231 | 232 | api_default = EasyAPI(urls_namespace="exception_default", easy_output=False) 233 | 234 | 235 | @api_default.get("/list_exception") 236 | async def list_exception_default(request): 237 | raise BaseAPIException( 238 | [ 239 | "some error 1", 240 | "some error 2", 241 | ] 242 | ) 243 | 244 | 245 | client_default = testing.EasyTestClient(api_default) 246 | 247 | 248 | async def test_exception_with_list_details_default(): 249 | res = await client_default.get("list_exception") 250 | assert res.status_code == 500 251 | assert res.json() == { 252 | "detail": "[ErrorDetail(string='some error 1', code='error'), " 253 | "ErrorDetail(string='some error 2', code='error')]", 254 | } 255 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from easy.conf.settings import ( 2 | CRUD_API_ENABLED_ALL_APPS, 3 | CRUD_API_EXCLUDE_APPS, 4 | CRUD_API_INCLUDE_APPS, 5 | ) 6 | 7 | 8 | def test_change_django_settings(settings): 9 | assert settings.CRUD_API_ENABLED_ALL_APPS == CRUD_API_ENABLED_ALL_APPS 10 | assert settings.CRUD_API_INCLUDE_APPS == CRUD_API_INCLUDE_APPS 11 | assert settings.CRUD_API_EXCLUDE_APPS == CRUD_API_EXCLUDE_APPS 12 | --------------------------------------------------------------------------------